@relayflows/sdk 2.0.55 → 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 (179) 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.map +1 -1
  36. package/dist/authored-node-runner.js +1 -0
  37. package/dist/authored-node-runner.js.map +1 -1
  38. package/dist/authored-plugin-effect.d.ts.map +1 -1
  39. package/dist/authored-plugin-effect.js +3 -2
  40. package/dist/authored-plugin-effect.js.map +1 -1
  41. package/dist/authored-preflight.d.ts.map +1 -1
  42. package/dist/authored-preflight.js +2 -1
  43. package/dist/authored-preflight.js.map +1 -1
  44. package/dist/authored-root.d.ts.map +1 -1
  45. package/dist/authored-root.js +13 -3
  46. package/dist/authored-root.js.map +1 -1
  47. package/dist/authored-serial-ordinals.d.ts +41 -0
  48. package/dist/authored-serial-ordinals.d.ts.map +1 -0
  49. package/dist/authored-serial-ordinals.js +45 -0
  50. package/dist/authored-serial-ordinals.js.map +1 -0
  51. package/dist/authored-worker-step.d.ts +5 -1
  52. package/dist/authored-worker-step.d.ts.map +1 -1
  53. package/dist/authored-worker-step.js +19 -2
  54. package/dist/authored-worker-step.js.map +1 -1
  55. package/dist/budget-preflight.d.ts.map +1 -1
  56. package/dist/budget-preflight.js +11 -2
  57. package/dist/budget-preflight.js.map +1 -1
  58. package/dist/bundle-transport.d.ts +2 -0
  59. package/dist/bundle-transport.d.ts.map +1 -1
  60. package/dist/bundle-transport.js +4 -0
  61. package/dist/bundle-transport.js.map +1 -1
  62. package/dist/cached-flow-runtime.d.ts +3 -0
  63. package/dist/cached-flow-runtime.d.ts.map +1 -0
  64. package/dist/cached-flow-runtime.js +37 -0
  65. package/dist/cached-flow-runtime.js.map +1 -0
  66. package/dist/cli/check.d.ts.map +1 -1
  67. package/dist/cli/check.js +13 -1
  68. package/dist/cli/check.js.map +1 -1
  69. package/dist/cli/cloud-deploy.js +6 -5
  70. package/dist/cli/cloud-deploy.js.map +1 -1
  71. package/dist/cli/cloud-mirror-session.d.ts +8 -2
  72. package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
  73. package/dist/cli/cloud-mirror-session.js +20 -27
  74. package/dist/cli/cloud-mirror-session.js.map +1 -1
  75. package/dist/cli/direct-run.d.ts.map +1 -1
  76. package/dist/cli/direct-run.js +2 -1
  77. package/dist/cli/direct-run.js.map +1 -1
  78. package/dist/cli/run.d.ts.map +1 -1
  79. package/dist/cli/run.js +19 -4
  80. package/dist/cli/run.js.map +1 -1
  81. package/dist/cli.d.ts.map +1 -1
  82. package/dist/cli.js +39 -18
  83. package/dist/cli.js.map +1 -1
  84. package/dist/cloud-deploy.d.ts +3 -3
  85. package/dist/cloud-deploy.d.ts.map +1 -1
  86. package/dist/cloud-deploy.js +15 -9
  87. package/dist/cloud-deploy.js.map +1 -1
  88. package/dist/cloud-flow-source.d.ts +13 -0
  89. package/dist/cloud-flow-source.d.ts.map +1 -0
  90. package/dist/cloud-flow-source.js +55 -0
  91. package/dist/cloud-flow-source.js.map +1 -0
  92. package/dist/cloud-mirror-transport.d.ts +2 -0
  93. package/dist/cloud-mirror-transport.d.ts.map +1 -1
  94. package/dist/cloud-mirror-transport.js +2 -0
  95. package/dist/cloud-mirror-transport.js.map +1 -1
  96. package/dist/cloud-mirror.d.ts.map +1 -1
  97. package/dist/cloud-mirror.js +10 -1
  98. package/dist/cloud-mirror.js.map +1 -1
  99. package/dist/communication/broker-preflight.d.ts +3 -0
  100. package/dist/communication/broker-preflight.d.ts.map +1 -0
  101. package/dist/communication/broker-preflight.js +22 -0
  102. package/dist/communication/broker-preflight.js.map +1 -0
  103. package/dist/communication/broker-spec.d.ts +20 -0
  104. package/dist/communication/broker-spec.d.ts.map +1 -0
  105. package/dist/communication/broker-spec.js +46 -0
  106. package/dist/communication/broker-spec.js.map +1 -0
  107. package/dist/communication/broker-worker.d.ts +17 -0
  108. package/dist/communication/broker-worker.d.ts.map +1 -0
  109. package/dist/communication/broker-worker.js +214 -0
  110. package/dist/communication/broker-worker.js.map +1 -0
  111. package/dist/communication/pty.d.ts +29 -0
  112. package/dist/communication/pty.d.ts.map +1 -0
  113. package/dist/communication/pty.js +40 -0
  114. package/dist/communication/pty.js.map +1 -0
  115. package/dist/communication/relay.d.ts +18 -0
  116. package/dist/communication/relay.d.ts.map +1 -1
  117. package/dist/communication/relay.js +4 -0
  118. package/dist/communication/relay.js.map +1 -1
  119. package/dist/communication/skills.d.ts +3 -0
  120. package/dist/communication/skills.d.ts.map +1 -0
  121. package/dist/communication/skills.js +17 -0
  122. package/dist/communication/skills.js.map +1 -0
  123. package/dist/communication/worker.d.ts.map +1 -1
  124. package/dist/communication/worker.js +9 -26
  125. package/dist/communication/worker.js.map +1 -1
  126. package/dist/failure-kinds.d.ts +1 -1
  127. package/dist/failure-kinds.d.ts.map +1 -1
  128. package/dist/failure-kinds.js +2 -0
  129. package/dist/failure-kinds.js.map +1 -1
  130. package/dist/plugin-manifest.js +1 -1
  131. package/dist/plugin-manifest.js.map +1 -1
  132. package/dist/preflight.d.ts.map +1 -1
  133. package/dist/preflight.js +2 -1
  134. package/dist/preflight.js.map +1 -1
  135. package/dist/worker.d.ts +1 -0
  136. package/dist/worker.d.ts.map +1 -1
  137. package/dist/worker.js +12 -0
  138. package/dist/worker.js.map +1 -1
  139. package/package.json +2 -2
  140. package/src/authored-broker.ts +73 -0
  141. package/src/authored-budget.ts +6 -2
  142. package/src/authored-flow-error.ts +6 -0
  143. package/src/authored-flow-executor.ts +115 -20
  144. package/src/authored-flow-loader.ts +43 -3
  145. package/src/authored-helper-effect.ts +2 -1
  146. package/src/authored-hooks.ts +4 -3
  147. package/src/authored-human.ts +35 -4
  148. package/src/authored-mcp.ts +7 -2
  149. package/src/authored-node-entry.ts +4 -1
  150. package/src/authored-node-runner.ts +1 -0
  151. package/src/authored-plugin-effect.ts +7 -2
  152. package/src/authored-preflight.ts +2 -1
  153. package/src/authored-root.ts +13 -3
  154. package/src/authored-serial-ordinals.ts +76 -0
  155. package/src/authored-worker-step.ts +17 -2
  156. package/src/budget-preflight.ts +9 -1
  157. package/src/bundle-transport.ts +4 -0
  158. package/src/cached-flow-runtime.ts +32 -0
  159. package/src/cli/check.ts +11 -1
  160. package/src/cli/cloud-deploy.ts +5 -5
  161. package/src/cli/cloud-mirror-session.ts +23 -29
  162. package/src/cli/direct-run.ts +2 -1
  163. package/src/cli/run.ts +18 -3
  164. package/src/cli.ts +31 -13
  165. package/src/cloud-deploy.ts +19 -14
  166. package/src/cloud-flow-source.ts +58 -0
  167. package/src/cloud-mirror-transport.ts +4 -0
  168. package/src/cloud-mirror.ts +9 -1
  169. package/src/communication/broker-preflight.ts +22 -0
  170. package/src/communication/broker-spec.ts +48 -0
  171. package/src/communication/broker-worker.ts +198 -0
  172. package/src/communication/pty.ts +47 -0
  173. package/src/communication/relay.ts +12 -1
  174. package/src/communication/skills.ts +16 -0
  175. package/src/communication/worker.ts +9 -26
  176. package/src/failure-kinds.ts +2 -0
  177. package/src/plugin-manifest.ts +1 -1
  178. package/src/preflight.ts +3 -1
  179. package/src/worker.ts +11 -0
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { agentEnvironment } from './environment.js';
4
+ import { resolveCliModel } from '../cli-adapter.js';
5
+ import type { RelayBroker } from './relay.js';
6
+
7
+ export const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
8
+
9
+ /** Both communication steps and retained sessions use Relay's PTY launch contract. */
10
+ export async function spawnFlowPty(broker: Pick<RelayBroker, 'spawnPty'>, options: {
11
+ name: string; cli: string; identity?: string; model?: string; task: string;
12
+ skipRelayPrompt?: boolean;
13
+ cwd: string; dataDir: string; channels: string[]; team?: string;
14
+ environment?: NodeJS.ProcessEnv; socket: string; token: string;
15
+ secrets?: string[]; scopeSecrets?: boolean;
16
+ metadata?: { runId: string; flow: string; step: string };
17
+ }) {
18
+ const identity = options.identity ?? options.cli;
19
+ const argv0 = basename(identity);
20
+ let command = options.cli;
21
+ let link: string | undefined;
22
+ try {
23
+ if (basename(command) !== argv0) {
24
+ const root = join(options.dataDir, 'communication');
25
+ await mkdir(root, { recursive: true, mode: 0o700 });
26
+ link = await mkdtemp(join(root, 'cli-'));
27
+ command = join(link, argv0);
28
+ await symlink(options.cli, command, 'file');
29
+ }
30
+ const handle = await broker.spawnPty({ name: options.name, cli: argv0.replace(/\.exe$/i, ''),
31
+ task: options.task, channels: options.channels, skipRelayPrompt: options.skipRelayPrompt ?? true,
32
+ ...(options.team === undefined ? {} : { team: options.team }),
33
+ model: resolveCliModel(identity, options.model), cwd: options.cwd,
34
+ harnessConfig: { runtime: 'pty', command: shellQuote(command), args: [], cwd: options.cwd,
35
+ env: { ...agentEnvironment(identity, options.environment ?? process.env,
36
+ options.secrets, options.scopeSecrets ?? true),
37
+ RELAYFLOW_COMMUNICATION_SOCKET: options.socket, RELAYFLOW_COMMUNICATION_TOKEN: options.token,
38
+ ...(options.metadata === undefined ? {} : {
39
+ RELAYFLOW_RUN_ID: options.metadata.runId, RELAYFLOW_FLOW_NAME: options.metadata.flow,
40
+ RELAYFLOW_STEP_NAME: options.metadata.step,
41
+ }) }, delivery: { mode: 'pty-injection', format: 'relay-block' } } });
42
+ return { handle, close: async () => { if (link) await rm(link, { recursive: true, force: true }); } };
43
+ } catch (error) {
44
+ if (link) await rm(link, { recursive: true, force: true });
45
+ throw error;
46
+ }
47
+ }
@@ -25,7 +25,8 @@ export interface RelayMessaging {
25
25
  };
