@agent-relay/sdk 3.1.14 → 3.1.17

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.
@@ -86,6 +86,10 @@ export class WorkflowRunner {
86
86
  lastIdleLog = new Map();
87
87
  /** Tracks last logged activity type per agent to avoid duplicate status lines. */
88
88
  lastActivity = new Map();
89
+ /** Runtime-name lookup for agents participating in supervised owner flows. */
90
+ supervisedRuntimeAgents = new Map();
91
+ /** Resolved named paths from the top-level `paths` config, keyed by name → absolute directory. */
92
+ resolvedPaths = new Map();
89
93
  constructor(options = {}) {
90
94
  this.db = options.db ?? new InMemoryWorkflowDb();
91
95
  this.workspaceId = options.workspaceId ?? 'local';
@@ -95,6 +99,76 @@ export class WorkflowRunner {
95
99
  this.workersPath = path.join(this.cwd, '.agent-relay', 'team', 'workers.json');
96
100
  this.executor = options.executor;
97
101
  }
102
+ // ── Path resolution ─────────────────────────────────────────────────────
103
+ /** Expand environment variables like $HOME or $VAR in a path string. */
104
+ static resolveEnvVars(p) {
105
+ return p.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, varName) => {
106
+ return process.env[varName] ?? _match;
107
+ });
108
+ }
109
+ /**
110
+ * Resolve and validate the top-level `paths` definitions from the config.
111
+ * Returns a map of name → absolute directory path.
112
+ * Throws if a required path does not exist.
113
+ */
114
+ resolvePathDefinitions(pathDefs, baseCwd) {
115
+ const resolved = new Map();
116
+ const errors = [];
117
+ const warnings = [];
118
+ if (!pathDefs || pathDefs.length === 0)
119
+ return { resolved, errors, warnings };
120
+ const seenNames = new Set();
121
+ for (const pd of pathDefs) {
122
+ if (seenNames.has(pd.name)) {
123
+ errors.push(`Duplicate path name "${pd.name}"`);
124
+ continue;
125
+ }
126
+ seenNames.add(pd.name);
127
+ const expanded = WorkflowRunner.resolveEnvVars(pd.path);
128
+ const abs = path.resolve(baseCwd, expanded);
129
+ resolved.set(pd.name, abs);
130
+ const isRequired = pd.required !== false; // default true
131
+ if (!existsSync(abs)) {
132
+ if (isRequired) {
133
+ errors.push(`Path "${pd.name}" resolves to "${abs}" which does not exist (required)`);
134
+ }
135
+ else {
136
+ warnings.push(`Path "${pd.name}" resolves to "${abs}" which does not exist (optional)`);
137
+ }
138
+ }
139
+ }
140
+ return { resolved, errors, warnings };
141
+ }
142
+ /**
143
+ * Resolve an agent's effective working directory, considering `workdir` (named path reference)
144
+ * and `cwd` (explicit path). `workdir` takes precedence when both are set.
145
+ */
146
+ resolveAgentCwd(agent) {
147
+ if (agent.workdir) {
148
+ const resolved = this.resolvedPaths.get(agent.workdir);
149
+ if (!resolved) {
150
+ throw new Error(`Agent "${agent.name}" references workdir "${agent.workdir}" which is not defined in paths`);
151
+ }
152
+ return resolved;
153
+ }
154
+ if (agent.cwd) {
155
+ return path.resolve(this.cwd, agent.cwd);
156
+ }
157
+ return this.cwd;
158
+ }
159
+ /**
160
+ * Resolve a step's working directory from its `workdir` field (named path reference).
161
+ * Returns undefined if no workdir is set.
162
+ */
163
+ resolveStepWorkdir(step) {
164
+ if (!step.workdir)
165
+ return undefined;
166
+ const resolved = this.resolvedPaths.get(step.workdir);
167
+ if (!resolved) {
168
+ throw new Error(`Step "${step.name}" references workdir "${step.workdir}" which is not defined in paths`);
169
+ }
170
+ return resolved;
171
+ }
98
172
  // ── Progress logging ────────────────────────────────────────────────────
99
173
  /** Log a progress message with elapsed time since run start. */
100
174
  log(msg) {
@@ -346,6 +420,17 @@ export class WorkflowRunner {
346
420
  estimatedWaves: 0,
347
421
  };
348
422
  }
423
+ // 1b. Resolve and validate named paths
424
+ const pathResult = this.resolvePathDefinitions(resolved.paths, this.cwd);
425
+ errors.push(...pathResult.errors);
426
+ warnings.push(...pathResult.warnings);
427
+ const dryRunPaths = pathResult.resolved;
428
+ // Validate workdir references on agents
429
+ for (const agent of resolved.agents) {
430
+ if (agent.workdir && !dryRunPaths.has(agent.workdir)) {
431
+ errors.push(`Agent "${agent.name}" references workdir "${agent.workdir}" which is not defined in paths`);
432
+ }
433
+ }
349
434
  // 2. Find target workflow
350
435
  const workflows = resolved.workflows ?? [];
351
436
  const workflow = workflowName ? workflows.find((w) => w.name === workflowName) : workflows[0];
@@ -411,6 +496,12 @@ export class WorkflowRunner {
411
496
  stepAgentCounts.set(step.agent, (stepAgentCounts.get(step.agent) ?? 0) + 1);
412
497
  }
413
498
  }
499
+ // Validate workdir references on steps
500
+ for (const step of resolvedSteps) {
501
+ if (step.workdir && !dryRunPaths.has(step.workdir)) {
502
+ errors.push(`Step "${step.name}" references workdir "${step.workdir}" which is not defined in paths`);
503
+ }
504
+ }
414
505
  // Validate cwd paths
415
506
  for (const agent of resolved.agents) {
416
507
  if (agent.cwd) {
@@ -499,7 +590,7 @@ export class WorkflowRunner {
499
590
  name: a.name,
500
591
  cli: a.cli,
501
592
  role: a.role,
502
- cwd: a.cwd,
593
+ cwd: a.workdir ? dryRunPaths.get(a.workdir) : a.cwd,
503
594
  stepCount: stepAgentCounts.get(a.name) ?? 0,
504
595
  }));
505
596
  // 5. Simulate execution waves
