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