26
26
  }
27
27
  interface RelayModules {
28
- HarnessDriverClient: { spawn(options: Record<string, unknown>): Promise<RelayBroker> };
28
+ HarnessDriverClient: { spawn(options: Record<string, unknown>): Promise<RelayBroker>;
29
+ connect(options: { cwd: string }): LocalBroker };
29
30
  RelaycastMessagingClient: new (options: Record<string, unknown>) => RelayMessaging;
30
31
  }
31
32
  export async function loadRelayModules(): Promise<RelayModules> {
@@ -100,3 +101,13 @@ async function createRuntime(dataDir: string, runId: string): Promise<RelayRunti
100
101
  throw error;
101
102
  }
102
103
  }
104
+
105
+ /** Connected clients disconnect on shutdown; they never terminate the user's broker. */
106
+ export interface LocalBroker extends RelayBroker {
107
+ connectEvents(): void;
108
+ sendMessage(input: { to: string; text: string; from: string; mode: 'wait'; data: Record<string, unknown> }): Promise<{ event_id: string; targets: string[] }>;
109
+ }
110
+ export async function connectLocalBroker(cwd: string): Promise<LocalBroker> {
111
+ const { HarnessDriverClient } = await loadRelayModules();
112
+ return HarnessDriverClient.connect({ cwd });
113
+ }
@@ -0,0 +1,16 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { basename } from 'node:path';
4
+
5
+ const execute = promisify(execFile);
6
+ /** Install full skill directories (including references) using the canonical package. */
7
+ export async function installBrokerSkills(skills: string[], cli: string, cwd: string, signal: AbortSignal): Promise<void> {
8
+ if (!skills.length) return;
9
+ const identity = basename(cli).replace(/\.exe$/i, '');
10
+ const harness = ({ claude: 'claude-code', codex: 'codex', gemini: 'gemini-cli',
11
+ 'cursor-agent': 'cursor', opencode: 'opencode' } as Record<string, string>)[identity];
12
+ if (!harness) throw new Error(`Skill installation is unsupported for broker CLI ${identity}`);
13
+ await execute('npx', ['--yes', 'skills', 'add', 'https://github.com/AgentWorkforce/skills',
14
+ '--skill', ...skills, '--agent', harness, '--yes', '--copy'],
15
+ { cwd, signal, timeout: 120_000, maxBuffer: 1024 * 1024 });
16
+ }
@@ -1,6 +1,4 @@
1
1
  import { communicationHistory } from './history.js';