@@ -775,6 +866,17 @@ export class WorkflowRunner {
775
866
  /** Execute a named workflow from a validated config. */
776
867
  async execute(config, workflowName, vars) {
777
868
  const resolved = vars ? this.resolveVariables(config, vars) : config;
869
+ // Resolve and validate named paths from the top-level `paths` config
870
+ const pathResult = this.resolvePathDefinitions(resolved.paths, this.cwd);
871
+ if (pathResult.errors.length > 0) {
872
+ throw new Error(`Path validation failed:\n ${pathResult.errors.join('\n ')}`);
873
+ }
874
+ this.resolvedPaths = pathResult.resolved;
875
+ if (this.resolvedPaths.size > 0) {
876
+ for (const [name, abs] of this.resolvedPaths) {
877
+ console.log(`[workflow] path "${name}" → ${abs}`);
878
+ }
879
+ }
778
880
  const workflows = resolved.workflows ?? [];
779
881
  const workflow = workflowName ? workflows.find((w) => w.name === workflowName) : workflows[0];
780
882
  if (!workflow) {
@@ -841,6 +943,12 @@ export class WorkflowRunner {
841
943
  throw new Error(`Run "${runId}" is in status "${run.status}" and cannot be resumed`);
842
944
  }
843
945
  const config = vars ? this.resolveVariables(run.config, vars) : run.config;
946
+ // Resolve path definitions (same as execute()) so workdir lookups work on resume
947
+ const pathResult = this.resolvePathDefinitions(config.paths, this.cwd);
948
+ if (pathResult.errors.length > 0) {
949
+ throw new Error(`Path validation failed:\n ${pathResult.errors.join('\n ')}`);
950
+ }
951
+ this.resolvedPaths = pathResult.resolved;
844
952
  const workflows = config.workflows ?? [];
845
953
  const workflow = workflows.find((w) => w.name === run.workflowName);
846
954
  if (!workflow) {
@@ -988,6 +1096,10 @@ export class WorkflowRunner {
988
1096
  const fromShort = msg.from.replace(/-[a-f0-9]{6,}$/, '');
989
1097
  const toShort = msg.to.replace(/-[a-f0-9]{6,}$/, '');
990
1098
  this.log(`[msg] ${fromShort} → ${toShort}: ${body}`);
1099
+ const supervision = this.supervisedRuntimeAgents.get(msg.from);
1100
+ if (supervision?.role === 'owner') {
1101
+ void this.trajectory?.ownerMonitoringEvent(supervision.stepName, supervision.logicalName, `Messaged ${msg.to}: ${msg.text.slice(0, 120)}`, { to: msg.to, text: msg.text });
1102
+ }
991
1103
  };
992
1104
  this.relay.onAgentSpawned = (agent) => {
993
1105
  // Skip agents already managed by step execution
@@ -1101,6 +1213,7 @@ export class WorkflowRunner {
1101
1213
  }
1102
1214
  this.lastIdleLog.clear();
1103
1215
  this.lastActivity.clear();
1216
+ this.supervisedRuntimeAgents.clear();
1104
1217
  this.log('Shutting down broker...');
1105
1218
  await this.relay?.shutdown();
1106
1219
  this.relay = undefined;
@@ -1386,10 +1499,12 @@ export class WorkflowRunner {
1386
1499
  const value = this.resolveDotPath(key, stepOutputContext);
1387
1500
  return value !== undefined ? String(value) : _match;
1388
1501
  });
1502
+ // Resolve step workdir (named path reference) for deterministic steps
1503
+ const stepCwd = this.resolveStepWorkdir(step) ?? this.cwd;
1389
1504
  try {
1390
1505
  // Delegate to executor if present
1391
1506
  if (this.executor?.executeDeterministicStep) {
1392
- const result = await this.executor.executeDeterministicStep(step, resolvedCommand, this.cwd);
1507
+ const result = await this.executor.executeDeterministicStep(step, resolvedCommand, stepCwd);
1393
1508
  const failOnError = step.failOnError !== false;
1394
1509
  if (failOnError && result.exitCode !== 0) {
1395
1510
  throw new Error(`Command failed with exit code ${result.exitCode}: ${result.output.slice(0, 500)}`);
@@ -1412,7 +1527,7 @@ export class WorkflowRunner {
1412
1527
  const output = await new Promise((resolve, reject) => {
1413
1528
  const child = cpSpawn('sh', ['-c', resolvedCommand], {
1414
1529
  stdio: 'pipe',
1415
- cwd: this.cwd,
1530
+ cwd: stepCwd,
1416
1531
  env: { ...process.env },
1417
1532
  });
1418
1533
  const stdoutChunks = [];
@@ -1527,6 +1642,8 @@ export class WorkflowRunner {
1527
1642
  ? this.interpolateStepTask(step.path, stepOutputContext)
1528
1643
  : path.join('.worktrees', step.name);
1529
1644
  const createBranch = step.createBranch !== false;
1645
+ // Resolve workdir for worktree steps (same as deterministic/agent steps)
1646
+ const stepCwd = this.resolveStepWorkdir(step) ?? this.cwd;
1530
1647
  if (!branch) {
1531
1648
  const errorMsg = 'Worktree step missing required "branch" field';
1532
1649
  await this.markStepFailed(state, errorMsg, runId);
@@ -1535,14 +1652,14 @@ export class WorkflowRunner {
1535
1652
  try {
1536
1653
  // Build the git worktree command
1537
1654
  // If createBranch is true and branch doesn't exist, use -b flag
1538
- const absoluteWorktreePath = path.resolve(this.cwd, worktreePath);
1655
+ const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);
1539
1656
  // First, check if the branch already exists
1540
1657
  const checkBranchCmd = `git rev-parse --verify --quiet ${branch} 2>/dev/null`;
1541
1658
  let branchExists = false;
1542
1659
  await new Promise((resolve) => {
1543
1660
  const checkChild = cpSpawn('sh', ['-c', checkBranchCmd], {
1544
1661
  stdio: 'pipe',
1545
- cwd: this.cwd,
1662
+ cwd: stepCwd,
1546
1663
  env: { ...process.env },
1547
1664
  });
1548
1665
  checkChild.on('close', (code) => {
@@ -1570,7 +1687,7 @@ export class WorkflowRunner {
1570
1687
  const output = await new Promise((resolve, reject) => {
1571
1688
  const child = cpSpawn('sh', ['-c', worktreeCmd], {
1572
1689
  stdio: 'pipe',
1573
- cwd: this.cwd,
1690
+ cwd: stepCwd,
1574
1691
  env: { ...process.env },
1575
1692
  });
1576
1693
  const stdoutChunks = [];
@@ -1669,10 +1786,26 @@ export class WorkflowRunner {
1669
1786
  if (!rawAgentDef) {
1670
1787
  throw new Error(`Agent "${agentName}" not found in config`);
1671
1788
  }
1672
- const agentDef = WorkflowRunner.resolveAgentDef(rawAgentDef);
1673
- const maxRetries = step.retries ?? agentDef.constraints?.retries ?? errorHandling?.maxRetries ?? 0;
1789
+ const specialistDef = WorkflowRunner.resolveAgentDef(rawAgentDef);
1790
+ const usesOwnerFlow = specialistDef.interactive !== false;
1791
+ const ownerDef = usesOwnerFlow ? this.resolveAutoStepOwner(specialistDef, agentMap) : specialistDef;
1792
+ const reviewDef = usesOwnerFlow ? this.resolveAutoReviewAgent(ownerDef, agentMap) : undefined;
1793
+ const supervised = {
1794
+ specialist: specialistDef,
1795
+ owner: ownerDef,
1796
+ reviewer: reviewDef,
1797
+ };
1798
+ const usesDedicatedOwner = usesOwnerFlow && ownerDef.name !== specialistDef.name;
1799
+ const maxRetries = step.retries ??
1800
+ ownerDef.constraints?.retries ??
1801
+ specialistDef.constraints?.retries ??
1802
+ errorHandling?.maxRetries ??
1803
+ 0;
1674
1804
  const retryDelay = errorHandling?.retryDelayMs ?? 1000;
1675
- const timeoutMs = step.timeoutMs ?? agentDef.constraints?.timeoutMs ?? this.currentConfig?.swarm?.timeoutMs;
1805
+ const timeoutMs = step.timeoutMs ??
1806
+ ownerDef.constraints?.timeoutMs ??
1807
+ specialistDef.constraints?.timeoutMs ??
1808
+ this.currentConfig?.swarm?.timeoutMs;
1676
1809
  let lastError;
1677
1810
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1678
1811
  this.checkAborted();
@@ -1697,52 +1830,110 @@ export class WorkflowRunner {
1697
1830
  updatedAt: new Date().toISOString(),
1698
1831
  });
1699
1832
  this.emit({ type: 'step:started', runId, stepName: step.name });
1700
- this.postToChannel(`**[${step.name}]** Started (agent: ${agentDef.name})`);
1701
- await this.trajectory?.stepStarted(step, agentDef.name);
1833
+ this.postToChannel(`**[${step.name}]** Started (owner: ${ownerDef.name}, specialist: ${specialistDef.name})`);
1834
+ await this.trajectory?.stepStarted(step, ownerDef.name, {
1835
+ role: usesDedicatedOwner ? 'owner' : 'specialist',
1836
+ owner: ownerDef.name,
1837
+ specialist: specialistDef.name,
1838
+ reviewer: reviewDef?.name,
1839
+ });
1840
+ if (usesDedicatedOwner) {
1841
+ await this.trajectory?.stepSupervisionAssigned(step, supervised);
1842
+ }
1843
+ this.emit({
1844
+ type: 'step:owner-assigned',
1845
+ runId,
1846
+ stepName: step.name,
1847
+ ownerName: ownerDef.name,
1848
+ specialistName: specialistDef.name,
1849
+ });
1702
1850
  // Resolve step-output variables (e.g. {{steps.plan.output}}) at execution time
1703
1851
  const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
1704
1852
  let resolvedTask = this.interpolateStepTask(step.task ?? '', stepOutputContext);
1705
1853
  // If this is an interactive agent, append awareness of non-interactive workers
1706
1854
  // so the lead knows not to message them and to use step output chaining instead
1707
- if (agentDef.interactive !== false) {
1855
+ if (specialistDef.interactive !== false || ownerDef.interactive !== false) {
1708
1856
  const nonInteractiveInfo = this.buildNonInteractiveAwareness(agentMap, stepStates);
1709
1857
  if (nonInteractiveInfo) {
1710
1858
  resolvedTask += nonInteractiveInfo;
1711
1859
  }
1712
1860
  }
1713
- // Spawn agent via AgentRelay
1714
- this.log(`[${step.name}] Spawning agent "${agentDef.name}" (cli: ${agentDef.cli})`);
1715
- const resolvedStep = { ...step, task: resolvedTask };
1716
- const output = this.executor
1717
- ? await this.executor.executeAgentStep(resolvedStep, agentDef, resolvedTask, timeoutMs)
1718
- : await this.spawnAndWait(agentDef, resolvedStep, timeoutMs);
1719
- this.log(`[${step.name}] Agent "${agentDef.name}" exited`);
1861
+ // Apply step-level workdir override to agent definitions if present
1862
+ const applyStepWorkdir = (def) => {
1863
+ if (step.workdir) {
1864
+ const stepWorkdir = this.resolveStepWorkdir(step);
1865
+ if (stepWorkdir) {
1866
+ return { ...def, cwd: stepWorkdir, workdir: undefined };
1867
+ }
1868
+ }
1869
+ return def;
1870
+ };
1871
+ const effectiveSpecialist = applyStepWorkdir(specialistDef);
1872
+ const effectiveOwner = applyStepWorkdir(ownerDef);
1873
+ let specialistOutput;
1874
+ let ownerOutput;
1875
+ let ownerElapsed;
1876
+ if (usesDedicatedOwner) {
1877
+ const result = await this.executeSupervisedAgentStep(step, { specialist: effectiveSpecialist, owner: effectiveOwner, reviewer: reviewDef }, resolvedTask, timeoutMs);
1878
+ specialistOutput = result.specialistOutput;
1879
+ ownerOutput = result.ownerOutput;
1880
+ ownerElapsed = result.ownerElapsed;
1881
+ }
1882
+ else {
1883
+ const ownerTask = this.injectStepOwnerContract(step, resolvedTask, effectiveOwner, effectiveSpecialist);
1884
+ this.log(`[${step.name}] Spawning owner "${effectiveOwner.name}" (cli: ${effectiveOwner.cli})${step.workdir ? ` [workdir: ${step.workdir}]` : ''}`);
1885
+ const resolvedStep = { ...step, task: ownerTask };
1886
+ const ownerStartTime = Date.now();
1887
+ const output = this.executor
1888
+ ? await this.executor.executeAgentStep(resolvedStep, effectiveOwner, ownerTask, timeoutMs)
1889
+ : await this.spawnAndWait(effectiveOwner, resolvedStep, timeoutMs);
1890
+ ownerElapsed = Date.now() - ownerStartTime;
1891
+ this.log(`[${step.name}] Owner "${effectiveOwner.name}" exited`);
1892
+ if (usesOwnerFlow) {
1893
+ this.assertOwnerCompletionMarker(step, output, ownerTask);
1894
+ }
1895
+ specialistOutput = output;
1896
+ ownerOutput = output;
1897
+ }
1720
1898
  // Run verification if configured
1721
1899
  if (step.verification) {
1722
- this.runVerification(step.verification, output, step.name, resolvedTask);
1900
+ this.runVerification(step.verification, specialistOutput, step.name, resolvedTask);
1901
+ }
1902
+ // Every interactive step gets a review pass; pick a dedicated reviewer when available.
1903
+ let combinedOutput = specialistOutput;
1904
+ if (usesOwnerFlow && reviewDef) {
1905
+ const remainingMs = timeoutMs ? Math.max(0, timeoutMs - ownerElapsed) : undefined;
1906
+ const reviewOutput = await this.runStepReviewGate(step, resolvedTask, specialistOutput, ownerOutput, ownerDef, reviewDef, remainingMs);
1907
+ combinedOutput = this.combineStepAndReviewOutput(specialistOutput, reviewOutput);
1723
1908
  }
1724
1909
  // Mark completed
1725
1910
  state.row.status = 'completed';
1726
- state.row.output = output;
1911
+ state.row.output = combinedOutput;
1727
1912
  state.row.completedAt = new Date().toISOString();
1728
1913
  await this.db.updateStep(state.row.id, {
1729
1914
  status: 'completed',
1730
- output,
1915
+ output: combinedOutput,
1731
1916
  completedAt: state.row.completedAt,
1732
1917
  updatedAt: new Date().toISOString(),
1733
1918
  });
1734
1919
  // Persist step output to disk so it survives restarts and is inspectable
1735
- await this.persistStepOutput(runId, step.name, output);
1736
- this.emit({ type: 'step:completed', runId, stepName: step.name, output });
1737
- await this.trajectory?.stepCompleted(step, output, attempt + 1);
1920
+ await this.persistStepOutput(runId, step.name, combinedOutput);
1921
+ this.emit({ type: 'step:completed', runId, stepName: step.name, output: combinedOutput });
1922
+ await this.trajectory?.stepCompleted(step, combinedOutput, attempt + 1);
1738
1923
  return;
1739
1924
  }
1740
1925
  catch (err) {
1741
1926
  lastError = err instanceof Error ? err.message : String(err);
1927
+ const ownerTimedOut = usesDedicatedOwner
1928
+ ? /\bowner timed out\b/i.test(lastError)
1929
+ : /\btimed out\b/i.test(lastError) && !lastError.includes(`${step.name}-review`);
1930
+ if (ownerTimedOut) {
1931
+ this.emit({ type: 'step:owner-timeout', runId, stepName: step.name, ownerName: ownerDef.name });
1932
+ }
1742
1933
  }
1743
1934
  }
1744
1935
  // All retries exhausted — record root-cause diagnosis and mark failed
1745
- const nonInteractive = agentDef.interactive === false || ['worker', 'reviewer', 'analyst'].includes(agentDef.preset ?? '');
1936
+ const nonInteractive = ownerDef.interactive === false || ['worker', 'reviewer', 'analyst'].includes(ownerDef.preset ?? '');
1746
1937
  const verificationValue = typeof step.verification === 'object' && 'value' in step.verification
1747
1938
  ? String(step.verification.value)
1748
1939
  : undefined;
@@ -1755,6 +1946,430 @@ export class WorkflowRunner {
1755
1946
  await this.markStepFailed(state, lastError ?? 'Unknown error', runId);
1756
1947
  throw new Error(`Step "${step.name}" failed after ${maxRetries} retries: ${lastError ?? 'Unknown error'}`);
1757
1948
  }
1949
+ injectStepOwnerContract(step, resolvedTask, ownerDef, specialistDef) {
1950
+ if (ownerDef.interactive === false)
1951
+ return resolvedTask;
1952
+ const specialistNote = ownerDef.name === specialistDef.name
1953
+ ? ''
1954
+ : `Specialist intended for this step: "${specialistDef.name}" (${specialistDef.role ?? specialistDef.cli}).`;
1955
+ return (resolvedTask +
1956
+ '\n\n---\n' +
1957
+ `STEP OWNER CONTRACT:\n` +
1958
+ `- You are the accountable owner for step "${step.name}".\n` +
1959
+ (specialistNote ? `- ${specialistNote}\n` : '') +
1960
+ `- If you delegate, you must still verify completion yourself.\n` +
1961
+ `- Before exiting, provide an explicit completion line: STEP_COMPLETE:${step.name}\n` +
1962
+ `- Then self-terminate immediately with /exit.`);
1963
+ }
1964
+ buildOwnerSupervisorTask(step, originalTask, supervised, workerRuntimeName) {
1965
+ const verificationGuide = this.buildSupervisorVerificationGuide(step.verification);
1966
+ const channelLine = this.channel ? `#${this.channel}` : '(workflow channel unavailable)';
1967
+ return (`You are the step owner/supervisor for step "${step.name}".\n\n` +
1968
+ `Worker: ${supervised.specialist.name} (runtime: ${workerRuntimeName}) on ${channelLine}\n` +
1969
+ `Task: ${originalTask}\n\n` +
1970
+ `Your job: Monitor the worker and determine when the task is complete.\n\n` +
1971
+ `How to verify completion:\n` +
1972
+ `- Watch ${channelLine} for the worker's progress messages and mirrored PTY output\n` +
1973
+ `- Check file changes: run \`git diff --stat\` or inspect expected files directly\n` +
1974
+ `- Ask the worker directly on ${channelLine} if you need a status update\n` +
1975
+ verificationGuide +
1976
+ `\nWhen you're satisfied the work is done correctly:\n` +
1977
+ `Output exactly: STEP_COMPLETE:${step.name}`);
1978
+ }
1979
+ buildSupervisorVerificationGuide(verification) {
1980
+ if (!verification)
1981
+ return '';
1982
+ switch (verification.type) {
1983
+ case 'output_contains':
1984
+ return `- Verification gate: confirm the worker output contains ${JSON.stringify(verification.value)}\n`;
1985
+ case 'file_exists':
1986
+ return `- Verification gate: confirm the file exists at ${JSON.stringify(verification.value)}\n`;
1987
+ case 'exit_code':
1988
+ return `- Verification gate: confirm the worker exits with code ${JSON.stringify(verification.value)}\n`;
1989
+ case 'custom':
1990
+ return `- Verification gate: apply the custom verification rule ${JSON.stringify(verification.value)}\n`;
1991
+ default:
1992
+ return '';
1993
+ }
1994
+ }
1995
+ async executeSupervisedAgentStep(step, supervised, resolvedTask, timeoutMs) {
1996
+ if (this.executor) {
1997
+ const supervisorTask = this.buildOwnerSupervisorTask(step, resolvedTask, supervised, supervised.specialist.name);
1998
+ const specialistStep = { ...step, task: resolvedTask };
1999
+ const ownerStep = {
2000
+ ...step,
2001
+ name: `${step.name}-owner`,
2002
+ agent: supervised.owner.name,
2003
+ task: supervisorTask,
2004
+ };
2005
+ this.log(`[${step.name}] Spawning specialist "${supervised.specialist.name}" and owner "${supervised.owner.name}"`);
2006
+ const specialistPromise = this.executor.executeAgentStep(specialistStep, supervised.specialist, resolvedTask, timeoutMs);
2007
+ const ownerStartTime = Date.now();
2008
+ const ownerOutput = await this.executor.executeAgentStep(ownerStep, supervised.owner, supervisorTask, timeoutMs);
2009
+ const ownerElapsed = Date.now() - ownerStartTime;
2010
+ this.assertOwnerCompletionMarker(step, ownerOutput, supervisorTask);
2011
+ const specialistOutput = await specialistPromise;
2012
+ return { specialistOutput, ownerOutput, ownerElapsed };
2013
+ }
2014
+ let workerHandle;
2015
+ let workerRuntimeName = supervised.specialist.name;
2016
+ let workerSpawned = false;
2017
+ let workerReleased = false;
2018
+ let resolveWorkerSpawn;
2019
+ let rejectWorkerSpawn;
2020
+ const workerReady = new Promise((resolve, reject) => {
2021
+ resolveWorkerSpawn = resolve;
2022
+ rejectWorkerSpawn = reject;
2023
+ });
2024
+ const specialistStep = { ...step, task: resolvedTask };
2025
+ this.log(`[${step.name}] Spawning specialist "${supervised.specialist.name}" (cli: ${supervised.specialist.cli})`);
2026
+ const workerPromise = this.spawnAndWait(supervised.specialist, specialistStep, timeoutMs, {
2027
+ agentNameSuffix: 'worker',
2028
+ onSpawned: ({ actualName, agent }) => {
2029
+ workerHandle = agent;
2030
+ workerRuntimeName = actualName;
2031
+ this.supervisedRuntimeAgents.set(actualName, {
2032
+ stepName: step.name,
2033
+ role: 'specialist',
2034
+ logicalName: supervised.specialist.name,
2035
+ });
2036
+ if (!workerSpawned) {
2037
+ workerSpawned = true;
2038
+ resolveWorkerSpawn();
2039
+ }
2040
+ },
2041
+ onChunk: ({ agentName, chunk }) => {
2042
+ this.forwardAgentChunkToChannel(step.name, 'Worker', agentName, chunk);
2043
+ },
2044
+ }).catch((error) => {
2045
+ if (!workerSpawned) {
2046
+ workerSpawned = true;
2047
+ rejectWorkerSpawn(error);
2048
+ }
2049
+ throw error;
2050
+ });
2051
+ const workerSettled = workerPromise.catch(() => undefined);
2052
+ workerPromise
2053
+ .then((output) => {
2054
+ workerReleased = true;
2055
+ this.postToChannel(`**[${step.name}]** Worker \`${workerRuntimeName}\` exited`);
2056
+ if (step.verification?.type === 'output_contains' && output.includes(step.verification.value)) {
2057
+ this.postToChannel(`**[${step.name}]** Verification gate observed: output contains ${JSON.stringify(step.verification.value)}`);
2058
+ }
2059
+ })
2060
+ .catch((error) => {
2061
+ const message = error instanceof Error ? error.message : String(error);
2062
+ this.postToChannel(`**[${step.name}]** Worker \`${workerRuntimeName}\` exited with error: ${message}`);
2063
+ });
2064
+ await workerReady;
2065
+ const supervisorTask = this.buildOwnerSupervisorTask(step, resolvedTask, supervised, workerRuntimeName);
2066
+ const ownerStep = {
2067
+ ...step,
2068
+ name: `${step.name}-owner`,
2069
+ agent: supervised.owner.name,
2070
+ task: supervisorTask,
2071
+ };
2072
+ this.log(`[${step.name}] Spawning owner "${supervised.owner.name}" (cli: ${supervised.owner.cli})`);
2073
+ const ownerStartTime = Date.now();
2074
+ try {
2075
+ const ownerOutput = await this.spawnAndWait(supervised.owner, ownerStep, timeoutMs, {
2076
+ agentNameSuffix: 'owner',
2077
+ onSpawned: ({ actualName }) => {
2078
+ this.supervisedRuntimeAgents.set(actualName, {
2079
+ stepName: step.name,
2080
+ role: 'owner',
2081
+ logicalName: supervised.owner.name,
2082
+ });
2083
+ },
2084
+ onChunk: ({ chunk }) => {
2085
+ void this.recordOwnerMonitoringChunk(step, supervised.owner, chunk);
2086
+ },
2087
+ });
2088
+ const ownerElapsed = Date.now() - ownerStartTime;
2089
+ this.log(`[${step.name}] Owner "${supervised.owner.name}" exited`);
2090
+ this.assertOwnerCompletionMarker(step, ownerOutput, supervisorTask);
2091
+ const specialistOutput = await workerPromise;
2092
+ return { specialistOutput, ownerOutput, ownerElapsed };
2093
+ }
2094
+ catch (error) {
2095
+ const message = error instanceof Error ? error.message : String(error);
2096
+ if (!workerReleased && workerHandle) {
2097
+ await workerHandle.release().catch(() => undefined);
2098
+ }
2099
+ await workerSettled;
2100
+ if (/\btimed out\b/i.test(message)) {
2101
+ throw new Error(`Step "${step.name}" owner timed out after ${timeoutMs ?? 'unknown'}ms`);
2102
+ }
2103
+ throw error;
2104
+ }
2105
+ }
2106
+ forwardAgentChunkToChannel(stepName, roleLabel, agentName, chunk) {
2107
+ const lines = WorkflowRunner.stripAnsi(chunk)
2108
+ .split('\n')
2109
+ .map((line) => line.trim())
2110
+ .filter(Boolean)
2111
+ .slice(0, 3);
2112
+ for (const line of lines) {
2113
+ this.postToChannel(`**[${stepName}]** ${roleLabel} \`${agentName}\`: ${line.slice(0, 280)}`);
2114
+ }
2115
+ }
2116
+ async recordOwnerMonitoringChunk(step, ownerDef, chunk) {
2117
+ const stripped = WorkflowRunner.stripAnsi(chunk);
2118
+ const details = [];
2119
+ if (/git diff --stat/i.test(stripped))
2120
+ details.push('Checked git diff stats');
2121
+ if (/\bls -la\b/i.test(stripped))
2122
+ details.push('Listed files for verification');
2123
+ if (/status update\?/i.test(stripped))
2124
+ details.push('Asked the worker for a status update');
2125
+ if (/STEP_COMPLETE:/i.test(stripped))
2126
+ details.push('Declared the step complete');
2127
+ for (const detail of details) {
2128
+ await this.trajectory?.ownerMonitoringEvent(step.name, ownerDef.name, detail, {
2129
+ output: stripped.slice(0, 240),
2130
+ });
2131
+ }
2132
+ }
2133
+ resolveAutoStepOwner(specialistDef, agentMap) {
2134
+ if (specialistDef.interactive === false)
2135
+ return specialistDef;
2136
+ const allDefs = [...agentMap.values()].map((d) => WorkflowRunner.resolveAgentDef(d));
2137
+ const candidates = allDefs.filter((d) => d.interactive !== false);
2138
+ const matchesHubRole = (text) => [...WorkflowRunner.HUB_ROLES].some((r) => new RegExp(`\\b${r}\\b`, 'i').test(text));
2139
+ const ownerish = (def) => {
2140
+ const nameLC = def.name.toLowerCase();
2141
+ const roleLC = def.role?.toLowerCase() ?? '';
2142
+ return matchesHubRole(nameLC) || matchesHubRole(roleLC);
2143
+ };
2144
+ const ownerPriority = (def) => {
2145
+ const roleLC = def.role?.toLowerCase() ?? '';
2146
+ const nameLC = def.name.toLowerCase();
2147
+ if (/\blead\b/.test(roleLC) || /\blead\b/.test(nameLC))
2148
+ return 6;
2149
+ if (/\bcoordinator\b/.test(roleLC) || /\bcoordinator\b/.test(nameLC))
2150
+ return 5;
2151
+ if (/\bsupervisor\b/.test(roleLC) || /\bsupervisor\b/.test(nameLC))
2152
+ return 4;
2153
+ if (/\borchestrator\b/.test(roleLC) || /\borchestrator\b/.test(nameLC))
2154
+ return 3;
2155
+ if (/\bhub\b/.test(roleLC) || /\bhub\b/.test(nameLC))
2156
+ return 2;
2157
+ return ownerish(def) ? 1 : 0;
2158
+ };
2159
+ const dedicatedOwner = candidates
2160
+ .filter((d) => d.name !== specialistDef.name && ownerish(d))
2161
+ .sort((a, b) => ownerPriority(b) - ownerPriority(a) || a.name.localeCompare(b.name))[0];
2162
+ if (dedicatedOwner)
2163
+ return dedicatedOwner;
2164
+ return specialistDef;
2165
+ }
2166
+ resolveAutoReviewAgent(ownerDef, agentMap) {
2167
+ const allDefs = [...agentMap.values()].map((d) => WorkflowRunner.resolveAgentDef(d));
2168
+ const isReviewer = (def) => {
2169
+ const roleLC = def.role?.toLowerCase() ?? '';
2170
+ const nameLC = def.name.toLowerCase();
2171
+ return (def.preset === 'reviewer' ||
2172
+ roleLC.includes('review') ||
2173
+ roleLC.includes('critic') ||
2174
+ roleLC.includes('verifier') ||
2175
+ roleLC.includes('qa') ||
2176
+ nameLC.includes('review'));
2177
+ };
2178
+ const reviewerPriority = (def) => {
2179
+ if (def.preset === 'reviewer')
2180
+ return 5;
2181
+ const roleLC = def.role?.toLowerCase() ?? '';
2182
+ const nameLC = def.name.toLowerCase();
2183
+ if (roleLC.includes('review') || nameLC.includes('review'))
2184
+ return 4;
2185
+ if (roleLC.includes('verifier') || roleLC.includes('qa'))
2186
+ return 3;
2187
+ if (roleLC.includes('critic'))
2188
+ return 2;
2189
+ return isReviewer(def) ? 1 : 0;
2190
+ };
2191
+ const dedicated = allDefs
2192
+ .filter((d) => d.name !== ownerDef.name && isReviewer(d))
2193
+ .sort((a, b) => reviewerPriority(b) - reviewerPriority(a) || a.name.localeCompare(b.name))[0];
2194
+ if (dedicated)
2195
+ return dedicated;
2196
+ const alternate = allDefs.find((d) => d.name !== ownerDef.name && d.interactive !== false);
2197
+ if (alternate)
2198
+ return alternate;
2199
+ // Self-review fallback — log a warning since owner reviewing itself is weak.
2200
+ return ownerDef;
2201
+ }
2202
+ assertOwnerCompletionMarker(step, output, injectedTaskText) {
2203
+ const marker = `STEP_COMPLETE:${step.name}`;
2204
+ const taskHasMarker = injectedTaskText.includes(marker);
2205
+ const first = output.indexOf(marker);
2206
+ if (first === -1) {
2207
+ throw new Error(`Step "${step.name}" owner completion marker missing: "${marker}"`);
2208
+ }
2209
+ // PTY output includes injected task text, so require a second marker occurrence
2210
+ // when the marker was present in the injected prompt (either owner contract or supervisor prompt).
2211
+ const outputLikelyContainsInjectedPrompt = output.includes('STEP OWNER CONTRACT') || output.includes('Output exactly: STEP_COMPLETE:');
2212
+ if (taskHasMarker && outputLikelyContainsInjectedPrompt) {
2213
+ const hasSecond = output.includes(marker, first + marker.length);
2214
+ if (!hasSecond) {
2215
+ throw new Error(`Step "${step.name}" owner completion marker missing in agent response: "${marker}"`);
2216
+ }
2217
+ }
2218
+ }
2219
+ async runStepReviewGate(step, resolvedTask, specialistOutput, ownerOutput, ownerDef, reviewerDef, timeoutMs) {
2220
+ const reviewSnippetMax = 12_000;
2221
+ let specialistSnippet = specialistOutput;
2222
+ if (specialistOutput.length > reviewSnippetMax) {
2223
+ const head = Math.floor(reviewSnippetMax / 2);
2224
+ const tail = reviewSnippetMax - head;
2225
+ const omitted = specialistOutput.length - head - tail;
2226
+ specialistSnippet =
2227
+ `${specialistOutput.slice(0, head)}\n` +
2228
+ `...[truncated ${omitted} chars for review]...\n` +
2229
+ `${specialistOutput.slice(specialistOutput.length - tail)}`;
2230
+ }
2231
+ let ownerSnippet = ownerOutput;
2232
+ if (ownerOutput.length > reviewSnippetMax) {
2233
+ const head = Math.floor(reviewSnippetMax / 2);
2234
+ const tail = reviewSnippetMax - head;
2235
+ const omitted = ownerOutput.length - head - tail;
2236
+ ownerSnippet =
2237
+ `${ownerOutput.slice(0, head)}\n` +
2238
+ `...[truncated ${omitted} chars for review]...\n` +
2239
+ `${ownerOutput.slice(ownerOutput.length - tail)}`;
2240
+ }
2241
+ const reviewTask = `Review workflow step "${step.name}" for completion and safe handoff.\n` +
2242
+ `Step owner: ${ownerDef.name}\n` +
2243
+ `Original objective:\n${resolvedTask}\n\n` +
2244
+ `Specialist output:\n${specialistSnippet}\n\n` +
2245
+ `Owner verification notes:\n${ownerSnippet}\n\n` +
2246
+ `Return exactly:\n` +
2247
+ `REVIEW_DECISION: APPROVE or REJECT\n` +
2248
+ `REVIEW_REASON: <one sentence>\n` +
2249
+ `Then output /exit.`;
2250
+ const safetyTimeoutMs = timeoutMs ?? 600_000;
2251
+ const reviewStep = {
2252
+ name: `${step.name}-review`,
2253
+ type: 'agent',
2254
+ agent: reviewerDef.name,
2255
+ task: reviewTask,
2256
+ };
2257
+ await this.trajectory?.registerAgent(reviewerDef.name, 'reviewer');
2258
+ this.postToChannel(`**[${step.name}]** Review started (reviewer: ${reviewerDef.name})`);
2259
+ const emitReviewCompleted = async (decision, reason) => {
2260
+ await this.trajectory?.reviewCompleted(step.name, reviewerDef.name, decision, reason);
2261
+ this.emit({
2262
+ type: 'step:review-completed',
2263
+ runId: this.currentRunId ?? '',
2264
+ stepName: step.name,
2265
+ reviewerName: reviewerDef.name,
2266
+ decision,
2267
+ });
2268
+ };
2269
+ if (this.executor) {
2270
+ const reviewOutput = await this.executor.executeAgentStep(reviewStep, reviewerDef, reviewTask, safetyTimeoutMs);
2271
+ const parsed = this.parseReviewDecision(reviewOutput);
2272
+ if (!parsed) {
2273
+ throw new Error(`Step "${step.name}" review response malformed from "${reviewerDef.name}" (missing REVIEW_DECISION)`);
2274
+ }
2275
+ await emitReviewCompleted(parsed.decision, parsed.reason);
2276
+ if (parsed.decision === 'rejected') {
2277
+ throw new Error(`Step "${step.name}" review rejected by "${reviewerDef.name}"`);
2278
+ }
2279
+ this.postToChannel(`**[${step.name}]** Review approved by \`${reviewerDef.name}\``);
2280
+ return reviewOutput;
2281
+ }
2282
+ let reviewerHandle;
2283
+ let reviewerReleased = false;
2284
+ let reviewOutput = '';
2285
+ let completedReview;
2286
+ let reviewCompletionPromise;
2287
+ const reviewCompletionStarted = { value: false };
2288
+ const startReviewCompletion = (parsed) => {
2289
+ if (reviewCompletionStarted.value)
2290
+ return;
2291
+ reviewCompletionStarted.value = true;
2292
+ completedReview = parsed;
2293
+ reviewCompletionPromise = (async () => {
2294
+ await emitReviewCompleted(parsed.decision, parsed.reason);
2295
+ if (reviewerHandle && !reviewerReleased) {
2296
+ reviewerReleased = true;
2297
+ await reviewerHandle.release().catch(() => undefined);
2298
+ }
2299
+ })();
2300
+ };
2301
+ try {
2302
+ reviewOutput = await this.spawnAndWait(reviewerDef, reviewStep, safetyTimeoutMs, {
2303
+ onSpawned: ({ agent }) => {
2304
+ reviewerHandle = agent;
2305
+ },
2306
+ onChunk: ({ chunk }) => {
2307
+ const nextOutput = reviewOutput + WorkflowRunner.stripAnsi(chunk);
2308
+ reviewOutput = nextOutput;
2309
+ const parsed = this.parseReviewDecision(nextOutput);
2310
+ if (parsed) {
2311
+ startReviewCompletion(parsed);
2312
+ }
2313
+ },
2314
+ });
2315
+ await reviewCompletionPromise;
2316
+ }
2317
+ catch (error) {
2318
+ const message = error instanceof Error ? error.message : String(error);
2319
+ if (/\btimed out\b/i.test(message)) {
2320
+ this.log(`[${step.name}] Review safety backstop timeout fired after ${safetyTimeoutMs}ms`);
2321
+ throw new Error(`Step "${step.name}" review safety backstop timed out after ${safetyTimeoutMs}ms`);
2322
+ }
2323
+ throw error;
2324
+ }
2325
+ if (!completedReview) {
2326
+ const parsed = this.parseReviewDecision(reviewOutput);
2327
+ if (!parsed) {
2328
+ throw new Error(`Step "${step.name}" review response malformed from "${reviewerDef.name}" (missing REVIEW_DECISION)`);
2329
+ }
2330
+ completedReview = parsed;
2331
+ await emitReviewCompleted(parsed.decision, parsed.reason);
2332
+ }
2333
+ if (completedReview.decision === 'rejected') {
2334
+ throw new Error(`Step "${step.name}" review rejected by "${reviewerDef.name}"`);
2335
+ }
2336
+ this.postToChannel(`**[${step.name}]** Review approved by \`${reviewerDef.name}\``);
2337
+ return reviewOutput;
2338
+ }
2339
+ parseReviewDecision(reviewOutput) {
2340
+ const decisionPattern = /REVIEW_DECISION:\s*(APPROVE|REJECT)/gi;
2341
+ const decisionMatches = [...reviewOutput.matchAll(decisionPattern)];
2342
+ if (decisionMatches.length === 0) {
2343
+ return null;
2344
+ }
2345
+ const outputLikelyContainsEchoedPrompt = reviewOutput.includes('Return exactly') || reviewOutput.includes('REVIEW_DECISION: APPROVE or REJECT');
2346
+ const decisionMatch = outputLikelyContainsEchoedPrompt && decisionMatches.length > 1
2347
+ ? decisionMatches[decisionMatches.length - 1]
2348
+ : decisionMatches[0];
2349
+ const decision = decisionMatch?.[1]?.toUpperCase();
2350
+ if (decision !== 'APPROVE' && decision !== 'REJECT') {
2351
+ return null;
2352
+ }
2353
+ const reasonPattern = /REVIEW_REASON:\s*(.+)/gi;
2354
+ const reasonMatches = [...reviewOutput.matchAll(reasonPattern)];
2355
+ const reasonMatch = outputLikelyContainsEchoedPrompt && reasonMatches.length > 1
2356
+ ? reasonMatches[reasonMatches.length - 1]
2357
+ : reasonMatches[0];
2358
+ const reason = reasonMatch?.[1]?.trim();
2359
+ return {
2360
+ decision: decision === 'APPROVE' ? 'approved' : 'rejected',
2361
+ reason: reason && reason !== '<one sentence>' ? reason : undefined,
2362
+ };
2363
+ }
2364
+ combineStepAndReviewOutput(stepOutput, reviewOutput) {
2365
+ const primary = stepOutput.trimEnd();
2366
+ const review = reviewOutput.trim();
2367
+ if (!review)
2368
+ return primary;
2369
+ if (!primary)
2370
+ return `REVIEW_OUTPUT\n${review}\n`;
2371
+ return `${primary}\n\n---\nREVIEW_OUTPUT\n${review}\n`;
2372
+ }
1758
2373
  /**
1759
2374
  * Build the CLI command and arguments for a non-interactive agent execution.
1760
2375
  * Each CLI has a specific flag for one-shot prompt mode.
@@ -1882,7 +2497,7 @@ export class WorkflowRunner {
1882
2497
  const output = await new Promise((resolve, reject) => {
1883
2498
  const child = cpSpawn(cmd, args, {
1884
2499
  stdio: ['ignore', 'pipe', 'pipe'],
1885
- cwd: agentDef.cwd ? path.resolve(this.cwd, agentDef.cwd) : this.cwd,
2500
+ cwd: this.resolveAgentCwd(agentDef),
1886
2501
  env: this.getRelayEnv() ?? { ...process.env },
1887
2502
  });
1888
2503
  // Update workers.json with PID now that we have it
@@ -1978,7 +2593,7 @@ export class WorkflowRunner {
1978
2593
  this.unregisterWorker(agentName);
1979
2594
  }
1980
2595
  }
1981
- async spawnAndWait(agentDef, step, timeoutMs) {
2596
+ async spawnAndWait(agentDef, step, timeoutMs, options = {}) {
1982
2597
  // Branch: non-interactive agents run as simple subprocesses
1983
2598
  if (agentDef.interactive === false) {
1984
2599
  return this.execNonInteractive(agentDef, step, timeoutMs);
@@ -1986,13 +2601,15 @@ export class WorkflowRunner {
1986
2601
  if (!this.relay) {
1987
2602
  throw new Error('AgentRelay not initialized');
1988
2603
  }
1989
- // Deterministic name: step name + first 8 chars of run ID.
1990
- let agentName = `${step.name}-${(this.currentRunId ?? this.generateShortId()).slice(0, 8)}`;
2604
+ // Deterministic name: step name + optional role suffix + first 8 chars of run ID.
2605
+ const requestedName = `${step.name}${options.agentNameSuffix ? `-${options.agentNameSuffix}` : ''}-${(this.currentRunId ?? this.generateShortId()).slice(0, 8)}`;
2606
+ let agentName = requestedName;
1991
2607
  // Only inject delegation guidance for lead/coordinator agents, not spokes/workers.
1992
2608
  // In non-hub patterns (pipeline, dag, etc.) every agent is autonomous so they all get it.
1993
2609
  const role = agentDef.role?.toLowerCase() ?? '';
1994
2610
  const nameLC = agentDef.name.toLowerCase();
1995
- const isHub = WorkflowRunner.HUB_ROLES.has(nameLC) || [...WorkflowRunner.HUB_ROLES].some((r) => role.includes(r));
2611
+ const isHub = WorkflowRunner.HUB_ROLES.has(nameLC) ||
2612
+ [...WorkflowRunner.HUB_ROLES].some((r) => new RegExp(`\\b${r}\\b`).test(role));
1996
2613
  const pattern = this.currentConfig?.swarm.pattern;
1997
2614
  const isHubPattern = pattern && WorkflowRunner.HUB_PATTERNS.has(pattern);
1998
2615
  const delegationGuidance = isHub || !isHubPattern ? this.buildDelegationGuidance(agentDef.cli, timeoutMs) : '';
@@ -2021,6 +2638,7 @@ export class WorkflowRunner {
2021
2638
  // Write raw output (with ANSI codes) to log file so dashboard's
2022
2639
  // XTermLogViewer can render colors/formatting natively via xterm.js
2023
2640
  logStream.write(chunk);
2641
+ options.onChunk?.({ agentName, chunk });
2024
2642
  });
2025
2643
  const agentChannels = this.channel ? [this.channel] : agentDef.channels;
2026
2644
  let agent;
@@ -2028,6 +2646,7 @@ export class WorkflowRunner {
2028
2646
  let stopHeartbeat;
2029
2647
  let ptyChunks = [];
2030
2648
  try {
2649
+ const agentCwd = this.resolveAgentCwd(agentDef);
2031
2650
  agent = await this.relay.spawnPty({
2032
2651
  name: agentName,
2033
2652
  cli: agentDef.cli,
@@ -2036,7 +2655,7 @@ export class WorkflowRunner {
2036
2655
  channels: agentChannels,
2037
2656
  task: taskWithExit,
2038
2657
  idleThresholdSecs: agentDef.constraints?.idleThresholdSecs,
2039
- cwd: agentDef.cwd ? path.resolve(this.cwd, agentDef.cwd) : undefined,
2658
+ cwd: agentCwd !== this.cwd ? agentCwd : undefined,
2040
2659
  });
2041
2660
  // Re-key PTY maps if broker assigned a different name than requested
2042
2661
  if (agent.name !== agentName) {
@@ -2068,10 +2687,12 @@ export class WorkflowRunner {
2068
2687
  const stripped = WorkflowRunner.stripAnsi(chunk);
2069
2688
  this.ptyOutputBuffers.get(agent.name)?.push(stripped);
2070
2689
  newLogStream.write(chunk);
2690
+ options.onChunk?.({ agentName: agent.name, chunk });
2071
2691
  });
2072
2692
  }
2073
2693
  agentName = agent.name;
2074
2694
  }
2695
+ await options.onSpawned?.({ requestedName, actualName: agent.name, agent });
2075
2696
  // Register in workers.json so `agents:kill` can find this agent
2076
2697
  let workerPid;
2077
2698
  try {
@@ -2141,6 +2762,7 @@ export class WorkflowRunner {
2141
2762
  this.ptyLogStreams.delete(agentName);
2142
2763
  }
2143
2764
  this.unregisterWorker(agentName);
2765
+ this.supervisedRuntimeAgents.delete(agentName);
2144
2766
  }
2145
2767
  let output;
2146
2768
  if (ptyChunks.length > 0) {
@@ -2298,7 +2920,7 @@ export class WorkflowRunner {
2298
2920
  const role = agentDef.role?.toLowerCase() ?? '';
2299
2921
  const nameLC = agentDef.name.toLowerCase();
2300
2922
  if (WorkflowRunner.HUB_ROLES.has(nameLC) ||
2301
- [...WorkflowRunner.HUB_ROLES].some((r) => role.includes(r))) {
2923
+ [...WorkflowRunner.HUB_ROLES].some((r) => new RegExp(`\\b${r}\\b`).test(role))) {
2302
2924
  // Found a hub candidate — check if we have a live handle
2303
2925
  const handle = this.activeAgentHandles.get(agentDef.name);
2304
2926
  if (handle)