@magnusekdahl/parallix 1.2.0 → 1.2.1

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 (51) hide show
  1. package/lib/agents/agents.js +737 -838
  2. package/lib/agents/agents.ts +930 -0
  3. package/lib/agents/claude-telemetry.js +149 -154
  4. package/lib/agents/claude-telemetry.ts +233 -0
  5. package/lib/agents/claude.js +121 -131
  6. package/lib/agents/claude.ts +156 -0
  7. package/lib/agents/codex-telemetry.js +176 -164
  8. package/lib/agents/codex-telemetry.ts +205 -0
  9. package/lib/agents/codex.js +168 -201
  10. package/lib/agents/codex.ts +236 -0
  11. package/lib/agents/limit-hit.js +216 -226
  12. package/lib/agents/limit-hit.ts +262 -0
  13. package/lib/agents/mistral-telemetry.js +7 -10
  14. package/lib/agents/mistral-telemetry.ts +44 -0
  15. package/lib/agents/mistral.js +40 -58
  16. package/lib/agents/mistral.ts +84 -0
  17. package/lib/agents/opencode-export.js +126 -127
  18. package/lib/agents/opencode-export.ts +160 -0
  19. package/lib/agents/opencode-telemetry.js +282 -261
  20. package/lib/agents/opencode-telemetry.ts +350 -0
  21. package/lib/agents/opencode.js +263 -305
  22. package/lib/agents/opencode.ts +370 -0
  23. package/lib/agents/stage-telemetry.js +23 -24
  24. package/lib/agents/stage-telemetry.ts +47 -0
  25. package/lib/commands/integrate.js +9 -3
  26. package/lib/core/fmt.js +140 -211
  27. package/lib/core/fmt.ts +189 -0
  28. package/lib/core/git.js +121 -102
  29. package/lib/core/git.ts +146 -0
  30. package/lib/core/gitignore.js +59 -106
  31. package/lib/core/gitignore.ts +103 -0
  32. package/lib/core/mission-utils.js +833 -875
  33. package/lib/core/mission-utils.ts +998 -0
  34. package/lib/core/persistent-data-migration.js +247 -205
  35. package/lib/core/persistent-data-migration.ts +255 -0
  36. package/lib/core/product-config.js +400 -476
  37. package/lib/core/product-config.ts +518 -0
  38. package/lib/core/runtime-matrix.js +41 -87
  39. package/lib/core/runtime-matrix.ts +84 -0
  40. package/lib/core/spawn-tee.js +152 -192
  41. package/lib/core/spawn-tee.ts +202 -0
  42. package/lib/core/state-map.js +92 -92
  43. package/lib/core/state-map.ts +115 -0
  44. package/lib/core/storage.js +101 -129
  45. package/lib/core/storage.ts +182 -0
  46. package/lib/core/verification.js +148 -138
  47. package/lib/core/verification.ts +200 -0
  48. package/package.json +4 -2
  49. package/prompts/draft.md +1 -0
  50. package/prompts/portfolio.md +1 -1
  51. package/px.js +0 -2
@@ -1,17 +1,71 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { spawnSync } = require('child_process');
4
- const fmt = require('../core/fmt');
5
- const { startCodexDraftAgent, resolveCodexCommand } = require('./codex');
6
- const { startClaudeAgent, resolveClaudeCommand } = require('./claude');
7
- const { startMistralAgent, resolveMistralCommand } = require('./mistral');
8
- const { startOpencodeAgent, resolveOpencodeCommand, isSpuriousOpencodeExit } = require('./opencode');
9
- const { detectLimitHit, formatBlockUntil, DEFAULT_FALLBACK_HOURS } = require('./limit-hit');
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.setCommandPathProbe = exports.WORKFLOW_AGENT_NAMES = exports.KNOWN_AGENT_NAMES = void 0;
40
+ exports.startAgent = startAgent;
41
+ exports.startDraftAgent = startDraftAgent;
42
+ exports.selectAgent = selectAgent;
43
+ exports.eligibleAgentsForStep = eligibleAgentsForStep;
44
+ exports.readAgentConfig = readAgentConfig;
45
+ exports.readAgentConfigOrExit = readAgentConfigOrExit;
46
+ exports.assertAgentSupported = assertAgentSupported;
47
+ exports.workflowLauncherStatus = workflowLauncherStatus;
48
+ exports.isAgentBlocked = isAgentBlocked;
49
+ exports.parseBlockUntil = parseBlockUntil;
50
+ exports.isInvalidAgentConfigError = isInvalidAgentConfigError;
51
+ exports.updateAgentBlock = updateAgentBlock;
52
+ exports.resolveBlocklistTargetPath = resolveBlocklistTargetPath;
53
+ exports.resolveNoOutputWatchdogConfig = resolveNoOutputWatchdogConfig;
54
+ const node_fs_1 = __importDefault(require("node:fs"));
55
+ const node_path_1 = __importDefault(require("node:path"));
56
+ const node_child_process_1 = require("node:child_process");
57
+ const fmt = __importStar(require("../core/fmt.js"));
58
+ const codex_js_1 = require("./codex.js");
59
+ const claude_js_1 = require("./claude.js");
60
+ const mistral_js_1 = require("./mistral.js");
61
+ const opencode_js_1 = require("./opencode.js");
62
+ const limit_hit_js_1 = require("./limit-hit.js");
63
+ const storage = __importStar(require("../core/storage.js"));
64
+ const product_config_js_1 = require("../core/product-config.js");
65
+ const persistent_data_migration_js_1 = require("../core/persistent-data-migration.js");
66
+ // tools/sessions is still CJS (not converted in this wave); require keeps it
67
+ // untyped (any) without pulling a non-included .js into the typecheck program.
10
68
  const sessions = require('../tools/sessions');
11
- const storage = require('../core/storage');
12
- const { resolveAgentModel } = require('../core/product-config');
13
- const { migrateAgentBlocklists } = require('../core/persistent-data-migration');
14
-
15
69
  // Launchers whose CLI accepts a per-call resume flag threaded by startAgent.
16
70
  // Each launcher outputs a session resume hint at the end of its run (e.g.
17
71
  // "codex resume <id>", "opencode -s ses_<id>",
@@ -20,913 +74,758 @@ const { migrateAgentBlocklists } = require('../core/persistent-data-migration');
20
74
  // custom (opencode) always uses --continue; claude uses --continue; codex uses
21
75
  // `exec resume --last`.
22
76
  const RESUME_CAPABLE = new Set(['claude', 'codex', 'custom']);
23
-
24
- const CONFIG_PATH = path.join(__dirname, '..', '..', 'config', 'agents.json');
25
-
77
+ const CONFIG_PATH = node_path_1.default.join(__dirname, '..', '..', 'config', 'agents.json');
26
78
  // Test hook: when set, used instead of spawning `command -v` to check PATH.
27
- /** @type {((name: string) => string | null) | null} */
28
79
  let _commandPathProbe = null;
29
-
30
- /** @type {{[key: string]: Function}} */
31
80
  const LAUNCHERS = {
32
- codex: startCodexDraftAgent,
33
- claude: startClaudeAgent,
34
- mistral: startMistralAgent,
35
- custom: startOpencodeAgent
81
+ codex: codex_js_1.startCodexDraftAgent,
82
+ claude: claude_js_1.startClaudeAgent,
83
+ mistral: mistral_js_1.startMistralAgent,
84
+ custom: opencode_js_1.startOpencodeAgent
36
85
  };
37
-
38
- /** @type {{[key: string]: () => string}} */
39
86
  const RESOLVERS = {
40
- codex: resolveCodexCommand,
41
- claude: resolveClaudeCommand,
42
- mistral: resolveMistralCommand,
43
- custom: resolveOpencodeCommand
87
+ codex: codex_js_1.resolveCodexCommand,
88
+ claude: claude_js_1.resolveClaudeCommand,
89
+ mistral: mistral_js_1.resolveMistralCommand,
90
+ custom: opencode_js_1.resolveOpencodeCommand
44
91
  };
45
-
46
- /** @type {{[key: string]: string[]}} */
47
92
  const HEALTH_PROBE_ARGS = Object.freeze({
48
- codex: ['--help'],
49
- claude: ['--help'],
50
- mistral: ['--help'],
51
- custom: ['--help']
93
+ codex: ['--help'],
94
+ claude: ['--help'],
95
+ mistral: ['--help'],
96
+ custom: ['--help']
52
97
  });
53
98
  const LAUNCHER_HEALTH_TIMEOUT_MS = 3000;
54
99
  const DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS = 60_000;
55
100
  const DEFAULT_NO_OUTPUT_INTERVAL_MS = 60_000;
56
101
  const DRAFT_NO_OUTPUT_INITIAL_DELAY_MS = 15_000;
57
102
  const DRAFT_NO_OUTPUT_INTERVAL_MS = 30_000;
58
-
59
103
  const WORKFLOW_AGENT_NAMES = Object.freeze(Object.keys(LAUNCHERS));
104
+ exports.WORKFLOW_AGENT_NAMES = WORKFLOW_AGENT_NAMES;
60
105
  const KNOWN_AGENT_NAMES = Object.freeze([
61
- ...WORKFLOW_AGENT_NAMES,
62
- 'human'
106
+ ...WORKFLOW_AGENT_NAMES,
107
+ 'human'
63
108
  ]);