2
- import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
3
- import { basename, join } from 'node:path';
4
2
  import { setTimeout as delay } from 'node:timers/promises';
5
3
  import type { JournalClient } from '../journal-client.js';
6
4
  import type { StepDispatchEvent } from '../protocol.js';
@@ -8,13 +6,12 @@ import type { ResolvedKernelAgentStep } from '../resolved-cli-identity.js';
8
6
  import { isLeaseLost, withWorkerLease } from '../worker-lease.js';
9
7
  import { isReadInterruptionError } from '../journal-read-policy.js';
10
8
  import { workerInstruction } from '../worker-input.js';
11
- import { resolveCliModel } from '../cli-adapter.js';
12
9
  import { resolveAgentCwd } from '../agent-cwd.js';
13
10
  import { channelName, type CommunicationInstruction } from './spec.js';
14
11
  import { acquireRelayRuntime, type RelayHandle } from './relay.js';
15
12
  import { CommunicationSession } from './session.js';
16
13
  import { openCommunicationTools } from './tools.js';
17
- import { agentEnvironment } from './environment.js';
14
+ import { spawnFlowPty, shellQuote } from './pty.js';
18
15
 
19
16
  export function requireCommunicationCli(cli: string | undefined): void {
20
17
  if (!cli?.trim()) throw new Error('Agent communication requires a declared CLI executable');
@@ -62,7 +59,7 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
62
59
  let pumping: Promise<void> | undefined;
63
60
  let receipts = Promise.resolve();
64
61
  let unsubscribe: (() => void) | undefined;
65
- let cliLinkDirectory: string | undefined;
62
+ let pty: Awaited<ReturnType<typeof spawnFlowPty>> | undefined;
66
63
  try {
67
64
  let resolve!: (value: unknown) => void;
68
65
  let reject!: (reason: unknown) => void;
@@ -74,8 +71,7 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
74
71
  if (signal.aborted) abort();
75
72
  const session = new CommunicationSession(client, dispatch, instruction, relay, summary => resolve({ summary }), reject);
76
73
  tools = await openCommunicationTools(request => session.invoke(request));
77
- const quote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
78
- const helper = `node ${quote(tools.helperPath)}`;
74
+ const helper = `node ${shellQuote(tools.helperPath)}`;
79
75
  const history = await communicationHistory(client, dispatch, instruction);
80
76
  const prompt = `${workerInstruction(instruction.instruction, dispatch)}${history}\n\nYou are flow agent ${dispatch.step_id}. Concurrent peers send messages automatically into your session through Relay. Do not poll for messages. Use your shell/terminal execution tool with ${helper} for flow communication:\n- send PEER STABLE_ID 'message text' (outgoing peers: ${instruction.outgoing.join(', ') || 'none'})\n- ack PEER DELIVERY_SEQ only after processing that message (incoming peers: ${instruction.incoming.join(', ') || 'none'})\n- complete 'summary' when your task and conversation are finished.\nUse these helpers for all peer communication; do not use Relay MCP messaging tools. Stay available for injected messages until the conversation is complete. Use stable semantic message IDs so retries deduplicate sends.`;
81
77
  const name = `${relay.prefix}-${dispatch.step_id}`;
@@ -89,24 +85,11 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
89
85
  });
