@yemi33/minions 0.1.2382 → 0.1.2383

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.
@@ -17,6 +17,7 @@ const harness = require('./harness');
17
17
  const smallStateStore = require('./small-state-store');
18
18
  const prdStore = require('./prd-store');
19
19
  const { isBranchActive } = require('./cooldown');
20
+ const { resolveExecutionModel } = require('./execution-model');
20
21
  const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
21
22
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
22
23
  MINIONS_DIR, ENGINE_DIR, PLANS_DIR, INBOX_DIR, AGENTS_DIR } = queries;
@@ -2319,7 +2320,7 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
2319
2320
  : null;
2320
2321
  }
2321
2322
 
2322
- async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null, dispatchItem = null) {
2323
+ async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null, dispatchItem = null, executionMetadata = null) {
2323
2324
 
2324
2325
  if (!config) config = getConfig();
2325
2326
  const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
@@ -2338,6 +2339,11 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2338
2339
  const reviewProject = reviewContext.project;
2339
2340
  const prScope = reviewContext.scope;
2340
2341
  const reviewerName = config.agents?.[agentId]?.name || agentId;
2342
+ const reviewExecutionModel = resolveExecutionModel({
2343
+ reportedModel: executionMetadata?.reportedModel,
2344
+ capturedModel: dispatchItem?.executionModel,
2345
+ requestedModel: dispatchItem?.requestedModel,
2346
+ });
2341
2347
 
2342
2348
  // Check actual review status from the platform (agent may have approved or requested changes)
2343
2349
  // If platform hasn't propagated the vote yet (returns 'pending'), keep current status unchanged.
@@ -2549,6 +2555,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
2549
2555
  reviewer: reviewerName,
2550
2556
  reviewedAt: ts(),
2551
2557
  note: reviewNote,
2558
+ model: reviewExecutionModel.model,
2552
2559
  dispatchId: dispatchItem?.id || structuredCompletion?.dispatchId || null,
2553
2560
  sourceItem: dispatchItem?.meta?.item?.id || null,
2554
2561
  ...(reviewThreads.length > 0 ? { threads: reviewThreads } : {}),
@@ -3134,7 +3141,6 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3134
3141
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
3135
3142
  options = { automationCauseKey: options, dispatchId: legacyDispatchId };
3136
3143
  }
3137
- const explicitlyChangedBranch = options.branchChanged !== false;
3138
3144
  const prScope = project || 'central';
3139
3145
  const automationCauseKey = options.automationCauseKey || options.dispatchItem?.meta?.automationCauseKey || '';
3140
3146
  const fixDispatchId = options.dispatchItem?.id || options.dispatchId || legacyDispatchId || '';
@@ -3143,6 +3149,15 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3143
3149
  source,
3144
3150
  task: options.dispatchItem?.task,
3145
3151
  });
3152
+ const evidenceOnlyReviewResolution = cause === shared.PR_FIX_CAUSE.REVIEW_FEEDBACK
3153
+ && options.reviewFindingResolution;
3154
+ // Review-fix completion is governed by the live branch probe. Preserve the
3155
+ // legacy files_changed behavior for unrelated fix causes.
3156
+ let explicitlyChangedBranch = options.branchChanged !== false;
3157
+ if (cause === shared.PR_FIX_CAUSE.REVIEW_FEEDBACK && options.branchChange) {
3158
+ explicitlyChangedBranch = true;
3159
+ }
3160
+ if (evidenceOnlyReviewResolution) explicitlyChangedBranch = false;
3146
3161
  let result = null;