64
-
65
- /** @param {string} agent */
109
+ exports.KNOWN_AGENT_NAMES = KNOWN_AGENT_NAMES;
66
110
  function workflowLauncherStatus(agent) {
67
- const resolver = RESOLVERS[agent];
68
- if (!resolver) {
69
- return { agent, supported: false, detail: `unknown agent: ${agent}` };
70
- }
71
- const command = resolver();
72
- const exists = command.includes('/') ? fs.existsSync(command) : commandInPath(command);
73
- if (!exists) {
74
- return { agent, supported: false, detail: command, health: 'missing' };
75
- }
76
-
77
- const probeArgs = HEALTH_PROBE_ARGS[agent] || ['--help'];
78
- const probe = spawnSync(command, probeArgs, {
79
- encoding: 'utf8',
80
- stdio: ['ignore', 'pipe', 'pipe'],
81
- timeout: LAUNCHER_HEALTH_TIMEOUT_MS
82
- });
83
-
84
- if (probe.error || probe.status !== 0) {
85
- /** @type {Error & {code?: string}} */
86
- const pErr = probe.error || new Error('');
87
- const reason = probe.error
88
- ? (pErr.code || pErr.message)
89
- : `exit ${probe.status}`;
90
- return {
91
- agent,
92
- supported: false,
93
- detail: `${command} ${probeArgs.join(' ')}`.trim(),
94
- health: 'probe-failed',
95
- reason
96
- };
97
- }
98
-
99
- return { agent, supported: true, detail: `${command} ${probeArgs.join(' ')}`.trim(), health: 'ok' };
111
+ const resolver = RESOLVERS[agent];
112
+ if (!resolver) {
113
+ return { agent, supported: false, detail: `unknown agent: ${agent}` };
114
+ }
115
+ const command = resolver();
116
+ const exists = command.includes('/') ? node_fs_1.default.existsSync(command) : commandInPath(command);
117
+ if (!exists) {
118
+ return { agent, supported: false, detail: command, health: 'missing' };
119
+ }
120
+ const probeArgs = HEALTH_PROBE_ARGS[agent] || ['--help'];
121
+ const probe = (0, node_child_process_1.spawnSync)(command, probeArgs, {
122
+ encoding: 'utf8',
123
+ stdio: ['ignore', 'pipe', 'pipe'],
124
+ timeout: LAUNCHER_HEALTH_TIMEOUT_MS
125
+ });
126
+ if (probe.error || probe.status !== 0) {
127
+ const pErr = probe.error || new Error('');
128
+ const reason = probe.error
129
+ ? (pErr.code || pErr.message)
130
+ : `exit ${probe.status}`;
131
+ return {
132
+ agent,
133
+ supported: false,
134
+ detail: `${command} ${probeArgs.join(' ')}`.trim(),
135
+ health: 'probe-failed',
136
+ reason
137
+ };
138
+ }
139
+ return { agent, supported: true, detail: `${command} ${probeArgs.join(' ')}`.trim(), health: 'ok' };
100
140
  }
101
-
102
- /** @param {string} name */
103
141
  function commandInPath(name) {
104
- if (_commandPathProbe) {
105
- return _commandPathProbe(name) || false;
106
- }
107
- const result = spawnSync('bash', ['-c', `command -v ${name}`], {
108
- encoding: 'utf8',
109
- stdio: ['ignore', 'pipe', 'ignore']
110
- });
111
- return result.status === 0 && result.stdout.trim().length > 0;
142
+ if (_commandPathProbe) {
143
+ return _commandPathProbe(name) || false;
144
+ }
145
+ const result = (0, node_child_process_1.spawnSync)('bash', ['-c', `command -v ${name}`], {
146
+ encoding: 'utf8',
147
+ stdio: ['ignore', 'pipe', 'ignore']
148
+ });
149
+ return result.status === 0 && result.stdout.trim().length > 0;
112
150
  }
113
-
114
- /** @param {string} configPath @param {string} scope @param {{message?: string} | null} originalError */
115
151
  function buildInvalidAgentConfigError(configPath, scope, originalError) {
116
- const location = path.resolve(configPath);
117
- const detail = originalError && originalError.message ? originalError.message : 'invalid JSON';
118
- /** @type {any} */
119
- const error = new Error(
120
- `Invalid ${scope} agent config at ${location}: ${detail}. ` +
121
- 'Fix or remove the malformed file before running workflow commands so agent blocking is applied deterministically.'
122
- );
123
- error.code = 'WORKFLOW_AGENT_CONFIG_INVALID';
124
- error.configPath = location;
125
- error.configScope = scope;
126
- return error;
152
+ const location = node_path_1.default.resolve(configPath);
153
+ const detail = originalError && originalError.message ? originalError.message : 'invalid JSON';
154
+ const error = new Error(`Invalid ${scope} agent config at ${location}: ${detail}. ` +
155
+ 'Fix or remove the malformed file before running workflow commands so agent blocking is applied deterministically.');
156
+ error.code = 'WORKFLOW_AGENT_CONFIG_INVALID';
157
+ error.configPath = location;
158
+ error.configScope = scope;
159
+ return error;
127
160
  }
128
-
129
- /** @param {{code?: string}} error */
130
161
  function isInvalidAgentConfigError(error) {
131
- return Boolean(error && error.code === 'WORKFLOW_AGENT_CONFIG_INVALID');
162
+ return Boolean(error && error.code === 'WORKFLOW_AGENT_CONFIG_INVALID');
132
163
  }
133
-
134
164
  function readAgentConfigOrExit(configPath = CONFIG_PATH, options = {}) {
135
- try {
136
- return readAgentConfig(configPath, options);
137
- } catch (/** @type {unknown} */ error) {
138
- if (isInvalidAgentConfigError(/** @type {any} */ (error))) {
139
- fmt.log.fail(/** @type {any} */ (error).message);
140
- process.exit(1);
141
- }
142
- throw error;
143
- }
165
+ try {
166
+ return readAgentConfig(configPath, options);
167
+ }
168
+ catch (error) {
169
+ if (isInvalidAgentConfigError(error)) {
170
+ fmt.log.fail(error.message);
171
+ process.exit(1);
172
+ }
173
+ throw error;
174
+ }
144
175
  }
145
-
146
- /** @param {string} configPath @param {string} scope */
147
176
  function parseAgentConfigFile(configPath, scope) {
148
- try {
149
- return JSON.parse(fs.readFileSync(configPath, 'utf8'));
150
- } catch (/** @type {unknown} */ err) {
151
- throw buildInvalidAgentConfigError(configPath, scope, /** @type {{message?: string}} */ (err));
152
- }
177
+ try {
178
+ return JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
179
+ }
180
+ catch (err) {
181
+ throw buildInvalidAgentConfigError(configPath, scope, err);
182
+ }
153
183
  }
154
-
155
- /** @param {{mergeLocal?: boolean, mainWorktreePath?: string | null, warn?: Function, targetPath?: string}} options */
156
184
  function readAgentConfig(configPath = CONFIG_PATH, options = {}) {
157
- const {
158
- mergeLocal = path.resolve(configPath) === path.resolve(CONFIG_PATH),
159
- mainWorktreePath,
160
- warn = fmt.log.warn
161
- } = options;
162
- /** @type {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}}} */
163
- let config = {};
164
- if (fs.existsSync(configPath)) {
165
- config = parseAgentConfigFile(configPath, 'workflow');
166
- }
167
-
168
- if (mergeLocal) {
169
- config = config || {};
170
- const projectRoot = path.resolve(path.dirname(configPath), '..', '..');
171
- const mainWorktree = mainWorktreePath !== undefined
172
- ? mainWorktreePath
173
- : getMainWorktreePath({ cwd: projectRoot, warn });
174
- const /** @type {string[]} */ legacyPaths = [
175
- path.join(path.dirname(configPath), 'agents.local.json'),
176
- path.join(projectRoot, 'agents.local.json'),
177
- mainWorktree ? path.join(mainWorktree, 'agents.local.json') : ''
178
- ].filter(/** @param {string} p */ (p) => Boolean(p));
179
- const /** @type {string} */ targetPath = options.targetPath || storage.resolveAgentsLocalPath({ ensureDir: true });
180
- if (!fs.existsSync(targetPath)) {
181
- try {
182
- migrateAgentBlocklists({
183
- sourcePaths: legacyPaths,
184
- destinationPath: targetPath,
185
- warn
186
- });
187
- } catch (/** @type {unknown} */ error) {
188
- throw buildInvalidAgentConfigError(targetPath, 'local', /** @type {any} */ (error));
189
- }
190
- }
191
- if (fs.existsSync(targetPath)) {
192
- const localConfig = parseAgentConfigFile(targetPath, 'local');
193
- if (localConfig && localConfig.blocklist) {
194
- /** @type {{blocklist?: {[key: string]: any}}} */ (config).blocklist = Object.assign(/** @type {{blocklist?: {[key: string]: any}}} */ (config).blocklist || {}, localConfig.blocklist);
195
- }
196
- }
197
- }
198
-
199
- return config;
185
+ const { mergeLocal = node_path_1.default.resolve(configPath) === node_path_1.default.resolve(CONFIG_PATH), mainWorktreePath, warn = fmt.log.warn } = options;
186
+ let config = {};
187
+ if (node_fs_1.default.existsSync(configPath)) {
188
+ config = parseAgentConfigFile(configPath, 'workflow');
189
+ }
190
+ if (mergeLocal) {
191
+ config = config || {};
192
+ const projectRoot = node_path_1.default.resolve(node_path_1.default.dirname(configPath), '..', '..');
193
+ const mainWorktree = mainWorktreePath !== undefined
194
+ ? mainWorktreePath
195
+ : getMainWorktreePath({ cwd: projectRoot, warn });
196
+ const /** @type {string[]} */ legacyPaths = [
197
+ node_path_1.default.join(node_path_1.default.dirname(configPath), 'agents.local.json'),
198
+ node_path_1.default.join(projectRoot, 'agents.local.json'),
199
+ mainWorktree ? node_path_1.default.join(mainWorktree, 'agents.local.json') : ''
200
+ ].filter(/** @param {string} p */ (p) => Boolean(p));
201
+ const /** @type {string} */ targetPath = options.targetPath || storage.resolveAgentsLocalPath({ ensureDir: true });
202
+ if (!node_fs_1.default.existsSync(targetPath)) {
203
+ try {
204
+ (0, persistent_data_migration_js_1.migrateAgentBlocklists)({
205
+ sourcePaths: legacyPaths,
206
+ destinationPath: targetPath,
207
+ warn: warn
208
+ });
209
+ }
210
+ catch (error) {
211
+ throw buildInvalidAgentConfigError(targetPath, 'local', error);
212
+ }
213
+ }
214
+ if (node_fs_1.default.existsSync(targetPath)) {
215
+ const localConfig = parseAgentConfigFile(targetPath, 'local');
216
+ if (localConfig && localConfig.blocklist) {
217
+ config.blocklist = Object.assign(config.blocklist || {}, localConfig.blocklist);
218
+ }
219
+ }
220
+ }
221
+ return config;
200
222
  }