90
86
  void receipts.catch(reject);
91
87
  });
92
- // Relay supplies each CLI's launch flags and injection behavior.
93
- const cliIdentity = spec.cli_identity ?? spec.cli!;
94
- const argv0 = basename(cliIdentity);
95
- let command = spec.cli!;
96
- if (basename(command) !== argv0) {
97
- const linkRoot = join(dataDir, 'communication');
98
- await mkdir(linkRoot, { recursive: true, mode: 0o700 });
99
- cliLinkDirectory = await mkdtemp(join(linkRoot, 'cli-'));
100
- command = join(cliLinkDirectory, argv0);
101
- await symlink(spec.cli!, command, 'file');
102
- }
103
- handle = await relay.broker.spawnPty({ name, cli: basename(cliIdentity).replace(/\.exe$/i, ''), task: prompt, channels: [], skipRelayPrompt: true,
104
- model: resolveCliModel(cliIdentity, spec.model), cwd: directory,
105
- harnessConfig: { runtime: 'pty', command: quote(command), args: [],
106
- cwd: directory, env: { ...agentEnvironment(cliIdentity, environment ?? process.env,
107
- spec.secrets, client.supportsFeature?.('step_secret_scoping') ?? true),
108
- RELAYFLOW_COMMUNICATION_SOCKET: tools.path, RELAYFLOW_COMMUNICATION_TOKEN: tools.token },
109
- delivery: { mode: 'pty-injection', format: 'relay-block' } } });
88
+ pty = await spawnFlowPty(relay.broker, { name, cli: spec.cli!, identity: spec.cli_identity,
89
+ model: spec.model, task: prompt, channels: [], cwd: directory, dataDir,
90
+ environment, socket: tools.path, token: tools.token,
91
+ secrets: spec.secrets, scopeSecrets: client.supportsFeature?.('step_secret_scoping') ?? true });
92
+ handle = pty.handle;
110
93
  const ready = await handle.waitForReady(Math.min(instruction.timeoutMs, 90_000));