3147
3162
  shared.mutatePullRequests(prScope, (prs) => {
3148
3163
  if (!Array.isArray(prs)) return prs;
@@ -4569,8 +4584,8 @@ function parseCompletionBoolean(value) {
4569
4584
  }
4570
4585
 
4571
4586
  // Detect a deliberate no-op completion — the agent correctly declined to make
4572
- // changes (work was already shipped, dispatch premise was wrong, self-authored
4573
- // review with no actionable feedback, etc.) and should NOT be flagged as a
4587
+ // changes (work was already shipped, dispatch premise was wrong, or a finding
4588
+ // was proven resolved/invalid with current-code evidence) and should NOT be flagged as a
4574
4589
  // silent failure for missing a PR. Honored signals:
4575
4590
  // - completion.noop === true (canonical, primary)
4576
4591
  // - completion.result === 'noop' OR completion.result.type === 'noop'
@@ -4651,6 +4666,39 @@ function classifyBenignZeroTargetOutcome(dispatchItem, completion, resultSummary
4651
4666
  return noMutation ? evidence : null;
4652
4667
  }
4653
4668
 
4669
+ function validateReviewFixCompletion({ type, meta, branchChange, structuredCompletion } = {}) {
4670
+ if (!shared.isFixLikeWorkType(type)) return { valid: true };
4671
+ const cause = shared.getPrFixAutomationCause({
4672
+ dispatchKey: meta?.dispatchKey,
4673
+ source: meta?.source,
4674
+ task: meta?.item?.title,
4675
+ });
4676
+ if (cause !== shared.PR_FIX_CAUSE.REVIEW_FEEDBACK
4677
+ || (branchChange?.changed === true && branchChange.evidence === 'remote-head')) {
4678
+ return { valid: true };
4679
+ }
4680
+
4681
+ const findingId = shared.prReviewFindingIdentity(meta?.pr);
4682
+ const resolution = structuredCompletion?.reviewFindingResolution;
4683
+ const disposition = String(resolution?.disposition || '').trim().toLowerCase();
4684
+ const reportedFindingId = String(resolution?.findingId || '').trim();
4685
+ const currentCodeEvidence = String(resolution?.currentCodeEvidence || '').trim();
4686
+ const hasCurrentCodeReference = /(?:[a-z]:[\\/])?[\w./\\-]+\.[a-z0-9]+:\d+\b/i.test(currentCodeEvidence)
4687
+ || /\bcommit\s+[0-9a-f]{7,40}\b/i.test(currentCodeEvidence);
4688
+ const validDisposition = disposition === 'already-resolved' || disposition === 'invalid';
4689
+ const explicitNoop = !!parseCompletionNoop(structuredCompletion);
4690
+
4691
+ if (explicitNoop && validDisposition && reportedFindingId === findingId && hasCurrentCodeReference) {
4692
+ return { valid: true, findingId, resolution };
4693
+ }
4694
+
4695
+ return {
4696
+ valid: false,
4697
+ findingId,
4698
+ reason: `Review-fix completion did not advance the PR branch and must explicitly prove the exact finding is already resolved or invalid with current-code evidence. Expected reviewFindingResolution.findingId="${findingId}", disposition "already-resolved" or "invalid", and a file:line or commit reference. Shared platform ownership, viewerDidAuthor, or reviewer/fixer agent equality is not evidence.`,
4699
+ };
4700
+ }
4701
+
4654
4702
  function normalizeReviewVerdict(verdict) {
4655
4703
  const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
4656
4704
  if (value === 'approve' || value === 'approved') return REVIEW_STATUS.APPROVED;
@@ -5807,13 +5855,70 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5807
5855
  }
5808
5856
 
5809
5857
  // No-op signal: agent declared the work was correctly NOT done (already
5810
- // shipped, dispatch premise wrong, self-authored review, etc.). Skip the PR
5858
+ // shipped, dispatch premise wrong, evidence-backed resolved finding, etc.). Skip the PR
5811
5859
  // attachment contract — a missing PR is intentional, not a silent failure.
5812
- const noopRationale = (effectiveSuccess && meta?.item?.id && !skipDoneStatus)
5860
+ let prFixBranchChange = null;
5861
+ let prFixBuildStillFailing = null;
5862
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
5863
+ try {
5864
+ prFixBranchChange = await detectPrFixBranchChange(meta, config);
5865
+ } catch (err) {
5866
+ log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
5867
+ prFixBranchChange = { changed: null, reason: err.message };
5868
+ }
5869
+ if (prFixBranchChange?.changed === false) {
5870
+ const fixCause = shared.getPrFixAutomationCause({
5871
+ dispatchKey: dispatchItem?.meta?.dispatchKey,
5872
+ source: meta?.source,
5873
+ task: dispatchItem?.task,
5874
+ });
5875
+ if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
5876
+ try {
5877
+ const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
5878
+ if (liveStatus === shared.BUILD_STATUS.FAILING) {
5879
+ prFixBuildStillFailing = liveStatus;
5880
+ }
5881
+ } catch (err) {
5882
+ log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
5883
+ }
5884
+ }
5885
+ }
5886
+ }
5887
+ let noopRationale = (effectiveSuccess && !skipDoneStatus)
5813
5888
  ? (parseCompletionNoop(structuredCompletion) || benignNoop)
5814
5889
  : null;
