@relayflows/sdk 2.0.37 → 2.0.38

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 (136) hide show
  1. package/dist/agent-reaper.d.ts +1 -1
  2. package/dist/agent-reaper.d.ts.map +1 -1
  3. package/dist/agent-reaper.js +6 -4
  4. package/dist/agent-reaper.js.map +1 -1
  5. package/dist/authored-budget.d.ts +26 -1
  6. package/dist/authored-budget.d.ts.map +1 -1
  7. package/dist/authored-budget.js +72 -2
  8. package/dist/authored-budget.js.map +1 -1
  9. package/dist/authored-flow-error.d.ts +7 -1
  10. package/dist/authored-flow-error.d.ts.map +1 -1
  11. package/dist/authored-flow-error.js.map +1 -1
  12. package/dist/authored-node-runner.d.ts.map +1 -1
  13. package/dist/authored-node-runner.js +3 -2
  14. package/dist/authored-node-runner.js.map +1 -1
  15. package/dist/authored-root.d.ts.map +1 -1
  16. package/dist/authored-root.js +141 -6
  17. package/dist/authored-root.js.map +1 -1
  18. package/dist/authored-worker-step.js +1 -0
  19. package/dist/authored-worker-step.js.map +1 -1
  20. package/dist/child-stop.d.ts +3 -1
  21. package/dist/child-stop.d.ts.map +1 -1
  22. package/dist/child-stop.js +42 -2
  23. package/dist/child-stop.js.map +1 -1
  24. package/dist/cli/add-extension.js +1 -1
  25. package/dist/cli/add-extension.js.map +1 -1
  26. package/dist/cli/check.d.ts.map +1 -1
  27. package/dist/cli/check.js +25 -7
  28. package/dist/cli/check.js.map +1 -1
  29. package/dist/cli/cli-probe.d.ts +1 -0
  30. package/dist/cli/cli-probe.d.ts.map +1 -1
  31. package/dist/cli/cli-probe.js +72 -36
  32. package/dist/cli/cli-probe.js.map +1 -1
  33. package/dist/cli/direct-run.d.ts.map +1 -1
  34. package/dist/cli/direct-run.js +4 -1
  35. package/dist/cli/direct-run.js.map +1 -1
  36. package/dist/cli/pinned-cli-alias.d.ts +13 -0
  37. package/dist/cli/pinned-cli-alias.d.ts.map +1 -0
  38. package/dist/cli/pinned-cli-alias.js +58 -0
  39. package/dist/cli/pinned-cli-alias.js.map +1 -0
  40. package/dist/cli/run.d.ts +8 -0
  41. package/dist/cli/run.d.ts.map +1 -1
  42. package/dist/cli/run.js +91 -8
  43. package/dist/cli/run.js.map +1 -1
  44. package/dist/cli/step-failure.d.ts.map +1 -1
  45. package/dist/cli/step-failure.js +18 -0
  46. package/dist/cli/step-failure.js.map +1 -1
  47. package/dist/cloud-run.d.ts.map +1 -1
  48. package/dist/cloud-run.js +9 -2
  49. package/dist/cloud-run.js.map +1 -1
  50. package/dist/communication/worker.d.ts.map +1 -1
  51. package/dist/communication/worker.js +24 -6
  52. package/dist/communication/worker.js.map +1 -1
  53. package/dist/compile.d.ts.map +1 -1
  54. package/dist/compile.js +16 -7
  55. package/dist/compile.js.map +1 -1
  56. package/dist/failure-kinds.d.ts +9 -1
  57. package/dist/failure-kinds.d.ts.map +1 -1
  58. package/dist/failure-kinds.js +6 -0
  59. package/dist/failure-kinds.js.map +1 -1
  60. package/dist/flow-extension-loader.d.ts +1 -1
  61. package/dist/flow-extension-loader.d.ts.map +1 -1
  62. package/dist/flow-extension-loader.js +143 -14
  63. package/dist/flow-extension-loader.js.map +1 -1
  64. package/dist/flow-extension-manifest.d.ts +1 -0
  65. package/dist/flow-extension-manifest.d.ts.map +1 -1
  66. package/dist/flow-extension-manifest.js +12 -3
  67. package/dist/flow-extension-manifest.js.map +1 -1
  68. package/dist/hosted-extension-manifest.d.ts.map +1 -1
  69. package/dist/hosted-extension-manifest.js +25 -4
  70. package/dist/hosted-extension-manifest.js.map +1 -1
  71. package/dist/hosted-extension-runtime.js +1 -1
  72. package/dist/hosted-extension-sandbox.js +2 -2
  73. package/dist/llm-worker.d.ts.map +1 -1
  74. package/dist/llm-worker.js +4 -2
  75. package/dist/llm-worker.js.map +1 -1
  76. package/dist/mcp-stdio.d.ts +2 -1
  77. package/dist/mcp-stdio.d.ts.map +1 -1
  78. package/dist/mcp-stdio.js +38 -21
  79. package/dist/mcp-stdio.js.map +1 -1
  80. package/dist/preflight.d.ts +2 -0
  81. package/dist/preflight.d.ts.map +1 -1
  82. package/dist/preflight.js.map +1 -1
  83. package/dist/protocol.d.ts +9 -0
  84. package/dist/protocol.d.ts.map +1 -1
  85. package/dist/resolved-cli-identity.d.ts +14 -0
  86. package/dist/resolved-cli-identity.d.ts.map +1 -0
  87. package/dist/resolved-cli-identity.js +19 -0
  88. package/dist/resolved-cli-identity.js.map +1 -0
  89. package/dist/worker-cli.d.ts +3 -1
  90. package/dist/worker-cli.d.ts.map +1 -1
  91. package/dist/worker-cli.js +116 -40
  92. package/dist/worker-cli.js.map +1 -1
  93. package/dist/worker-lease.d.ts +3 -1
  94. package/dist/worker-lease.d.ts.map +1 -1
  95. package/dist/worker-lease.js +47 -9
  96. package/dist/worker-lease.js.map +1 -1
  97. package/dist/worker.d.ts.map +1 -1
  98. package/dist/worker.js +4 -2
  99. package/dist/worker.js.map +1 -1
  100. package/dist/wrapper-session.d.ts +3 -1
  101. package/dist/wrapper-session.d.ts.map +1 -1
  102. package/dist/wrapper-session.js +93 -53
  103. package/dist/wrapper-session.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/agent-reaper.ts +7 -4
  106. package/src/authored-budget.ts +73 -1
  107. package/src/authored-flow-error.ts +6 -0
  108. package/src/authored-node-runner.ts +3 -2
  109. package/src/authored-root.ts +150 -7
  110. package/src/authored-worker-step.ts +1 -0
  111. package/src/child-stop.ts +40 -1
  112. package/src/cli/add-extension.ts +1 -1
  113. package/src/cli/check.ts +25 -4
  114. package/src/cli/cli-probe.ts +73 -37
  115. package/src/cli/direct-run.ts +4 -1
  116. package/src/cli/pinned-cli-alias.ts +74 -0
  117. package/src/cli/run.ts +103 -7
  118. package/src/cli/step-failure.ts +18 -0
  119. package/src/cloud-run.ts +9 -2
  120. package/src/communication/worker.ts +24 -9
  121. package/src/compile.ts +28 -13
  122. package/src/failure-kinds.ts +14 -0
  123. package/src/flow-extension-loader.ts +154 -14
  124. package/src/flow-extension-manifest.ts +6 -3
  125. package/src/hosted-extension-manifest.ts +28 -5
  126. package/src/hosted-extension-runtime.ts +1 -1
  127. package/src/hosted-extension-sandbox.ts +2 -2
  128. package/src/llm-worker.ts +6 -4
  129. package/src/mcp-stdio.ts +34 -20
  130. package/src/preflight.ts +2 -0
  131. package/src/protocol.ts +9 -0
  132. package/src/resolved-cli-identity.ts +42 -0
  133. package/src/worker-cli.ts +123 -37
  134. package/src/worker-lease.ts +50 -9
  135. package/src/worker.ts +6 -4
  136. package/src/wrapper-session.ts +89 -56