111
94
  if (ready.reason !== 'ready') throw new Error(`Communication agent did not become ready: ${ready.reason}`);
112
95
  pumping = (async () => {
@@ -123,7 +106,7 @@ async function run(client: JournalClient, dispatch: StepDispatchEvent, instructi
123
106
  finally { try { await handle?.release('Flow communication attempt ended', { deleteIdentity: true }); }
124
107
  finally { try { await tools?.close(); } finally {
125
108
  try { await relay.close(); }
126
- finally { if (cliLinkDirectory) await rm(cliLinkDirectory, { recursive: true, force: true }); }
109
+ finally { await pty?.close(); }
127
110
  } } }
128
111
  }
129
112
  }
@@ -138,6 +138,8 @@ export const RUN_FAILURE_KINDS = [
138
138
  'unsafe_parallel_runtime',
139
139
  /** A predicate `.gate(fn)` judged false; the verdict is journaled as `<step>.gate`. */
140
140
  'gate_failed',
141
+ /** A resumed serial body reached a journaled step id with a different call; refused, not executed. */
142
+ 'serial_replay_order_mismatch',
141
143
  /** `flows answer` named a wait the run is not asking: unknown, or already answered. */
142
144
  'human_wait_unknown',
143
145
  /** `f.memory.learn` was given a finding without non-empty string fields. */