201
-
202
- /** @param {{cwd?: string, warn?: Function}} options */
203
223
  function getMainWorktreePath(options = {}) {
204
- const { cwd = process.cwd(), warn = fmt.log.warn } = options;
205
- try {
206
- const commonDir = getGitPath(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
207
- if (commonDir && MainWorktreeDetector.byCommonDir.has(commonDir)) {
208
- return MainWorktreeDetector.byCommonDir.get(commonDir);
209
- }
210
-
211
- const result = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
212
- encoding: 'utf8',
213
- stdio: ['ignore', 'pipe', 'ignore'],
214
- timeout: 1000
215
- });
216
- if (result.status !== 0) {
217
- warn(
218
- `Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
219
- `skipping that lookup (git exited with status ${result.status}).`
220
- );
221
- return null;
222
- }
223
-
224
- const lines = result.stdout.split('\n');
225
- const mainWorktreePath = detectMainWorktreePath(lines, cwd, commonDir);
226
- if (mainWorktreePath) {
227
- if (commonDir) {MainWorktreeDetector.byCommonDir.set(commonDir, mainWorktreePath);}
228
- return mainWorktreePath;
229
- }
230
-
231
- // Fallback: pick the first worktree whose HEAD points to main
232
- let i = 0;
233
- while (i < lines.length) {
234
- if (lines[i].startsWith('worktree ')) {
235
- const wt = lines[i].slice('worktree '.length).trim();
236
- const branchLineIdx = i + 1;
237
- if (branchLineIdx < lines.length && lines[branchLineIdx].startsWith('branch refs/heads/main')) {
238
- if (commonDir) {MainWorktreeDetector.byCommonDir.set(commonDir, wt);}
239
- return wt;
224
+ const { cwd = process.cwd(), warn = fmt.log.warn } = options;
225
+ try {
226
+ const commonDir = getGitPath(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
227
+ if (commonDir && MainWorktreeDetector.byCommonDir.has(commonDir)) {
228
+ return MainWorktreeDetector.byCommonDir.get(commonDir);
229
+ }
230
+ const result = (0, node_child_process_1.spawnSync)('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
231
+ encoding: 'utf8',
232
+ stdio: ['ignore', 'pipe', 'ignore'],
233
+ timeout: 1000
234
+ });
235
+ if (result.status !== 0) {
236
+ warn(`Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
237
+ `skipping that lookup (git exited with status ${result.status}).`);
238
+ return null;
239
+ }
240
+ const lines = result.stdout.split('\n');
241
+ const mainWorktreePath = detectMainWorktreePath(lines, cwd, commonDir);
242
+ if (mainWorktreePath) {
243
+ if (commonDir) {
244
+ MainWorktreeDetector.byCommonDir.set(commonDir, mainWorktreePath);
245
+ }
246
+ return mainWorktreePath;
240
247
  }
241
- }
242
- i++;
243
- }
244
-
245
- // Last resort: the first worktree in the list that isn't the current cwd
246
- for (i = 0; i < lines.length; i++) {
247
- if (lines[i].startsWith('worktree ')) {
248
- const wt = lines[i].slice('worktree '.length).trim();
249
- if (wt !== cwd) {
250
- if (commonDir) {MainWorktreeDetector.byCommonDir.set(commonDir, wt);}
251
- return wt;
248
+ // Fallback: pick the first worktree whose HEAD points to main
249
+ let i = 0;
250
+ while (i < lines.length) {
251
+ if (lines[i].startsWith('worktree ')) {
252
+ const wt = lines[i].slice('worktree '.length).trim();
253
+ const branchLineIdx = i + 1;
254
+ if (branchLineIdx < lines.length && lines[branchLineIdx].startsWith('branch refs/heads/main')) {
255
+ if (commonDir) {
256
+ MainWorktreeDetector.byCommonDir.set(commonDir, wt);
257
+ }
258
+ return wt;
259
+ }
260
+ }
261
+ i++;
252
262
  }
253
- }
254
- }
255
- } catch (/** @type {unknown} */ err) {
256
- /** @type {Error & {code?: string}} */
257
- const e = /** @type {any} */ (err);
258
- const detail = e && (e.code || e.message) ? (e.code || e.message) : 'unknown error';
259
- warn(
260
- `Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
261
- `skipping that lookup (${detail}).`
262
- );
263
+ // Last resort: the first worktree in the list that isn't the current cwd
264
+ for (i = 0; i < lines.length; i++) {
265
+ if (lines[i].startsWith('worktree ')) {
266
+ const wt = lines[i].slice('worktree '.length).trim();
267
+ if (wt !== cwd) {
268
+ if (commonDir) {
269
+ MainWorktreeDetector.byCommonDir.set(commonDir, wt);
270
+ }
271
+ return wt;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ catch (err) {
277
+ const e = err;
278
+ const detail = e && (e.code || e.message) ? (e.code || e.message) : 'unknown error';
279
+ warn(`Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
280
+ `skipping that lookup (${detail}).`);
281
+ return null;
282
+ }
283
+ warn('Could not determine the main worktree from `git worktree list --porcelain`; ' +
284
+ 'skipping main-worktree agents.local.json lookup.');
263
285
  return null;
264
- }
265
-
266
- warn(
267
- 'Could not determine the main worktree from `git worktree list --porcelain`; ' +
268
- 'skipping main-worktree agents.local.json lookup.'
269
- );
270
- return null;
271
286
  }
272
-
273
287
  // Extract the known main worktree path from the repo metadata so worktree
274
288
  // iteration doesn't accidentally pick the current (non-main) worktree.
275
289
  // Cached per git common directory to avoid repeated subprocess calls without
276
290
  // leaking a temp-repo answer into later tests or nested workflow invocations.
277
291
  const MainWorktreeDetector = {
278
- byCommonDir: new Map()
292
+ byCommonDir: new Map()
279
293
  };
280
-
281
- /** @param {string} cwd @param {string[]} args */
282
294
  function getGitPath(cwd, args) {
283
- const result = spawnSync('git', ['-C', cwd, ...args], {
284
- encoding: 'utf8',
285
- stdio: ['ignore', 'pipe', 'ignore'],
286
- timeout: 1000
287
- });
288
- if (result.status !== 0) {
289
- return null;
290
- }
291
- return result.stdout.trim() || null;
295
+ const result = (0, node_child_process_1.spawnSync)('git', ['-C', cwd, ...args], {
296
+ encoding: 'utf8',
297
+ stdio: ['ignore', 'pipe', 'ignore'],
298
+ timeout: 1000
299
+ });
300
+ if (result.status !== 0) {
301
+ return null;
302
+ }
303
+ return result.stdout.trim() || null;
292
304
  }
293
-
294
- /** @param {string[]} lines */
295
305
  function parseWorktreePaths(lines) {
296
- return lines
297
- .filter(/** @param {string} line */ (line) => line.startsWith('worktree '))
298
- .map(/** @param {string} line */ (line) => line.slice('worktree '.length).trim())
299
- .filter(Boolean);
306
+ return lines
307
+ .filter(/** @param {string} line */ (line) => line.startsWith('worktree '))
308
+ .map(/** @param {string} line */ (line) => line.slice('worktree '.length).trim())
309
+ .filter(Boolean);
300
310
  }
301
-
302
- /** @param {string[]} lines @param {string} cwd @param {string | null} commonDir */
303
311
  function detectMainWorktreePath(lines, cwd, commonDir) {
304
- const worktrees = parseWorktreePaths(lines);
305
- if (worktrees.length === 0) {
312
+ const worktrees = parseWorktreePaths(lines);
313
+ if (worktrees.length === 0) {
314
+ return null;
315
+ }
316
+ const resolvedCommonDir = commonDir ? node_path_1.default.resolve(commonDir) : null;
317
+ for (const wt of worktrees) {
318
+ const gitDir = getGitPath(wt, ['rev-parse', '--absolute-git-dir']);
319
+ const wtCommonDir = getGitPath(wt, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
320
+ if (gitDir &&
321
+ wtCommonDir &&
322
+ node_path_1.default.resolve(gitDir) === node_path_1.default.resolve(wtCommonDir) &&
323
+ (!resolvedCommonDir || node_path_1.default.resolve(wtCommonDir) === resolvedCommonDir)) {
324
+ return wt;
325
+ }
326
+ }
327
+ const currentTopLevel = getGitPath(cwd, ['rev-parse', '--show-toplevel']);
328
+ if (currentTopLevel && worktrees.length === 1 && node_path_1.default.resolve(worktrees[0]) === node_path_1.default.resolve(currentTopLevel)) {
329
+ return worktrees[0];
330
+ }
306
331
  return null;
307
- }
308
-
309
- const resolvedCommonDir = commonDir ? path.resolve(commonDir) : null;
310
- for (const wt of worktrees) {
311
- const gitDir = getGitPath(wt, ['rev-parse', '--absolute-git-dir']);
312
- const wtCommonDir = getGitPath(wt, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
313
- if (
314
- gitDir &&
315
- wtCommonDir &&
316
- path.resolve(gitDir) === path.resolve(wtCommonDir) &&
317
- (!resolvedCommonDir || path.resolve(wtCommonDir) === resolvedCommonDir)
318
- ) {
319
- return wt;
320
- }
321
- }
322
-
323
- const currentTopLevel = getGitPath(cwd, ['rev-parse', '--show-toplevel']);
324
- if (currentTopLevel && worktrees.length === 1 && path.resolve(worktrees[0]) === path.resolve(currentTopLevel)) {
325
- return worktrees[0];
326
- }
327
- return null;
328
332
  }
329
-
330
- /** @param {string | number} value */
331
333
  function parseBlockUntil(value) {
332
- if (typeof value !== 'string') {
333
- return NaN;
334
- }
335
-
336
- const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2})$/);
337
- if (!match) {
338
- return NaN;
339
- }
340
-
341
- const [, yearStr, monthStr, dayStr, hourStr] = match;
342
- const year = Number(yearStr);
343
- const month = Number(monthStr);
344
- const day = Number(dayStr);
345
- const hour = Number(hourStr);
346
- const parsed = new Date(year, month - 1, day, hour, 0, 0, 0);
347
-
348
- if (
349
- parsed.getFullYear() !== year ||
350
- parsed.getMonth() !== month - 1 ||
351
- parsed.getDate() !== day ||
352
- parsed.getHours() !== hour
353
- ) {
354
- return NaN;
355
- }
356
-
357
- return parsed.getTime();
334
+ if (typeof value !== 'string') {
335
+ return NaN;
336
+ }
337
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2})$/);
338
+ if (!match) {
339
+ return NaN;
340
+ }
341
+ const [, yearStr, monthStr, dayStr, hourStr] = match;
342
+ const year = Number(yearStr);
343
+ const month = Number(monthStr);
344
+ const day = Number(dayStr);
345
+ const hour = Number(hourStr);
346
+ const parsed = new Date(year, month - 1, day, hour, 0, 0, 0);
347
+ if (parsed.getFullYear() !== year ||
348
+ parsed.getMonth() !== month - 1 ||
349
+ parsed.getDate() !== day ||
350
+ parsed.getHours() !== hour) {
351
+ return NaN;
352
+ }
353
+ return parsed.getTime();
358
354
  }
