@relayflows/sdk 2.0.36 → 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 (230) 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-flow-executor.d.ts +2 -0
  13. package/dist/authored-flow-executor.d.ts.map +1 -1
  14. package/dist/authored-flow-executor.js +2 -1
  15. package/dist/authored-flow-executor.js.map +1 -1
  16. package/dist/authored-helper-effect.d.ts.map +1 -1
  17. package/dist/authored-helper-effect.js +3 -2
  18. package/dist/authored-helper-effect.js.map +1 -1
  19. package/dist/authored-mcp.d.ts.map +1 -1
  20. package/dist/authored-mcp.js +2 -0
  21. package/dist/authored-mcp.js.map +1 -1
  22. package/dist/authored-node-entry.js +6 -0
  23. package/dist/authored-node-entry.js.map +1 -1
  24. package/dist/authored-node-runner.d.ts.map +1 -1
  25. package/dist/authored-node-runner.js +5 -3
  26. package/dist/authored-node-runner.js.map +1 -1
  27. package/dist/authored-plugin-effect.d.ts.map +1 -1
  28. package/dist/authored-plugin-effect.js +2 -0
  29. package/dist/authored-plugin-effect.js.map +1 -1
  30. package/dist/authored-preflight.d.ts +1 -1
  31. package/dist/authored-preflight.d.ts.map +1 -1
  32. package/dist/authored-preflight.js +2 -2
  33. package/dist/authored-preflight.js.map +1 -1
  34. package/dist/authored-root.d.ts +2 -0
  35. package/dist/authored-root.d.ts.map +1 -1
  36. package/dist/authored-root.js +146 -6
  37. package/dist/authored-root.js.map +1 -1
  38. package/dist/authored-verdict.d.ts +2 -0
  39. package/dist/authored-verdict.d.ts.map +1 -1
  40. package/dist/authored-verdict.js +2 -1
  41. package/dist/authored-verdict.js.map +1 -1
  42. package/dist/authored-worker-step.d.ts +1 -1
  43. package/dist/authored-worker-step.d.ts.map +1 -1
  44. package/dist/authored-worker-step.js +3 -2
  45. package/dist/authored-worker-step.js.map +1 -1
  46. package/dist/child-stop.d.ts +3 -1
  47. package/dist/child-stop.d.ts.map +1 -1
  48. package/dist/child-stop.js +42 -2
  49. package/dist/child-stop.js.map +1 -1
  50. package/dist/cli/add-extension.js +1 -1
  51. package/dist/cli/add-extension.js.map +1 -1
  52. package/dist/cli/check.d.ts +2 -0
  53. package/dist/cli/check.d.ts.map +1 -1
  54. package/dist/cli/check.js +28 -10
  55. package/dist/cli/check.js.map +1 -1
  56. package/dist/cli/cli-probe.d.ts +3 -2
  57. package/dist/cli/cli-probe.d.ts.map +1 -1
  58. package/dist/cli/cli-probe.js +79 -42
  59. package/dist/cli/cli-probe.js.map +1 -1
  60. package/dist/cli/direct-run.d.ts.map +1 -1
  61. package/dist/cli/direct-run.js +11 -3
  62. package/dist/cli/direct-run.js.map +1 -1
  63. package/dist/cli/pinned-cli-alias.d.ts +13 -0
  64. package/dist/cli/pinned-cli-alias.d.ts.map +1 -0
  65. package/dist/cli/pinned-cli-alias.js +58 -0
  66. package/dist/cli/pinned-cli-alias.js.map +1 -0
  67. package/dist/cli/run.d.ts +8 -0
  68. package/dist/cli/run.d.ts.map +1 -1
  69. package/dist/cli/run.js +106 -17
  70. package/dist/cli/run.js.map +1 -1
  71. package/dist/cli/step-failure.d.ts.map +1 -1
  72. package/dist/cli/step-failure.js +18 -0
  73. package/dist/cli/step-failure.js.map +1 -1
  74. package/dist/cloud-run.d.ts.map +1 -1
  75. package/dist/cloud-run.js +9 -2
  76. package/dist/cloud-run.js.map +1 -1
  77. package/dist/communication/local.d.ts +1 -1
  78. package/dist/communication/local.d.ts.map +1 -1
  79. package/dist/communication/local.js +2 -2
  80. package/dist/communication/local.js.map +1 -1
  81. package/dist/communication/worker.d.ts +1 -1
  82. package/dist/communication/worker.d.ts.map +1 -1
  83. package/dist/communication/worker.js +27 -9
  84. package/dist/communication/worker.js.map +1 -1
  85. package/dist/compile.d.ts.map +1 -1
  86. package/dist/compile.js +16 -7
  87. package/dist/compile.js.map +1 -1
  88. package/dist/decimal-dollars.d.ts +12 -0
  89. package/dist/decimal-dollars.d.ts.map +1 -0
  90. package/dist/decimal-dollars.js +39 -0
  91. package/dist/decimal-dollars.js.map +1 -0
  92. package/dist/effect-channel.d.ts.map +1 -1
  93. package/dist/effect-channel.js +2 -0
  94. package/dist/effect-channel.js.map +1 -1
  95. package/dist/failure-kinds.d.ts +9 -1
  96. package/dist/failure-kinds.d.ts.map +1 -1
  97. package/dist/failure-kinds.js +6 -0
  98. package/dist/failure-kinds.js.map +1 -1
  99. package/dist/flow-extension-loader.d.ts +1 -1
  100. package/dist/flow-extension-loader.d.ts.map +1 -1
  101. package/dist/flow-extension-loader.js +143 -14
  102. package/dist/flow-extension-loader.js.map +1 -1
  103. package/dist/flow-extension-manifest.d.ts +1 -0
  104. package/dist/flow-extension-manifest.d.ts.map +1 -1
  105. package/dist/flow-extension-manifest.js +12 -3
  106. package/dist/flow-extension-manifest.js.map +1 -1
  107. package/dist/helper-instruction.d.ts +3 -0
  108. package/dist/helper-instruction.d.ts.map +1 -0
  109. package/dist/helper-instruction.js +3 -0
  110. package/dist/helper-instruction.js.map +1 -0
  111. package/dist/hosted-extension-manifest.d.ts.map +1 -1
  112. package/dist/hosted-extension-manifest.js +25 -4
  113. package/dist/hosted-extension-manifest.js.map +1 -1
  114. package/dist/hosted-extension-runtime.js +1 -1
  115. package/dist/hosted-extension-sandbox.js +2 -2
  116. package/dist/journal-client.d.ts +5 -2
  117. package/dist/journal-client.d.ts.map +1 -1
  118. package/dist/journal-client.js +12 -4
  119. package/dist/journal-client.js.map +1 -1
  120. package/dist/llm-worker.d.ts +2 -1
  121. package/dist/llm-worker.d.ts.map +1 -1
  122. package/dist/llm-worker.js +10 -3
  123. package/dist/llm-worker.js.map +1 -1
  124. package/dist/local-agent-environment.d.ts +13 -0
  125. package/dist/local-agent-environment.d.ts.map +1 -0
  126. package/dist/local-agent-environment.js +104 -0
  127. package/dist/local-agent-environment.js.map +1 -0
  128. package/dist/local-agent.d.ts +1 -1
  129. package/dist/local-agent.d.ts.map +1 -1
  130. package/dist/local-agent.js +2 -2
  131. package/dist/local-agent.js.map +1 -1
  132. package/dist/mcp-stdio.d.ts +2 -1
  133. package/dist/mcp-stdio.d.ts.map +1 -1
  134. package/dist/mcp-stdio.js +38 -21
  135. package/dist/mcp-stdio.js.map +1 -1
  136. package/dist/preflight.d.ts +2 -0
  137. package/dist/preflight.d.ts.map +1 -1
  138. package/dist/preflight.js.map +1 -1
  139. package/dist/protocol.d.ts +30 -0
  140. package/dist/protocol.d.ts.map +1 -1
  141. package/dist/reported-cost-total.d.ts +41 -0
  142. package/dist/reported-cost-total.d.ts.map +1 -0
  143. package/dist/reported-cost-total.js +63 -0
  144. package/dist/reported-cost-total.js.map +1 -0
  145. package/dist/reported-cost.d.ts +24 -0
  146. package/dist/reported-cost.d.ts.map +1 -0
  147. package/dist/reported-cost.js +49 -0
  148. package/dist/reported-cost.js.map +1 -0
  149. package/dist/resolved-cli-identity.d.ts +14 -0
  150. package/dist/resolved-cli-identity.d.ts.map +1 -0
  151. package/dist/resolved-cli-identity.js +19 -0
  152. package/dist/resolved-cli-identity.js.map +1 -0
  153. package/dist/run-state.d.ts +9 -11
  154. package/dist/run-state.d.ts.map +1 -1
  155. package/dist/run-state.js +15 -38
  156. package/dist/run-state.js.map +1 -1
  157. package/dist/worker-cli.d.ts +3 -1
  158. package/dist/worker-cli.d.ts.map +1 -1
  159. package/dist/worker-cli.js +118 -42
  160. package/dist/worker-cli.js.map +1 -1
  161. package/dist/worker-lease.d.ts +3 -1
  162. package/dist/worker-lease.d.ts.map +1 -1
  163. package/dist/worker-lease.js +47 -9
  164. package/dist/worker-lease.js.map +1 -1
  165. package/dist/worker.d.ts +2 -0
  166. package/dist/worker.d.ts.map +1 -1
  167. package/dist/worker.js +8 -3
  168. package/dist/worker.js.map +1 -1
  169. package/dist/wrapper-session.d.ts +3 -1
  170. package/dist/wrapper-session.d.ts.map +1 -1
  171. package/dist/wrapper-session.js +93 -53
  172. package/dist/wrapper-session.js.map +1 -1
  173. package/dist/yaml-helper-effect.d.ts.map +1 -1
  174. package/dist/yaml-helper-effect.js +2 -1
  175. package/dist/yaml-helper-effect.js.map +1 -1
  176. package/dist/yaml-helpers.d.ts.map +1 -1
  177. package/dist/yaml-helpers.js +2 -1
  178. package/dist/yaml-helpers.js.map +1 -1
  179. package/package.json +2 -2
  180. package/src/agent-reaper.ts +7 -4
  181. package/src/authored-budget.ts +73 -1
  182. package/src/authored-flow-error.ts +6 -0
  183. package/src/authored-flow-executor.ts +4 -1
  184. package/src/authored-helper-effect.ts +3 -2
  185. package/src/authored-mcp.ts +2 -0
  186. package/src/authored-node-entry.ts +8 -1
  187. package/src/authored-node-runner.ts +5 -3
  188. package/src/authored-plugin-effect.ts +2 -0
  189. package/src/authored-preflight.ts +2 -2
  190. package/src/authored-root.ts +157 -7
  191. package/src/authored-verdict.ts +2 -1
  192. package/src/authored-worker-step.ts +3 -1
  193. package/src/child-stop.ts +40 -1
  194. package/src/cli/add-extension.ts +1 -1
  195. package/src/cli/check.ts +31 -7
  196. package/src/cli/cli-probe.ts +80 -42
  197. package/src/cli/direct-run.ts +11 -2
  198. package/src/cli/pinned-cli-alias.ts +74 -0
  199. package/src/cli/run.ts +118 -14
  200. package/src/cli/step-failure.ts +18 -0
  201. package/src/cloud-run.ts +9 -2
  202. package/src/communication/local.ts +3 -2
  203. package/src/communication/worker.ts +29 -11
  204. package/src/compile.ts +28 -13
  205. package/src/decimal-dollars.ts +38 -0
  206. package/src/effect-channel.ts +2 -0
  207. package/src/failure-kinds.ts +14 -0
  208. package/src/flow-extension-loader.ts +154 -14
  209. package/src/flow-extension-manifest.ts +6 -3
  210. package/src/helper-instruction.ts +2 -0
  211. package/src/hosted-extension-manifest.ts +28 -5
  212. package/src/hosted-extension-runtime.ts +1 -1
  213. package/src/hosted-extension-sandbox.ts +2 -2
  214. package/src/journal-client.ts +14 -5
  215. package/src/llm-worker.ts +11 -4
  216. package/src/local-agent-environment.ts +92 -0
  217. package/src/local-agent.ts +2 -1
  218. package/src/mcp-stdio.ts +34 -20
  219. package/src/preflight.ts +2 -0
  220. package/src/protocol.ts +31 -0
  221. package/src/reported-cost-total.ts +82 -0
  222. package/src/reported-cost.ts +51 -0
  223. package/src/resolved-cli-identity.ts +42 -0
  224. package/src/run-state.ts +25 -37
  225. package/src/worker-cli.ts +126 -39
  226. package/src/worker-lease.ts +50 -9
  227. package/src/worker.ts +14 -5
  228. package/src/wrapper-session.ts +89 -56
  229. package/src/yaml-helper-effect.ts +2 -1
  230. package/src/yaml-helpers.ts +2 -1
