@relayflows/sdk 2.0.54 → 2.0.56

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 (180) hide show
  1. package/dist/authored-broker.d.ts +13 -0
  2. package/dist/authored-broker.d.ts.map +1 -0
  3. package/dist/authored-broker.js +75 -0
  4. package/dist/authored-broker.js.map +1 -0
  5. package/dist/authored-budget.d.ts +1 -1
  6. package/dist/authored-budget.d.ts.map +1 -1
  7. package/dist/authored-budget.js +8 -2
  8. package/dist/authored-budget.js.map +1 -1
  9. package/dist/authored-flow-error.d.ts +6 -0
  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 +11 -0
  13. package/dist/authored-flow-executor.d.ts.map +1 -1
  14. package/dist/authored-flow-executor.js +104 -20
  15. package/dist/authored-flow-executor.js.map +1 -1
  16. package/dist/authored-flow-loader.d.ts.map +1 -1
  17. package/dist/authored-flow-loader.js +42 -3
  18. package/dist/authored-flow-loader.js.map +1 -1
  19. package/dist/authored-helper-effect.d.ts.map +1 -1
  20. package/dist/authored-helper-effect.js +1 -1
  21. package/dist/authored-helper-effect.js.map +1 -1
  22. package/dist/authored-hooks.d.ts +2 -1
  23. package/dist/authored-hooks.d.ts.map +1 -1
  24. package/dist/authored-hooks.js +2 -2
  25. package/dist/authored-hooks.js.map +1 -1
  26. package/dist/authored-human.d.ts +18 -0
  27. package/dist/authored-human.d.ts.map +1 -1
  28. package/dist/authored-human.js +32 -4
  29. package/dist/authored-human.js.map +1 -1
  30. package/dist/authored-mcp.d.ts.map +1 -1
  31. package/dist/authored-mcp.js +3 -2
  32. package/dist/authored-mcp.js.map +1 -1
  33. package/dist/authored-node-entry.js +4 -0
  34. package/dist/authored-node-entry.js.map +1 -1
  35. package/dist/authored-node-runner.d.ts +8 -0
  36. package/dist/authored-node-runner.d.ts.map +1 -1
  37. package/dist/authored-node-runner.js +60 -3
  38. package/dist/authored-node-runner.js.map +1 -1
  39. package/dist/authored-plugin-effect.d.ts.map +1 -1
  40. package/dist/authored-plugin-effect.js +3 -2
  41. package/dist/authored-plugin-effect.js.map +1 -1
  42. package/dist/authored-preflight.d.ts.map +1 -1
  43. package/dist/authored-preflight.js +2 -1
  44. package/dist/authored-preflight.js.map +1 -1
  45. package/dist/authored-root.d.ts.map +1 -1
  46. package/dist/authored-root.js +13 -3
  47. package/dist/authored-root.js.map +1 -1
  48. package/dist/authored-serial-ordinals.d.ts +41 -0
  49. package/dist/authored-serial-ordinals.d.ts.map +1 -0
  50. package/dist/authored-serial-ordinals.js +45 -0
  51. package/dist/authored-serial-ordinals.js.map +1 -0
  52. package/dist/authored-worker-step.d.ts +5 -1
  53. package/dist/authored-worker-step.d.ts.map +1 -1
  54. package/dist/authored-worker-step.js +19 -2
  55. package/dist/authored-worker-step.js.map +1 -1
  56. package/dist/budget-preflight.d.ts.map +1 -1
  57. package/dist/budget-preflight.js +11 -2
  58. package/dist/budget-preflight.js.map +1 -1
  59. package/dist/bundle-transport.d.ts +2 -0
  60. package/dist/bundle-transport.d.ts.map +1 -1
  61. package/dist/bundle-transport.js +4 -0
  62. package/dist/bundle-transport.js.map +1 -1
  63. package/dist/cached-flow-runtime.d.ts +3 -0
  64. package/dist/cached-flow-runtime.d.ts.map +1 -0
  65. package/dist/cached-flow-runtime.js +37 -0
  66. package/dist/cached-flow-runtime.js.map +1 -0
  67. package/dist/cli/check.d.ts.map +1 -1
  68. package/dist/cli/check.js +13 -1
  69. package/dist/cli/check.js.map +1 -1
  70. package/dist/cli/cloud-deploy.js +6 -5
  71. package/dist/cli/cloud-deploy.js.map +1 -1
  72. package/dist/cli/cloud-mirror-session.d.ts +8 -2
  73. package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
  74. package/dist/cli/cloud-mirror-session.js +20 -27
  75. package/dist/cli/cloud-mirror-session.js.map +1 -1
  76. package/dist/cli/direct-run.d.ts.map +1 -1
  77. package/dist/cli/direct-run.js +2 -1
  78. package/dist/cli/direct-run.js.map +1 -1
  79. package/dist/cli/run.d.ts.map +1 -1
  80. package/dist/cli/run.js +19 -4
  81. package/dist/cli/run.js.map +1 -1
  82. package/dist/cli.d.ts.map +1 -1
  83. package/dist/cli.js +39 -18
  84. package/dist/cli.js.map +1 -1
  85. package/dist/cloud-deploy.d.ts +3 -3
  86. package/dist/cloud-deploy.d.ts.map +1 -1
  87. package/dist/cloud-deploy.js +15 -9
  88. package/dist/cloud-deploy.js.map +1 -1
  89. package/dist/cloud-flow-source.d.ts +13 -0
  90. package/dist/cloud-flow-source.d.ts.map +1 -0
  91. package/dist/cloud-flow-source.js +55 -0
  92. package/dist/cloud-flow-source.js.map +1 -0
  93. package/dist/cloud-mirror-transport.d.ts +2 -0
  94. package/dist/cloud-mirror-transport.d.ts.map +1 -1
  95. package/dist/cloud-mirror-transport.js +2 -0
  96. package/dist/cloud-mirror-transport.js.map +1 -1
  97. package/dist/cloud-mirror.d.ts.map +1 -1
  98. package/dist/cloud-mirror.js +10 -1
  99. package/dist/cloud-mirror.js.map +1 -1
  100. package/dist/communication/broker-preflight.d.ts +3 -0
  101. package/dist/communication/broker-preflight.d.ts.map +1 -0
  102. package/dist/communication/broker-preflight.js +22 -0
  103. package/dist/communication/broker-preflight.js.map +1 -0
  104. package/dist/communication/broker-spec.d.ts +20 -0
  105. package/dist/communication/broker-spec.d.ts.map +1 -0
  106. package/dist/communication/broker-spec.js +46 -0
  107. package/dist/communication/broker-spec.js.map +1 -0
  108. package/dist/communication/broker-worker.d.ts +17 -0
  109. package/dist/communication/broker-worker.d.ts.map +1 -0
  110. package/dist/communication/broker-worker.js +214 -0
  111. package/dist/communication/broker-worker.js.map +1 -0
  112. package/dist/communication/pty.d.ts +29 -0
  113. package/dist/communication/pty.d.ts.map +1 -0
  114. package/dist/communication/pty.js +40 -0
  115. package/dist/communication/pty.js.map +1 -0
  116. package/dist/communication/relay.d.ts +18 -0
  117. package/dist/communication/relay.d.ts.map +1 -1
  118. package/dist/communication/relay.js +4 -0
  119. package/dist/communication/relay.js.map +1 -1
  120. package/dist/communication/skills.d.ts +3 -0
  121. package/dist/communication/skills.d.ts.map +1 -0
  122. package/dist/communication/skills.js +17 -0
  123. package/dist/communication/skills.js.map +1 -0
  124. package/dist/communication/worker.d.ts.map +1 -1
  125. package/dist/communication/worker.js +9 -26
  126. package/dist/communication/worker.js.map +1 -1
  127. package/dist/failure-kinds.d.ts +1 -1
  128. package/dist/failure-kinds.d.ts.map +1 -1
  129. package/dist/failure-kinds.js +2 -0
  130. package/dist/failure-kinds.js.map +1 -1
  131. package/dist/plugin-manifest.js +1 -1
  132. package/dist/plugin-manifest.js.map +1 -1
  133. package/dist/preflight.d.ts.map +1 -1
  134. package/dist/preflight.js +2 -1
  135. package/dist/preflight.js.map +1 -1
  136. package/dist/worker.d.ts +1 -0
  137. package/dist/worker.d.ts.map +1 -1
  138. package/dist/worker.js +12 -0
  139. package/dist/worker.js.map +1 -1
  140. package/package.json +2 -2
  141. package/src/authored-broker.ts +73 -0
  142. package/src/authored-budget.ts +6 -2
  143. package/src/authored-flow-error.ts +6 -0
  144. package/src/authored-flow-executor.ts +115 -20
  145. package/src/authored-flow-loader.ts +43 -3
  146. package/src/authored-helper-effect.ts +2 -1
  147. package/src/authored-hooks.ts +4 -3
  148. package/src/authored-human.ts +35 -4
  149. package/src/authored-mcp.ts +7 -2
  150. package/src/authored-node-entry.ts +4 -1
  151. package/src/authored-node-runner.ts +57 -3
  152. package/src/authored-plugin-effect.ts +7 -2
  153. package/src/authored-preflight.ts +2 -1
  154. package/src/authored-root.ts +13 -3
  155. package/src/authored-serial-ordinals.ts +76 -0
  156. package/src/authored-worker-step.ts +17 -2
  157. package/src/budget-preflight.ts +9 -1
  158. package/src/bundle-transport.ts +4 -0
  159. package/src/cached-flow-runtime.ts +32 -0
  160. package/src/cli/check.ts +11 -1
  161. package/src/cli/cloud-deploy.ts +5 -5
  162. package/src/cli/cloud-mirror-session.ts +23 -29
  163. package/src/cli/direct-run.ts +2 -1
  164. package/src/cli/run.ts +18 -3
  165. package/src/cli.ts +31 -13
  166. package/src/cloud-deploy.ts +19 -14
  167. package/src/cloud-flow-source.ts +58 -0
  168. package/src/cloud-mirror-transport.ts +4 -0
  169. package/src/cloud-mirror.ts +9 -1
  170. package/src/communication/broker-preflight.ts +22 -0
  171. package/src/communication/broker-spec.ts +48 -0
  172. package/src/communication/broker-worker.ts +198 -0
  173. package/src/communication/pty.ts +47 -0
  174. package/src/communication/relay.ts +12 -1
  175. package/src/communication/skills.ts +16 -0
  176. package/src/communication/worker.ts +9 -26
  177. package/src/failure-kinds.ts +2 -0
  178. package/src/plugin-manifest.ts +1 -1
  179. package/src/preflight.ts +3 -1
  180. package/src/worker.ts +11 -0
