agentxchain 2.27.0 → 2.29.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.27.0",
3
+ "version": "2.29.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,7 +62,6 @@
62
62
  "chalk": "^5.4.0",
63
63
  "commander": "^13.0.0",
64
64
  "inquirer": "^12.0.0",
65
- "ora": "^8.0.0",
66
65
  "zod": "^4.3.6"
67
66
  },
68
67
  "engines": {
@@ -2,7 +2,7 @@
2
2
  * agentxchain run — drive a governed run to completion.
3
3
  *
4
4
  * Thin CLI surface over the runLoop library. Wires runLoop callbacks to:
5
- * - Existing adapter system (api_proxy, local_cli, mcp)
5
+ * - Existing adapter system (api_proxy, local_cli, mcp, remote_agent)
6
6
  * - Interactive gate prompting (stdin) or auto-approve mode
7
7
  * - Terminal output via chalk
8
8
  *
@@ -27,6 +27,7 @@ import {
27
27
  resolvePromptTransport,
28
28
  } from '../lib/adapters/local-cli-adapter.js';
29
29
  import { dispatchMcp, resolveMcpTransport, describeMcpRuntimeTarget } from '../lib/adapters/mcp-adapter.js';
30
+ import { dispatchRemoteAgent, describeRemoteAgentTarget } from '../lib/adapters/remote-agent-adapter.js';
30
31
  import { runHooks } from '../lib/hook-runner.js';
31
32
  import { finalizeDispatchManifest } from '../lib/dispatch-manifest.js';
32
33
  import { deriveRecoveryDescriptor } from '../lib/blocked-state.js';
@@ -200,6 +201,9 @@ export async function runCommand(opts) {
200
201
  const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
201
202
  console.log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
202
203
  adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
204
+ } else if (runtimeType === 'remote_agent') {
205
+ console.log(chalk.dim(` Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
206
+ adapterResult = await dispatchRemoteAgent(projectRoot, state, cfg, adapterOpts);
203
207
  } else {
204
208
  return { accept: false, reason: `unknown runtime type "${runtimeType}"` };
205
209
  }
@@ -18,6 +18,7 @@
18
18
  * - api_proxy: implemented for synchronous review-only turns and stages
19
19
  * provider-backed JSON before validation/acceptance
20
20
  * - mcp: implemented for synchronous MCP stdio or streamable_http tool dispatch
21
+ * - remote_agent: implemented for synchronous HTTP dispatch to external agent services
21
22
  */
22
23
 
23
24
  import chalk from 'chalk';
@@ -49,6 +50,7 @@ import {
49
50
  resolvePromptTransport,
50
51
  } from '../lib/adapters/local-cli-adapter.js';
51
52
  import { describeMcpRuntimeTarget, dispatchMcp, resolveMcpTransport } from '../lib/adapters/mcp-adapter.js';
53
+ import { dispatchRemoteAgent, describeRemoteAgentTarget } from '../lib/adapters/remote-agent-adapter.js';
52
54
  import {
53
55
  getDispatchAssignmentPath,
54
56
  getDispatchContextPath,
@@ -504,11 +506,63 @@ export async function stepCommand(opts) {
504
506
 
505
507
  console.log(chalk.green(`MCP tool completed${mcpResult.toolName ? ` (${mcpResult.toolName})` : ''}. Staged result detected.`));
506
508
  console.log('');
509
+ } else if (runtimeType === 'remote_agent') {
510
+ console.log(chalk.cyan(`Dispatching to remote agent: ${describeRemoteAgentTarget(runtime)}`));
511
+ console.log(chalk.dim(`Turn: ${turn.turn_id} Role: ${roleId} Phase: ${state.phase}`));
512
+
513
+ const remoteResult = await dispatchRemoteAgent(root, state, config, {
514
+ signal: controller.signal,
515
+ onStatus: (msg) => console.log(chalk.dim(` ${msg}`)),
516
+ verifyManifest: true,
517
+ });
518
+
519
+ if (remoteResult.logs?.length) {
520
+ saveDispatchLogs(root, turn.turn_id, remoteResult.logs);
521
+ }
522
+
523
+ if (remoteResult.aborted) {
524
+ console.log('');
525
+ console.log(chalk.yellow('Aborted. Turn remains assigned.'));
526
+ console.log(chalk.dim('Resume later with: agentxchain step --resume'));
527
+ console.log(chalk.dim('Or accept/reject manually: agentxchain accept-turn / reject-turn'));
528
+ process.exit(0);
529
+ }
530
+
531
+ if (!remoteResult.ok) {
532
+ const blocked = markRunBlocked(root, {
533
+ blockedOn: `dispatch:remote_agent_failure`,
534
+ category: 'dispatch_error',
535
+ recovery: {
536
+ typed_reason: 'dispatch_error',
537
+ owner: 'human',
538
+ recovery_action: 'Resolve the remote agent dispatch issue, then run agentxchain step --resume',
539
+ turn_retained: true,
540
+ detail: remoteResult.error,
541
+ },
542
+ turnId: turn.turn_id,
543
+ hooksConfig,
544
+ notificationConfig: config,
545
+ });
546
+ if (blocked.ok) {
547
+ state = blocked.state;
548
+ }
549
+
550
+ console.log('');
551
+ console.log(chalk.red(`Remote agent dispatch failed: ${remoteResult.error}`));
552
+ console.log(chalk.dim('The turn remains assigned. You can:'));
553
+ console.log(chalk.dim(' - Fix the issue and retry: agentxchain step --resume'));
554
+ console.log(chalk.dim(' - Complete manually: edit .agentxchain/staging/turn-result.json'));
555
+ console.log(chalk.dim(' - Reject: agentxchain reject-turn --reason "remote agent failed"'));
556
+ process.exit(1);
557
+ }
558
+
559
+ console.log(chalk.green('Remote agent completed. Staged result detected.'));
560
+ console.log('');
507
561
  }
508
562
 
509
563
  // ── Phase 3: Wait for turn completion ─────────────────────────────────────
510
564
 
511
- if (runtimeType === 'api_proxy' || runtimeType === 'mcp') {
565
+ if (runtimeType === 'api_proxy' || runtimeType === 'mcp' || runtimeType === 'remote_agent') {
512
566
  // api_proxy and mcp are synchronous — result already staged in Phase 2
513
567
  } else if (runtimeType === 'local_cli') {
514
568
  // ── Local CLI adapter: spawn subprocess ──
@@ -762,6 +816,7 @@ export async function stepCommand(opts) {
762
816
  console.log(chalk.dim(' - Fix the staged result and run: agentxchain accept-turn'));
763
817
  console.log(chalk.dim(' - Reject and retry: agentxchain reject-turn'));
764
818
  console.log(chalk.dim(' - Auto-reject on failure: agentxchain step --auto-reject'));
819
+ process.exit(1);
765
820
  }
766
821
  }
767
822
  }
@@ -28,4 +28,10 @@ export {
28
28
  describeMcpRuntimeTarget,
29
29
  } from './adapters/mcp-adapter.js';
30
30
 
31
+ export {
32
+ dispatchRemoteAgent,
33
+ describeRemoteAgentTarget,
34
+ DEFAULT_REMOTE_AGENT_TIMEOUT_MS,
35
+ } from './adapters/remote-agent-adapter.js';
36
+
31
37
  export const ADAPTER_INTERFACE_VERSION = '0.1';
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Remote Agent adapter — HTTP-based governed turn dispatch.
3
+ *
4
+ * Specification: .planning/REMOTE_AGENT_BRIDGE_CONNECTOR_SPEC.md
5
+ *
6
+ * This adapter proves connector replaceability (VISION.md Layer 3) by executing
7
+ * governed turns over HTTP against an external agent service. The protocol's
8
+ * staging, validation, and acceptance flow is preserved exactly — the remote
9
+ * service receives a turn envelope and must return a valid turn-result JSON.
10
+ *
11
+ * v1 scope: synchronous request/response only. No polling, no webhooks.
12
+ *
13
+ * Security: authorization headers are sent to the remote service but are
14
+ * never echoed into dispatch logs or governance artifacts.
15
+ */
16
+
17
+ import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
18
+ import { join } from 'path';
19
+ import {
20
+ getDispatchContextPath,
21
+ getDispatchPromptPath,
22
+ getDispatchTurnDir,
23
+ getTurnStagingDir,
24
+ getTurnStagingResultPath,
25
+ } from '../turn-paths.js';
26
+ import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
27
+
28
+ /** Default timeout for remote agent requests (ms). */
29
+ export const DEFAULT_REMOTE_AGENT_TIMEOUT_MS = 120_000;
30
+
31
+ /** Header keys that must never appear in logs or artifacts. */
32
+ const REDACTED_HEADER_PATTERNS = [/^authorization$/i, /^x-api-key$/i, /^cookie$/i, /^proxy-authorization$/i];
33
+
34
+ function isSecretHeader(key) {
35
+ return REDACTED_HEADER_PATTERNS.some(p => p.test(key));
36
+ }
37
+
38
+ /**
39
+ * Build a safe header summary for logs (redacts secret values).
40
+ */
41
+ function safeHeaderSummary(headers) {
42
+ if (!headers || typeof headers !== 'object') return {};
43
+ const safe = {};
44
+ for (const [k, v] of Object.entries(headers)) {
45
+ safe[k] = isSecretHeader(k) ? '[REDACTED]' : v;
46
+ }
47
+ return safe;
48
+ }
49
+
50
+ /**
51
+ * Dispatch a governed turn to a remote agent service.
52
+ *
53
+ * @param {string} root - project root directory
54
+ * @param {object} state - current governed state
55
+ * @param {object} config - normalized config
56
+ * @param {object} [options]
57
+ * @param {AbortSignal} [options.signal] - abort signal
58
+ * @param {function} [options.onStatus] - status callback
59
+ * @param {boolean} [options.verifyManifest] - require dispatch manifest verification
60
+ * @param {boolean} [options.skipManifestVerification] - skip manifest check
61
+ * @param {string} [options.turnId] - override turn ID
62
+ * @returns {Promise<{ ok: boolean, error?: string, logs?: string[], aborted?: boolean, timedOut?: boolean }>}
63
+ */
64
+ export async function dispatchRemoteAgent(root, state, config, options = {}) {
65
+ const { signal, onStatus, turnId } = options;
66
+ const logs = [];
67
+
68
+ const turn = resolveTargetTurn(state, turnId);
69
+ if (!turn) {
70
+ return { ok: false, error: 'No active turn in state', logs };
71
+ }
72
+
73
+ const manifestCheck = verifyDispatchManifestForAdapter(root, turn.turn_id, options);
74
+ if (!manifestCheck.ok) {
75
+ return { ok: false, error: `Dispatch manifest verification failed: ${manifestCheck.error}`, logs };
76
+ }
77
+
78
+ const runtimeId = turn.runtime_id;
79
+ const runtime = config.runtimes?.[runtimeId];
80
+ if (!runtime || runtime.type !== 'remote_agent') {
81
+ return { ok: false, error: `Runtime "${runtimeId}" is not a remote_agent runtime`, logs };
82
+ }
83
+
84
+ if (!runtime.url) {
85
+ return { ok: false, error: `Runtime "${runtimeId}" is missing required "url"`, logs };
86
+ }
87
+
88
+ const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
89
+ const contextPath = join(root, getDispatchContextPath(turn.turn_id));
90
+
91
+ if (!existsSync(promptPath)) {
92
+ return { ok: false, error: 'Dispatch bundle not found — PROMPT.md missing', logs };
93
+ }
94
+
95
+ const prompt = readFileSync(promptPath, 'utf8');
96
+ const context = existsSync(contextPath) ? readFileSync(contextPath, 'utf8') : '';
97
+
98
+ // Build request envelope — governed turn metadata + rendered content
99
+ const timeoutMs = runtime.timeout_ms || DEFAULT_REMOTE_AGENT_TIMEOUT_MS;
100
+ const requestBody = {
101
+ run_id: state.run_id,
102
+ turn_id: turn.turn_id,
103
+ role: turn.assigned_role,
104
+ phase: state.phase,
105
+ runtime_id: runtimeId,
106
+ dispatch_dir: join(root, getDispatchTurnDir(turn.turn_id)),
107
+ prompt,
108
+ context,
109
+ };
110
+
111
+ // Ensure staging directory exists
112
+ const stagingDir = join(root, getTurnStagingDir(turn.turn_id));
113
+ mkdirSync(stagingDir, { recursive: true });
114
+
115
+ // Log transport metadata (safe — no secrets)
116
+ logs.push(`[remote] POST ${runtime.url}`);
117
+ logs.push(`[remote] timeout_ms=${timeoutMs}`);
118
+ const safeHeaders = safeHeaderSummary(runtime.headers);
119
+ if (Object.keys(safeHeaders).length > 0) {
120
+ logs.push(`[remote] headers=${JSON.stringify(safeHeaders)}`);
121
+ }
122
+
123
+ onStatus?.(`Posting to remote agent: ${runtime.url}`);
124
+
125
+ try {
126
+ if (signal?.aborted) {
127
+ return { ok: false, aborted: true, logs };
128
+ }
129
+
130
+ const headers = {
131
+ 'content-type': 'application/json',
132
+ ...(runtime.headers || {}),
133
+ };
134
+
135
+ const controller = new AbortController();
136
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
137
+
138
+ // Chain external abort signal to our controller
139
+ if (signal) {
140
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
141
+ }
142
+
143
+ let response;
144
+ try {
145
+ response = await fetch(runtime.url, {
146
+ method: 'POST',
147
+ headers,
148
+ body: JSON.stringify(requestBody),
149
+ signal: controller.signal,
150
+ });
151
+ } catch (err) {
152
+ clearTimeout(timeout);
153
+ if (signal?.aborted) {
154
+ return { ok: false, aborted: true, logs };
155
+ }
156
+ if (err.name === 'AbortError' || err.code === 'ABORT_ERR') {
157
+ logs.push(`[remote] Request timed out after ${timeoutMs}ms`);
158
+ return { ok: false, timedOut: true, error: `Remote agent timed out after ${timeoutMs}ms`, logs };
159
+ }
160
+ logs.push(`[remote] Network error: ${err.message}`);
161
+ return { ok: false, error: `Remote agent network error: ${err.message}`, logs };
162
+ } finally {
163
+ clearTimeout(timeout);
164
+ }
165
+
166
+ if (signal?.aborted) {
167
+ return { ok: false, aborted: true, logs };
168
+ }
169
+
170
+ logs.push(`[remote] HTTP ${response.status} ${response.statusText}`);
171
+
172
+ if (!response.ok) {
173
+ let body = '';
174
+ try { body = await response.text(); } catch { /* ignore */ }
175
+ if (body) logs.push(`[remote] Body: ${body.slice(0, 500)}`);
176
+ return {
177
+ ok: false,
178
+ error: `Remote agent returned HTTP ${response.status}`,
179
+ logs,
180
+ };
181
+ }
182
+
183
+ // Parse response JSON
184
+ let responseData;
185
+ try {
186
+ responseData = await response.json();
187
+ } catch (err) {
188
+ logs.push(`[remote] JSON parse error: ${err.message}`);
189
+ return {
190
+ ok: false,
191
+ error: 'Remote agent response is not valid JSON',
192
+ logs,
193
+ };
194
+ }
195
+
196
+ // Validate turn result structure (lightweight — full validation happens in the acceptance pipeline)
197
+ if (!looksLikeTurnResult(responseData)) {
198
+ logs.push('[remote] Response missing required turn-result fields (need at least run_id/turn_id + status/role)');
199
+ return {
200
+ ok: false,
201
+ error: 'Remote agent response does not contain a valid turn result',
202
+ logs,
203
+ };
204
+ }
205
+
206
+ // Stage the result
207
+ const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
208
+ writeFileSync(stagingPath, JSON.stringify(responseData, null, 2));
209
+
210
+ logs.push(`[remote] Turn result staged at ${getTurnStagingResultPath(turn.turn_id)}`);
211
+ onStatus?.('Remote agent returned valid turn result');
212
+ return { ok: true, logs };
213
+ } catch (error) {
214
+ if (signal?.aborted) {
215
+ return { ok: false, aborted: true, logs };
216
+ }
217
+ logs.push(`[remote] Unexpected error: ${error.message}`);
218
+ return {
219
+ ok: false,
220
+ error: `Remote agent dispatch failed: ${error.message}`,
221
+ logs,
222
+ };
223
+ }
224
+ }
225
+
226
+ // ── Helpers ─────────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Lightweight structural check: does this object look like a turn result?
230
+ * Full validation happens later via validateStagedTurnResult.
231
+ */
232
+ function looksLikeTurnResult(value) {
233
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
234
+ const hasIdentity = 'run_id' in value || 'turn_id' in value;
235
+ const hasLifecycle = 'status' in value || 'role' in value || 'runtime_id' in value;
236
+ return hasIdentity && hasLifecycle;
237
+ }
238
+
239
+ function resolveTargetTurn(state, turnId) {
240
+ if (turnId && state?.active_turns?.[turnId]) {
241
+ return state.active_turns[turnId];
242
+ }
243
+ return state?.current_turn || Object.values(state?.active_turns || {})[0];
244
+ }
245
+
246
+ /**
247
+ * Describe a remote_agent runtime target for display (safe — no secrets).
248
+ */
249
+ export function describeRemoteAgentTarget(runtime) {
250
+ if (!runtime?.url) return '(unknown)';
251
+ try {
252
+ const u = new URL(runtime.url);
253
+ return `${u.protocol}//${u.host}${u.pathname}`;
254
+ } catch {
255
+ return runtime.url;
256
+ }
257
+ }
@@ -210,7 +210,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
210
210
  lines.push('- You may create/modify files under `.planning/` and `.agentxchain/reviews/`.');
211
211
  lines.push('- Your artifact type must be `review`.');
212
212
  lines.push('- You MUST raise at least one objection (even if minor).');
213
- if (runtimeType === 'api_proxy') {
213
+ if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
214
214
  const reviewArtifactPath = getReviewArtifactPath(turn.turn_id, roleId);
215
215
  lines.push('- **This runtime cannot write repo files directly.** Do NOT claim `.planning/*` or `.agentxchain/reviews/*` changes you did not actually make.');
216
216
  lines.push(`- The orchestrator will materialize your accepted review at \`${reviewArtifactPath}\`.`);
@@ -230,7 +230,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
230
230
  lines.push('');
231
231
  lines.push('- You may propose changes as patches but cannot directly commit.');
232
232
  lines.push('- Your artifact type should be `patch`.');
233
- if (runtimeType === 'api_proxy') {
233
+ if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
234
234
  lines.push('- **This runtime cannot write repo files directly.** When doing work, you MUST return proposed changes as structured JSON.');
235
235
  lines.push('- Include a `proposed_changes` array in your turn result with each file change (omit or set to `[]` on completion-only turns):');
236
236
  lines.push(' ```json');
@@ -393,7 +393,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
393
393
  lines.push(`- **You are in the \`${currentPhase}\` phase (not final phase).** When your work is complete${gateClause}, set \`phase_transition_request: "${nextPhase}"\`.`);
394
394
  } else if (phaseIdx >= 0 && phaseIdx === phaseNames.length - 1) {
395
395
  lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
396
- if (runtimeType === 'api_proxy') {
396
+ if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
397
397
  lines.push('- **Completion turns must be no-op:** set `proposed_changes` to `[]` or omit it, set `files_changed` to `[]`, and set `artifact.type` to `"review"`. Do NOT propose file changes on a completion turn.');
398
398
  }
399
399
  }
@@ -408,7 +408,7 @@ function renderPrompt(role, roleId, turn, state, config, root) {
408
408
  lines.push('- **If you found genuine blocking issues that prevent shipping:** set `status: "needs_human"` and explain the blockers in `needs_human_reason`.');
409
409
  lines.push('- Do NOT use `status: "needs_human"` to mean "human should approve the release." That is what `run_completion_request: true` is for.');
410
410
  lines.push('- Do NOT set `phase_transition_request` to the exit gate name.');
411
- if (runtimeType === 'api_proxy') {
411
+ if (runtimeType === 'api_proxy' || runtimeType === 'remote_agent') {
412
412
  lines.push('- `run_completion_request: true` does **not** mean this runtime wrote `.planning/acceptance-matrix.md`, `.planning/ship-verdict.md`, or `.planning/RELEASE_NOTES.md` for you.');
413
413
  }
414
414
  }
@@ -139,7 +139,10 @@ function renderDerivedReviewArtifact(turnResult, state) {
139
139
  }
140
140
 
141
141
  function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType, baseline = null) {
142
- if (turnResult?.artifact?.type !== 'review' || runtimeType !== 'api_proxy') {
142
+ if (
143
+ turnResult?.artifact?.type !== 'review'
144
+ || (runtimeType !== 'api_proxy' && runtimeType !== 'remote_agent')
145
+ ) {
143
146
  return null;
144
147
  }
145
148
 
@@ -156,7 +159,10 @@ function materializeDerivedReviewArtifact(root, turnResult, state, runtimeType,
156
159
  }
157
160
 
158
161
  function materializeDerivedProposalArtifact(root, turnResult, state, runtimeType) {
159
- if (turnResult?.artifact?.type !== 'patch' || runtimeType !== 'api_proxy') {
162
+ if (
163
+ turnResult?.artifact?.type !== 'patch'
164
+ || (runtimeType !== 'api_proxy' && runtimeType !== 'remote_agent')
165
+ ) {
160
166
  return null;
161
167
  }
162
168
  if (!Array.isArray(turnResult.proposed_changes) || turnResult.proposed_changes.length === 0) {
@@ -17,7 +17,7 @@ import { validateNotificationsConfig } from './notification-runner.js';
17
17
  import { SUPPORTED_TOKEN_COUNTER_PROVIDERS } from './token-counter.js';
18
18
 
19
19
  const VALID_WRITE_AUTHORITIES = ['authoritative', 'proposed', 'review_only'];
20
- const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp'];
20
+ const VALID_RUNTIME_TYPES = ['manual', 'local_cli', 'api_proxy', 'mcp', 'remote_agent'];
21
21
  const VALID_API_PROXY_PROVIDERS = ['anthropic', 'openai'];
22
22
  export const VALID_PROMPT_TRANSPORTS = ['argv', 'stdin', 'dispatch_bundle_only'];
23
23
  const VALID_MCP_TRANSPORTS = ['stdio', 'streamable_http'];
@@ -157,6 +157,42 @@ function validateMcpRuntime(runtimeId, runtime, errors) {
157
157
  }
158
158
  }
159
159
 
160
+ function validateRemoteAgentRuntime(runtimeId, runtime, errors) {
161
+ // url: required, absolute http(s)
162
+ if (typeof runtime?.url !== 'string' || !runtime.url.trim()) {
163
+ errors.push(`Runtime "${runtimeId}": remote_agent requires "url"`);
164
+ } else {
165
+ try {
166
+ const parsed = new URL(runtime.url);
167
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
168
+ errors.push(`Runtime "${runtimeId}": remote_agent url must use http or https`);
169
+ }
170
+ } catch {
171
+ errors.push(`Runtime "${runtimeId}": remote_agent url must be a valid absolute URL`);
172
+ }
173
+ }
174
+
175
+ // headers: optional object of string values
176
+ if ('headers' in runtime) {
177
+ if (!runtime.headers || typeof runtime.headers !== 'object' || Array.isArray(runtime.headers)) {
178
+ errors.push(`Runtime "${runtimeId}": remote_agent headers must be an object of string values`);
179
+ } else {
180
+ for (const [key, value] of Object.entries(runtime.headers)) {
181
+ if (typeof value !== 'string') {
182
+ errors.push(`Runtime "${runtimeId}": remote_agent headers["${key}"] must be a string`);
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ // timeout_ms: optional positive integer
189
+ if ('timeout_ms' in runtime) {
190
+ if (!Number.isInteger(runtime.timeout_ms) || runtime.timeout_ms <= 0) {
191
+ errors.push(`Runtime "${runtimeId}": remote_agent timeout_ms must be a positive integer`);
192
+ }
193
+ }
194
+ }
195
+
160
196
  function validateApiProxyRetryPolicy(runtimeId, retryPolicy, errors) {
161
197
  if (!retryPolicy || typeof retryPolicy !== 'object' || Array.isArray(retryPolicy)) {
162
198
  errors.push(`Runtime "${runtimeId}": retry_policy must be an object`);
@@ -391,6 +427,9 @@ export function validateV4Config(data, projectRoot) {
391
427
  if (rt.type === 'mcp') {
392
428
  validateMcpRuntime(id, rt, errors);
393
429
  }
430
+ if (rt.type === 'remote_agent') {
431
+ validateRemoteAgentRuntime(id, rt, errors);
432
+ }
394
433
  }
395
434
  }
396
435
 
@@ -412,11 +451,18 @@ export function validateV4Config(data, projectRoot) {
412
451
  errors.push(`Role "${id}" is review_only but uses local_cli runtime "${role.runtime}" — review_only roles should not have authoritative write access`);
413
452
  }
414
453
  }
415
- // api_proxy restriction: only review_only and proposed roles may bind to api_proxy runtimes
454
+ // api_proxy and remote_agent restriction: only review_only and proposed roles may bind.
455
+ // These adapters do not have a proven local workspace mutation path in v1.
416
456
  if (role.runtime && data.runtimes[role.runtime]) {
417
457
  const rt = data.runtimes[role.runtime];
418
- if (rt.type === 'api_proxy' && role.write_authority !== 'review_only' && role.write_authority !== 'proposed') {
419
- errors.push(`Role "${id}" has write_authority "${role.write_authority}" but uses api_proxy runtime "${role.runtime}" api_proxy only supports review_only and proposed roles`);
458
+ if (
459
+ (rt.type === 'api_proxy' || rt.type === 'remote_agent')
460
+ && role.write_authority !== 'review_only'
461
+ && role.write_authority !== 'proposed'
462
+ ) {
463
+ errors.push(
464
+ `Role "${id}" has write_authority "${role.write_authority}" but uses ${rt.type} runtime "${role.runtime}" — ${rt.type} only supports review_only and proposed roles`
465
+ );
420
466
  }
421
467
  }
422
468
  }
@@ -387,14 +387,14 @@ function validateArtifact(tr, config) {
387
387
  warnings.push('Authoritative role completed with no files_changed — is this intentional?');
388
388
  }
389
389
 
390
- // Validate proposed_changes for proposed + api_proxy turns
390
+ // Validate proposed_changes for proposed runtimes that cannot write repo files directly.
391
391
  const runtimeType = config.runtimes?.[tr.runtime_id]?.type;
392
- if (writeAuthority === 'proposed' && runtimeType === 'api_proxy') {
392
+ if (writeAuthority === 'proposed' && (runtimeType === 'api_proxy' || runtimeType === 'remote_agent')) {
393
393
  // Completion-request turns are explicitly allowed to have empty proposed_changes —
394
394
  // the turn is signaling run completion, not delivering work.
395
395
  const isCompletionRequest = tr.run_completion_request === true;
396
396
  if (tr.status === 'completed' && (!tr.proposed_changes || tr.proposed_changes.length === 0) && !isCompletionRequest) {
397
- errors.push('Proposed api_proxy turn completed but proposed_changes is empty or missing.');
397
+ errors.push(`Proposed ${runtimeType} turn completed but proposed_changes is empty or missing.`);
398
398
  }
399
399
  }
400
400
  if (tr.proposed_changes && Array.isArray(tr.proposed_changes)) {