package/src/child-stop.ts CHANGED
@@ -6,6 +6,8 @@ import { execFileSync, type ChildProcess } from 'node:child_process';
6
6
  * the `flows run` event loop alive long after the step itself has settled.
7
7
  */
8
8
  export const FORCE_KILL_DELAY_MS = 1_000;
9
+ /** Bound for proving that a forced process group has actually disappeared. */
10
+ export const GROUP_EXIT_CONFIRM_TIMEOUT_MS = 1_000;
9
11
 
10
12
  /**
11
13
  * The one stop path for a spawned agent process.
@@ -65,6 +67,7 @@ export function childStop(
65
67
  child: ChildProcess,
66
68
  ownsGroup: boolean,
67
69
  forceKillDelayMs: number = FORCE_KILL_DELAY_MS,
70
+ onStopped: (error?: Error) => void = () => {},
68
71
  ): ChildStop {
69
72
  // Pinned at the spawn rather than read per signal. Every interesting use of
70
73
  // this id happens AFTER the direct child has been reaped — the escalation
@@ -74,6 +77,17 @@ export function childStop(
74
77
  // which is exactly the window both of those need to address.
75
78
  const pid = child.pid;
76
79
  let forceTimer: NodeJS.Timeout | undefined;
80
+ let groupPoll: NodeJS.Timeout | undefined;
81
+ let forced = false;
82
+ let forcedAt: number | undefined;
83
+ let stopped = false;
84
+ const notifyStopped = (error?: Error): void => {
85
+ if (stopped) return;
86
+ stopped = true;
87
+ if (groupPoll !== undefined) clearTimeout(groupPoll);
88
+ groupPoll = undefined;
89
+ onStopped(error);
90
+ };
77
91
  const cancel = (): void => {
78
92
  if (forceTimer !== undefined) clearTimeout(forceTimer);
79
93
  forceTimer = undefined;
@@ -106,6 +120,21 @@ export function childStop(
106
120
  }
107
121
  });
108
122
  };
123
+ const awaitGroupExit = (): void => {
124
+ if (!groupAnswers()) {
125
+ notifyStopped();
126
+ return;
127
+ }
128
+ if (forcedAt !== undefined && Date.now() - forcedAt >= GROUP_EXIT_CONFIRM_TIMEOUT_MS) {
129
+ notifyStopped(new Error(`Process groups did not stop answering within ${GROUP_EXIT_CONFIRM_TIMEOUT_MS}ms after SIGKILL.`));
130
+ return;
131
+ }
132
+ if (groupPoll !== undefined) return;
133
+ groupPoll = setTimeout(() => {
134
+ groupPoll = undefined;
135
+ awaitGroupExit();
136
+ }, 10);
137
+ };
109
138
  const signalTree = (name: NodeJS.Signals): void => {
110
139
  if (ownsGroup && pid !== undefined) {
111
140
  escapedGroups ??= descendantGroups(pid);
@@ -127,6 +156,9 @@ export function childStop(
127
156
  kill: (): void => {
128
157
  cancel();
129
158
  signalTree('SIGKILL');
159
+ forced = true;
160
+ forcedAt ??= Date.now();
161
+ awaitGroupExit();
130
162
  },
131
163
  terminate: (): void => {
132
164
  cancel();
@@ -134,6 +166,9 @@ export function childStop(
134
166
  forceTimer = setTimeout(() => {
135
167
  forceTimer = undefined;
136
168
  signalTree('SIGKILL');
169
+ forced = true;
170
+ forcedAt ??= Date.now();
171
+ awaitGroupExit();
137
172
  }, forceKillDelayMs);
138
173
  // Deliberately REFERENCED, unlike every other timer we arm. The survivor
139
174
  // this escalation exists for is the one that ignored `SIGTERM` and holds
@@ -144,9 +179,13 @@ export function childStop(
144
179
  // `maySettleOnChildExit` confirms the group is empty.
145
180
  },
146
181
  maySettleOnChildExit: (): boolean => {
147
- if (forceTimer === undefined) return true;
182
+ if (forceTimer === undefined && !forced) {
183
+ notifyStopped();
184
+ return true;
185
+ }
148
186
  if (groupAnswers()) return false;
149
187
  cancel();
188
+ notifyStopped();
150
189
  return true;
151
190
  },
152
191
  };
@@ -36,7 +36,7 @@ function eventKeys(manifest: FlowExtensionManifest): readonly string[] {
36
36
 
37
37
  function formatBudget(budget: FlowExtensionManifest['permissions']['budget']): string {
38
38
  if (budget === undefined) return 'inherits base';
39
- return [budget.dollars === undefined ? '' : `$${budget.dollars}`, budget.wallclock ?? ''].filter(Boolean).join(' / ') || 'inherits base';
39
+ return [budget.tokens === undefined ? '' : `${budget.tokens} tokens`, budget.dollars === undefined ? '' : `$${budget.dollars}`, budget.wallclock ?? ''].filter(Boolean).join(' / ') || 'inherits base';
40
40
  }
41
41
 
42
42
  export function describeExtension(manifest: FlowExtensionManifest): string[] {
package/src/cli/check.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { communicationInstruction } from '../communication/spec.js';
2
+ import { rememberResolvedCliIdentities } from '../resolved-cli-identity.js';
2
3
  import { checkCommunicationEnvironment } from '../communication/preflight.js';
3
4
  import { accessSync, constants, readFileSync } from 'node:fs';
4
5
  import { dirname, isAbsolute, join, parse as parsePath, resolve } from 'node:path';
@@ -15,6 +16,7 @@ import { parseMcpConfig } from '../mcp-config.js';
15
16
  import type { StepGateInspection } from '../gate-contract.js';
16
17
  import type { CheckFailureKind, CheckWarningKind } from '../failure-kinds.js';
17
18
  import {
19
+ cliProbeKey,
18
20
  preflight,
19
21
  type CliResolution,
20
22
  type CliProbeResult,
@@ -123,6 +125,8 @@ type CliProbeOutcomeMap = Map<string, CliProbeOutcome>;
123
125
  * anything that depends on how the flow is about to be invoked opts in here.
124
126
  */