@@ -13,7 +13,18 @@ import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js';
13
13
  const JSON_PARSE = JSON.parse;
14
14
  const ARRAY_IS_ARRAY = Array.isArray;
15
15
  const JSON_STRINGIFY = JSON.stringify;
16
+ const NUMBER = Number;
17
+ const NUMBER_IS_FINITE = Number.isFinite.bind(Number);
18
+ const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger.bind(Number);
19
+ const NUMBER_TO_STRING = Function.prototype.call.bind(Number.prototype.toString) as (
20
+ value: number,
21
+ ) => string;
22
+ const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor;
16
23
  const OBJECT_FREEZE = Object.freeze;
24
+ const POSITIVE_INFINITY = Number.POSITIVE_INFINITY;
25
+ const REGEXP_EXEC = Function.prototype.call.bind(RegExp.prototype.exec) as (
26
+ regexp: RegExp, value: string,
27
+ ) => RegExpExecArray | null;
17
28
  const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (
18
29
  regexp: RegExp, value: string,
19
30
  ) => boolean;
@@ -74,12 +85,111 @@ export interface LoadFlowExtensionsOptions<Authority> {
74
85
 
75
86
  const EXTENSION_HEADER_FIELDS = new Set(['budget', 'tools']);
76
87
  const WALLCLOCK_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const;
88
+ type StructuredFlowBudget = Exclude<NonNullable<AuthoredFlowDefinition['header']['budget']>, string>;
89
+
90
+ function ownBudgetField<K extends keyof StructuredFlowBudget>(
91
+ budget: StructuredFlowBudget,
92
+ field: K,
93
+ ): StructuredFlowBudget[K] | undefined {
94
+ const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(budget, field);
95
+ return descriptor !== undefined && 'value' in descriptor
96
+ ? descriptor.value as StructuredFlowBudget[K]
97
+ : undefined;
98
+ }
77
99
 
78
100
  function wallclockMs(value: string): number | undefined {
79
- const match = /^(\d+)(ms|s|m|h|d)$/.exec(value);
101
+ const match = REGEXP_EXEC(/^(\d+)(ms|s|m|h|d)$/, value);
80
102
  if (!match) return undefined;
81
103
  const unit = match[2] as keyof typeof WALLCLOCK_MS;
82
- return Number(match[1]) * WALLCLOCK_MS[unit];
104
+ const milliseconds = NUMBER(match[1]) * WALLCLOCK_MS[unit];
105
+ return NUMBER_IS_FINITE(milliseconds) ? milliseconds : undefined;
106
+ }
107
+
108
+ function hasBudgetCeiling(budget: StructuredFlowBudget): boolean {
109
+ return ownBudgetField(budget, 'tokens') !== undefined
110
+ || ownBudgetField(budget, 'dollars') !== undefined
111
+ || ownBudgetField(budget, 'wallclock') !== undefined;
112
+ }
113
+
114
+ function validateStructuredBudget(budget: StructuredFlowBudget): void {
115
+ const tokens = ownBudgetField(budget, 'tokens');
116
+ if (tokens !== undefined && (!NUMBER_IS_SAFE_INTEGER(tokens) || tokens < 0)) {
117
+ throw new PluginError('plugin_incompatible', `Malformed token budget ceiling ${JSON_STRINGIFY(tokens)}.`);
118
+ }
119
+ const dollars = ownBudgetField(budget, 'dollars');
120
+ if (dollars !== undefined && (!NUMBER_IS_FINITE(dollars) || dollars < 0
121
+ || REGEXP_EXEC(/^\d+(?:\.\d{1,6})?$/, NUMBER_TO_STRING(dollars)) === null)) {
122
+ throw new PluginError('plugin_incompatible', `Malformed dollar budget ceiling ${JSON_STRINGIFY(dollars)}.`);
123
+ }
124
+ }
125
+
126
+ function composeBudget(
127
+ base: AuthoredFlowDefinition['header']['budget'],
128
+ extensions: readonly LoadedFlowExtension[],
129
+ ): AuthoredFlowDefinition['header']['budget'] {
130
+ type Budget = NonNullable<AuthoredFlowDefinition['header']['budget']>;
131
+ type StructuredBudget = Exclude<Budget, string>;
132
+ const effectiveBase = typeof base === 'object' && !hasBudgetCeiling(base)
133
+ ? undefined
134
+ : base;
135
+ const ceilings: Budget[] = [];
136
+ for (let index = 0; index < extensions.length; index += 1) {
137
+ const extension = extensions[index]!;
138
+ const manifestBudget = extension.manifest.permissions.budget;
139
+ const entryBudget = extension.getDefinition(extension.handle).header.budget;
140
+ if (manifestBudget !== undefined
141
+ && (typeof manifestBudget === 'string' || hasBudgetCeiling(manifestBudget))) {
142
+ appendIntrinsicArray(ceilings, manifestBudget);
143
+ }
144
+ if (entryBudget !== undefined
145
+ && (typeof entryBudget === 'string' || hasBudgetCeiling(entryBudget))) {
146
+ appendIntrinsicArray(ceilings, entryBudget);
147
+ }
148
+ }
149
+ if (ceilings.length === 0) return base;
150
+ if (effectiveBase === undefined && ceilings.length === 1 && typeof ceilings[0] === 'string') {
151
+ return ceilings[0];
152
+ }
153
+ let shorthand = typeof effectiveBase === 'string';
154
+ for (let index = 0; index < ceilings.length; index += 1) {
155
+ if (typeof ceilings[index] === 'string') shorthand = true;
156
+ }
157
+ if (shorthand) {
158
+ throw new PluginError('plugin_incompatible', 'A shorthand budget cannot compose with structured base-flow or extension budget ceilings.');
159
+ }
160
+ const budgets: StructuredBudget[] = [];
161
+ if (effectiveBase !== undefined) appendIntrinsicArray(budgets, effectiveBase as StructuredBudget);
162
+ for (let index = 0; index < ceilings.length; index += 1) {
163
+ appendIntrinsicArray(budgets, ceilings[index] as StructuredBudget);
164
+ }
165
+ let tokens: number | undefined;
166
+ let dollars: number | undefined;
167
+ let wallclock: string | undefined;
168
+ let wallclockLimit = POSITIVE_INFINITY;
169
+ for (let index = 0; index < budgets.length; index += 1) {
170
+ const budget = budgets[index]!;
171
+ const budgetTokens = ownBudgetField(budget, 'tokens');
172
+ const budgetDollars = ownBudgetField(budget, 'dollars');
173
+ const budgetWallclock = ownBudgetField(budget, 'wallclock');
174
+ validateStructuredBudget(budget);
175
+ if (budgetTokens !== undefined && (tokens === undefined || budgetTokens < tokens)) tokens = budgetTokens;
176
+ if (budgetDollars !== undefined && (dollars === undefined || budgetDollars < dollars)) dollars = budgetDollars;
177
+ if (budgetWallclock !== undefined) {
178
+ const candidate = wallclockMs(budgetWallclock);
179
+ if (candidate === undefined) {
180
+ throw new PluginError('plugin_incompatible', `Malformed wallclock budget ceiling ${JSON_STRINGIFY(budgetWallclock)}.`);
181
+ }
182
+ if (wallclock === undefined || candidate < wallclockLimit) {
183
+ wallclock = budgetWallclock;
184
+ wallclockLimit = candidate;
185
+ }
186
+ }
187
+ }
188
+ return OBJECT_FREEZE({
189
+ ...(tokens === undefined ? {} : { tokens }),
190
+ ...(dollars === undefined ? {} : { dollars }),
191
+ ...(wallclock === undefined ? {} : { wallclock }),
192
+ });
83
193
  }
84
194
 
85
195
  /** Credentials and servers declared on a flow-extension, probed before the base body starts. */
@@ -253,14 +363,26 @@ async function loadOne<Authority>(
253
363
  assertBaseCompatible(manifest, { name: base.definition.name, version: base.definition.header.version });
254
364
  const baseBudget = base.definition.header.budget;
255
365
  const ceiling = manifest.permissions.budget;
256
- if (ceiling?.dollars !== undefined && typeof baseBudget === 'object' && baseBudget.dollars !== undefined && ceiling.dollars > baseBudget.dollars) {
257
- throw new PluginError('plugin_incompatible', `${manifest.name} declares a $${ceiling.dollars} budget ceiling above the base flow's $${baseBudget.dollars}.`);
366
+ if (ceiling !== undefined && hasBudgetCeiling(ceiling) && typeof baseBudget === 'string') {
367
+ throw new PluginError('plugin_incompatible', `${manifest.name} declares a structured budget ceiling that cannot compose with the base flow's shorthand budget.`);
258
368
  }
259
- if (ceiling?.wallclock !== undefined && typeof baseBudget === 'object' && baseBudget.wallclock !== undefined) {
260
- const pluginMs = wallclockMs(ceiling.wallclock);
261
- const baseMs = wallclockMs(baseBudget.wallclock);
369
+ const ceilingTokens = ceiling === undefined ? undefined : ownBudgetField(ceiling, 'tokens');
370
+ const ceilingDollars = ceiling === undefined ? undefined : ownBudgetField(ceiling, 'dollars');
371
+ const ceilingWallclock = ceiling === undefined ? undefined : ownBudgetField(ceiling, 'wallclock');
372
+ const baseTokens = typeof baseBudget === 'object' ? ownBudgetField(baseBudget, 'tokens') : undefined;
373
+ const baseDollars = typeof baseBudget === 'object' ? ownBudgetField(baseBudget, 'dollars') : undefined;
374
+ const baseWallclock = typeof baseBudget === 'object' ? ownBudgetField(baseBudget, 'wallclock') : undefined;
375
+ if (ceilingTokens !== undefined && baseTokens !== undefined && ceilingTokens > baseTokens) {
376
+ throw new PluginError('plugin_incompatible', `${manifest.name} declares a ${ceilingTokens}-token budget ceiling above the base flow's ${baseTokens}-token ceiling.`);
377
+ }
378
+ if (ceilingDollars !== undefined && baseDollars !== undefined && ceilingDollars > baseDollars) {
379
+ throw new PluginError('plugin_incompatible', `${manifest.name} declares a $${ceilingDollars} budget ceiling above the base flow's $${baseDollars}.`);
380
+ }
381
+ if (ceilingWallclock !== undefined && baseWallclock !== undefined) {
382
+ const pluginMs = wallclockMs(ceilingWallclock);
383
+ const baseMs = wallclockMs(baseWallclock);
262
384
  if (pluginMs !== undefined && baseMs !== undefined && pluginMs > baseMs) {
263
- throw new PluginError('plugin_incompatible', `${manifest.name} declares a ${ceiling.wallclock} wallclock ceiling above the base flow's ${baseBudget.wallclock}.`);
385
+ throw new PluginError('plugin_incompatible', `${manifest.name} declares a ${ceilingWallclock} wallclock ceiling above the base flow's ${baseWallclock}.`);
264
386
  }
265
387
  }
266
388
  const entryPath = join(directory, manifest.entry);
@@ -279,7 +401,9 @@ async function loadOne<Authority>(
279
401
  if (!manifest.extends.handlers && definition.handlers.length > 0) {
280
402
  throw new PluginError('plugin_manifest_invalid', `${manifest.name}: ${manifest.entry} declares handlers but extends.handlers is false.`);
281
403
  }
282
- definition.handlers.forEach((handler, index) => assertDeclaredSubscription(manifest.name, manifest, handler, index));
404
+ for (let index = 0; index < definition.handlers.length; index += 1) {
405
+ assertDeclaredSubscription(manifest.name, manifest, definition.handlers[index]!, index);
406
+ }
283
407
  const hooks = imported.hooks;
284
408
  const exported = Object.keys(hooks).sort();
285
409
  const declared = [...manifest.extends.hooks].sort();
@@ -292,9 +416,13 @@ async function loadOne<Authority>(
292
416
  throw new PluginError('plugin_incompatible', `${manifest.name}: hook ${hook} is not declared by the base flow.`);
293
417
  }
294
418
  }
295
- return Object.freeze({
419
+ const handlers: TriggerHandler[] = [];
420
+ for (let index = 0; index < definition.handlers.length; index += 1) {
421
+ appendIntrinsicArray(handlers, definition.handlers[index]!);
422
+ }
423
+ return OBJECT_FREEZE({
296
424
  name: manifest.name, version: manifest.version, ref, digest: lock.digest, directory, entryPath, manifest,
297
- handle: imported.handle, getDefinition: imported.getDefinition, handlers: Object.freeze([...definition.handlers]),
425
+ handle: imported.handle, getDefinition: imported.getDefinition, handlers: OBJECT_FREEZE(handlers),
298
426
  hooks,
299
427
  });
300
428
  }
@@ -329,11 +457,23 @@ export async function loadFlowExtensions<Authority>(
329
457
  return Object.freeze(loaded);
330
458
  }
331
459
 
332
- /** The base definition with extension handlers appended in lock order; the base's own fields are untouched. */
460
+ /** The base definition with extension handlers appended and every declared budget ceiling retained. */
333
461
  export function composeDefinition<Input>(base: AuthoredFlowDefinition<Input>, extensions: readonly LoadedFlowExtension[]): AuthoredFlowDefinition<Input> {
334
462
  if (extensions.length === 0) return base;
335
- return Object.freeze({
463
+ const budget = composeBudget(base.header.budget, extensions);
464
+ const handlers: TriggerHandler[] = [];
465
+ for (let index = 0; index < base.handlers.length; index += 1) appendIntrinsicArray(handlers, base.handlers[index]!);
466
+ for (let extensionIndex = 0; extensionIndex < extensions.length; extensionIndex += 1) {
467
+ const extensionHandlers = extensions[extensionIndex]!.handlers;
468
+ for (let handlerIndex = 0; handlerIndex < extensionHandlers.length; handlerIndex += 1) {
469
+ appendIntrinsicArray(handlers, extensionHandlers[handlerIndex]!);
470
+ }
471
+ }
472
+ return OBJECT_FREEZE({
336
473
  ...base,
337
- handlers: Object.freeze([...base.handlers, ...extensions.flatMap(extension => extension.handlers)]),
474
+ header: budget === base.header.budget
475
+ ? base.header
476
+ : OBJECT_FREEZE({ ...base.header, budget }),
477
+ handlers: OBJECT_FREEZE(handlers),
338
478
  });
339
479
  }
@@ -6,6 +6,7 @@ import { PluginError, pluginKindOf } from './plugin-manifest.js';
6
6
  import { safePath } from './bundle.js';
7
7
  import { SHA, parsePluginSource, type PluginSourceInput } from './plugin-source.js';
8
8
  import { isVersionRange, parseVersion } from './semver-range.js';
9
+ import { parseBudget } from './budget.js';
9
10
 
10
11
  /**
11
12
  * Schema 2, `kind: "flow-extension"`: a plugin whose `entry` default-exports
@@ -29,7 +30,7 @@ export interface FlowExtensionPermissions {
29
30
  readonly mcp: readonly string[];
30
31
  /** Declared effect classes, shown for review; not enforced by this runtime (gate 8 / #442). */
31
32
  readonly writes: readonly string[];
32
- readonly budget?: { readonly dollars?: number; readonly wallclock?: string };
33
+ readonly budget?: { readonly tokens?: number; readonly dollars?: number; readonly wallclock?: string };
33
34
  }
34
35
  export interface FlowExtensionManifest {
35
36
  readonly schema: 2;
@@ -117,10 +118,12 @@ function permissions(value: unknown): FlowExtensionPermissions {
117
118
  for (const harness of harnesses) if (!(FLOW_HARNESSES as readonly string[]).includes(harness)) return invalid(`permissions.harnesses: unknown harness ${harness}.`);
118
119
  let budget: FlowExtensionPermissions['budget'];
119
120
  if (value.budget !== undefined) {
120
- if (!object(value.budget) || Object.keys(value.budget).some(k => !['dollars', 'wallclock'].includes(k))) return invalid('permissions.budget expects dollars and/or wallclock.');
121
+ if (!object(value.budget) || Object.keys(value.budget).some(k => !['tokens', 'dollars', 'wallclock'].includes(k))) return invalid('permissions.budget expects tokens, dollars, and/or wallclock.');
122
+ if (value.budget.tokens !== undefined && (typeof value.budget.tokens !== 'number' || !Number.isSafeInteger(value.budget.tokens) || value.budget.tokens < 0)) return invalid('permissions.budget.tokens must be a non-negative safe integer.');
121
123
  if (value.budget.dollars !== undefined && (typeof value.budget.dollars !== 'number' || !(value.budget.dollars > 0) || !Number.isFinite(value.budget.dollars))) return invalid('permissions.budget.dollars must be a positive number.');
122
124
  if (value.budget.wallclock !== undefined && (typeof value.budget.wallclock !== 'string' || !WALLCLOCK.test(value.budget.wallclock))) return invalid('permissions.budget.wallclock must be a duration such as 45m.');
123
- budget = Object.freeze({ ...(value.budget.dollars === undefined ? {} : { dollars: value.budget.dollars }), ...(value.budget.wallclock === undefined ? {} : { wallclock: value.budget.wallclock }) });
125
+ try { parseBudget(value.budget); } catch { return invalid('permissions.budget must use runtime budget syntax.'); }
126
+ budget = Object.freeze({ ...(value.budget.tokens === undefined ? {} : { tokens: value.budget.tokens }), ...(value.budget.dollars === undefined ? {} : { dollars: value.budget.dollars }), ...(value.budget.wallclock === undefined ? {} : { wallclock: value.budget.wallclock }) });
124
127
  }
125
128
  return Object.freeze({
126
129
  integrations: stringList(value.integrations, 'permissions.integrations', PROVIDER),
@@ -12,6 +12,7 @@ import { PluginError } from './plugin-manifest.js';
12
12
 
13
13
  const ARRAY_IS_ARRAY = Array.isArray;
14
14
  const NUMBER_IS_FINITE = Number.isFinite;
15
+ const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger;
15
16
  const OBJECT_CREATE = Object.create;
16
17
  const OBJECT_FREEZE = Object.freeze;
17
18
  const OBJECT_HAS_OWN = Object.hasOwn;
@@ -20,6 +21,10 @@ const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (
20
21
  regexp: RegExp,
21
22
  value: string,
22
23
  ) => boolean;
24
+ const REGEXP_EXEC = Function.prototype.call.bind(RegExp.prototype.exec) as (
25
+ regexp: RegExp,
26
+ value: string,
27
+ ) => RegExpExecArray | null;
23
28
  const STRING_ENDS_WITH = Function.prototype.call.bind(String.prototype.endsWith) as (
24
29
  value: string,
25
30
  search: string,
@@ -38,7 +43,8 @@ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
38
43
  const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
39
44
  const PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/;
40
45
  const WRITE_CLASS = /^[a-z0-9-]+(?::[a-z0-9_-]+)+$/;
41
- const WALLCLOCK = /^\d+(?:ms|s|m|h|d)$/;
46
+ const DOLLARS = /^\d+(?:\.\d{1,6})?$/;
47
+ const WALLCLOCK = /^(\d+)(ms|s|m|h|d)$/;
42
48
  const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
43
49
  const VERSION_RANGE = /^(?:\*|(?:[\^~]|>=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: <\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)?)$/;
44
50
  const MAX_DESCRIPTION = 500;
@@ -168,19 +174,36 @@ function permissionsShape(value: unknown): FlowExtensionPermissions {
168
174
  }
169
175
  let budget: FlowExtensionPermissions['budget'];
170
176
  if (value.budget !== undefined) {
171
- if (!record(value.budget) || !hasOnlyKeys(value.budget, ['dollars', 'wallclock'])) {
172
- return invalid('permissions.budget expects dollars and/or wallclock.');
177
+ if (!record(value.budget) || !hasOnlyKeys(value.budget, ['tokens', 'dollars', 'wallclock'])) {
178
+ return invalid('permissions.budget expects tokens, dollars, and/or wallclock.');
179
+ }
180
+ if (value.budget.tokens !== undefined
181
+ && (typeof value.budget.tokens !== 'number'
182
+ || !NUMBER_IS_SAFE_INTEGER(value.budget.tokens) || value.budget.tokens < 0)) {
183
+ return invalid('permissions.budget.tokens must be a non-negative safe integer.');
173
184
  }
174
185
  if (value.budget.dollars !== undefined
175
186
  && (typeof value.budget.dollars !== 'number' || value.budget.dollars <= 0
176
- || !NUMBER_IS_FINITE(value.budget.dollars))) {
187
+ || !NUMBER_IS_FINITE(value.budget.dollars)
188
+ || !matches(DOLLARS, `${value.budget.dollars}`))) {
177
189
  return invalid('permissions.budget.dollars must be a positive number.');
178
190
  }
179
191
  if (value.budget.wallclock !== undefined
180
192
  && (typeof value.budget.wallclock !== 'string' || !matches(WALLCLOCK, value.budget.wallclock))) {
181
193
  return invalid('permissions.budget.wallclock must be a duration such as 45m.');
182
194
  }
183
- const projected = OBJECT_CREATE(null) as { dollars?: number; wallclock?: string };
195
+ if (value.budget.wallclock !== undefined) {
196
+ const match = REGEXP_EXEC(WALLCLOCK, value.budget.wallclock)!;
197
+ const multiplier = match[2] === 'ms' ? 1
198
+ : match[2] === 's' ? 1_000
199
+ : match[2] === 'm' ? 60_000
200
+ : match[2] === 'h' ? 3_600_000 : 86_400_000;
201
+ if (!NUMBER_IS_SAFE_INTEGER(+match[1]! * multiplier)) {
202
+ return invalid('permissions.budget.wallclock must fit the runtime duration range.');
203
+ }
204
+ }
205
+ const projected = OBJECT_CREATE(null) as { tokens?: number; dollars?: number; wallclock?: string };
206
+ if (value.budget.tokens !== undefined) projected.tokens = value.budget.tokens;
184
207
  if (value.budget.dollars !== undefined) projected.dollars = value.budget.dollars;
185
208
  if (value.budget.wallclock !== undefined) projected.wallclock = value.budget.wallclock;
186
209
  budget = OBJECT_FREEZE(projected);
@@ -26,7 +26,7 @@ const REALPATH = realpath;
26
26
  const PATH_DIRNAME = dirname;
27
27
  const PATH_JOIN = join;
28
28
  const PATH_RESOLVE = resolve;
29
- const SOFTWARE_FACTORY_SHA256 = '8bbcf0e42f47d6bc6491a129935f96b791be03c8a5ed5b73d651c4e77913e2d7';
29
+ const SOFTWARE_FACTORY_SHA256 = '4339c0c45a4fc928092a3e32275c061000887ed95acdc2f898966c35aca91a2d';
30
30
  const ARRAY_IS_ARRAY = Array.isArray;
31
31
  const OBJECT_FREEZE = Object.freeze;
32
32
  const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as <K extends object, V>(
@@ -124,11 +124,11 @@ const NODE_EXECUTABLE = captureNodeExecutable();
124
124
  const ADDRESS_SPACE_BYTES = 16 * 1024 * 1024 * 1024;
125
125
  const DATA_BYTES = 3 * 1024 * 1024 * 1024;
126
126
  const SURFACE_RUNTIME_SHA256 = OBJECT_FREEZE({
127
- 'flow.js': '4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c',
127
+ 'flow.js': '2ba972849bf435bba36b918c348efeab71ec34d180cec7a722e535f482687a6a',
128
128
  'helpers/providers.js': '4eb06d0d85ca0a3434bb2dbba7407e3d95eef2dfbedaeb0e2de0c0c0ea812457',
129
129
  'provider-trigger.js': 'e2664c65397f93fb486eb6f1e756c7cec3f88b3851d79c23567cad986f80f1ff',
130
130
  'schedule.js': '8fe72f176a75ec0b5f26e12db7a597575c259a2e2cbb59690f9dc20a5e63940b',
131
- 'triggers.js': '4a3515b571a318f6c7a5661f9310bc9af43e3faf20ea39903a9b363c51258e4c',
131
+ 'triggers.js': '421609a6ea6d2ea77051dd4826a32e773b7db81dedd1dde91f4187b382cd46e3',
132
132
  'triggers/github.js': 'e312994320f82aad0af00d09c504175d929cc6c601dfe1bc522632462ad9a48b',
133
133
  });
134
134
  const SURFACE_PACKAGE_JSON = '{"name":"@relayflows/surface","type":"module","exports":{".":"./index.js","./runtime":"./runtime.js"}}';
package/src/llm-worker.ts CHANGED
@@ -5,7 +5,7 @@ import type { WorkerCliResult } from './worker-cli.js';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import type { JournalClient } from './journal-client.js';
7
7
  import type { CompletionReason, StepDispatchEvent } from './protocol.js';
8
- import type { KernelLlmStep } from './spec.js';
8
+ import type { ResolvedKernelLlmStep } from './resolved-cli-identity.js';
9
9
  import { runAgentCli } from './worker-cli.js';
10
10
  import { resolveCliModel } from './cli-adapter.js';
11
11
  import { withWorkerLease } from './worker-lease.js';
@@ -54,15 +54,17 @@ export class LlmWorker extends EventEmitter {
54
54
  };
55
55
 
56
56
  private async execute(dispatch: StepDispatchEvent): Promise<void> {
57
- const spec = dispatch.spec as KernelLlmStep;
57
+ const spec = dispatch.spec as ResolvedKernelLlmStep;
58
58
  const schema = spec.verification?.json_schema;
59
59
  const prompt = schema === undefined ? spec.prompt
60
60
  : `${spec.prompt}\n\nReturn only a JSON value matching this JSON Schema (no Markdown fences):\n${JSON.stringify(schema)}`;
61
- const effectiveModel = typeof spec.cli === 'string' ? resolveCliModel(spec.cli, spec.model) : spec.model;
61
+ const effectiveModel = typeof spec.cli === 'string'
62
+ ? resolveCliModel(spec.cli_identity ?? spec.cli, spec.model)
63
+ : spec.model;
62
64
  const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal =>
63
65
  typeof spec.cli === 'string' && typeof spec.prompt === 'string'
64
66
  ? runAgentCli(spec.cli, workerInstruction(prompt, dispatch), dispatch.wake_context, effectiveModel, undefined, signal, 'llm',
65
- undefined, undefined, 'direct', undefined, this.environment)
67
+ undefined, undefined, 'direct', undefined, this.environment, spec.cli_identity)
66
68
  : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'llm step has no declared CLI' }));
67
69
  const { result, usage } = workerSpend(completed, effectiveModel);
68
70
  const cost = reportedCost(completed, effectiveModel);
package/src/mcp-stdio.ts CHANGED
@@ -13,8 +13,9 @@ export class McpStdioTransport implements Transport {
13
13
  onmessage?: Transport['onmessage'];
14
14
  private child?: ChildProcessWithoutNullStreams;
15
15
  private stopTree?: ChildStop;
16
+ private childClosed = false;
16
17
  private readonly buffer = new ReadBuffer({ maxBufferSize: 1_048_576 });
17
- private closed?: Promise<void>;
18
+ private stopped?: Promise<void>;
18
19
  private closing?: Promise<void>;
19
20
  constructor(private readonly config: Extract<McpServerConfig, { command: string }>) {}
20
21
 
@@ -23,11 +24,24 @@ export class McpStdioTransport implements Transport {
23
24
  for (const name of this.config.env ?? []) {
24
25
  if (process.env[name] !== undefined) env[name] = process.env[name];
25
26
  }
27
+ const ownsGroup = process.platform !== 'win32';
26
28
  const child = this.child = spawn(this.config.command, this.config.args ?? [], {
27
- env, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32',
29
+ env, stdio: ['pipe', 'pipe', 'pipe'], detached: ownsGroup,
30
+ });
31
+ let resolveStopped!: () => void;
32
+ let rejectStopped!: (error: Error) => void;
33
+ this.stopped = new Promise<void>((resolve, reject) => {
34
+ resolveStopped = resolve;
35
+ rejectStopped = reject;
36
+ });
37
+ this.stopTree = childStop(child, ownsGroup, undefined, error => {
38
+ if (error === undefined) resolveStopped();
39
+ else rejectStopped(error);
40
+ });
41
+ child.once('close', () => {
42
+ this.childClosed = true;
43
+ this.stopTree?.maySettleOnChildExit();
28
44
  });
29
- this.stopTree = childStop(child, process.platform !== 'win32');
30
- this.closed = new Promise(resolve => child.once('close', () => resolve()));
31
45
  child.stderr.resume();
32
46
  child.stdout.on('data', (chunk: Buffer) => {
33
47
  if (this.closing) return;
@@ -63,22 +77,22 @@ export class McpStdioTransport implements Transport {
63
77
  if (!child) return;
64
78
  child.stdin.end();
65
79
  this.stopTree!.terminate();
66
- // A direct-child close is insufficient: wrappers can leave descendants
67
- // alive with either inherited pipes or completely detached stdio.
68
- let timer: ReturnType<typeof setTimeout> | undefined;
69
- const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, 1000); });
70
- await Promise.race([this.closed, deadline]);
71
- if (!this.stopTree!.maySettleOnChildExit()) await deadline;
72
- clearTimeout(timer);
73
- if (child.exitCode === null && child.signalCode === null && child.pid !== undefined) {
74
- const exited = new Promise<void>(resolve => child.once('exit', () => resolve()));
75
- this.stopTree!.kill();
76
- await exited;
80
+ // `close()` may be called after the direct child already emitted close and
81
+ // resolved `stopped`. Refund the just-armed escalation in that case before
82
+ // awaiting the already-settled promise; otherwise its referenced timer can
83
+ // fire later against a captured, potentially reused process-group id.
84
+ if (this.childClosed) this.stopTree!.maySettleOnChildExit();
85
+ try {
86
+ // A direct-child close is insufficient: wrappers can leave descendants
87
+ // alive with either inherited pipes or completely detached stdio. The
88
+ // shared stop owns the bound and rejects when group death is unprovable.
89
+ await this.stopped;
90
+ } finally {
91
+ child.stdin.destroy();
92
+ child.stdout.destroy();
93
+ child.stderr.destroy();
94
+ this.buffer.clear();
95
+ this.onclose?.();
77
96
  }
78
- child.stdin.destroy();
79
- child.stdout.destroy();
80
- child.stderr.destroy();
81
- this.buffer.clear();
82
- this.onclose?.();
83
97
  }
84
98
  }
package/src/preflight.ts CHANGED
@@ -35,6 +35,8 @@ export interface CliResolution {
35
35
  export interface CliProbeResult {
36
36
  exists: boolean;
37
37
  authenticated: boolean | 'unverified';
38
+ /** Absolute executable selected once for both this probe and later dispatch. */
39
+ executable?: string;
38
40
  /** False when a custom executable did not identify as a wrapper adapter. */
39
41
  supported?: boolean;
40
42
  /** Exact declared model passed the CLI's model-scoped readiness probe. */
package/src/protocol.ts CHANGED
@@ -253,6 +253,8 @@ export interface StepDispatchEvent {
253
253
  lease_id: string;
254
254
  idempotency_key: string;
255
255
  pins: Pins;
256
+ /** Remaining lease duration at dispatch; `lease_deadline_ms` stays absolute. */
257
+ lease_ttl_ms?: number;
256
258
  /**
257
259
  * The `wake_context` payload from the run's `subscription.matched`
258
260
  * journal entry — carries the triggering event and any epoch summary
@@ -291,6 +293,13 @@ export interface StepHeartbeatParams {
291
293
  }
292
294
  export interface StepHeartbeatResult {
293
295
  lease_deadline_ms: number;
296
+ /**
297
+ * The renewed lease as a duration on the daemon's clock. Absent from older
298
+ * daemons; a worker that has it times the lease on its own monotonic clock
299
+ * instead of comparing the absolute deadline with a wall clock that may
300
+ * disagree with the daemon's.
301
+ */
302
+ lease_ttl_ms?: number;
294
303
  }
295
304
 
296
305
  /** completionReason mirrors kernel DESIGN.md §1.3. */
@@ -0,0 +1,42 @@
1
+ import type {
2
+ FlowSpec,
3
+ KernelAgentStep,
4
+ KernelLlmStep,
5
+ KernelStepCommon,
6
+ KernelStepSpec,
7
+ } from './spec.js';
8
+
9
+ interface ResolvedCliIdentityField {
10
+ /** Host-proved authored identity used to select the adapter for canonical CLI bytes. */
11
+ cli_identity?: string;
12
+ }
13
+
14
+ export type ResolvedKernelStepCommon = KernelStepCommon & ResolvedCliIdentityField;
15
+ export type ResolvedKernelAgentStep = KernelAgentStep & ResolvedCliIdentityField;
16
+ export type ResolvedKernelLlmStep = KernelLlmStep & ResolvedCliIdentityField;
17
+ export type ResolvedKernelStepSpec = KernelStepSpec & ResolvedCliIdentityField;
18
+
19
+ /**
20
+ * Host-proved CLI identities are deliberately out-of-band from the authoring
21
+ * schema. A flow cannot claim that a canonical `cli.js` target is Claude; only
22
+ * the successful preflight that resolved the authored `claude` declaration
23
+ * can attach that identity before kernel lowering.
24
+ */
25
+ const IDENTITIES = new WeakMap<object, ReadonlyMap<string, string>>();
26
+
27
+ export function resolvedCliIdentities(value: unknown): ReadonlyMap<string, string> | undefined {
28
+ return typeof value === 'object' && value !== null ? IDENTITIES.get(value) : undefined;
29
+ }
30
+
31
+ export function rememberResolvedCliIdentities<T extends FlowSpec>(
32
+ flow: T,
33
+ identities: ReadonlyMap<string, string>,
34
+ ): T {
35
+ IDENTITIES.set(flow, new Map(identities));
36
+ return flow;
37
+ }
38
+
39
+ export function inheritResolvedCliIdentities<T extends FlowSpec>(source: unknown, flow: T): T {
40
+ const identities = resolvedCliIdentities(source);
41
+ return identities === undefined ? flow : rememberResolvedCliIdentities(flow, identities);
42
+ }