5890
+ let reviewFixResolution = null;
5891
+ if (effectiveSuccess && !skipDoneStatus) {
5892
+ const reviewFixValidation = validateReviewFixCompletion({
5893
+ type,
5894
+ meta,
5895
+ branchChange: prFixBranchChange,
5896
+ structuredCompletion,
5897
+ });
5898
+ if (!reviewFixValidation.valid) {
5899
+ skipDoneStatus = true;
5900
+ meta._agentId = agentId;
5901
+ const detection = {
5902
+ phrase: 'review-fix-resolution-evidence-missing',
5903
+ reason: reviewFixValidation.reason,
5904
+ };
5905
+ const reason = meta?.item?.id
5906
+ ? deferNonTerminalCompletion(meta, detection)
5907
+ : detection.reason;
5908
+ completionContractFailure = {
5909
+ reason,
5910
+ itemId: meta?.item?.id || dispatchItem.id,
5911
+ nonTerminal: true,
5912
+ processWorkItemFailure: false,
5913
+ };
5914
+ noopRationale = null;
5915
+ log('warn', `Review-fix completion rejected for ${meta?.item?.id || dispatchItem.id}: ${reviewFixValidation.reason}`);
5916
+ } else {
5917
+ reviewFixResolution = reviewFixValidation.resolution || null;
5918
+ }
5919
+ }
5815
5920
  if (noopRationale) {
5816
- log('info', `No-op completion for ${meta.item.id}: ${noopRationale.slice(0, 200)}`);
5921
+ log('info', `No-op completion for ${meta?.item?.id || dispatchItem.id}: ${noopRationale.slice(0, 200)}`);
5817
5922
  }
5818
5923
 
5819
5924
  if (effectiveSuccess && meta?.item?.id && !skipDoneStatus && !noopRationale) {
@@ -6051,41 +6156,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6051
6156
 
6052
6157
  // Archive is manual — user archives plans from the dashboard when ready
6053
6158
 
6054
- let prFixBranchChange = null;
6055
- let prFixBuildStillFailing = null;
6056
- if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
6057
- try {
6058
- prFixBranchChange = await detectPrFixBranchChange(meta, config);
6059
- } catch (err) {
6060
- log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
6061
- prFixBranchChange = { changed: null, reason: err.message };
6062
- }
6063
- // #639 — a BUILD_FAILURE fix that didn't change the branch is not
6064
- // necessarily a genuine no-op: the agent may have found its own (or a
6065
- // prior dispatch's) fix commit already present and concluded "nothing to
6066
- // change" without checking whether CI actually turned green on that
6067
- // commit. Re-poll live CI for the head before letting updatePrAfterFix
6068
- // accept the no-op — if it's still failing, flag it so the no-op path
6069
- // records a distinct fixIneffective signal instead of a clean no-op.
6070
- if (prFixBranchChange?.changed === false) {
6071
- const fixCause = shared.getPrFixAutomationCause({
6072
- dispatchKey: dispatchItem?.meta?.dispatchKey,
6073
- source: meta?.source,
6074
- task: dispatchItem?.task,
6075
- });
6076
- if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
6077
- try {
6078
- const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
6079
- if (liveStatus === shared.BUILD_STATUS.FAILING) {
6080
- prFixBuildStillFailing = liveStatus;
6081
- }
6082
- } catch (err) {
6083
- log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
6084
- }
6085
- }
6086
- }
6087
- }
6088
-
6089
6159
  // Scheduled task back-reference: update SQL state and write a linked inbox note.
6090
6160
  if (meta?.item?._scheduleId) {
6091
6161
  try {
@@ -6191,11 +6261,20 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6191
6261
  || completionContractFailure?.nonTerminal === true;
6192
6262
  const finalResult = hardContractFail ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
6193
6263
  if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus && !benignNoop) {
6194
- await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary, structuredCompletion, dispatchItem);
6264
+ await updatePrAfterReview(
6265
+ agentId,
6266
+ meta?.pr,
6267
+ meta?.project,
6268
+ config,
6269
+ resultSummary,
6270
+ structuredCompletion,
6271
+ dispatchItem,
6272
+ { reportedModel: model },
6273
+ );
6195
6274
  } else if (type === WORK_TYPE.REVIEW) {
6196
6275
  log('warn', `Skipping PR review metadata update for ${meta?.pr?.id || meta?.pr?.url || '(unknown PR)'} because review dispatch ${dispatchItem.id} did not complete cleanly`);
6197
6276
  }