359
-
360
- /**
361
- * @param {string} agent
362
- * @param {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}} | null} config
363
- */
364
355
  function isAgentBlocked(agent, config) {
365
- if (!config || !config.blocklist || config.blocklist[agent] === undefined) {
366
- return false;
367
- }
368
- const entry = config.blocklist[agent];
369
- if (entry === true) {return true;}
370
- if (entry === false) {return false;}
371
- if (entry && typeof entry === 'object') {
372
- if (entry.blocked === true) {return true;}
373
- if (entry.blocked === false) {return false;}
374
- if (entry.until) {
375
- const until = parseBlockUntil(entry.until);
376
- if (!isNaN(until) && until > Date.now()) {
356
+ if (!config || !config.blocklist || config.blocklist[agent] === undefined) {
357
+ return false;
358
+ }
359
+ const entry = config.blocklist[agent];
360
+ if (entry === true) {
377
361
  return true;
378
- }
379
362
  }
380
- }
381
- return false;
363
+ if (entry === false) {
364
+ return false;
365
+ }
366
+ if (entry && typeof entry === 'object') {
367
+ if (entry.blocked === true) {
368
+ return true;
369
+ }
370
+ if (entry.blocked === false) {
371
+ return false;
372
+ }
373
+ if (entry.until) {
374
+ const until = parseBlockUntil(entry.until);
375
+ if (!isNaN(until) && until > Date.now()) {
376
+ return true;
377
+ }
378
+ }
379
+ }
380
+ return false;
382
381
  }
383
-
384
- /**
385
- * @param {string} step
386
- * @param {{config?: {blocklist?: {[key: string]: any}, steps?: {[key: string]: any}}, configPath?: string}} options
387
- */
388
382
  function eligibleAgentsForStep(step, options = {}) {
389
- const /** @type {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}} | null} */ config = options.config !== undefined
390
- ? options.config
391
- : readAgentConfig(options.configPath || CONFIG_PATH, /** @type {{mergeLocal?: boolean, mainWorktreePath?: string | null, warn?: Function, targetPath?: string}} */ (options));
392
- let eligible;
393
- if (!config || !config.steps || !config.steps[step]) {
394
- eligible = Object.keys(LAUNCHERS);
395
- } else {
396
- eligible = config.steps[step].eligible || Object.keys(LAUNCHERS);
397
- }
398
- return eligible.filter(/** @param {string} agent */ (agent) => !isAgentBlocked(agent, config));
383
+ const /** @type {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}} | null} */ config = options.config !== undefined
384
+ ? options.config
385
+ : readAgentConfig(options.configPath || CONFIG_PATH, options);
386
+ let eligible;
387
+ if (!config || !config.steps || !config.steps[step]) {
388
+ eligible = Object.keys(LAUNCHERS);
389
+ }
390
+ else {
391
+ eligible = config.steps[step].eligible || Object.keys(LAUNCHERS);
392
+ }
393
+ return eligible.filter(/** @param {string} agent */ (agent) => !isAgentBlocked(agent, config));
399
394
  }
400
-
401
- /** @param {string[]} agents @param {{[key: string]: number}} weights */
402
395
  function weightedRandom(agents, weights) {
403
- const total = agents.reduce(/** @param {number} sum @param {string} a */ (sum, a) => sum + (weights[a] || 1), 0);
404
- let r = Math.random() * total;
405
- for (const agent of agents) {
406
- r -= weights[agent] || 1;
407
- if (r <= 0) {return agent;}
408
- }
409
- return agents[agents.length - 1];
396
+ const total = agents.reduce(/** @param {number} sum @param {string} a */ (sum, a) => sum + (weights[a] || 1), 0);
397
+ let r = Math.random() * total;
398
+ for (const agent of agents) {
399
+ r -= weights[agent] || 1;
400
+ if (r <= 0) {
401
+ return agent;
402
+ }
403
+ }
404
+ return agents[agents.length - 1];
410
405
  }
411
-
412
- /**
413
- * @param {string} step
414
- * @param {{exclude?: Set<string>, config?: {blocklist?: {[key: string]: any}, steps?: {[key: string]: any}}, configPath?: string}} options
415
- */
416
406
  function selectAgent(step, options = {}) {
417
- const envOverride = process.env.WORKFLOW_AGENT;
418
- const excluded = options.exclude instanceof Set ? options.exclude : new Set();
419
- const eligible = eligibleAgentsForStep(step, options);
420
- // Honor the env override only when it is in the current eligible-and-unblocked
421
- // pool and not already excluded (a previous limit-hit attempt in the same
422
- // startAgent retry loop). A pinned agent that is hard-blocked in
423
- // agents.local.json or excluded by step eligibility falls through to normal
424
- // selection — matches parallix/docs/agents.md, which documents that
425
- // WORKFLOW_AGENT is honored alongside the eligibility config and blocklist.
426
- if (envOverride && !excluded.has(envOverride) && eligible.includes(envOverride)) {
427
- return envOverride;
428
- }
429
-
430
- const pool = eligible.filter(/** @param {string} agent */ (agent) => !excluded.has(agent));
431
- if (eligible.length === 0) {
432
- throw new Error(`No agents are eligible for workflow step: ${step}`);
433
- }
434
- if (pool.length === 0) {
435
- throw new Error(
436
- `All eligible agents for step "${step}" are exhausted (limit-hit or excluded). ` +
437
- `Tried: ${[...excluded].join(', ')}.`
438
- );
439
- }
440
-
441
- // Filter to agents that are both eligible (per config) and supported (launcher present).
442
- const statuses = new Map(
443
- pool
444
- .filter(/** @param {string} agent */ (agent) => LAUNCHERS[agent])
445
- .map(/** @param {string} agent */ (agent) => [agent, workflowLauncherStatus(agent)])
446
- );
447
- const available = pool.filter(/** @param {string} agent */ (agent) => {
448
- const status = statuses.get(agent);
449
- return Boolean(status && status.supported);
450
- });
451
- if (available.length === 0) {
452
- const blockers = pool.map(/** @param {string} agent */ (agent) => {
453
- const status = statuses.get(agent) || { detail: agent, reason: 'unsupported-agent' };
454
- const suffix = status.reason ? `; ${status.reason}` : '';
455
- return `${agent} (looked for: ${status.detail}${suffix})`;
407
+ const envOverride = process.env.WORKFLOW_AGENT;
408
+ const excluded = options.exclude instanceof Set ? options.exclude : new Set();
409
+ const eligible = eligibleAgentsForStep(step, options);
410
+ // Honor the env override only when it is in the current eligible-and-unblocked
411
+ // pool and not already excluded (a previous limit-hit attempt in the same
412
+ // startAgent retry loop). A pinned agent that is hard-blocked in
413
+ // agents.local.json or excluded by step eligibility falls through to normal
414
+ // selection — matches parallix/docs/agents.md, which documents that
415
+ // WORKFLOW_AGENT is honored alongside the eligibility config and blocklist.
416
+ if (envOverride && !excluded.has(envOverride) && eligible.includes(envOverride)) {
417
+ return envOverride;
418
+ }
419
+ const pool = eligible.filter(/** @param {string} agent */ (agent) => !excluded.has(agent));
420
+ if (eligible.length === 0) {
421
+ throw new Error(`No agents are eligible for workflow step: ${step}`);
422
+ }
423
+ if (pool.length === 0) {
424
+ throw new Error(`All eligible agents for step "${step}" are exhausted (limit-hit or excluded). ` +
425
+ `Tried: ${[...excluded].join(', ')}.`);
426
+ }
427
+ // Filter to agents that are both eligible (per config) and supported (launcher present).
428
+ const statuses = new Map(pool
429
+ .filter(/** @param {string} agent */ (agent) => LAUNCHERS[agent])
430
+ .map(/** @param {string} agent */ (agent) => [agent, workflowLauncherStatus(agent)]));
431
+ const available = pool.filter(/** @param {string} agent */ (agent) => {
432
+ const status = statuses.get(agent);
433
+ return Boolean(status && status.supported);
456
434
  });
457
- throw new Error(
458
- `No eligible agents have a working launcher for step "${step}". ` +
459
- `Eligible but blocked: ${blockers.join(', ')}. ` +
460
- `Set WORKFLOW_AGENT=<name> to override or install a supported agent.`
461
- );
462
- }
463
-
464
- const /** @type {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}} | null} */ config = options.config !== undefined
465
- ? options.config
466
- : readAgentConfig(options.configPath || CONFIG_PATH, /** @type {{mergeLocal?: boolean, mainWorktreePath?: string | null, warn?: Function, targetPath?: string}} */ (options));
467
- const stepConfig = config && config.steps && config.steps[step] ? config.steps[step] : {};
468
- const selection = stepConfig.selection || 'random';
469
-
470
- if (selection === 'weighted') {
471
- const weights = stepConfig.weights || {};
472
- return weightedRandom(available, weights);
473
- }
474
-
475
- if (selection === 'random') {
476
- return available[Math.floor(Math.random() * available.length)];
477
- }
478
-
479
- return available[0];
435
+ if (available.length === 0) {
436
+ const blockers = pool.map(/** @param {string} agent */ (agent) => {
437
+ const status = statuses.get(agent) || { detail: agent, reason: 'unsupported-agent' };
438
+ const suffix = status.reason ? `; ${status.reason}` : '';
439
+ return `${agent} (looked for: ${status.detail}${suffix})`;
440
+ });
441
+ throw new Error(`No eligible agents have a working launcher for step "${step}". ` +
442
+ `Eligible but blocked: ${blockers.join(', ')}. ` +
443
+ `Set WORKFLOW_AGENT=<name> to override or install a supported agent.`);
444
+ }
445
+ const /** @type {{blocklist?: {[key: string]: any}, steps?: {[key: string]: any}} | null} */ config = options.config !== undefined
446
+ ? options.config
447
+ : readAgentConfig(options.configPath || CONFIG_PATH, options);
448
+ const stepConfig = config && config.steps && config.steps[step] ? config.steps[step] : {};
449
+ const selection = stepConfig.selection || 'random';
450
+ if (selection === 'weighted') {
451
+ const weights = stepConfig.weights || {};
452
+ return weightedRandom(available, weights);
453
+ }
454
+ if (selection === 'random') {
455
+ return available[Math.floor(Math.random() * available.length)];
456
+ }
457
+ return available[0];
480
458
  }
481
-
482
- /** @param {string} agent */
483
459
  function assertAgentSupported(agent) {
484
- if (!LAUNCHERS[agent]) {
485
- /** @type {any} */
486
- const error = new Error(
487
- `Unknown agent: "${fmt.agent(agent)}". Supported agents: ${Object.keys(LAUNCHERS).join(', ')}.`
488
- );
489
- error.code = 'UNKNOWN_AGENT';
490
- throw error;
491
- }
492
-
493
- const status = workflowLauncherStatus(agent);
494
- if (!status.supported) {
495
- const health = status.health ? ` (${status.health})` : '';
496
- const reason = status.reason ? `; reason: ${status.reason}` : '';
497
- /** @type {any} */
498
- const error = new Error(
499
- `Agent "${fmt.agent(agent)}" launcher is not available on this workstation${health}. ` +
500
- `Looked for: ${fmt.path(status.detail)}${reason}. ` +
501
- `Ensure ${fmt.agent(agent)} is on your PATH and retry.`
502
- );
503
- error.code = 'LAUNCHER_UNAVAILABLE';
504
- throw error;
505
- }
460
+ if (!LAUNCHERS[agent]) {
461
+ const error = new Error(`Unknown agent: "${fmt.agent(agent)}". Supported agents: ${Object.keys(LAUNCHERS).join(', ')}.`);
462
+ error.code = 'UNKNOWN_AGENT';
463
+ throw error;
464
+ }
465
+ const status = workflowLauncherStatus(agent);
466
+ if (!status.supported) {
467
+ const health = status.health ? ` (${status.health})` : '';
468
+ const reason = status.reason ? `; reason: ${status.reason}` : '';
469
+ const error = new Error(`Agent "${fmt.agent(agent)}" launcher is not available on this workstation${health}. ` +
470
+ `Looked for: ${fmt.path(status.detail)}${reason}. ` +
471
+ `Ensure ${fmt.agent(agent)} is on your PATH and retry.`);
472
+ error.code = 'LAUNCHER_UNAVAILABLE';
473
+ throw error;
474
+ }
506
475
  }
507
-
508
- /** @param {{targetPath?: string}} options */
509
476
  function resolveBlocklistTargetPath(options = {}) {
510
- if (options.targetPath) {return options.targetPath;}
511
- return storage.resolveAgentsLocalPath({ ensureDir: true });
477
+ if (options.targetPath) {
478
+ return options.targetPath;
479
+ }
480
+ return storage.resolveAgentsLocalPath({ ensureDir: true });
512
481
  }
513
-
514
- /** @param {string} agent @param {string} until @param {{targetPath?: string}} options */
515
482
  function updateAgentBlock(agent, until, options = {}) {
516
- if (!agent || typeof agent !== 'string') {
517
- throw new Error('updateAgentBlock requires an agent name');
518
- }
519
- if (!until || typeof until !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}$/.test(until)) {
520
- throw new Error(`updateAgentBlock requires an "YYYY-MM-DD HH" timestamp; got: ${until}`);
521
- }
522
-
523
- const targetPath = resolveBlocklistTargetPath(options);
524
-
525
- /** @type {{blocklist?: {[key: string]: any}}} */
526
- let payload = {};
527
- if (fs.existsSync(targetPath)) {
528
- // Match the read-path contract (parseAgentConfigFile): malformed local agent
529
- // JSON is a hard failure, not a silent overwrite. Otherwise a limit hit on a
530
- // corrupted agents.local.json would destroy whatever was on disk.
531
- try {
532
- payload = JSON.parse(fs.readFileSync(targetPath, 'utf8')) || {};
533
- } catch (/** @type {unknown} */ err) {
534
- throw buildInvalidAgentConfigError(targetPath, 'local', /** @type {{message?: string}} */ (err));
535
- }
536
- if (typeof payload !== 'object' || Array.isArray(payload)) {
537
- throw buildInvalidAgentConfigError(
538
- targetPath,
539
- 'local',
540
- new Error('expected a JSON object at the file root')
541
- );
542
- }
543
- }
544
- if (!payload.blocklist || typeof payload.blocklist !== 'object' || Array.isArray(payload.blocklist)) {
545
- payload.blocklist = {};
546
- }
547
- payload.blocklist[agent] = { until };
548
-
549
- storage.writeJson(targetPath, payload);
550
- return { path: targetPath, blocklist: payload.blocklist };
483
+ if (!agent || typeof agent !== 'string') {
484
+ throw new Error('updateAgentBlock requires an agent name');
485
+ }
486
+ if (!until || typeof until !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}$/.test(until)) {
487
+ throw new Error(`updateAgentBlock requires an "YYYY-MM-DD HH" timestamp; got: ${until}`);
488
+ }
489
+ const targetPath = resolveBlocklistTargetPath(options);
490
+ let payload = {};
491
+ if (node_fs_1.default.existsSync(targetPath)) {
492
+ // Match the read-path contract (parseAgentConfigFile): malformed local agent
493
+ // JSON is a hard failure, not a silent overwrite. Otherwise a limit hit on a
494
+ // corrupted agents.local.json would destroy whatever was on disk.
495
+ try {
496
+ payload = JSON.parse(node_fs_1.default.readFileSync(targetPath, 'utf8')) || {};
497
+ }
498
+ catch (err) {
499
+ throw buildInvalidAgentConfigError(targetPath, 'local', err);
500
+ }
501
+ if (typeof payload !== 'object' || Array.isArray(payload)) {
502
+ throw buildInvalidAgentConfigError(targetPath, 'local', new Error('expected a JSON object at the file root'));
503
+ }
504
+ }
505
+ if (!payload.blocklist || typeof payload.blocklist !== 'object' || Array.isArray(payload.blocklist)) {
506
+ payload.blocklist = {};
507
+ }
508
+ payload.blocklist[agent] = { until };
509
+ storage.writeJson(targetPath, payload);
510
+ return { path: targetPath, blocklist: payload.blocklist };
551
511
  }
552
-
553
- /** @param {string} agent */
554
512
  function defaultIsAgentBlockedNow(agent) {
555
- try {
556
- const config = readAgentConfig(CONFIG_PATH, {});
557
- return isAgentBlocked(agent, config);
558
- } catch (/** @type {unknown} */ err) {
559
- // If the config is malformed, surface that through the launcher path
560
- // (assertAgentSupported / launch) instead of silently rerouting. Treat as
561
- // not-blocked here so the existing error path runs.
562
- return false;
563
- }
513
+ try {
514
+ const config = readAgentConfig(CONFIG_PATH, {});
515
+ return isAgentBlocked(agent, config);
516
+ }
517
+ catch (err) {
518
+ // If the config is malformed, surface that through the launcher path
519
+ // (assertAgentSupported / launch) instead of silently rerouting. Treat as
520
+ // not-blocked here so the existing error path runs.
521
+ return false;
522
+ }
564
523
  }
565
-
566
- /** @param {string} name */
567
524
  function readPositiveMsEnv(name) {
568
- const raw = process.env[name];
569
- if (raw === undefined || raw === '') {return null;}
570
- const value = Number(raw);
571
- return Number.isFinite(value) && value >= 0 ? value : null;
525
+ const raw = process.env[name];
526
+ if (raw === undefined || raw === '') {
527
+ return null;
528
+ }
529
+ const value = Number(raw);
530
+ return Number.isFinite(value) && value >= 0 ? value : null;
572
531
  }
573
-
574
- /** @param {{initialDelayMs?: number, intervalMs?: number}|boolean} config @param {string | null} step */
575
532
  function resolveNoOutputWatchdogConfig(config, step = null) {
576
- if (config === false || process.env.WORKFLOW_AGENT_NO_OUTPUT_WATCHDOG === '0') {
577
- return null;
578
- }
579
- const /** @type {{initialDelayMs?: number, intervalMs?: number}} */ explicit = config && typeof config === 'object' ? config : {};
580
- // Draft gets a shorter default watchdog to surface agent-launch visibility
581
- // quickly; the generic default (60s) is too slow for the draft entrypoint
582
- // where an operator cannot tell launch from hang.
583
- let initialDelayMs;
584
- let intervalMs;
585
- if (step === 'draft') {
586
- initialDelayMs = explicit.initialDelayMs ??
587
- readPositiveMsEnv('WORKFLOW_DRAFT_AGENT_NO_OUTPUT_INITIAL_MS') ??
588
- DRAFT_NO_OUTPUT_INITIAL_DELAY_MS;
589
- intervalMs = explicit.intervalMs ??
590
- readPositiveMsEnv('WORKFLOW_DRAFT_AGENT_NO_OUTPUT_INTERVAL_MS') ??
591
- DRAFT_NO_OUTPUT_INTERVAL_MS;
592
- } else {
593
- initialDelayMs = explicit.initialDelayMs ??
594
- readPositiveMsEnv('WORKFLOW_AGENT_NO_OUTPUT_INITIAL_MS') ??
595
- DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS;
596
- intervalMs = explicit.intervalMs ??
597
- readPositiveMsEnv('WORKFLOW_AGENT_NO_OUTPUT_INTERVAL_MS') ??
598
- DEFAULT_NO_OUTPUT_INTERVAL_MS;
599
- }
600
- return { initialDelayMs, intervalMs };
533
+ if (config === false || process.env.WORKFLOW_AGENT_NO_OUTPUT_WATCHDOG === '0') {
534
+ return null;
535
+ }
536
+ const explicit = config && typeof config === 'object' ? config : {};
537
+ // Draft gets a shorter default watchdog to surface agent-launch visibility
538
+ // quickly; the generic default (60s) is too slow for the draft entrypoint
539
+ // where an operator cannot tell launch from hang.
540
+ let initialDelayMs;
541
+ let intervalMs;
542
+ if (step === 'draft') {
543
+ initialDelayMs = explicit.initialDelayMs ??
544
+ readPositiveMsEnv('WORKFLOW_DRAFT_AGENT_NO_OUTPUT_INITIAL_MS') ??
545
+ DRAFT_NO_OUTPUT_INITIAL_DELAY_MS;
546
+ intervalMs = explicit.intervalMs ??
547
+ readPositiveMsEnv('WORKFLOW_DRAFT_AGENT_NO_OUTPUT_INTERVAL_MS') ??
548
+ DRAFT_NO_OUTPUT_INTERVAL_MS;
549
+ }
550
+ else {
551
+ initialDelayMs = explicit.initialDelayMs ??
552
+ readPositiveMsEnv('WORKFLOW_AGENT_NO_OUTPUT_INITIAL_MS') ??
553
+ DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS;
554
+ intervalMs = explicit.intervalMs ??
555
+ readPositiveMsEnv('WORKFLOW_AGENT_NO_OUTPUT_INTERVAL_MS') ??
556
+ DEFAULT_NO_OUTPUT_INTERVAL_MS;
557
+ }
558
+ return { initialDelayMs, intervalMs };
601
559
  }
602
-
603
- /** @param {number} elapsedMs */
604
560
  function formatElapsed(elapsedMs) {
605
- const seconds = Math.max(0, Math.round(elapsedMs / 1000));
606
- if (seconds < 60) {return `${seconds}s`;}
607
- const minutes = Math.floor(seconds / 60);
608
- const remainder = seconds % 60;
609
- return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
561
+ const seconds = Math.max(0, Math.round(elapsedMs / 1000));
562
+ if (seconds < 60) {
563
+ return `${seconds}s`;
564
+ }
565
+ const minutes = Math.floor(seconds / 60);
566
+ const remainder = seconds % 60;
567
+ return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
610
568
  }
611
-
612
- /**
613
- * @param {string} step
614
- * @param {{prompt: string | Function, worktree?: string, agent?: string, env?: {[key: string]: string}, exclude?: (string|null|Set<string>)[], onLimitHit?: Function, onLaunch?: Function, slug?: string | null, role?: string | null, detectLimitHitFn?: Function, updateAgentBlockFn?: Function, selectAgentFn?: Function, resolveAgentModelFn?: Function, isAgentBlockedFn?: Function, sessionsModule?: object, log?: Function, noOutputWatchdog?: {initialDelayMs?: number, intervalMs?: number}}} opts
615
- */
616
569
  async function startAgent(step, opts = { prompt: '' }) {
617
- const {
618
- prompt,
619
- worktree,
620
- agent: agentOverride,
621
- env = {},
622
- exclude = [],
623
- onLimitHit,
624
- onLaunch,
625
- slug = null,
626
- role = null,
627
- detectLimitHitFn = detectLimitHit,
628
- updateAgentBlockFn = updateAgentBlock,
629
- selectAgentFn = selectAgent,
630
- resolveAgentModelFn = resolveAgentModel,
631
- isAgentBlockedFn = defaultIsAgentBlockedNow,
632
- sessionsModule = sessions,
633
- log = fmt.log.plain,
634
- noOutputWatchdog = {}
635
- } = opts;
636
-
637
- // `exclude` seeds the tried-set so callers can reserve agents (e.g. exclude
638
- // the current implementer from reviewer fallback to preserve family separation).
639
- const excludeIterable = exclude instanceof Set ? exclude : exclude;
640
- const tried = new Set(excludeIterable);
641
- // Track per-agent failure details for accurate exhaustion diagnostics (SC 3)
642
- const agentErrors = new Map();
643
- // Track agents actually launched (not just pre-excluded) for accurate reporting
644
- const launched = new Set();
645
- let iteration = 0;
646
- let chosen = agentOverride;
647
-
648
- while (true) {
649
- iteration += 1;
650
- if (!chosen) {
651
- try {
652
- chosen = selectAgentFn(step, { exclude: tried });
653
- } catch (/** @type {unknown} */ err) {
654
- // Only catch pool exhaustion errors from selectAgent.
655
- // Configuration errors (no eligible agents, no working launcher) must
656
- // propagate unchanged to preserve diagnostics (SC 3).
657
- // Exhaustion is indicated by:
658
- // - "exhausted" from real selectAgent pool exhaustion ("are exhausted")
659
- // - "No agents available" from test mocks simulating exhaustion
660
- if (!(/** @type {any} */ (err).message || '').includes('exhausted') &&
661
- !(/** @type {any} */ (err).message || '').includes('No agents available')
662
- ) {
663
- throw err;
570
+ const { prompt, worktree, agent: agentOverride, env = {}, exclude = [], onLimitHit, onLaunch, slug = null, role = null, detectLimitHitFn = limit_hit_js_1.detectLimitHit, updateAgentBlockFn = updateAgentBlock, selectAgentFn = selectAgent, resolveAgentModelFn = product_config_js_1.resolveAgentModel, isAgentBlockedFn = defaultIsAgentBlockedNow, sessionsModule = sessions, log = fmt.log.plain, noOutputWatchdog = {} } = opts;
571
+ // `exclude` seeds the tried-set so callers can reserve agents (e.g. exclude
572
+ // the current implementer from reviewer fallback to preserve family separation).
573
+ const excludeIterable = exclude instanceof Set ? exclude : exclude;
574
+ const tried = new Set(excludeIterable);
575
+ // Track per-agent failure details for accurate exhaustion diagnostics (SC 3)
576
+ const agentErrors = new Map();
577
+ // Track agents actually launched (not just pre-excluded) for accurate reporting
578
+ const launched = new Set();
579
+ let iteration = 0;
580
+ let chosen = agentOverride;
581
+ while (true) {
582
+ iteration += 1;
583
+ if (!chosen) {
584
+ try {
585
+ chosen = selectAgentFn(step, { exclude: tried });
586
+ }
587
+ catch (err) {
588
+ // Only catch pool exhaustion errors from selectAgent.
589
+ // Configuration errors (no eligible agents, no working launcher) must
590
+ // propagate unchanged to preserve diagnostics (SC 3).
591
+ // Exhaustion is indicated by:
592
+ // - "exhausted" from real selectAgent pool exhaustion ("are exhausted")
593
+ // - "No agents available" from test mocks simulating exhaustion
594
+ if (!(err.message || '').includes('exhausted') &&
595
+ !(err.message || '').includes('No agents available')) {
596
+ throw err;
597
+ }
598
+ // Pool exhausted; build clear exhaustion diagnostics with per-agent errors (SC 3)
599
+ const errorDetails = [...agentErrors.entries()].map(([agent, details]) => {
600
+ const status = details.exitInfo === 'stalled'
601
+ ? 'stalled (no output)'
602
+ : (details.status !== undefined && details.status !== null
603
+ ? `exit ${details.status}`
604
+ : (details.signal ? `signal ${details.signal}` : 'unknown'));
605
+ const stderrSnippet = details.stderr ? ` (${details.stderr.trim().split('\n')[0]})` : '';
606
+ return `${agent}: ${status}${stderrSnippet}`;
607
+ }).join('; ');
608
+ const launchedList = [...launched].join(', ');
609
+ throw new Error(`All eligible agents exhausted for step "${step}". ` +
610
+ `Tried: ${launchedList}. Errors: ${errorDetails}.`);
611
+ }
664
612
  }
665
- // Pool exhausted; build clear exhaustion diagnostics with per-agent errors (SC 3)
666
- const errorDetails = [...agentErrors.entries()].map(([agent, details]) => {
667
- const status = details.exitInfo === 'stalled'
668
- ? 'stalled (no output)'
669
- : (details.status !== undefined && details.status !== null
670
- ? `exit ${details.status}`
671
- : (details.signal ? `signal ${details.signal}` : 'unknown'));
672
- const stderrSnippet = details.stderr ? ` (${details.stderr.trim().split('\n')[0]})` : '';
673
- return `${agent}: ${status}${stderrSnippet}`;
674
- }).join('; ');
675
- const launchedList = [...launched].join(', ');
676
- throw new Error(
677
- `All eligible agents exhausted for step "${step}". ` +
678
- `Tried: ${launchedList}. Errors: ${errorDetails}.`
679
- );
680
- }
681
- } else if (isAgentBlockedFn(chosen)) {
682
- // Pre-launch blocklist gate. An explicit `agent:` override (e.g. a pinned
683
- // reviewer/implementer carried over from review-state.json) bypasses
684
- // selectAgent's blocklist filter. Without this check, a known-blocked
685
- // family is relaunched immediately and the harness wastes a retry hitting
686
- // the same limit. Reroute through normal selection on the next iteration.
687
- log(fmt.status('WARN', `Pinned agent "${fmt.agent(chosen)}" is currently blocked in agents.local.json; rerouting via selectAgent for step "${step}".`));
688
- tried.add(chosen);
689
- chosen = undefined;
690
- continue;
691
- }
692
-
693
- try {
694
- assertAgentSupported(chosen || '');
695
- } catch (/** @type {unknown} */ err) {
696
- /** @type {Error & {code?: string}} */
697
- const e = /** @type {any} */ (err);
698
- if (e.code !== 'LAUNCHER_UNAVAILABLE') {
699
- throw err;
700
- }
701
- log(fmt.status('WARN', /** @type {any} */ (err).message));
702
- // Only reroute for launcher-availability failures (missing or probe-failed).
703
- tried.add(chosen || '');
704
- // If the caller pinned a specific agent, allow one retry that ignores
705
- // the override and falls back to normal selection (matches limit-hit logic).
706
- if (agentOverride && agentOverride === chosen && iteration === 1) {
707
- chosen = undefined;
708
- continue;
709
- }
710
- chosen = undefined;
711
- continue;
712
- }
713
- tried.add(chosen || '');
714
- launched.add(chosen || '');
715
-
716
- const launcher = LAUNCHERS[chosen || ''];
717
- log(fmt.status('INFO', `Selected agent for step "${step}": ${fmt.agent(chosen || '')}${iteration > 1 ? ` (attempt ${iteration})` : ''}`));
718
-
719
- // Enforce the agent family as the Forgejo identity (ADR 0029 / task-095).
720
- // FORGEJO_USER is set last so the harness-selected identity always wins;
721
- // a caller-supplied env.FORGEJO_USER cannot override it.
722
- const agentEnv = { ...env, FORGEJO_USER: chosen };
723
-
724
- // Decide whether to resume the agent's prior session for this (slug, role).
725
- // Only honored when the caller passed slug+role+worktree AND the previous
726
- // marker matches the chosen agent family (a fallback to a different family
727
- // invalidates the prior session).
728
- const resume = Boolean(
729
- worktree && slug && role &&
730
- RESUME_CAPABLE.has(chosen || '') &&
731
- (/** @type {any} */ (sessionsModule)).shouldResume(worktree, slug, role, chosen || '')
732
- );
733
- const sessionId = (/** @type {any} */ (sessionsModule)).getSessionId(worktree, slug, role);
734
- if (slug && role) {
735
- if (resume) {
736
- log(fmt.status('INFO', `Resuming ${fmt.agent(chosen || '')} session for ${fmt.slug(slug)} (${role}).${sessionId ? ` Session: ${sessionId}` : ''}`));
737
- } else if (RESUME_CAPABLE.has(chosen || '')) {
738
- log(fmt.status('INFO', `No prior ${fmt.agent(chosen || '')} session for ${fmt.slug(slug)} (${role}); launching fresh.`));
739
- }
740
- }
741
-
742
- // Resolve the prompt string. If a function was provided, call it with the
743
- // currently chosen agent name (TASK-1051). This ensures that if startAgent
744
- // falls back to a different family after a limit hit, the fallback agent
745
- // receives a prompt tailored to its own identity.
746
- const actualPrompt = typeof prompt === 'function' ? prompt(chosen) : prompt;
747
-
748
- // Resolve the per-family model override (adapters.agents.models[chosen]).
749
- // null when the family is not configured, in which case the launcher omits
750
- // the model flag entirely and the agent uses its own default.
751
- const model = resolveAgentModelFn(chosen || '', worktree || process.cwd());
752
- if (model) {
753
- log(fmt.status('INFO', `Using configured model for ${fmt.agent(chosen || '')}: ${model}`));
754
- }
755
-
756
- const watchdogConfig = resolveNoOutputWatchdogConfig(noOutputWatchdog, step);
757
- const launchResult = launcher({
758
- prompt: actualPrompt,
759
- worktree,
760
- env: agentEnv,
761
- resume,
762
- sessionId,
763
- model,
764
- slug,
765
- role,
766
- teeOptions: watchdogConfig ? {
767
- noOutputWatchdog: {
768
- ...watchdogConfig,
769
- onNoOutput: (/** @type {{pid: number, elapsedMs: number}} */ evt) => {
770
- const stage = evt.elapsedMs < (step === 'draft' ? DRAFT_NO_OUTPUT_INITIAL_DELAY_MS : DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS)
771
- ? 'starting up'
772
- : 'running';
773
- log(fmt.status(
774
- 'INFO',
775
- `No output yet from ${fmt.agent(chosen || '')} for step "${step}" after ${formatElapsed(evt.elapsedMs)} ` +
776
- `(pid ${evt.pid || 'unknown'}, agent ${stage}). ` +
777
- `Launcher is still running; stdout/stderr have not produced visible output.`
778
- ));
779
- }
613
+ else if (isAgentBlockedFn(chosen)) {
614
+ // Pre-launch blocklist gate. An explicit `agent:` override (e.g. a pinned
615
+ // reviewer/implementer carried over from review-state.json) bypasses
616
+ // selectAgent's blocklist filter. Without this check, a known-blocked
617
+ // family is relaunched immediately and the harness wastes a retry hitting
618
+ // the same limit. Reroute through normal selection on the next iteration.
619
+ log(fmt.status('WARN', `Pinned agent "${fmt.agent(chosen)}" is currently blocked in agents.local.json; rerouting via selectAgent for step "${step}".`));
620
+ tried.add(chosen);
621
+ chosen = undefined;
622
+ continue;
780
623
  }
781
- } : {}
782
- });
783
- const { invocation, resultPromise } = launchResult;
784
- if (invocation) {
785
- log(fmt.status('INFO', `Launching: ${fmt.command(`${invocation.command} ${invocation.args.join(' ')}`)}`));
786
- if (invocation.options && invocation.options.cwd) {
787
- log(fmt.status('INFO', `Working directory: ${fmt.path(invocation.options.cwd)}`));
788
- }
789
- }
790
-
791
- if (onLaunch) {
792
- await onLaunch({ agent: chosen, invocation });
793
- }
794
-
795
- const result = resultPromise ? await resultPromise : launchResult.result;
796
-
797
- // Pass exit metadata so detectLimitHit only treats matching transcript text
798
- // as a real limit hit when the launcher actually failed. A successful run
799
- // (status === 0) that happens to contain limit-hit phrases — for example,
800
- // an agent reviewing code or logs that quote those phrases — must not block
801
- // the healthy agent.
802
- const limitHit = detectLimitHitFn({
803
- agent: chosen,
804
- stdout: result && result.stdout,
805
- stderr: result && result.stderr,
806
- status: result && result.status,
807
- signal: result && result.signal,
808
- error: result && result.error
809
- });
810
-
811
- if (limitHit) {
812
- log(fmt.status('WARN', `Limit hit detected for ${fmt.agent(chosen || '')}; reset estimate "${limitHit.until}" (${limitHit.source}). Blocking and retrying.`));
813
- try {
814
- const blockResult = updateAgentBlockFn(chosen || '', limitHit.until);
815
- log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)}`));
816
- } catch (/** @type {unknown} */ err) {
817
- log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${/** @type {any} */ (err).message}`));
818
- }
819
- if (typeof onLimitHit === 'function') {
820
- onLimitHit({ agent: chosen, until: limitHit.until, source: limitHit.source });
821
- }
822
- // Reset chosen so next iteration reselects, but only when no explicit override.
823
- // If the caller pinned a specific agent, fail loudly — there is no fallback.
824
- if (agentOverride && agentOverride === chosen && iteration === 1) {
825
- // Allow one retry that ignores the override.
826
- chosen = undefined;
827
- continue;
828
- }
829
- chosen = undefined;
830
- continue;
831
- }
832
-
833
- // Reroute if the launcher binary could not be started (ENOENT = not found, EACCES = not executable).
834
- if (result && result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {
835
- log(fmt.status('WARN', `Launcher for "${chosen || ''}" could not be started (${result.error.code}); rerouting.`));
836
- tried.add(chosen || '');
837
- if (agentOverride && agentOverride === chosen && iteration === 1) {
838
- chosen = undefined;
839
- continue;
840
- }
841
- chosen = undefined;
842
- continue;
843
- }
844
-
845
- // Detect launch failure: agent started but exited with non-zero status and
846
- // no limit-hit was detected. This catches errors like "Model not found" in
847
- // opencode that cause the launcher to exit immediately with an error code.
848
- // Retry with the next eligible agent instead of returning the failure.
849
- // Only treat `status !== null && status !== 0` or `signal` (with no spawn
850
- // error) as a launch failure; `status: null` without signal is ambiguous
851
- // (spawn-tee close event can emit null code) and should not trigger a retry.
852
- // Spurious opencode v2.0.0 JSON-mode exits (exit 1 after a valid
853
- // "reason":"stop" completion) are excluded — the agent completed, the
854
- // non-zero code is a post-run cleanup race.
855
- const launchFailed = result &&
856
- ((result.status !== null && result.status !== 0) || (result.signal && !result.error)) &&
857
- !limitHit &&
858
- !isSpuriousOpencodeExit(result);
859
- if (launchFailed) {
860
- const exitInfo = result.signal
861
- ? `signal ${result.signal}`
862
- : `exit ${result.status}`;
863
- const stderrSnippet = result && result.stderr
864
- ? ` (${result.stderr.trim().split('\n')[0]})`
865
- : '';
866
- log(fmt.status('WARN', `Agent ${fmt.agent(chosen || '')} failed to complete (${exitInfo}${stderrSnippet}); retrying with next eligible agent.`));
867
- agentErrors.set(chosen || '', {
868
- exitInfo,
869
- stderr: result.stderr,
870
- stdout: result.stdout,
871
- signal: result.signal,
872
- status: result.status,
873
- });
874
- tried.add(chosen || '');
875
- launched.add(chosen || '');
876
- // Block non-custom agents on non-limit failures so selectAgent excludes them
877
- // on the next retry iteration, and the review-loop fallback path can activate.
878
- // custom (opencode/local AI) is excluded — exit 1 is a temporary local error.
879
- if (chosen !== 'custom') {
880
- const blockUntil = formatBlockUntil(new Date(Date.now() + DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000));
881
624
  try {
882
- const blockResult = updateAgentBlockFn(chosen || '', blockUntil);
883
- log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${DEFAULT_FALLBACK_HOURS}h block)`));
884
- } catch (/** @type {unknown} */ err) {
885
- log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${/** @type {any} */ (err).message}`));
625
+ assertAgentSupported(chosen || '');
626
+ }
627
+ catch (err) {
628
+ /** @type {Error & {code?: string}} */
629
+ const e = err;
630
+ if (e.code !== 'LAUNCHER_UNAVAILABLE') {
631
+ throw err;
632
+ }
633
+ log(fmt.status('WARN', err.message));
634
+ // Only reroute for launcher-availability failures (missing or probe-failed).
635
+ tried.add(chosen || '');
636
+ // If the caller pinned a specific agent, allow one retry that ignores
637
+ // the override and falls back to normal selection (matches limit-hit logic).
638
+ if (agentOverride && agentOverride === chosen && iteration === 1) {
639
+ chosen = undefined;
640
+ continue;
641
+ }
642
+ chosen = undefined;
643
+ continue;
886
644
  }
887
- }
888
- chosen = undefined;
889
- continue;
890
- }
891
- // Record the marker so a subsequent same-(slug, role) launch knows which
892
- // family last ran here. We only persist when the run exited cleanly
893
- // (status 0 and no spawn error); a failed launch should not overwrite
894
- // the canonical session marker with a stale transcript.
895
- if (worktree && slug && role && result && result.status === 0 && !result.error) {
896
- try {
897
- const sessionId = result && result.sessionId ? result.sessionId : null;
898
- (/** @type {any} */ (sessionsModule)).writeSession(worktree, slug, role, { agent: chosen || '', sessionId });
899
- } catch (/** @type {unknown} */ err) {
900
- log(fmt.status('WARN', `Could not persist session marker for ${fmt.slug(slug)} (${role}): ${/** @type {any} */ (err).message}`));
901
- }
902
- }
903
-
904
- return { agent: chosen, invocation, result };
905
- }
645
+ tried.add(chosen || '');
646
+ launched.add(chosen || '');
647
+ const launcher = LAUNCHERS[chosen || ''];
648
+ log(fmt.status('INFO', `Selected agent for step "${step}": ${fmt.agent(chosen || '')}${iteration > 1 ? ` (attempt ${iteration})` : ''}`));
649
+ // Enforce the agent family as the Forgejo identity (ADR 0029 / task-095).
650
+ // FORGEJO_USER is set last so the harness-selected identity always wins;
651
+ // a caller-supplied env.FORGEJO_USER cannot override it.
652
+ const agentEnv = { ...env, FORGEJO_USER: chosen };
653
+ // Decide whether to resume the agent's prior session for this (slug, role).
654
+ // Only honored when the caller passed slug+role+worktree AND the previous
655
+ // marker matches the chosen agent family (a fallback to a different family
656
+ // invalidates the prior session).
657
+ const resume = Boolean(worktree && slug && role &&
658
+ RESUME_CAPABLE.has(chosen || '') &&
659
+ sessionsModule.shouldResume(worktree, slug, role, chosen || ''));
660
+ const sessionId = sessionsModule.getSessionId(worktree, slug, role);
661
+ if (slug && role) {
662
+ if (resume) {
663
+ log(fmt.status('INFO', `Resuming ${fmt.agent(chosen || '')} session for ${fmt.slug(slug)} (${role}).${sessionId ? ` Session: ${sessionId}` : ''}`));
664
+ }
665
+ else if (RESUME_CAPABLE.has(chosen || '')) {
666
+ log(fmt.status('INFO', `No prior ${fmt.agent(chosen || '')} session for ${fmt.slug(slug)} (${role}); launching fresh.`));
667
+ }
668
+ }
669
+ // Resolve the prompt string. If a function was provided, call it with the
670
+ // currently chosen agent name (TASK-1051). This ensures that if startAgent
671
+ // falls back to a different family after a limit hit, the fallback agent
672
+ // receives a prompt tailored to its own identity.
673
+ const actualPrompt = typeof prompt === 'function' ? prompt(chosen) : prompt;
674
+ // Resolve the per-family model override (adapters.agents.models[chosen]).
675
+ // null when the family is not configured, in which case the launcher omits
676
+ // the model flag entirely and the agent uses its own default.
677
+ const model = resolveAgentModelFn(chosen || '', worktree || process.cwd());
678
+ if (model) {
679
+ log(fmt.status('INFO', `Using configured model for ${fmt.agent(chosen || '')}: ${model}`));
680
+ }
681
+ const watchdogConfig = resolveNoOutputWatchdogConfig(noOutputWatchdog, step);
682
+ const launchResult = launcher({
683
+ prompt: actualPrompt,
684
+ worktree,
685
+ env: agentEnv,
686
+ resume,
687
+ sessionId,
688
+ model,
689
+ slug,
690
+ role,
691
+ teeOptions: watchdogConfig ? {
692
+ noOutputWatchdog: {
693
+ ...watchdogConfig,
694
+ onNoOutput: (evt) => {
695
+ const stage = evt.elapsedMs < (step === 'draft' ? DRAFT_NO_OUTPUT_INITIAL_DELAY_MS : DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS)
696
+ ? 'starting up'
697
+ : 'running';
698
+ log(fmt.status('INFO', `No output yet from ${fmt.agent(chosen || '')} for step "${step}" after ${formatElapsed(evt.elapsedMs)} ` +
699
+ `(pid ${evt.pid || 'unknown'}, agent ${stage}). ` +
700
+ `Launcher is still running; stdout/stderr have not produced visible output.`));
701
+ }
702
+ }
703
+ } : {}
704
+ });
705
+ const { invocation, resultPromise } = launchResult;
706
+ if (invocation) {
707
+ log(fmt.status('INFO', `Launching: ${fmt.command(`${invocation.command} ${invocation.args.join(' ')}`)}`));
708
+ if (invocation.options && invocation.options.cwd) {
709
+ log(fmt.status('INFO', `Working directory: ${fmt.path(invocation.options.cwd)}`));
710
+ }
711
+ }
712
+ if (onLaunch) {
713
+ await onLaunch({ agent: chosen, invocation });
714
+ }
715
+ const result = resultPromise ? await resultPromise : launchResult.result;
716
+ // Pass exit metadata so detectLimitHit only treats matching transcript text
717
+ // as a real limit hit when the launcher actually failed. A successful run
718
+ // (status === 0) that happens to contain limit-hit phrases — for example,
719
+ // an agent reviewing code or logs that quote those phrases — must not block
720
+ // the healthy agent.
721
+ const limitHit = detectLimitHitFn({
722
+ agent: chosen,
723
+ stdout: result && result.stdout,
724
+ stderr: result && result.stderr,
725
+ status: result && result.status,
726
+ signal: result && result.signal,
727
+ error: result && result.error
728
+ });
729
+ if (limitHit) {
730
+ log(fmt.status('WARN', `Limit hit detected for ${fmt.agent(chosen || '')}; reset estimate "${limitHit.until}" (${limitHit.source}). Blocking and retrying.`));
731
+ try {
732
+ const blockResult = updateAgentBlockFn(chosen || '', limitHit.until);
733
+ log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)}`));
734
+ }
735
+ catch (err) {
736
+ log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${err.message}`));
737
+ }
738
+ if (typeof onLimitHit === 'function') {
739
+ onLimitHit({ agent: chosen, until: limitHit.until, source: limitHit.source });
740
+ }
741
+ // Reset chosen so next iteration reselects, but only when no explicit override.
742
+ // If the caller pinned a specific agent, fail loudly — there is no fallback.
743
+ if (agentOverride && agentOverride === chosen && iteration === 1) {
744
+ // Allow one retry that ignores the override.
745
+ chosen = undefined;
746
+ continue;
747
+ }
748
+ chosen = undefined;
749
+ continue;
750
+ }
751
+ // Reroute if the launcher binary could not be started (ENOENT = not found, EACCES = not executable).
752
+ if (result && result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {
753
+ log(fmt.status('WARN', `Launcher for "${chosen || ''}" could not be started (${result.error.code}); rerouting.`));
754
+ tried.add(chosen || '');
755
+ if (agentOverride && agentOverride === chosen && iteration === 1) {
756
+ chosen = undefined;
757
+ continue;
758
+ }
759
+ chosen = undefined;
760
+ continue;
761
+ }
762
+ // Detect launch failure: agent started but exited with non-zero status and
763
+ // no limit-hit was detected. This catches errors like "Model not found" in
764
+ // opencode that cause the launcher to exit immediately with an error code.
765
+ // Retry with the next eligible agent instead of returning the failure.
766
+ // Only treat `status !== null && status !== 0` or `signal` (with no spawn
767
+ // error) as a launch failure; `status: null` without signal is ambiguous
768
+ // (spawn-tee close event can emit null code) and should not trigger a retry.
769
+ // Spurious opencode v2.0.0 JSON-mode exits (exit 1 after a valid
770
+ // "reason":"stop" completion) are excluded — the agent completed, the
771
+ // non-zero code is a post-run cleanup race.
772
+ const launchFailed = result &&
773
+ ((result.status !== null && result.status !== 0) || (result.signal && !result.error)) &&
774
+ !limitHit &&
775
+ !(0, opencode_js_1.isSpuriousOpencodeExit)(result);
776
+ if (launchFailed) {
777
+ const exitInfo = result.signal
778
+ ? `signal ${result.signal}`
779
+ : `exit ${result.status}`;
780
+ const stderrSnippet = result && result.stderr
781
+ ? ` (${result.stderr.trim().split('\n')[0]})`
782
+ : '';
783
+ log(fmt.status('WARN', `Agent ${fmt.agent(chosen || '')} failed to complete (${exitInfo}${stderrSnippet}); retrying with next eligible agent.`));
784
+ agentErrors.set(chosen || '', {
785
+ exitInfo,
786
+ stderr: result.stderr,
787
+ stdout: result.stdout,
788
+ signal: result.signal,
789
+ status: result.status,
790
+ });
791
+ tried.add(chosen || '');
792
+ launched.add(chosen || '');
793
+ // Block non-custom agents on non-limit failures so selectAgent excludes them
794
+ // on the next retry iteration, and the review-loop fallback path can activate.
795
+ // custom (opencode/local AI) is excluded — exit 1 is a temporary local error.
796
+ if (chosen !== 'custom') {
797
+ const blockUntil = (0, limit_hit_js_1.formatBlockUntil)(new Date(Date.now() + limit_hit_js_1.DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000));
798
+ try {
799
+ const blockResult = updateAgentBlockFn(chosen || '', blockUntil);
800
+ log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen || '')} -> ${fmt.path(blockResult.path)} (${limit_hit_js_1.DEFAULT_FALLBACK_HOURS}h block)`));
801
+ }
802
+ catch (err) {
803
+ log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen || '')}: ${err.message}`));
804
+ }
805
+ }
806
+ chosen = undefined;
807
+ continue;
808
+ }
809
+ // Record the marker so a subsequent same-(slug, role) launch knows which
810
+ // family last ran here. We only persist when the run exited cleanly
811
+ // (status 0 and no spawn error); a failed launch should not overwrite
812
+ // the canonical session marker with a stale transcript.
813
+ if (worktree && slug && role && result && result.status === 0 && !result.error) {
814
+ try {
815
+ const sessionId = result && result.sessionId ? result.sessionId : null;
816
+ sessionsModule.writeSession(worktree, slug, role, { agent: chosen || '', sessionId });
817
+ }
818
+ catch (err) {
819
+ log(fmt.status('WARN', `Could not persist session marker for ${fmt.slug(slug)} (${role}): ${err.message}`));
820
+ }
821
+ }
822
+ return { agent: chosen, invocation, result };
823
+ }
906
824
  }
907
-
908
825
  // Legacy alias kept for backwards compatibility — draft.js calls this directly.
909
826
  // Returns { agent, invocation, result } so callers can log which agent ran.
910
827
  async function startDraftAgent(opts = { prompt: '' }) {
911
- return startAgent('draft', opts);
828
+ return startAgent('draft', opts);
912
829
  }
913
-
914
- module.exports = {
915
- KNOWN_AGENT_NAMES,
916
- WORKFLOW_AGENT_NAMES,
917
- startAgent,
918
- startDraftAgent,
919
- selectAgent,
920
- eligibleAgentsForStep,
921
- readAgentConfig,
922
- readAgentConfigOrExit,
923
- assertAgentSupported,
924
- workflowLauncherStatus,
925
- setCommandPathProbe: (/** @type {(name: string) => string | null} */ fn) => { _commandPathProbe = fn; },
926
- isAgentBlocked,
927
- parseBlockUntil,
928
- isInvalidAgentConfigError,
929
- updateAgentBlock,
930
- resolveBlocklistTargetPath,
931
- resolveNoOutputWatchdogConfig
932
- };
830
+ const setCommandPathProbe = (fn) => { _commandPathProbe = fn; };
831
+ exports.setCommandPathProbe = setCommandPathProbe;