125
127
  export interface CheckInvocation {
128
+ /** Environment used only by provider executable/auth/model probes. */
129
+ environment?: NodeJS.ProcessEnv;
126
130
  /**
127
131
  * Report `agent_worker_unresolved` when the spec has `agent` steps.
128
132
  *
@@ -200,11 +204,15 @@ export function checkAuthoredFlow(
200
204
  const projectConfig = options !== undefined
201
205
  ? options.projectConfig
202
206
  : projectConfigOrOptions as ProjectConfig | undefined;
203
- const effectiveProbeCache = cliProbeCache ?? options?.probeCache;
207
+ // Keep the exact probe result so the executable identity selected during
208
+ // readiness is the one written onto the admitted step. Without an
209
+ // explicit map, preflight would allocate a private cache and a bare CLI
210
+ // name could be resolved again under the later worker cwd.
211
+ const effectiveProbeCache = cliProbeCache ?? options?.probeCache ?? new Map<string, CliProbeOutcome>();
204
212
  const config = projectConfig ?? readProjectConfig(dirname(absolutePath));
205
- const probes = systemProbes(dirname(absolutePath), config);
213
+ const probes = systemProbes(dirname(absolutePath), config, invocation.environment);
206
214
  const result = preflight(authoring, {
207
- ...(effectiveProbeCache === undefined ? {} : { cliProbeCache: effectiveProbeCache }),
215
+ cliProbeCache: effectiveProbeCache,
208
216
  projectCli: config.cli,
209
217
  projectConfigPath: config.path,
210
218
  projectSearchStart: dirname(absolutePath),
@@ -218,6 +226,7 @@ export function checkAuthoredFlow(
218
226
  result.resolutions,
219
227
  dirname(absolutePath),
220
228
  config.directory,
229
+ effectiveProbeCache,
221
230
  )
222
231
  : undefined;
223
232
  if (flow?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
@@ -450,10 +459,11 @@ function findConfig(start: string): string | undefined {
450
459
  }
451
460
  }
452
461
 
453
- function systemProbes(flowDirectory: string, config: ProjectConfig): PreflightProbes {
462
+ function systemProbes(flowDirectory: string, config: ProjectConfig, environment?: NodeJS.ProcessEnv): PreflightProbes {
454
463
  return {
455
464
  helper: helperReady,
456
- cli: (cli, source, model, execution) => probeCli(cli, source === 'project' ? config.directory : flowDirectory, model, execution),
465
+ cli: (cli, source, model, execution) => probeCli(cli,
466
+ source === 'project' ? config.directory : flowDirectory, model, execution, environment),
457
467
  executor: (trigger) => config.executors.includes(trigger.executor),
458
468
  // A deterministic step runs in the daemon's working directory — the
459
469
  // directory `flows run` was invoked from, or Cloud's code mount — not in
@@ -469,20 +479,34 @@ function bindResolvedCliPaths(
469
479
  resolutions: readonly CliResolution[],
470
480
  flowDirectory: string,
471
481
  configDirectory: string,
482
+ probeCache: ReadonlyMap<string, CliProbeOutcome>,
472
483
  ): FlowSpec {
473
484
  const byStep = new Map(resolutions.map((resolution) => [resolution.stepId, resolution]));
474
- return {
485
+ const identities = new Map<string, string>();
486
+ const bound: FlowSpec = {
475
487
  ...flow,
476
488
  steps: flow.steps.map((step) => {
477
489
  if (step.type === 'deterministic') return step;
478
490
  const resolution = byStep.get(step.id);
479
491
  if (resolution === undefined) return step;
480
492
  const directory = resolution.source === 'project' ? configDirectory : flowDirectory;
481
- return { ...step, cli: canonicalCli(resolution.cli, directory),
493
+ const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined;
494
+ const outcome = probeCache.get(cliProbeKey(resolution, managed));
495
+ const executable = outcome !== undefined && 'result' in outcome
496
+ ? outcome.result.executable
497
+ : undefined;
498
+ const boundCli = executable !== undefined && isAbsolute(executable)
499
+ ? executable
500
+ : canonicalCli(resolution.cli, directory);
501
+ if (executable !== undefined && isAbsolute(executable)) {
502
+ identities.set(step.id, resolution.cli);
503
+ }
504
+ return { ...step, cli: boundCli,
482
505
  ...(resolution.modelSource === 'adapter' && resolution.model !== undefined
483
506
  ? { model: resolution.model } : {}) };
484
507
  }),
485
508
  };
509
+ return rememberResolvedCliIdentities(bound, identities);
486
510
  }
487
511
 
488
512
  function canonicalCli(cli: string, directory: string): string {
@@ -1,13 +1,15 @@
1
- import { accessSync, constants } from 'node:fs';
2
- import { isAbsolute, resolve } from 'node:path';
1
+ import { accessSync, constants, realpathSync } from 'node:fs';
2
+ import { basename, isAbsolute, resolve } from 'node:path';
3
3
  import { execFile, spawnSync } from 'node:child_process';
4
4
  import { agentEnvironment, brokerEnvironment } from '../communication/environment.js';
5
5
  import { adapterIdentification, authenticationProbe, classifyModelProbeFailure, cliAdapterKind,
6
6
  displayInvocation, modelReadinessProbe, type CliInvocation } from '../cli-adapter.js';
7
7
  import { MODEL_ENV } from '../worker-cli.js';
8
8
  import { CliProbeError, type CliProbeResult } from '../preflight.js';
9
+ import { pinCliAlias, pinCliAliasSync } from './pinned-cli-alias.js';
9
10
 
10
11
  interface ProbeRequest {
12
+ argv0?: string;
11
13
  executable: string;
12
14
  directory: string;
13
15
  invocation: CliInvocation;
@@ -27,20 +29,27 @@ export async function probeCliAsync(...args: Parameters<typeof probeSequence>):
27
29
  return next.value;
28
30
  }
29
31
 
30
- export function resolveExecutable(command: string, directory: string): string | undefined {
31
- return driveSync(executableSequence(command, directory));
32
+ export function resolveExecutable(command: string, directory: string, environment: NodeJS.ProcessEnv = process.env): string | undefined {
33
+ return driveSync(executableSequence(command, directory, environment));
32
34
  }
33
35
 
34
36
  function driveSync<T>(sequence: Generator<ProbeRequest, T, ProbeOutput>): T {
35
37
  let next = sequence.next();
36
38
  while (!next.done) {
37
39
  const request = next.value;
38
- const result = spawnSync(request.executable, request.invocation.args, {
39
- ...probeOptions(request),
40
- // Preserve the old synchronous probe contract: provider CLIs must not
41
- // inherit a readable stdin that can block auth/identify probes.
42
- stdio: ['ignore', 'pipe', 'pipe'],
43
- });
40
+ const pinned = pinCliAliasSync(request.executable, request.argv0 ?? basename(request.executable));
41
+ const result = (() => {
42
+ try {
43
+ return spawnSync(pinned.executable, request.invocation.args, {
44
+ ...probeOptions(request),
45
+ // Preserve the old synchronous probe contract: provider CLIs must not
46
+ // inherit a readable stdin that can block auth/identify probes.
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ } finally {
50
+ pinned.release();
51
+ }
52
+ })();
44
53
  const failure = classifySpawnFailure(result.error, result.signal, request.invocation.timeoutMs);
45
54
  if (failure !== undefined) throw failure;
46
55
  next = sequence.next({ status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' });
@@ -48,30 +57,35 @@ function driveSync<T>(sequence: Generator<ProbeRequest, T, ProbeOutput>): T {
48
57
  return next.value;
49
58
  }
50
59
 
51
- function probeOptions({ directory, invocation, environment }: ProbeRequest) {
60
+ function probeOptions({ argv0, directory, invocation, environment }: ProbeRequest) {
52
61
  const env = { ...environment };
53
62
  delete env[MODEL_ENV];
54
63
  if (invocation.modelEnv !== undefined) env[MODEL_ENV] = invocation.modelEnv;
55
64
  return { cwd: directory, encoding: 'utf8' as const, timeout: invocation.timeoutMs,
56
- maxBuffer: 1024 * 1024, env };
65
+ maxBuffer: 1024 * 1024, env, ...(argv0 === undefined ? {} : { argv0 }) };
57
66
  }
58
67
 
59
- function runProbeAsync(request: ProbeRequest): Promise<ProbeOutput> {
60
- return new Promise((resolve, reject) => {
61
- const child = execFile(request.executable, request.invocation.args, probeOptions(request),
62
- (error, stdout, stderr) => {
63
- // Numeric exit codes are probe results; launch, timeout, signal and buffer
64
- // errors are failures to collect a fact, just as in the synchronous driver.
65
- if (error?.killed && typeof error.code !== 'string') return reject(new CliProbeError(`timeout:${request.invocation.timeoutMs}ms`));
66
- const failure = classifySpawnFailure(
67
- error !== null && typeof error.code !== 'number' && error.signal == null ? error : undefined,
68
- error?.signal ?? null, request.invocation.timeoutMs);
69
- if (failure !== undefined) return reject(failure);
70
- resolve({ status: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
71
- stdout, stderr });
72
- });
73
- child.stdin?.end();
74
- });
68
+ async function runProbeAsync(request: ProbeRequest): Promise<ProbeOutput> {
69
+ const pinned = await pinCliAlias(request.executable, request.argv0 ?? basename(request.executable));
70
+ try {
71
+ return await new Promise((resolve, reject) => {
72
+ const child = execFile(pinned.executable, request.invocation.args, probeOptions(request),
73
+ (error, stdout, stderr) => {
74
+ // Numeric exit codes are probe results; launch, timeout, signal and buffer
75
+ // errors are failures to collect a fact, just as in the synchronous driver.
76
+ if (error?.killed && typeof error.code !== 'string') return reject(new CliProbeError(`timeout:${request.invocation.timeoutMs}ms`));
77
+ const failure = classifySpawnFailure(
78
+ error !== null && typeof error.code !== 'number' && error.signal == null ? error : undefined,
79
+ error?.signal ?? null, request.invocation.timeoutMs);
80
+ if (failure !== undefined) return reject(failure);
81
+ resolve({ status: error === null ? 0 : typeof error.code === 'number' ? error.code : null,
82
+ stdout, stderr });
83
+ });
84
+ child.stdin?.end();
85
+ });
86
+ } finally {
87
+ await pinned.release();
88
+ }
75
89
  }
76
90
 
77
91
  function* probeSequence(
@@ -79,19 +93,31 @@ function* probeSequence(
79
93
  directory: string,
80
94
  model?: string,
81
95
  execution?: 'managed',
96
+ sourceEnvironment: NodeJS.ProcessEnv = process.env,
82
97
  ): Generator<ProbeRequest, CliProbeResult, ProbeOutput> {
83
- const executable = yield* executableSequence(cli, directory);
98
+ const executable = yield* executableSequence(cli, directory, sourceEnvironment);
84
99
  if (executable === undefined) return { exists: false, authenticated: false };
85
- const kind = cliAdapterKind(executable);
100
+ // The declaration selects the adapter; the canonical executable selects
101
+ // the bytes. Package-manager links commonly name `claude` or `codex` while
102
+ // targeting a generic `cli.js`, whose basename must not rewrite the adapter
103
+ // contract after resolution.
104
+ const kind = cliAdapterKind(cli);
86
105
  // Relay owns interactive CLI launch/injection. Its generic PTY path is not
87
106
  // the headless wrapper protocol; do not demand that protocol from Gemini,
88
107
  // Cursor, OpenCode, or other interactive tools. Never invent an auth pass.
89
108
  if (execution === 'managed' && kind === 'relayflows-wrapper-v1') {
90
- return { exists: true, supported: true, authenticated: 'unverified' };
109
+ return { exists: true, supported: true, authenticated: 'unverified', executable };
91
110
  }
92
111
  const environment = execution === 'managed'
93
- ? { ...brokerEnvironment(process.env), ...agentEnvironment(executable) } : process.env;
94
- const probe = (invocation: CliInvocation): ProbeRequest => ({ executable, directory, invocation, environment });
112
+ ? { ...brokerEnvironment(sourceEnvironment), ...agentEnvironment(cli, sourceEnvironment) }
113
+ : sourceEnvironment;
114
+ const probe = (invocation: CliInvocation): ProbeRequest => ({
115
+ executable,
116
+ argv0: basename(cli),
117
+ directory,
118
+ invocation,
119
+ environment,
120
+ });
95
121
  const identification = adapterIdentification(kind);
96
122
  const identified = yield probe(identification.invocation);
97
123
  if (
@@ -108,6 +134,7 @@ function* probeSequence(
108
134
  exists: true,
109
135
  supported: true,
110
136
  authenticated: (yield probe(auth)).status === 0,
137
+ executable,
111
138
  authCommand,
112
139
  };
113
140
  }
@@ -124,6 +151,7 @@ function* probeSequence(
124
151
  supported: true,
125
152
  authenticated: true,
126
153
  modelAvailable: true,
154
+ executable,
127
155
  authCommand,
128
156
  modelCommand,
129
157
  };
@@ -143,6 +171,7 @@ function* probeSequence(
143
171
  supported: true,
144
172
  authenticated,
145
173
  modelAvailable: false,
174
+ executable,
146
175
  authCommand,
147
176
  modelCommand,
148
177
  modelExitCode: scopedProbe.status,
@@ -176,19 +205,28 @@ function redactProbeOutput(text: string): string {
176
205
  .replace(/\b[A-Fa-f0-9]{32,}\b/g, '<redacted-hex>');
177
206
  }
178
207
 
179
- function* executableSequence(command: string, directory: string): Generator<ProbeRequest, string | undefined, ProbeOutput> {
208
+ function* executableSequence(command: string, directory: string, environment: NodeJS.ProcessEnv): Generator<ProbeRequest, string | undefined, ProbeOutput> {
180
209
  if (command.includes('/') || isAbsolute(command)) {
181
210
  const path = isAbsolute(command) ? command : resolve(directory, command);
182
- try {
183
- accessSync(path, constants.X_OK);
184
- return path;
185
- } catch {
186
- return undefined;
187
- }
211
+ return canonicalExecutable(path);
188
212
  }
189
213
  const result = yield { executable: 'which', directory: process.cwd(),
190
- invocation: { args: [command], timeoutMs: 5_000 }, environment: process.env };
191
- return result.status === 0 ? result.stdout.trim() : undefined;
214
+ invocation: { args: [command], timeoutMs: 5_000 }, environment };
215
+ return result.status === 0
216
+ ? canonicalExecutable(resolve(process.cwd(), result.stdout.trim()))
217
+ : undefined;
218
+ }
219
+
220
+ function canonicalExecutable(path: string): string | undefined {
221
+ try {
222
+ accessSync(path, constants.X_OK);
223
+ // Dispatch must retain the exact target that readiness probed. Keeping a
224
+ // symlink path would allow the link to be retargeted between admission and
225
+ // worker spawn even though the compiled step carries an absolute path.
226
+ return realpathSync(path);
227
+ } catch {
228
+ return undefined;
229
+ }
192
230
  }
193
231
 
194
232
  function classifySpawnFailure(
@@ -6,6 +6,7 @@ import { McpPreflightError } from './check-typescript.js';
6
6
  import { attachLocalAgent } from '../local-agent.js';
7
7
  import { LlmWorker } from '../llm-worker.js';
8
8
  import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
9
+ import { localAgentEnvironment } from '../local-agent-environment.js';
9
10
  import {
10
11
  AuthoredFlowExecutionError,
11
12
  } from '../authored-flow-executor.js';
@@ -20,7 +21,7 @@ import { authoredInput, authoredWorkerRemedy, localAgentRemedy } from './local-a
20
21
  import {
21
22
  authoredCompletion,
22
23
  authoredHumanParked,
23
- authoredStepFailure,
24
+ authoredStepFailure, rootLeaseLostReport,
24
25
  connect,
25
26
  emptyReport,
26
27
  fromCheckReport,
@@ -38,6 +39,9 @@ export async function runDirectFlow(
38
39
  dataDir: string,
39
40
  options: RunLifecycleOptions = {},
40
41
  ): Promise<RunExecution> {
42
+ // Consume and close the agent-only descriptor before authored source is
43
+ // imported by trigger preflight. Authored code never receives this object.
44
+ const agentEnvironment = localAgentEnvironment();
41
45
  let input: unknown;
42
46
  try {
43
47
  input = parseDirectInput(inputArgument);
@@ -78,13 +82,14 @@ export async function runDirectFlow(
78
82
  if (options.localAgent) {
79
83
  localAgent = await attachLocalAgent(
80
84
  client, dataDir, options.onPtyReady, authoredLocalAgentStream(admissionIdentity), workerCapacity,
85
+ undefined, [], agentEnvironment,
81
86
  );
82
87
  // A session owns one worker registration. Keep the workspace-free LLM
83
88
  // worker on its own connection so it cannot replace the agent worker.
84
89
  llmClient = new JournalClient(socketPath);
85
90
  await llmClient.connect();
86
91
  await llmClient.hello('flows-local-llm');
87
- localLlm = new LlmWorker(llmClient, `${localAgent.stream}-llm`, workerCapacity);
92
+ localLlm = new LlmWorker(llmClient, `${localAgent.stream}-llm`, workerCapacity, agentEnvironment);
88
93
  localLlm.on('error', onWorkerFailure('local-llm', error => { llmFailure = error; client.close(); }));
89
94
  await localLlm.attach();
90
95
  }
@@ -96,6 +101,7 @@ export async function runDirectFlow(
96
101
  onAdmitted: runId => { base.rootRunId = runId; },
97
102
  localAgentStream: localAgent?.stream,
98
103
  ...(localAgent === undefined ? {} : { workerCapacity }),
104
+ ...(agentEnvironment === undefined ? {} : { agentEnvironment }),
99
105
  lifecycle: {
100
106
  onProgress: options.onProgress,
101
107
  ...(options.onRunStarted !== undefined ? { onRunStarted: options.onRunStarted } : {}),
@@ -206,6 +212,9 @@ export async function runDirectFlow(
206
212
  if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
207
213
  return authoredStepFailure('run', base, socketPath, error);
208
214
  }
215
+ if (error instanceof AuthoredFlowExecutionError && error.code === 'root_lease_lost') {
216
+ return rootLeaseLostReport('run', base, socketPath, error);
217
+ }
209
218
  if (error instanceof AuthoredHumanParked) {
210
219
  return authoredHumanParked('run', base, socketPath, error, { dataDir, localAgent: options.localAgent === true });
211
220
  }
@@ -0,0 +1,74 @@
1
+ import {
2
+ mkdtempSync,
3
+ rmSync,
4
+ symlinkSync,
5
+ } from 'node:fs';
6
+ import {
7
+ mkdtemp,
8
+ rm,
9
+ symlink,
10
+ } from 'node:fs/promises';
11
+ import { tmpdir } from 'node:os';
12
+ import { basename, join } from 'node:path';
13
+
14
+ export interface PinnedCliAlias {
15
+ executable: string;
16
+ release(): void;
17
+ }
18
+
19
+ export interface PinnedCliAliasSync {
20
+ executable: string;
21
+ release(): void;
22
+ }
23
+
24
+ function aliasName(identity: string): string {
25
+ const name = basename(identity);
26
+ if (name.length === 0 || name === '.' || name === '..') {
27
+ throw new Error(`Invalid CLI identity basename: ${JSON.stringify(identity)}`);
28
+ }
29
+ return name;
30
+ }
31
+
32
+ /** Execute pinned canonical bytes through the proved basename, including shebang scripts. */
33
+ export async function pinCliAlias(executable: string, identity: string): Promise<PinnedCliAlias> {
34
+ const name = aliasName(identity);
35
+ if (basename(executable) === name) {
36
+ return { executable, release() {} };
37
+ }
38
+ const directory = await mkdtemp(join(tmpdir(), 'relayflow-cli-'));
39
+ const alias = join(directory, name);
40
+ try {
41
+ await symlink(executable, alias, 'file');
42
+ } catch (error) {
43
+ await rm(directory, { recursive: true, force: true });
44
+ throw error;
45
+ }
46
+ let released = false;
47
+ return { executable: alias, release: () => {
48
+ if (released) return;
49
+ rmSync(directory, { recursive: true, force: true });
50
+ released = true;
51
+ } };
52
+ }
53
+
54
+ /** Synchronous counterpart for the synchronous preflight contract. */
55
+ export function pinCliAliasSync(executable: string, identity: string): PinnedCliAliasSync {
56
+ const name = aliasName(identity);
57
+ if (basename(executable) === name) {
58
+ return { executable, release() {} };
59
+ }
60
+ const directory = mkdtempSync(join(tmpdir(), 'relayflow-cli-'));
61
+ const alias = join(directory, name);
62
+ try {
63
+ symlinkSync(executable, alias, 'file');
64
+ } catch (error) {
65
+ rmSync(directory, { recursive: true, force: true });
66
+ throw error;
67
+ }
68
+ let released = false;
69
+ return { executable: alias, release: () => {
70
+ if (released) return;
71
+ rmSync(directory, { recursive: true, force: true });
72
+ released = true;
73
+ } };
74
+ }