6198
- if (shared.isFixLikeWorkType(type) && effectiveSuccess) {
6277
+ if (shared.isFixLikeWorkType(type) && effectiveSuccess && !skipDoneStatus) {
6199
6278
  updatePrAfterFix(meta?.pr, meta?.project, meta?.source, {
6200
6279
  branchChanged: fixCompletionChangedBranch(structuredCompletion),
6201
6280
  automationCauseKey: meta?.automationCauseKey,
@@ -6204,6 +6283,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
6204
6283
  buildStillFailing: prFixBuildStillFailing,
6205
6284
  config,
6206
6285
  noopReason: noopRationale || meta?._noopReason || '',
6286
+ reviewFindingResolution: reviewFixResolution,
6207
6287
  });
6208
6288
  // W-mpg58wv3 — closure-loop dispatch. When the completed fix WI was spawned
6209
6289
  // to address a minion REQUEST_CHANGES (its meta carries
@@ -7044,6 +7124,7 @@ module.exports = {
7044
7124
  isReviewBailout,
7045
7125
  parseCompletionNoop,
7046
7126
  classifyBenignZeroTargetOutcome, // exported for testing
7127
+ validateReviewFixCompletion,
7047
7128
  detectNonTerminalResultSummary,
7048
7129
  deferNonTerminalCompletion,
7049
7130
  deferPhantomCompletion,
package/engine/llm.js CHANGED
@@ -18,7 +18,15 @@ const {
18
18
  ENGINE_DEFAULTS,
19
19
  resolveCcCli, resolveCcModel,
20
20
  } = shared;
21
- const { resolveRuntime } = require('./runtimes');
21
+ const {
22
+ resolveRuntime,
23
+ resolveRuntimeByCapability,
24
+ listRuntimes,
25
+ RUNTIME_IMAGES_FILE_OPTION,
26
+ sanitizeInvocationOptions,
27
+ resolveInvocationOptions,
28
+ buildSpawnFlags,
29
+ } = require('./runtimes');
22
30
 
23
31
  const MINIONS_DIR = shared.MINIONS_DIR;
24
32
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
@@ -217,15 +225,17 @@ function _runtimeInstallHint(runtimeName, runtime) {
217
225
  function _missingRuntimeMessage(runtimeName, runtime, reason) {
218
226
  const selected = runtime?.name || runtimeName || 'configured';
219
227
  if (!runtime) {
228
+ const installLines = listRuntimes().map((name) => {
229
+ const adapter = resolveRuntime(name);
230
+ return `- ${name}: ${_runtimeInstallHint(name, adapter)}`;
231
+ });
220
232
  return [
221
233
  `Minions can't run the configured runtime "${selected}".`,
222
234
  '',
223
235
  reason || 'The configured runtime is not registered.',
224
236
  '',
225
237
  'Choose a supported runtime in Settings -> Engine -> defaultCli/ccCli, then install its CLI:',
226
- '- Claude Code: npm install -g @anthropic-ai/claude-code or download from https://claude.ai/download',
227
- '- OpenAI Codex CLI: npm install -g @openai/codex or brew install --cask codex',
228
- '- GitHub Copilot CLI: install via GitHub CLI/gh-copilot or download from https://github.com/github/copilot-cli/releases',
238
+ ...installLines,
229
239
  '',
230
240
  'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
231
241
  ].join('\n');
@@ -239,10 +249,9 @@ function _missingRuntimeMessage(runtimeName, runtime, reason) {
239
249
  '',
240
250
  'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
241
251
  ];
242
- if (selected === 'claude') {
243
- lines.push('Or switch Settings -> Engine -> defaultCli/ccCli to "copilot" or "codex" after installing another supported runtime.');
244
- } else {
245
- lines.push('Or switch Settings -> Engine -> defaultCli/ccCli back to "claude" after installing Claude Code.');
252
+ const alternatives = listRuntimes().filter(name => name !== selected);
253
+ if (alternatives.length) {
254
+ lines.push(`Or switch Settings -> Engine -> defaultCli/ccCli to another installed runtime: ${alternatives.join(', ')}.`);
246
255
  }
247
256
  return lines.join('\n');
248
257
  }
@@ -337,37 +346,12 @@ function _runtimeUnavailableResult(callOpts = {}) {
337
346
  // ─── Spawn Helpers ───────────────────────────────────────────────────────────
338
347
 
339
348
  /**
340
- * Translate the unified opts bag into the named CLI flags consumed by
341
- * `engine/spawn-agent.js`. spawn-agent.js parses these back into an opts
342
- * object and calls `runtime.buildArgs(opts)` once keeping the adapter as
343
- * the single source of truth and avoiding double-flag emission.
344
- *
345
- * Capability gating (matches engine.js _buildAgentSpawnFlags from P-2a6d9c4f):
346
- * - effort/sessionId/maxBudget/bare/fallbackModel are dropped when the
347
- * runtime's matching capability is false.
348
- * - Copilot-specific opts (stream, disableBuiltinMcps,
349
- * reasoningSummaries) are emitted unconditionally; the Claude adapter
350
- * ignores them via the "tolerate unknown opts" rule.
349
+ * Encode the semantic invocation options through the stable runtime facade.
350
+ * Legacy named wrapper flags remain for compatibility; the opaque options bag
351
+ * is authoritative for adapter fields the wrapper does not know yet.
351
352
  */
352
353
  function _buildSpawnAgentFlags(runtime, opts = {}) {
353
- const caps = (runtime && runtime.capabilities) || {};
354
- const flags = ['--runtime', String(runtime?.name || 'claude')];
355
-
356
- if (opts.maxTurns != null) flags.push('--max-turns', String(opts.maxTurns));
357
- if (opts.model) flags.push('--model', String(opts.model));
358
- if (opts.allowedTools) flags.push('--allowedTools', String(opts.allowedTools));
359
-
360
- if (caps.effortLevels && opts.effort) flags.push('--effort', String(opts.effort));
361
- if (caps.sessionResume && opts.sessionId) flags.push('--resume', String(opts.sessionId));
362
- if (caps.budgetCap && opts.maxBudget != null) flags.push('--max-budget-usd', String(opts.maxBudget));
363
- if (caps.bareMode && opts.bare === true) flags.push('--bare');
364
- if (caps.fallbackModel && opts.fallbackModel) flags.push('--fallback-model', String(opts.fallbackModel));
365
-
366
- if (opts.stream === 'on' || opts.stream === 'off') flags.push('--stream', opts.stream);
367
- if (opts.disableBuiltinMcps === true) flags.push('--disable-builtin-mcps');
368
- if (opts.reasoningSummaries === true) flags.push('--enable-reasoning-summaries');
369
-
370
- return flags;
354
+ return buildSpawnFlags(runtime, opts);
371
355
  }
372
356
 
373
357
  /**
@@ -379,15 +363,12 @@ function _buildSpawnAgentFlags(runtime, opts = {}) {
379
363
  *
380
364
  * Indirect path: uses engine/spawn-agent.js — mostly a fallback when the
381
365
  * direct path can't resolve the binary cache. spawn-agent.js handles
382
- * adapter resolution itself; we just hand it `--runtime <name>` plus the
383
- * named flags it knows how to parse.
366
+ * adapter resolution itself; we hand it the runtime plus an opaque options
367
+ * bag, so adding a harness flag does not require wrapper changes.
384
368
  */
385
369
  function _spawnProcess(promptText, sysPromptText, callOpts) {
386
370
  const {
387
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
388
- maxBudget, bare, fallbackModel,
389
- stream, disableBuiltinMcps, reasoningSummaries,
390
- images,
371
+ direct, label, runtime, adapterOptions = {},
391
372
  } = callOpts;
392
373
 
393
374
  const id = uid();
@@ -399,24 +380,10 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
399
380
  const cleanupFiles = [];
400
381
  const cleanupDirs = [llmTmpDir];
401
382
  const caps = (runtime && runtime.capabilities) || {};
402
- const adapterOpts = {
403
- model, maxTurns, allowedTools, effort, sessionId,
404
- maxBudget, bare, fallbackModel,
405
- stream, disableBuiltinMcps, reasoningSummaries,
406
- images,
383
+ const adapterOpts = sanitizeInvocationOptions(runtime, {
384
+ ...adapterOptions,
407
385
  tmpDir: llmTmpDir,
408
- };
409
- // Capability-gate per-flag opts before prompt construction so adapters can
410
- // make resume-aware prompt decisions from the same opts used for argv.
411
- if (!caps.effortLevels) adapterOpts.effort = undefined;
412
- if (!caps.sessionResume) adapterOpts.sessionId = undefined;
413
- if (!caps.budgetCap) adapterOpts.maxBudget = undefined;
414
- if (!caps.bareMode) adapterOpts.bare = undefined;
415
- if (!caps.fallbackModel) adapterOpts.fallbackModel = undefined;
416
- // P-7b2e4d01 — defense in depth: never forward images to a runtime that
417
- // can't read them (callLLM/callLLMStreaming already gate + surface a typed
418
- // error upstream; this protects any other _spawnProcess caller).
419
- if (!caps.imageInput) adapterOpts.images = undefined;
386
+ });
420
387
  const finalPrompt = runtime.buildPrompt(promptText, sysPromptText, adapterOpts);
421
388
 
422
389
  // ── Direct path ──
@@ -426,7 +393,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
426
393
  // Only write a sys-prompt tmp file when the runtime actually consumes one
427
394
  // via --system-prompt-file (Claude) AND we're not resuming (resumed sessions
428
395
  // already have the sys prompt baked in).
429
- if (!sessionId && sysPromptText && caps.systemPromptFile) {
396
+ if (!adapterOpts.sessionId && sysPromptText && caps.systemPromptFile) {
430
397
  sysTmpPath = path.join(llmTmpDir, `direct-sys-${id}.md`);
431
398
  fs.writeFileSync(sysTmpPath, sysPromptText);
432
399
  cleanupFiles.push(sysTmpPath);
@@ -472,12 +439,16 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
472
439
  const pidPath = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
473
440
  cleanupFiles.push(promptPath, sysPath, pidPath);
474
441
 
475
- const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
476
- const adapterFlags = _buildSpawnAgentFlags(runtime, {
477
- model, maxTurns, allowedTools, effort, sessionId,
478
- maxBudget, bare, fallbackModel,
479
- stream, disableBuiltinMcps, reasoningSummaries,
480
- });
442
+ const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
443
+ const indirectAdapterOpts = { ...adapterOpts };
444
+ if (Array.isArray(indirectAdapterOpts.images) && indirectAdapterOpts.images.length) {
445
+ const imagesPath = path.join(llmTmpDir, `runtime-images-${id}.json`);
446
+ safeWrite(imagesPath, JSON.stringify(indirectAdapterOpts.images));
447
+ cleanupFiles.push(imagesPath);
448
+ indirectAdapterOpts[RUNTIME_IMAGES_FILE_OPTION] = imagesPath;
449
+ delete indirectAdapterOpts.images;
450
+ }
451
+ const adapterFlags = _buildSpawnAgentFlags(runtime, indirectAdapterOpts);
481
452
  const args = [spawnScript, promptPath, sysPath, ...adapterFlags];
482
453
 
483
454
  const proc = runFile(process.execPath, args, {
@@ -692,11 +663,16 @@ function _resolveModelForRuntime(runtime, callOpts) {
692
663
  function _resolveRuntimeFeatureOpts({
693
664
  stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
694
665
  } = {}) {
695
- const engine = engineConfig || {};
666
+ const runtime = resolveRuntimeByCapability('acpWorkerPool');
667
+ const resolved = resolveInvocationOptions(runtime, {
668
+ stream,
669
+ disableBuiltinMcps,
670
+ reasoningSummaries,
671
+ }, { engineConfig: engineConfig || {} });
696
672
  return {
697
- stream: stream ?? engine.copilotStreamMode,
698
- disableBuiltinMcps: disableBuiltinMcps ?? engine.copilotDisableBuiltinMcps,
699
- reasoningSummaries: reasoningSummaries ?? engine.copilotReasoningSummaries,
673
+ stream: resolved.stream,
674
+ disableBuiltinMcps: resolved.disableBuiltinMcps,
675
+ reasoningSummaries: resolved.reasoningSummaries,
700
676
  };
701
677
  }
702
678
 
@@ -770,17 +746,17 @@ function callLLM(promptText, sysPromptText, opts = {}) {
770
746
  const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
771
747
  const imageGate = _resolveImageOpts(images, runtime && runtime.capabilities);
772
748
  if (imageGate.error) return _resolvedCallResult(_imageUnsupportedResult(runtime, imageGate.error));
773
- const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
774
- stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
775
- });
749
+ const runtimeOptions = resolveInvocationOptions(runtime, {
750
+ model, maxTurns, allowedTools, effort, sessionId,
751
+ maxBudget, bare, fallbackModel, images: imageGate.images,
752
+ stream, disableBuiltinMcps, reasoningSummaries,
753
+ }, { engineConfig: engineConfig || {} });
776
754
 
777
755
  let _abort = null;
778
756
  const promise = new Promise((resolve) => {
779
757
  const _startMs = Date.now();
780
758
  const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
781
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
782
- maxBudget, bare, fallbackModel, images: imageGate.images,
783
- ...runtimeFeatureOpts,
759
+ direct, label, runtime, adapterOptions: runtimeOptions,
784
760
  });
785
761
  let resolved = false;
786
762
  let exitFallbackTimer = null;
@@ -895,17 +871,17 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
895
871
  const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
896
872
  const imageGate = _resolveImageOpts(images, runtime && runtime.capabilities);
897
873
  if (imageGate.error) return _resolvedCallResult(_imageUnsupportedResult(runtime, imageGate.error));
898
- const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
899
- stream, disableBuiltinMcps, reasoningSummaries, engineConfig,
900
- });
874
+ const runtimeOptions = resolveInvocationOptions(runtime, {
875
+ model, maxTurns, allowedTools, effort, sessionId,
876
+ maxBudget, bare, fallbackModel, images: imageGate.images,
877
+ stream, disableBuiltinMcps, reasoningSummaries,
878
+ }, { engineConfig: engineConfig || {} });
901
879
 
902
880
  let _abort = null;
903
881
  const promise = new Promise((resolve) => {
904
882
  const _startMs = Date.now();
905
883
  const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
906
- direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
907
- maxBudget, bare, fallbackModel, images: imageGate.images,
908
- ...runtimeFeatureOpts,
884
+ direct, label, runtime, adapterOptions: runtimeOptions,
909
885
  });
910
886
  let resolved = false;
911
887
  let exitFallbackTimer = null;
@@ -269,10 +269,12 @@ function searchMemoryRecords(query, options = {}) {
269
269
  }
270
270
  const limit = Math.max(1, Math.min(200, Number(options.limit) || 50));
271
271
  args.push(limit);
272
+ // CROSS JOIN pins FTS as the outer loop; Node 22's SQLite otherwise prefers
273
+ // the scope index and evaluates MATCH once for every scoped record.
272
274
  const sql = `
273
275
  SELECT m.*, bm25(memory_records_fts, 5.0, 1.0, 2.0, 3.0) AS fts_rank
274
276
  FROM memory_records_fts
275
- JOIN memory_records m ON m.rowid=memory_records_fts.rowid
277
+ CROSS JOIN memory_records m ON m.rowid=memory_records_fts.rowid
276
278
  WHERE ${where.join(' AND ')}
277
279
  ORDER BY fts_rank
278
280
  LIMIT ?
@@ -1214,12 +1214,10 @@ function formatPrContextSuffix(pr) {
1214
1214
  // ─── Work Discovery Helpers ──────────────────────────────────────────────────
1215
1215
 
1216
1216
  function buildBaseVars(agentId, config, project) {
1217
- // W-mq15r0cx surface the LLM model id alongside the agent name so PR
1218
- // comment sign-offs can stamp `<agent> · <model>` and humans can tell
1219
- // which agent + which model authored the comment. Falls through to
1220
- // 'unknown' (defensive only) rather than crashing or omitting the
1221
- // field when neither agent.model nor engine.defaultModel resolves —
1222
- // omitting would leave a dangling `· ` in the rendered sign-off.
1217
+ // This is the provisional requested model used in prompt examples. The
1218
+ // `minions pr comment` path replaces it with runtime/session evidence from
1219
+ // the active dispatch before posting; `unknown` survives only when neither
1220
+ // runtime nor requested-model evidence exists.
1223
1221
  const resolvedModel = shared.resolveAgentModel(
1224
1222
  config?.agents?.[agentId] || null,
1225
1223
  config?.engine || null
@@ -15,30 +15,23 @@
15
15
  * completely unmodified whether `proc` is a real child_process or a pooled
16
16
  * ACP lease.
17
17
  *
18
- * Output translation (parity, not full fidelity): ACP `session/update` text
19
- * chunks are re-encoded as the SAME Copilot JSONL event shape
20
- * `engine/runtimes/copilot.js#parseOutput` already parses from a real
21
- * `copilot` CLI stdout stream — `{"type":"assistant.message_delta","data":
22
- * {"deltaContent":"..."}}` per chunk, plus a terminal `{"type":"result",
23
- * "sessionId":...,"usage":{...}}` line once the turn completes. This is the
24
- * minimum subset `parseOutput` needs to recover an equivalent `{text,
25
- * sessionId, usage}` to the cold-spawn path (deltas alone populate its
26
- * `pendingDeltaContent` accumulator; no `assistant.message`/`session.
27
- * tools_updated`/`tool.execution_*` events are required for that recovery).
28
- * Tool-call and model-announcement events are intentionally NOT synthesized
29
- * — a deliberate, documented scope limit, not an oversight: the live-output
30
- * log still gets the real text in real time either way, and `usage`'s
31
- * cost/token fields are already null-for-copilot on the cold path (see
32
- * `runtimes/copilot.js#parseOutput`), so nothing measurable is lost.
18
+ * Output translation (parity, not full fidelity) is adapter-owned through
19
+ * `encodePooledOutput()`. The facade only forwards semantic chunk/result
20
+ * events, so a harness can change its JSONL schema without changing this file.
33
21
  */
34
22
 
35
23
  const { EventEmitter } = require('events');
36
24
  const { PassThrough } = require('stream');
25
+ const {
26
+ resolveRuntimeByCapability,
27
+ encodePooledOutput,
28
+ } = require('./runtimes');
37
29
 
38
30
  class PooledAgentProcess extends EventEmitter {
39
31
  /**
40
32
  * @param {object} opts
41
33
  * @param {object} opts.pool - engine/agent-worker-pool.js module (or a test double)
34
+ * @param {object} opts.runtime - resolved runtime adapter
42
35
  * @param {string} opts.dispatchId
43
36
  * @param {string} opts.cwd
44
37
  * @param {string} [opts.model]
@@ -51,7 +44,7 @@ class PooledAgentProcess extends EventEmitter {
51
44
  * @param {string} opts.promptText - task/user prompt only
52
45
  */
53
46
  constructor({
54
- pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
47
+ pool, runtime, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
55
48
  processEnv, sessionContext, systemPromptText, promptText,
56
49
  }) {
57
50
  super();
@@ -61,10 +54,12 @@ class PooledAgentProcess extends EventEmitter {
61
54
  this.stdout = new PassThrough();
62
55
  this.stderr = new PassThrough();
63
56
  this._pool = pool;
57
+ this._runtime = runtime || resolveRuntimeByCapability('acpWorkerPool');
64
58
  this._dispatchId = dispatchId;
65
59
  this._handle = null;
66
60
  this._closed = false;
67
61
  this._startedAt = Date.now();
62
+ this._runtimeOutputError = null;
68
63
 
69
64
  // Fire-and-forget, mirroring child_process.spawn()'s async nature — the
70
65
  // facade is usable (event listeners can be attached) immediately, before
@@ -82,6 +77,7 @@ class PooledAgentProcess extends EventEmitter {
82
77
  let handle;
83
78
  try {
84
79
  handle = await this._pool.acquireWorker({
80
+ runtime: this._runtime,
85
81
  dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
86
82
  processEnv, sessionContext,
87
83
  });
@@ -102,6 +98,9 @@ class PooledAgentProcess extends EventEmitter {
102
98
 
103
99
  this._handle = handle;
104
100
  this.pid = typeof handle.pid === 'number' ? handle.pid : null;
101
+ if (handle.currentModel) {
102
+ this._writeRuntimeEvent({ kind: 'model', model: handle.currentModel });
103
+ }
105
104
  // Lets engine.js write the PID-file equivalent (spawn-agent.js normally
106
105
  // does this itself, from inside the child process) as soon as a real OS
107
106
  // PID is known — see engine.js's pooled branch in spawnAgent().
@@ -113,16 +112,14 @@ class PooledAgentProcess extends EventEmitter {
113
112
  systemPromptText,
114
113
  onChunk: (text) => {
115
114
  if (!text) return;
116
- this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
115
+ this._writeRuntimeEvent({ kind: 'chunk', text });
117
116
  },
118
117
  onDone: (result) => {
119
- this._writeStdoutEvent({
120
- type: 'result',
118
+ this._writeRuntimeEvent({
119
+ kind: 'result',
121
120
  sessionId: handle.sessionId || null,
122
- usage: {
123
- totalApiDurationMs: Date.now() - this._startedAt,
124
- stopReason: (result && result.stopReason) || null,
125
- },
121
+ durationMs: Date.now() - this._startedAt,
122
+ stopReason: (result && result.stopReason) || null,
126
123
  });
127
124
  },
128
125
  onError: (err) => {
@@ -137,11 +134,16 @@ class PooledAgentProcess extends EventEmitter {
137
134
  try { handle.release(); } catch { /* best-effort */ }
138
135
  this._handle = null;
139
136
  }
140
- this._finish(sawError ? 1 : 0);
137
+ this._finish(sawError || this._runtimeOutputError ? 1 : 0);
141
138
  }
142
139
 
143
- _writeStdoutEvent(obj) {
144
- try { this.stdout.write(JSON.stringify(obj) + '\n'); } catch { /* best-effort */ }
140
+ _writeRuntimeEvent(event) {
141
+ try {
142
+ this.stdout.write(encodePooledOutput(this._runtime, event));
143
+ } catch (err) {
144
+ this._runtimeOutputError = err;
145
+ try { this.stderr.write(`Runtime output encoding failed: ${err.message}\n`); } catch {}
146
+ }
145
147
  }
146
148
 
147
149
  _finish(code) {