@@ -221,7 +221,8 @@ export async function runDirectFlow(
221
221
  // diagnostic carried up from `classifyOutcome` already names the step, its
222
222
  // exit code and its output tail; this branch is what lets it reach the
223
223
  // terminal. `resumeFlow` takes the same branch, through the same helper.
224
- if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
224
+ if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed'
225
+ || error.code === 'serial_replay_order_mismatch')) {
225
226
  return authoredStepFailure('run', base, socketPath, error);
226
227
  }
227
228
  if (error instanceof AuthoredFlowExecutionError && error.code === 'result_unreadable') {
package/src/cli/run.ts CHANGED
@@ -7,6 +7,7 @@ import { communicationInstruction } from '../communication/spec.js';
7
7
  import { checkCommunicationEnvironment, CommunicationEnvironmentError } from '../communication/preflight.js';
8
8
  import { parseHumanRecipient } from '../human-to.js';
9
9
  import { parseDigestReference } from '../bundle-transport.js';
10
+ import { resolveNamedFlow } from '../cloud-flow-source.js';
10
11
  import { prepareDigestRun } from './run-digest.js';
11
12
  import { attachReuseSummary } from './reuse.js';
12
13
  import { resumeHelperEffect } from '../authored-helper-effect.js';
@@ -47,6 +48,7 @@ import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
47
48
  import { localAgentEnvironment } from '../local-agent-environment.js';
48
49
  import {
49
50
  checkFlow,
51
+ inputFailureReport,
50
52
  type CheckReport,
51
53
  } from './check.js';
52
54
 
@@ -182,6 +184,17 @@ export async function runFlow(
182
184
  dataDir: string,
183
185
  options: RunLifecycleOptions = {},
184
186
  ): Promise<RunExecution> {
187
+ try {
188
+ const named = await resolveNamedFlow(path);
189
+ if (named) {
190
+ const { runDirectFlow } = await import('./direct-run.js');
191
+ return runDirectFlow(named.path, '{}', dataDir, options);
192
+ }
193
+ } catch (error) {
194
+ return { exitCode: 2, report: fromCheckReport('run', inputFailureReport({
195
+ kind: 'input_unreadable', message: error instanceof Error ? error.message : String(error),
196
+ }, path)) };
197
+ }
185
198
  const agentEnvironment = localAgentEnvironment();
186
199
  const prepared = parseDigestReference(path) ? await prepareDigestRun(path, options.bucket) : undefined;
187
200
  if (prepared && 'exitCode' in prepared) return prepared;
@@ -432,7 +445,8 @@ export async function resumeFlow(
432
445
  // leaving it on `protocolFailure` meant `flows run` printed the evidence
433
446
  // while `flows resume` still printed `protocol_error` and
434
447
  // `RUN <id> unknown` for the identical failure.
435
- if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
448
+ if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed'
449
+ || error.code === 'serial_replay_order_mismatch')) {
436
450
  return authoredStepFailure('resume', base, socketPath, error, runId);
437
451
  }
438
452
  if (error instanceof AuthoredFlowExecutionError && error.code === 'result_unreadable') {
@@ -632,10 +646,11 @@ export function authoredStepFailure(
632
646
  severity: 'failure',
633
647
  // A predicate gate that judged false is a run failure with its own
634
648
  // name, so the report says which kind of check the body did not pass.
635
- kind: error.code === 'gate_failed' ? 'gate_failed' : 'step_failed',
649
+ // A refused out-of-order replay names itself the same way.
650
+ kind: error.code === 'gate_failed' || error.code === 'serial_replay_order_mismatch' ? error.code : 'step_failed',
636
651
  // The `step_failed: ` prefix `AuthoredFlowExecutionError` adds is
637
652
  // redundant once the diagnostic is labelled `[step_failed]`.
638
- message: error.message.replace(/^(?:step_failed|gate_failed): /, ''),
653
+ message: error.message.replace(/^(?:step_failed|gate_failed|serial_replay_order_mismatch): /, ''),
639
654
  }],
640
655
  },
641
656
  };
package/src/cli.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { resolveNamedFlow, type NamedFlowSource } from './cloud-flow-source.js';
3
+ import { isFlowName } from './bundle-transport.js';
4
+ import { CloudFlowError } from './cloud-http.js';
2
5
  import { unavailableValidation, validationAnnotation, type DaemonValidationMode } from './daemon-spec-validation.js';
3
6
  import { foldUnprovableEffects, type FoldOptions } from './cli/diagnostic-fold.js';
4
7
  import { startDetachedRun, emitDetachedHandle } from './cli/detached-run.js';
@@ -134,6 +137,7 @@ const USAGE = [
134
137
  'flows plugin update [--json] [--yes] [--to <ref>] [<name>]',
135
138
  'flows build [--out <dir>] <flow.yaml|flow.ts>',
136
139
  'flows build --verify <bundle-dir>',
140
+ 'flows deploy <flow.ts> [--name <name>] [--json]',
137
141
  'flows deploy <flow.ts> --repo <owner/name|gitlab:group/project> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--plugin <ref>] [--no-connect] [--json]',
138
142
  'flows deploy <flow.ts> --flow <name|listener-id> [--plugin <ref>] [--no-connect] [--json]',
139
143
  'flows deployments [--json]',
@@ -152,6 +156,7 @@ const USAGE = [
152
156
  'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] [--budget-minutes <n>] <flow.ts> --input <inline-json-or-file>',
153
157
  'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
154
158
  'flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
159
+ 'flows run <name> [--input <inline-json-or-file>]',
155
160
  'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
156
161
  'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
157
162
  'flows answer --cloud [--json] [--note <text>] [--source <path>] <run-id> <yes|no>',
@@ -259,7 +264,7 @@ export async function runCli(
259
264
  const link = resolveObserverLinkEnv(process.env);
260
265
  if (!link.suppressed && link.workspaceKey === undefined) {
261
266
  io.stderr('Observer unavailable: no workspace key configured; set RELAYCAST_WORKSPACE_KEY or configure '
262
- + '~/.agentworkforce/relay/workspaces.json for an Observer link, or pass --cloud-mirror for the Cloud dashboard');
267
+ + '~/.agentworkforce/relay/workspaces.json for an Observer link, or sign in with agent-relay cloud login for the Cloud dashboard');
263
268
  }
264
269
  }
265
270
 
@@ -379,6 +384,27 @@ export async function runCli(
379
384
  }, io) as CliExitCode);
380
385
  }
381
386
 
387
+ let named: NamedFlowSource | undefined;
388
+ if (parsed.command === 'run') {
389
+ try {
390
+ named = await resolveNamedFlow(parsed.value);
391
+ if (!named && parsed.input !== undefined && !isAuthoredFlowPath(parsed.value)) {
392
+ throw new CloudFlowError('invalid_input', '--input requires an authored .flow.ts or a saved flow name.');
393
+ }
394
+ if (named) {
395
+ if (parsed.reuseFromRunId !== undefined) throw new CloudFlowError('invalid_input', '--reuse-from is not supported for authored named flows.');
396
+ parsed.value = named.path;
397
+ parsed.input ??= '{}';
398
+ }
399
+ } catch (error) {
400
+ const code = error instanceof CloudFlowError ? error.refusal?.code ?? error.code : 'flow_resolution_failed';
401
+ const message = error instanceof Error ? error.message : String(error);
402
+ if (parsed.json) io.stdout(JSON.stringify({ ok: false, code, message }));
403
+ else io.stderr(`${code}: ${message}`);
404
+ return 2;
405
+ }
406
+ }
407
+
382
408
  // Attach-or-spawn runs inside `runFlow`/`resumeFlow`/`runDirectFlow`, at the
383
409
  // single `connect()` seam immediately before journal-client.ts is used --
384
410
  // not here. Hoisting it above the dispatch would start a daemon as a side
@@ -388,21 +414,13 @@ export async function runCli(
388
414
  onObserverUrl: url => options.detachedReceipt?.observerUrl(url),
389
415
  });
390
416
  const runnerLog: string[] = [];
391
- // Opt-in, unlike the observer link beside it. The observer is the default
392
- // way to watch a local run: it is free, it needs only a workspace key, and
393
- // it carries a step projection. The dashboard is the richer, hosted view —
394
- // it stores the flow source, every step's transcript and this invocation's
395
- // own output — so a local run joins it because someone asked, never because
396
- // a login happened to be lying around.
397
- const mirror = !(parsed.cloudMirror || cloudMirrorRequested(process.env))
398
- ? undefined
399
- : createCloudMirrorSession({
417
+ const mirror = createCloudMirrorSession({
400
418
  source: parsed.command === 'run'
401
- ? mirrorSourceFromPath(parsed.value, parsed.input, parsed.dataDir)
419
+ ? mirrorSourceFromPath(parsed.value, parsed.input, parsed.dataDir, named)
402
420
  : mirrorSourceFromJournal(parsed.dataDir),
403
421
  dataDir: parsed.dataDir,
404
422
  log: () => runnerLog,
405
- requested: parsed.cloudMirror ? 'flag' : 'env',
423
+ requested: parsed.cloudMirror ? 'flag' : cloudMirrorRequested(process.env) ? 'env' : 'login',
406
424
  }, io);
407
425
  // Everything this invocation prints, kept in order, so the mirror can upload
408
426
  // it as the run's `runner.log` — the object the dashboard's log pane reads,
@@ -888,7 +906,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
888
906
  if (wait || syncCode || noConnect || budgetMinutes !== undefined) return undefined;
889
907
  if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
890
908
 
891
- if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
909
+ if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!) && !isFlowName(positionals[0]!)) return undefined;
892
910
  return command === 'check'
893
911
  ? { command, json, watch, explainWarnings, value: positionals[0]!,
894
912
  daemonValidation: daemonValidation ?? (process.env['FLOWS_NO_DAEMON_CHECK'] === '1' ? 'off'
@@ -1,3 +1,4 @@
1
+ import { isFlowName } from './bundle-transport.js';
1
2
  import { randomBytes, createHash } from 'node:crypto';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { dirname, resolve } from 'node:path';
@@ -73,10 +74,10 @@ export interface FlowTriggerSource {
73
74
 
74
75
  export interface DeployToCloudInput {
75
76
  path: string;
76
- repository: DeployRepository;
77
+ repository?: DeployRepository;
77
78
  sources: FlowTriggerSource[];
78
79
  /** The `f.human` approver handle every launched run receives as `input.approver`. */
79
- approver: string;
80
+ approver?: string;
80
81
  /** Defaults to the flow's declared name. */
81
82
  name?: string;
82
83
  /**
@@ -118,7 +119,7 @@ export interface CloudDeployment {
118
119
  agentId: string;
119
120
  name: string;
120
121
  status: string;
121
- repository: DeployRepository;
122
+ repository?: DeployRepository;
122
123
  sources: FlowTriggerSource[];
123
124
  sourceSha256: string;
124
125
  /** What the source declared it needs; the harnesses became `inputs.agents` unless `agents` was given. */
@@ -255,28 +256,32 @@ export async function deployToCloud(
255
256
  input: DeployToCloudInput, options: CloudConnectionOptions = {},
256
257
  ): Promise<CloudDeployment> {
257
258
  const { bytes, source, definition, extensions, projectCli } = await loadDeploySource(input);
258
- if (input.sources.length === 0 || input.sources.length > 10) {
259
- throw new CloudFlowError('invalid_input', 'Give between one and ten --on trigger sources.');
259
+ if (input.sources.length > 10) {
260
+ throw new CloudFlowError('invalid_input', 'Give at most ten --on trigger sources.');
260
261
  }
261
262
  if (new Set(input.sources.map(s => s.provider)).size !== input.sources.length) {
262
263
  throw new CloudFlowError('invalid_input', 'Each trigger provider can be given once.');
263
264
  }
264
- const approver = input.approver.trim();
265
- if (!approver) throw new CloudFlowError('invalid_input', '--approver must name who approves f.human questions.');
265
+ const manual = input.sources.length === 0;
266
+ const approver = input.approver?.trim();
267
+ if (!manual && !input.repository) throw new CloudFlowError('invalid_input', 'Triggered deployments require --repo.');
268
+ if (!manual && !approver) throw new CloudFlowError('invalid_input', '--approver must name who approves f.human questions.');
266
269
  const name = (input.name ?? definition.name).trim();
267
270
  if (!name || name.length > 100) throw new CloudFlowError('invalid_input', 'Deployment name must be 1-100 characters.');
271
+ if (manual && !isFlowName(name)) throw new CloudFlowError('invalid_input',
272
+ 'Manual flow names must start with a letter or digit and contain only letters, digits, underscores or hyphens (up to 100 characters). Use --name.');
268
273
  // Scope same-host sources to the target rather than waking on every project.
269
- const target = `${input.repository.owner}/${input.repository.name}`;
274
+ const target = input.repository ? `${input.repository.owner}/${input.repository.name}` : undefined;
270
275
  const sources = input.sources.map(s => {
271
276
  if (s.provider === 'github' && s.settings['repository'] === undefined) {
272
- if (input.repository.host === 'gitlab') {
277
+ if (input.repository?.host === 'gitlab') {
273
278
  throw new CloudFlowError('invalid_input',
274
279
  'A GitLab target needs an explicit --on github:repository=owner/name for a GitHub source.');
275
280
  }
276
- return { ...s, settings: { ...s.settings, repository: target } };
281
+ return { ...s, settings: { ...s.settings, repository: target! } };
277
282
  }
278
- if (s.provider === 'gitlab' && s.settings['project'] === undefined && input.repository.host === 'gitlab') {
279
- return { ...s, settings: { ...s.settings, project: target } };
283
+ if (s.provider === 'gitlab' && s.settings['project'] === undefined && input.repository?.host === 'gitlab') {
284
+ return { ...s, settings: { ...s.settings, project: target! } };
280
285
  }
281
286
  return s;
282
287
  });
@@ -298,7 +303,7 @@ export async function deployToCloud(
298
303
  // refused here rather than silently replaced by Claude, which activation
299
304
  // would then check while the deployed runs still call the declared CLI.
300
305
  const unsupported = requirements.harnesses.filter(harness => !(FLOW_AGENT_HARNESSES as readonly string[]).includes(harness));
301
- if (input.agents === undefined && unsupported.length > 0) {
306
+ if (!manual && input.agents === undefined && unsupported.length > 0) {
302
307
  const uses = requirements.harnessUses.filter(use => unsupported.includes(use.harness));
303
308
  throw new CloudFlowError('unsupported_source',
304
309
  `This flow declares ${uses.map(use => `${use.harness} (${use.detail})`).join(', ')}, which Cloud deployments cannot run yet; `
@@ -307,7 +312,7 @@ export async function deployToCloud(
307
312
  const declaredAgents = requirements.harnesses.filter((harness): harness is FlowAgentHarness => !unsupported.includes(harness));
308
313
  const agents = input.agents ?? (declaredAgents.length > 0 ? declaredAgents : ['claude']);
309
314
  // A draft activates nothing, so Cloud checks nothing; match it here.
310
- const connected = input.draft || input.checkConnections === false
315
+ const connected = manual || input.draft || input.checkConnections === false
311
316
  ? []
312
317
  : (await ensureIntegrationsConnected(requirements, {
313
318
  ...options, workspaceId: workspace.id, ...(input.connect === undefined ? {} : { prompt: input.connect }),
@@ -0,0 +1,58 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { isFlowName } from './bundle-transport.js';
7
+ import { cloudFetch, CloudFlowError, isCloudRecord, type CloudConnectionOptions } from './cloud-http.js';
8
+ import { prepareCachedFlowRuntime } from './cached-flow-runtime.js';
9
+
10
+ export interface NamedFlowSource {
11
+ path: string;
12
+ flowId: string;
13
+ flowVersionId: string;
14
+ sourceSha256: string;
15
+ source: string;
16
+ }
17
+
18
+ /** Files win over names; paths and digest references retain their existing meaning. */
19
+ export async function resolveNamedFlow(
20
+ value: string,
21
+ options: CloudConnectionOptions & { cacheRoot?: string } = {},
22
+ ): Promise<NamedFlowSource | undefined> {
23
+ if (!isFlowName(value)) return undefined;
24
+ try {
25
+ if ((await stat(value)).isFile()) return undefined;
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
28
+ }
29
+ const result = await cloudFetch(`/api/v1/flows/by-name/${encodeURIComponent(value)}`, options, { method: 'GET', detail: true });
30
+ if (!isCloudRecord(result) || result.name !== value || result.fileType !== 'ts'
31
+ || typeof result.source !== 'string' || Buffer.byteLength(result.source) > 256_000
32
+ || typeof result.sourceSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(result.sourceSha256)
33
+ || typeof result.flowId !== 'string' || !result.flowId
34
+ || typeof result.versionId !== 'string' || !result.versionId) {
35
+ throw new CloudFlowError('invalid_response', 'Cloud returned an invalid named .flow.ts source.');
36
+ }
37
+ const digest = (source: string | Buffer): string => createHash('sha256').update(source).digest('hex');
38
+ if (digest(result.source) !== result.sourceSha256) {
39
+ throw new CloudFlowError('invalid_response', `Source hash verification failed for flow "${value}".`);
40
+ }
41
+ const directory = join(options.cacheRoot ?? join(homedir(), '.agentworkforce', 'flows', 'cache'), value);
42
+ const path = join(directory, `${result.sourceSha256}.flow.ts`);
43
+ await mkdir(directory, { recursive: true, mode: 0o700 });
44
+ if (existsSync(path)) {
45
+ if (digest(await readFile(path)) !== result.sourceSha256) {
46
+ throw new CloudFlowError('invalid_input', `Cached source hash verification failed: ${path}. Remove this file and retry.`);
47
+ }
48
+ } else {
49
+ const temporary = `${path}.${randomUUID()}.tmp`;
50
+ try {
51
+ await writeFile(temporary, result.source, { mode: 0o600, flag: 'wx' });
52
+ await rename(temporary, path);
53
+ } finally { await rm(temporary, { force: true }); }
54
+ }
55
+ await prepareCachedFlowRuntime(directory);
56
+ return { path, flowId: result.flowId, flowVersionId: result.versionId,
57
+ sourceSha256: result.sourceSha256, source: result.source };
58
+ }
@@ -69,6 +69,8 @@ export interface MirrorRunSource {
69
69
  * red "Needs review" row can see it was answered.
70
70
  */
71
71
  resumedFromRunId?: string;
72
+ flowId?: string;
73
+ flowVersionId?: string;
72
74
  }
73
75
 
74
76
  /** One lifecycle event, in the vocabulary Cloud's session event stream uses. */
@@ -98,6 +100,8 @@ export async function registerLocalRun(
98
100
  body: JSON.stringify({
99
101
  workflow: source.workflow,
100
102
  fileType: source.fileType,
103
+ ...(source.flowId === undefined ? {} : { flowId: source.flowId }),
104
+ ...(source.flowVersionId === undefined ? {} : { flowVersionId: source.flowVersionId }),
101
105
  relayflowVersion: 'v2',
102
106
  ...(source.inputs === undefined ? {} : { inputs: source.inputs }),
103
107
  ...(source.resumedFromRunId === undefined ? {} : { resumedFromRunId: source.resumedFromRunId }),
@@ -304,6 +304,7 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
304
304
  let stopped = false;
305
305
  /** Set immediately before the terminal callback; nothing may publish after it. */
306
306
  let terminalReported = false;
307
+ const pendingEvents = new Set<Promise<boolean>>();
307
308
  let sequence = 0;
308
309
  let acknowledged: string | undefined;
309
310
  let acknowledgedAt = now();
@@ -558,7 +559,10 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
558
559
  timer.unref?.();
559
560
  },
560
561
  event(event) {
561
- void options.client.publishEvent(event);
562
+ if (stopped || terminalReported || pendingEvents.size >= 256) return;
563
+ const pending = options.client.publishEvent(event);
564
+ pendingEvents.add(pending);
565
+ void pending.finally(() => pendingEvents.delete(pending));
562
566
  },
563
567
  async finish(outcome) {
564
568
  if (timer !== undefined) { clearInterval(timer); timer = undefined; }
@@ -592,6 +596,10 @@ export function createRunMirror(options: RunMirrorOptions): RunMirror {
592
596
  }
593
597
  await uploadTranscripts(deadline);
594
598
  await publishFinal(deadline);
599
+ await Promise.race([
600
+ Promise.allSettled(pendingEvents),
601
+ new Promise<void>(done => { setTimeout(done, remaining(deadline)).unref?.(); }),
602
+ ]);
595
603
  // Last, always: this transition revokes the credential every call
596
604
  // above depends on.
597
605
  terminalReported = true;
@@ -0,0 +1,22 @@
1
+ import { createRequire } from 'node:module';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const require = createRequire(import.meta.url);
6
+
7
+ /** Read-only SDK connection probe: never starts a broker or an agent harness. */
8
+ export function checkBrokerEnvironment(cwd = process.cwd()): void {
9
+ let driver: string;
10
+ try {
11
+ require.resolve('@agent-relay/sdk');
12
+ driver = require.resolve('@agent-relay/harness-driver');
13
+ } catch {
14
+ throw new Error('Broker agents require optional @agent-relay/harness-driver and @agent-relay/sdk packages (>=12.3.1).');
15
+ }
16
+ const probe = spawnSync(process.execPath, ['--input-type=module', '-e',
17
+ 'const { HarnessDriverClient } = await import(process.argv[1]); const client = HarnessDriverClient.connect({ cwd: process.argv[2] }); try { await client.healthCheck(); } finally { await client.shutdown(); }',
18
+ pathToFileURL(driver).href, cwd], { encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024 });
19
+ if (probe.status !== 0) {
20
+ throw new Error(`Broker agents require a reachable local Relay broker for ${JSON.stringify(cwd)}. Run agent-relay up in the run root or set AGENT_RELAY_STATE_DIR to its connection directory.`);
21
+ }
22
+ }
@@ -0,0 +1,48 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { AgentOptions } from '@relayflows/surface';
3
+
4
+ export const BROKER_TYPE = 'relayflows.broker.v1';
5
+ export interface BrokerInstruction {
6
+ type: typeof BROKER_TYPE;
7
+ operation: 'spawn' | 'message' | 'release';
8
+ owner: string;
9
+ flow: string;
10
+ step: string;
11
+ agentName: string;
12
+ task: string;
13
+ cwd?: string;
14
+ skills: string[];
15
+ channel: string;
16
+ keepAlive: boolean;
17
+ timeoutMs: number;
18
+ }
19
+ export function brokerAgentName(flow: string, step: string, owner: string, id: string): string {
20
+ const label = `${flow}-${step}`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+/, '') || 'flow-agent';
21
+ return `${label.slice(0, 46)}-${createHash('sha256').update(JSON.stringify([owner, id])).digest('hex').slice(0, 16)}`;
22
+ }
23
+ export function brokerOptionsError(options: AgentOptions): string | undefined {
24
+ if (options.transport !== 'broker') {
25
+ if (options.skills !== undefined || options.keepAlive !== undefined || options.channel !== undefined) {
26
+ return 'skills, keepAlive, and channel require transport: broker';
27
+ }
28
+ return;
29
+ }
30
+ if (options.resume !== undefined) return 'broker sessions use f.message instead of resume';
31
+ if (options.keepAlive !== undefined && typeof options.keepAlive !== 'boolean') return 'keepAlive must be a boolean';
32
+ if (options.channel !== undefined && (typeof options.channel !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(options.channel))) return 'channel must be a channel name';
33
+ if (options.skills !== undefined && (!Array.isArray(options.skills)
34
+ || !options.skills.every(skill => typeof skill === 'string' && /^[a-z0-9][a-z0-9-]{0,99}$/.test(skill)))) return 'skills must be canonical skill names';
35
+ if (options.cwd !== undefined && (typeof options.cwd !== 'string' || !options.cwd.trim() || options.cwd.includes('\0'))) return 'cwd must be a nonempty directory path';
36
+ }
37
+ export function brokerInstruction(raw: unknown): BrokerInstruction | undefined {
38
+ if (typeof raw !== 'string' || !raw.startsWith('{')) return;
39
+ let value: any;
40
+ try { value = JSON.parse(raw); } catch { return; }
41
+ if (value?.type !== BROKER_TYPE) return;
42
+ if (!['spawn', 'message', 'release'].includes(value.operation)
43
+ || !['owner', 'flow', 'step', 'agentName', 'task', 'channel'].every(key => typeof value[key] === 'string' && value[key].length > 0)
44
+ || !Number.isSafeInteger(value.timeoutMs) || value.timeoutMs < 1 || value.timeoutMs > 3_600_000
45
+ || !Array.isArray(value.skills) || typeof value.keepAlive !== 'boolean'
46
+ || brokerOptionsError({ ...value, transport: 'broker' })) throw new Error('Invalid broker agent instruction');
47
+ return value;
48
+ }
@@ -0,0 +1,198 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import type { JournalClient } from '../journal-client.js';
4
+ import type { StepDispatchEvent } from '../protocol.js';
5
+ import type { ResolvedKernelAgentStep } from '../resolved-cli-identity.js';
6
+ import { isLeaseLost, withWorkerLease } from '../worker-lease.js';
7
+ import { isReadInterruptionError } from '../journal-read-policy.js';
8
+ import { workerInstruction } from '../worker-input.js';
9
+ import { connectLocalBroker, type LocalBroker } from './relay.js';
10
+ import { spawnFlowPty, shellQuote } from './pty.js';
11
+ import { installBrokerSkills } from './skills.js';
12
+ import { openCommunicationTools } from './tools.js';
13
+ import type { BrokerInstruction } from './broker-spec.js';
14
+
15
+ interface Turn {
16
+ id: string;
17
+ completed?: boolean;
18
+ resolve(summary: string): void;
19
+ reject(error: unknown): void;
20
+ }
21
+ interface Session {
22
+ owner: string;
23
+ broker: LocalBroker;
24
+ pty?: Awaited<ReturnType<typeof spawnFlowPty>>;
25
+ tools?: Awaited<ReturnType<typeof openCommunicationTools>>;
26
+ turn?: Turn;
27
+ unsubscribe?: () => void;
28
+ exited?: string;
29
+ releasing?: Promise<void>;
30
+ }
31
+
32
+ /** Worker-owned live resources. Only journaled agent dispatches start a turn. */
33
+ export class BrokerSessions {
34
+ private readonly sessions = new Map<string, Session>();
35
+ constructor(private readonly runRoot: string = process.cwd(), private readonly environment?: NodeJS.ProcessEnv) {}
36
+
37
+ async close(): Promise<void> {
38
+ const results = await Promise.allSettled([...this.sessions.keys()].map(name => this.release(name)));
39
+ const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
40
+ if (errors.length) throw new AggregateError(errors, 'Failed to release flow broker sessions');
41
+ }
42
+
43
+ private async release(name: string): Promise<void> {
44
+ const session = this.sessions.get(name);
45
+ if (!session) return;
46
+ if (session.releasing) return session.releasing;
47
+ session.releasing = Promise.resolve().then(() => this.releaseSession(name, session));
48
+ try { await session.releasing; }
49
+ finally { session.releasing = undefined; }
50
+ }
51
+
52
+ private async releaseSession(name: string, session: Session): Promise<void> {
53
+ session.turn?.reject(new Error('Broker session released'));
54
+ await session.pty?.handle.release('Flow broker session ended', { deleteIdentity: true });
55
+ session.unsubscribe?.();
56
+ try { await session.tools?.close(); }
57
+ finally { try { await session.pty?.close(); } finally { await session.broker.shutdown(); } }
58
+ this.sessions.delete(name);
59
+ }
60
+
61
+ async complete(client: JournalClient, dispatch: StepDispatchEvent, instruction: BrokerInstruction, dataDir: string): Promise<void> {
62
+ let output: unknown;
63
+ let reason: 'success' | 'worker_error' | 'timeout' = 'success';
64
+ try {
65
+ output = await withWorkerLease(client, dispatch, signal => this.run(dispatch, instruction, dataDir, signal,
66
+ client.supportsFeature?.('step_secret_scoping') ?? true));
67
+ } catch (error) {
68
+ if (isLeaseLost(error) || isReadInterruptionError(error)) throw error;
69
+ reason = error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'worker_error';
70
+ output = { error: error instanceof Error ? error.message : String(error) };
71
+ }
72
+ try {
73
+ await client.stepComplete(dispatch.run_id, dispatch.step_id, dispatch.attempt, dispatch.idempotency_key,
74
+ reason, { output, usage: { tokens_in: 0, tokens_out: 0, dollars_unmetered: true },
75
+ started_pins: dispatch.pins, end_pins: dispatch.pins });
76
+ } catch (error) {
77
+ await this.release(instruction.agentName);
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ private async run(dispatch: StepDispatchEvent, instruction: BrokerInstruction, dataDir: string, lease: AbortSignal,
83
+ scopeSecrets = true): Promise<unknown> {
84
+ const { agentName: name, owner } = instruction;
85
+ let session = this.sessions.get(name);
86
+ if (session && session.owner !== owner) throw new Error('Broker agent belongs to another flow');
87
+ if (session?.turn) throw new Error('Broker agent already has work in progress');
88
+ if (instruction.operation === 'release') {
89
+ await this.release(name);
90
+ return { released: true };
91
+ }
92
+ if (session?.exited) {
93
+ await this.release(name);
94
+ throw new Error(`Broker agent ${name}: ${session.exited}`);
95
+ }
96
+ if (instruction.operation === 'message' && !session) {
97
+ throw new Error(`Broker agent ${name} is no longer available; its worker session was lost or released`);
98
+ }
99
+ if (instruction.operation === 'spawn' && session) throw new Error(`Broker agent ${name} already exists`);
100
+ const deadline = AbortSignal.timeout(instruction.timeoutMs);
101
+ const signal = AbortSignal.any([lease, deadline]);
102
+ const turnId = `${dispatch.run_id}:${dispatch.step_id}:${dispatch.attempt}`;
103
+ let succeed!: (summary: string) => void;
104
+ let fail!: (error: unknown) => void;
105
+ const result = new Promise<string>((yes, no) => { succeed = yes; fail = no; });
106
+ void result.catch(() => {});
107
+ let abortWait!: (error: unknown) => void;
108
+ const aborted = new Promise<never>((_, reject) => { abortWait = reject; });
109
+ void aborted.catch(() => {});
110
+ const abort = () => { fail(signal.reason); abortWait(signal.reason); };
111
+ signal.addEventListener('abort', abort, { once: true });
112
+ if (signal.aborted) abort();
113
+ let succeeded = false;
114
+ try {
115
+ if (instruction.operation === 'spawn') {
116
+ const spec = dispatch.spec as ResolvedKernelAgentStep;
117
+ if (!spec.cli) throw new Error('Broker agent requires a declared CLI');
118
+ const cwd = await realpath(resolve(this.runRoot, instruction.cwd ?? '.'));
119
+ if (!(await stat(cwd)).isDirectory()) throw new Error(`Broker cwd is not a directory: ${cwd}`);
120
+ signal.throwIfAborted();
121
+ await installBrokerSkills(instruction.skills, spec.cli_identity ?? spec.cli, cwd, signal);
122
+ const broker = await connectLocalBroker(this.runRoot);
123
+ session = { owner, broker, turn: { id: turnId, resolve: succeed, reject: fail } };
124
+ this.sessions.set(name, session);
125
+ const current = session;
126
+ session.tools = await openCommunicationTools(async ({ operation, values }) => {
127
+ if (operation !== 'complete' || values.length !== 2 || values[0] !== current.turn?.id || current.turn?.completed) {
128
+ throw new Error('Use complete CURRENT_TURN_ID JSON_SUMMARY; stale and duplicate completions are refused');
129
+ }
130
+ const summary = JSON.stringify(JSON.parse(values[1]!));
131
+ const turn = current.turn;
132
+ turn!.completed = true;
133
+ // Allow the helper's response to flush before releasing the PTY.
134
+ setTimeout(() => turn!.resolve(summary), 100);
135
+ return { completed: true };
136
+ });
137
+ session.unsubscribe = broker.onEvent(event => {
138
+ if (event.name !== name || !['agent_exit', 'agent_exited', 'agent_released'].includes(event.kind)) return;
139
+ current.exited = event.kind;
140
+ if (!current.turn?.completed) current.turn?.reject(new Error(`Broker agent ${name}: ${event.kind}`));
141
+ // An idle exit retires resources immediately. A failed release stays
142
+ // in the map and is retried (and surfaced) on the next call or close.
143
+ if (!current.turn) void this.release(name).catch(() => {});
144
+ });
145
+ broker.connectEvents();
146
+ const task = `${workerInstruction(instruction.task, dispatch)}\n\n${this.completionPrompt(session, turnId)}\n${instruction.keepAlive ? 'Stay alive after completing; later flow steps can send more work.' : 'The flow releases this session after completion.'}`;
147
+ const spawned = spawnFlowPty(broker, { name, cli: spec.cli, identity: spec.cli_identity,
148
+ model: spec.model, task, cwd, dataDir, skipRelayPrompt: false, channels: [instruction.channel], team: `flow-${owner}`,
149
+ environment: this.environment, socket: session.tools.path, token: session.tools.token,
150
+ secrets: spec.secrets, scopeSecrets,
151
+ metadata: { runId: owner, flow: instruction.flow, step: instruction.step } });
152
+ let pty: Awaited<ReturnType<typeof spawnFlowPty>> | undefined;
153
+ try {
154
+ pty = await Promise.race([spawned, aborted]);
155
+ signal.throwIfAborted();
156
+ session.pty = pty;
157
+ } catch (error) {
158
+ // Spawn can resolve after the timeout. The session release below
159
+ // only sees a handle that was stored in time.
160
+ if (session.pty === undefined) void spawned.then(async late => {
161
+ try { await late.handle.release('Flow broker session ended', { deleteIdentity: true }); }
162
+ catch { /* The timed-out step is already journaled. */ }
163
+ try { await late.close(); }
164
+ catch { /* A failed link removal must not reject this detached cleanup. */ }
165
+ }, () => {});
166
+ throw error;
167
+ }
168
+ const ready = await Promise.race([session.pty.handle.waitForReady(Math.min(instruction.timeoutMs, 90_000)),
169
+ result.then(() => ({ reason: 'ready' }))]);
170
+ if (ready.reason !== 'ready') throw new Error(`Broker agent did not become ready: ${ready.reason}`);
171
+ } else {
172
+ session!.turn = { id: turnId, resolve: succeed, reject: fail };
173
+ signal.throwIfAborted();
174
+ const receipt = await Promise.race([session!.broker.sendMessage({ to: name, from: `flow-${owner}`,
175
+ text: `${instruction.task}\n\n${this.completionPrompt(session!, turnId)}`, mode: 'wait',
176
+ data: { runId: owner, flow: instruction.flow, step: instruction.step, turnId } }), aborted]);
177
+ if (!receipt.targets.includes(name) || receipt.event_id === 'unsupported_operation') {
178
+ throw new Error(`Broker did not accept the message for ${name}`);
179
+ }
180
+ }
181
+ const summary = await result;
182
+ signal.throwIfAborted();
183
+ succeeded = true;
184
+ return { completionReason: 'success', summary, artifacts: [], agentName: name };
185
+ } catch (error) {
186
+ if (signal.aborted) throw signal.reason;
187
+ throw error;
188
+ } finally {
189
+ signal.removeEventListener('abort', abort);
190
+ if (session) session.turn = undefined;
191
+ if (!succeeded || !instruction.keepAlive || session?.exited) await this.release(name);
192
+ }
193
+ }
194
+
195
+ private completionPrompt(session: Session, turn: string): string {
196
+ return `Report completion with your shell tool: node ${shellQuote(session.tools!.helperPath)} complete ${shellQuote(turn)} 'JSON_SUMMARY'. The summary must be valid JSON (object, array, or string). Call exactly once for this turn, after the work is finished.`;
197
+ }
198
+ }