@sweny-ai/core 0.1.198 → 0.1.200

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 (44) hide show
  1. package/dist/agent-env.d.ts +94 -2
  2. package/dist/agent-env.js +144 -7
  3. package/dist/budget.d.ts +77 -11
  4. package/dist/budget.js +132 -16
  5. package/dist/cli/renderer.js +5 -0
  6. package/dist/cli/run-history.d.ts +12 -0
  7. package/dist/cli/run-history.js +16 -0
  8. package/dist/cli/run-output.d.ts +16 -1
  9. package/dist/cli/run-output.js +46 -4
  10. package/dist/cli/runs.js +38 -1
  11. package/dist/cli/ticket.js +7 -2
  12. package/dist/eval/index.d.ts +10 -0
  13. package/dist/eval/index.js +27 -6
  14. package/dist/eval/judge.d.ts +3 -0
  15. package/dist/eval/judge.js +12 -2
  16. package/dist/executor.d.ts +5 -1
  17. package/dist/executor.js +276 -31
  18. package/dist/harness/acp.d.ts +5 -2
  19. package/dist/harness/acp.js +11 -23
  20. package/dist/harness/auth.js +2 -1
  21. package/dist/harness/claude-code.d.ts +18 -13
  22. package/dist/harness/claude-code.js +34 -15
  23. package/dist/harness/codex.d.ts +22 -5
  24. package/dist/harness/codex.js +67 -11
  25. package/dist/harness/compat.js +1 -0
  26. package/dist/harness/index.d.ts +1 -1
  27. package/dist/harness/index.js +1 -1
  28. package/dist/harness/pi.d.ts +11 -5
  29. package/dist/harness/pi.js +27 -9
  30. package/dist/harness/prompts.d.ts +11 -1
  31. package/dist/harness/prompts.js +21 -2
  32. package/dist/harness/sandbox-wrapper.d.ts +13 -2
  33. package/dist/harness/sandbox-wrapper.js +29 -4
  34. package/dist/harness/types.d.ts +9 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.js +2 -2
  37. package/dist/journal.d.ts +48 -10
  38. package/dist/journal.js +164 -16
  39. package/dist/safe-outputs.d.ts +9 -0
  40. package/dist/safe-outputs.js +41 -26
  41. package/dist/skills/linear.js +3 -1
  42. package/dist/studio.js +2 -1
  43. package/dist/types.d.ts +34 -1
  44. package/package.json +1 -1
@@ -40,7 +40,11 @@ export interface ExecuteOptions {
40
40
  logger?: Logger;
41
41
  /** Working directory for resolving file Sources (default: process.cwd()) */
42
42
  cwd?: string;
43
- /** Environment variables for Source resolution auth lookup */
43
+ /**
44
+ * The env SWEny itself reads for this run: skill config, `context:` Source
45
+ * auth, the branch pusher's token and the run's identity. It never reaches
46
+ * the agent; to change the agent's env, pass `env` / `envRemove` to the harness.
47
+ */
44
48
  env?: NodeJS.ProcessEnv;
45
49
  /** Host → env-var-name map for URL Source authentication */
46
50
  fetchAuth?: Record<string, string>;
package/dist/executor.js CHANGED
@@ -30,7 +30,7 @@ import { toolRunContext } from "./run-context.js";
30
30
  import { fenceUntrusted, fenceUntrustedJson } from "./untrusted.js";
31
31
  import { asClaude } from "./harness/compat.js";
32
32
  import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
33
- import { BudgetGuard, describeOverrun, minLimits, spendOf, toLimits } from "./budget.js";
33
+ import { BudgetGuard, BudgetRefusal, describeOverrun, minLimits, spendOf, toLimits } from "./budget.js";
34
34
  import { createRunDecider, wantsDecider } from "./decider.js";
35
35
  import { redact, runSecretValues } from "./journal.js";
36
36
  import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
@@ -90,6 +90,7 @@ export async function execute(workflow, input, options) {
90
90
  };
91
91
  results.set(at.node, result);
92
92
  trace.steps.push({ node: at.node, status: "failed", iteration: at.iteration });
93
+ at.syncSpend?.();
93
94
  safeObserve(options.observer, { type: "node:exit", node: at.node, result }, logger);
94
95
  logger.error(` ${err.message}`, { node: at.node });
95
96
  journalDegraded();
@@ -130,6 +131,130 @@ async function executeRun(workflow, input, options, results, trace, at) {
130
131
  // Spend budgets (#449): the run ceiling is the lower of the workflow's
131
132
  // `budget` and the caller's (`--max-tokens`, `--max-cost`).
132
133
  const budgetGuard = new BudgetGuard(minLimits(toLimits(workflow.budget), toLimits(options.budget)));
134
+ // The executor's own completions (judge, route, retry reflection, safe-output
135
+ // screen) report usage into the same guard as node runs, under their kind,
136
+ // and into the journal so a resume keeps them. Every call passes a gate
137
+ // first: once a ceiling is reached (or a usage record could not be
138
+ // journaled) the call is refused, not made. `sink` counts one completion's
139
+ // total and keeps any breach for the caller; it never throws into the
140
+ // adapter, so a journal failure inside it is held in `meterFault` and
141
+ // rethrown by the executor at the next step.
142
+ let meterFault;
143
+ const throwIfMeterFault = () => {
144
+ if (meterFault)
145
+ throw meterFault;
146
+ };
147
+ const metered = (refuse, sink) => {
148
+ const gate = () => {
149
+ throwIfMeterFault();
150
+ const stop = refuse();
151
+ if (stop)
152
+ throw new BudgetRefusal(stop);
153
+ };
154
+ return {
155
+ defaultJudgeModel: claude.defaultJudgeModel,
156
+ run: (req) => claude.run(req),
157
+ evaluate: async (opts) => {
158
+ gate();
159
+ return claude.evaluate({ ...opts, onUsage: sink });
160
+ },
161
+ ask: async (opts) => {
162
+ gate();
163
+ return claude.ask({ ...opts, onUsage: sink });
164
+ },
165
+ };
166
+ };
167
+ const journalMeter = (kind, node, iteration, u) => {
168
+ if (!journal?.meter || meterFault)
169
+ return;
170
+ try {
171
+ journal.meter(kind, node, iteration, u);
172
+ }
173
+ catch (err) {
174
+ meterFault = err;
175
+ }
176
+ };
177
+ const syncSpend = () => {
178
+ if (budgetGuard.hasMeteredCalls)
179
+ trace.spend = budgetGuard.breakdown();
180
+ };
181
+ at.syncSpend = syncSpend;
182
+ // A route call that crossed the run budget, or was refused because the run
183
+ // budget was already spent. The next node is then refused at its start; a
184
+ // route that ends the run fails the node it left.
185
+ let routeBreach;
186
+ let routingFrom;
187
+ const routeClaude = metered(() => (routeBreach ??= budgetGuard.runExhausted()), (u) => {
188
+ const b = budgetGuard.meter("route", u);
189
+ if (routingFrom)
190
+ journalMeter("route", routingFrom.node, routingFrom.iteration, u);
191
+ syncSpend();
192
+ if (b) {
193
+ routeBreach ??= b;
194
+ logger.warn(` ${describeOverrun(b)}; the next node does not start`);
195
+ }
196
+ });
197
+ // Routing from `from`. `budgetStop` says the run ended there on the budget:
198
+ // the caller then writes no route record, so the journal keeps the visit
199
+ // ended but unrouted. A resume (with a larger cap) then routes a visit that
200
+ // succeeded again, like a crash between node:end and the route; a visit
201
+ // that had failed (`on_fail: continue`) is run again.
202
+ const advance = async (from) => {
203
+ routeBreach = undefined;
204
+ routingFrom = { node: from, iteration: nodeRunCounts.get(from) ?? 1 };
205
+ // No paid routing once the run budget is spent: the decider (an HTTP
206
+ // model call) is skipped like the agent's route evaluation, which the gate refuses.
207
+ const spentBeforeRouting = budgetGuard.runExhausted();
208
+ if (spentBeforeRouting && routing.decider && !trace.deciderOff)
209
+ trace.deciderOff = "run budget spent";
210
+ const routeOpts = spentBeforeRouting ? { ...routing, decider: null } : routing;
211
+ let routed;
212
+ let refused = false;
213
+ try {
214
+ routed = await advanceFromNode(workflow, from, results, input, routeClaude, observer, edgeCounts, logger, trace, routeOpts);
215
+ }
216
+ catch (err) {
217
+ throwIfMeterFault();
218
+ if (!(err instanceof BudgetRefusal))
219
+ throw err;
220
+ logger.warn(` ${describeOverrun(err.breach)}; not routing from '${from}'`, { node: from });
221
+ routed = { next: null };
222
+ refused = true;
223
+ }
224
+ finally {
225
+ routingFrom = undefined;
226
+ }
227
+ throwIfMeterFault();
228
+ const breach = routeBreach;
229
+ routeBreach = undefined;
230
+ if (breach && routed.next === null) {
231
+ // No node is left to refuse: the stop lands on the node the route left.
232
+ const prior = results.get(from);
233
+ if (prior && !prior.budget) {
234
+ const error = describeOverrun(breach, from);
235
+ // A node that had already failed keeps its own error; the budget reason has its own field.
236
+ const priorError = prior.data?.error;
237
+ const amended = {
238
+ ...prior,
239
+ status: "failed",
240
+ data: { ...prior.data, error: priorError ?? error, budget_error: error, budget_exceeded: true },
241
+ budget: breach,
242
+ };
243
+ results.set(from, amended);
244
+ // The trace and observers already saw the visit end: they get the amended result as an amendment, not a second exit.
245
+ const iteration = nodeRunCounts.get(from) ?? 1;
246
+ const step = [...trace.steps].reverse().find((st) => st.node === from && st.iteration === iteration);
247
+ if (step)
248
+ step.status = "failed";
249
+ safeObserve(observer, { type: "node:amended", node: from, result: amended }, logger);
250
+ }
251
+ // Routing already announced its own end (an invalid target); a refused route announced nothing.
252
+ if (refused)
253
+ safeObserve(observer, { type: "route", from, to: "(end)", reason: "budget exceeded" }, logger);
254
+ return { ...routed, budgetStop: true };
255
+ }
256
+ return routed;
257
+ };
133
258
  const harnessPolicy = options.harnessPolicy ?? resolveHarnessPolicy(runEnv);
134
259
  // #473: a checkout that persisted a git credential (actions/checkout's
135
260
  // default). Each harness masks it for read-only and staged nodes, or reports
@@ -279,8 +404,9 @@ async function executeRun(workflow, input, options, results, trace, at) {
279
404
  logger.info(` replayed from the run journal: ${replay.result.status}`, { node: currentId });
280
405
  const from = currentId;
281
406
  if (replay.next === undefined) {
282
- const routed = await advanceFromNode(workflow, from, results, input, claude, observer, edgeCounts, logger, trace, routing);
283
- journal?.route(from, routed.next, routed.rung, decider?.counters);
407
+ const routed = await advance(from);
408
+ if (!routed.budgetStop)
409
+ journal?.route(from, routed.next, routed.rung, decider?.counters);
284
410
  currentId = routed.next;
285
411
  }
286
412
  else {
@@ -304,6 +430,27 @@ async function executeRun(workflow, input, options, results, trace, at) {
304
430
  continue;
305
431
  }
306
432
  journal?.nodeStart(currentId, iteration);
433
+ // The run budget is already spent (by a route call, or by the node before
434
+ // a skipped one): this node neither starts nor routes on, whatever its
435
+ // `requires` say, so a skipped node cannot carry the run past the stop.
436
+ // A checkpointed visit (resume) already ran its agent: its writes still apply.
437
+ const runSpent = replay?.kind === "checkpoint" ? undefined : budgetGuard.runExhausted();
438
+ if (runSpent) {
439
+ const error = describeOverrun(runSpent, currentId);
440
+ const result = {
441
+ status: "failed",
442
+ data: { error, budget_exceeded: true },
443
+ toolCalls: [],
444
+ budget: runSpent,
445
+ };
446
+ results.set(currentId, result);
447
+ journal?.nodeEnd(currentId, iteration, result, writeState);
448
+ trace.steps.push({ node: currentId, status: "failed", iteration });
449
+ safeObserve(observer, { type: "node:exit", node: currentId, result }, logger);
450
+ logger.warn(` ${error}; not starting the node`, { node: currentId });
451
+ safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: "node failed (budget exceeded)" }, logger);
452
+ break;
453
+ }
307
454
  // Gather tools from the node's skills, then apply the node's optional
308
455
  // skill-tool filter (`tools.allow` / `tools.deny`). Filtered tools are
309
456
  // never registered for the run, so the model cannot see or call them.
@@ -406,8 +553,9 @@ async function executeRun(workflow, input, options, results, trace, at) {
406
553
  }
407
554
  // Apply normal routing rules (dry run gate + resolveNext).
408
555
  // TODO: dedupe with requires path — see advanceFromNode helper below
409
- const routed = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, routing);
410
- journal?.route(currentId, routed.next, routed.rung, decider?.counters);
556
+ const routed = await advance(currentId);
557
+ if (!routed.budgetStop)
558
+ journal?.route(currentId, routed.next, routed.rung, decider?.counters);
411
559
  currentId = routed.next;
412
560
  continue;
413
561
  }
@@ -496,12 +644,40 @@ async function executeRun(workflow, input, options, results, trace, at) {
496
644
  // run's. The gate says whether the harness can keep them; a strict policy
497
645
  // refuses the node when it cannot report the budgeted unit at all.
498
646
  const nodeBudget = budgetGuard.node(toLimits(node.budget));
647
+ // Resume: this visit's spend before the crash counts against its node
648
+ // budget again (the run total already has it from the seed), whether the
649
+ // write stage is replayed or the visit runs again.
650
+ const visitPrior = replay?.kind === "checkpoint" ? replay.spent : replay ? undefined : journal?.visitSpent?.(currentId, iteration);
651
+ if (visitPrior)
652
+ nodeBudget.restore(visitPrior);
499
653
  const budgetOn = budgetGuard.active(nodeBudget.limits);
500
654
  const budgetCheck = budgetOn && options.harness
501
655
  ? budgetGate(options.harness.capabilities, minLimits(nodeBudget.limits, budgetGuard.runLimits), permissions.strict || harnessPolicy === "strict")
502
656
  : undefined;
503
657
  // A budget stop: fail_soft and on_fail: continue never apply, the run halts.
504
658
  let budgetStop;
659
+ // This visit's own completions (judge scoring, retry reflection, output
660
+ // screen) count against the node and the run; a crossing is kept here.
661
+ let meterBreach;
662
+ const visitId = currentId;
663
+ const visitClaude = (kind) => metered(
664
+ // Latched: once refused, every later call of this visit is refused too.
665
+ () => (meterBreach ??= budgetOn ? nodeBudget.exhausted() : undefined), (u) => {
666
+ const b = nodeBudget.meter(kind, u);
667
+ journalMeter(kind, visitId, iteration, u);
668
+ syncSpend();
669
+ meterBreach ??= b;
670
+ });
671
+ const judgeClaude = visitClaude("judge");
672
+ const reflectClaude = visitClaude("reflect");
673
+ const screenClaude = visitClaude("screen");
674
+ // A ceiling reached before a paid executor call (judges, reflection): the
675
+ // node fails with the budget reason instead of making the call.
676
+ const stopAtCeiling = (r, b) => {
677
+ const error = describeOverrun(b, visitId);
678
+ logger.warn(` ${error}`, { node: currentId });
679
+ return { ...r, status: "failed", data: { ...r.data, error, budget_exceeded: true }, budget: b };
680
+ };
505
681
  let previous;
506
682
  while (true) {
507
683
  if (replay?.kind === "checkpoint") {
@@ -509,6 +685,8 @@ async function executeRun(workflow, input, options, results, trace, at) {
509
685
  // write intents; the write stage below skips what the journal shows was already applied.
510
686
  ({ result, agentRunFailed, attempt } = replay);
511
687
  intents.push(...replay.intents);
688
+ // The visit's own spend before the crash counts against its node budget
689
+ // again (the run total already has it from the seed).
512
690
  break;
513
691
  }
514
692
  const refusal = gate?.refuse ?? budgetCheck?.refuse;
@@ -562,7 +740,6 @@ async function executeRun(workflow, input, options, results, trace, at) {
562
740
  }
563
741
  const journalUsage = journal?.usage
564
742
  ? (u) => {
565
- liveUsage = { ...liveUsage, ...u };
566
743
  if (journalFault)
567
744
  return;
568
745
  try {
@@ -574,12 +751,12 @@ async function executeRun(workflow, input, options, results, trace, at) {
574
751
  }
575
752
  }
576
753
  : undefined;
577
- const onUsage = attemptBudget || journalUsage
578
- ? (u) => {
579
- attemptBudget?.onUsage(u);
580
- journalUsage?.(u);
581
- }
582
- : undefined;
754
+ // Live usage is always collected: with no limit it still feeds the split by kind.
755
+ const onUsage = (u) => {
756
+ liveUsage = { ...liveUsage, ...u };
757
+ attemptBudget?.onUsage(u);
758
+ journalUsage?.(u);
759
+ };
583
760
  try {
584
761
  result = await claude.run({
585
762
  nodeId: currentId,
@@ -597,13 +774,17 @@ async function executeRun(workflow, input, options, results, trace, at) {
597
774
  ...(nodePolicy ? { policy: nodePolicy } : {}),
598
775
  ...(readOnlyNode || stageOutputs ? {} : { mcpServers: skillMcpServers }),
599
776
  ...(readOnlyNode ? { readOnly: true } : {}),
600
- ...(onUsage ? { onUsage } : {}),
777
+ onUsage,
601
778
  onProgress: (message) => {
602
779
  safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
603
780
  },
604
781
  });
605
782
  // Commit the attempt's spend before anything here can throw (#549); `finish` below reads the breach.
606
- attemptBudget?.settle(result.usage);
783
+ if (attemptBudget)
784
+ attemptBudget.settle(result.usage);
785
+ // No limit to enforce: still count the run, so the split by kind is complete.
786
+ else
787
+ budgetGuard.meter("node", attemptSpend(result.usage, liveUsage));
607
788
  }
608
789
  catch (err) {
609
790
  if (journalFault)
@@ -741,13 +922,37 @@ async function executeRun(workflow, input, options, results, trace, at) {
741
922
  result.evals = [failure];
742
923
  }
743
924
  else {
744
- const evalResults = await evaluateAll(node.eval, result, {
745
- aliases: toolAliases,
746
- claude,
747
- node,
748
- workflow,
749
- });
750
- result.evals = evalResults;
925
+ // With a budget on, judges run one at a time, so a crossing (or a
926
+ // ceiling already reached) refuses the rest instead of all starting at once.
927
+ let evalResults;
928
+ try {
929
+ evalResults = await evaluateAll(node.eval, result, {
930
+ aliases: toolAliases,
931
+ claude: judgeClaude,
932
+ node,
933
+ workflow,
934
+ sequential: budgetOn,
935
+ ...(signal ? { signal } : {}),
936
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
937
+ });
938
+ }
939
+ catch (err) {
940
+ throwIfMeterFault();
941
+ if (!(err instanceof BudgetRefusal))
942
+ throw err;
943
+ }
944
+ throwIfMeterFault();
945
+ if (evalResults)
946
+ result.evals = evalResults;
947
+ if (meterBreach || !evalResults) {
948
+ // Judge spend crossed a ceiling, or a judge was refused at one (the
949
+ // refusal latched it): the node fails whatever the verdicts said.
950
+ const stop = meterBreach ?? nodeBudget.exhausted() ?? budgetGuard.runExhausted();
951
+ result = stop ? stopAtCeiling(result, stop) : result;
952
+ agentRunFailed = true;
953
+ budgetStop = stop;
954
+ break;
955
+ }
751
956
  outcome = aggregateEval(evalResults, node.eval_policy ?? "all_pass");
752
957
  }
753
958
  if (outcome.pass)
@@ -786,13 +991,21 @@ async function executeRun(workflow, input, options, results, trace, at) {
786
991
  evalFailures: outcome.failures,
787
992
  toolCalls: result.toolCalls,
788
993
  nodeInstruction: resolvedInstruction,
789
- claude,
994
+ claude: reflectClaude,
790
995
  logger,
791
996
  context,
792
997
  model: nodeModel,
793
998
  signal,
794
999
  timeoutMs,
795
1000
  });
1001
+ throwIfMeterFault();
1002
+ // A reflection refused at a ceiling (or one whose spend crossed it): no further attempt.
1003
+ if (meterBreach) {
1004
+ result = stopAtCeiling(result, meterBreach);
1005
+ agentRunFailed = true;
1006
+ budgetStop = meterBreach;
1007
+ break;
1008
+ }
796
1009
  currentInstruction = `${preamble}\n\n---\n\n${instruction}`;
797
1010
  safeObserve(observer, { type: "node:retry", node: currentId, attempt: attempt + 1, reason: outcome.error ?? "eval failed", preamble }, logger);
798
1011
  attempt++;
@@ -840,19 +1053,35 @@ async function executeRun(workflow, input, options, results, trace, at) {
840
1053
  state: writeState,
841
1054
  logger,
842
1055
  input: runInput,
843
- screen: async (writes) => claude.ask({
844
- instruction: SAFE_OUTPUT_SCREEN_INSTRUCTION,
845
- context: { writes },
846
- model: nodeModel,
847
- signal,
848
- timeoutMs,
849
- }),
1056
+ ...(journal?.reconcile
1057
+ ? {
1058
+ reconcile: (sk, tool, args, ctx) => journal.reconcile(visitId, iteration, sk, tool, args, ctx),
1059
+ }
1060
+ : {}),
1061
+ screen: async (writes) => {
1062
+ const verdict = await screenClaude.ask({
1063
+ instruction: SAFE_OUTPUT_SCREEN_INSTRUCTION,
1064
+ context: { writes },
1065
+ model: nodeModel,
1066
+ signal,
1067
+ timeoutMs,
1068
+ });
1069
+ // A screen whose spend crossed a ceiling, or whose meter record the
1070
+ // journal could not write, allows nothing: the writes are vetoed.
1071
+ return meterBreach || meterFault ? null : verdict;
1072
+ },
850
1073
  });
1074
+ throwIfMeterFault();
851
1075
  result = { ...result, outputs: stage.receipts };
852
1076
  if (stage.error) {
853
1077
  logger.warn(` ${stage.error}`, { node: currentId });
854
1078
  result = { ...result, status: "failed", data: { ...result.data, error: stage.error } };
855
1079
  }
1080
+ if (meterBreach && !budgetStop) {
1081
+ // The screen's spend crossed a ceiling: the node fails and the run halts.
1082
+ result = stopAtCeiling(result, meterBreach);
1083
+ budgetStop = meterBreach;
1084
+ }
856
1085
  }
857
1086
  else if (intents.length > 0) {
858
1087
  result = {
@@ -885,17 +1114,33 @@ async function executeRun(workflow, input, options, results, trace, at) {
885
1114
  }
886
1115
  // Dry run gate + routing — shared with requires path via advanceFromNode helper.
887
1116
  const routedFrom = currentId;
888
- const routed = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, routing);
889
- journal?.route(routedFrom, routed.next, routed.rung, decider?.counters);
1117
+ const routed = await advance(routedFrom);
1118
+ if (!routed.budgetStop)
1119
+ journal?.route(routedFrom, routed.next, routed.rung, decider?.counters);
890
1120
  currentId = routed.next;
891
1121
  }
892
1122
  safeObserve(observer, {
893
1123
  type: "workflow:end",
894
1124
  results: Object.fromEntries(results),
895
1125
  }, logger);
1126
+ syncSpend();
896
1127
  return { results, trace };
897
1128
  }
898
1129
  // ─── Internals ───────────────────────────────────────────────────
1130
+ /**
1131
+ * An attempt's spend as usage: per unit, the larger of the final report and
1132
+ * the last live one (what `BudgetAttempt.settle` commits), so the split is the
1133
+ * same with and without a limit. Undefined when neither reported anything.
1134
+ */
1135
+ function attemptSpend(final, live) {
1136
+ const f = spendOf(final);
1137
+ const l = spendOf(live);
1138
+ const tokens = f.tokens === undefined && l.tokens === undefined ? undefined : Math.max(f.tokens ?? 0, l.tokens ?? 0);
1139
+ const costUsd = f.costUsd === undefined && l.costUsd === undefined ? undefined : Math.max(f.costUsd ?? 0, l.costUsd ?? 0);
1140
+ if (tokens === undefined && costUsd === undefined)
1141
+ return undefined;
1142
+ return { ...(tokens !== undefined ? { inputTokens: tokens } : {}), ...(costUsd !== undefined ? { costUsd } : {}) };
1143
+ }
899
1144
  /**
900
1145
  * Thrown when a conditional routing decision could not be made and there is
901
1146
  * no explicit default (unconditional) edge to fall back to. Fails closed: the
@@ -45,7 +45,7 @@
45
45
  * - Timeout: `session/cancel`, a grace period, then the process is killed.
46
46
  */
47
47
  import type { Logger, McpServerConfig, ToolContext } from "../types.js";
48
- import { type SandboxMode } from "../agent-env.js";
48
+ import { type HarnessEnvOptions, type SandboxMode } from "../agent-env.js";
49
49
  import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
50
50
  import { ACP_CAPABILITIES } from "./capabilities.js";
51
51
  import { type HarnessPolicyMode } from "./policy.js";
@@ -53,7 +53,7 @@ import { type SandboxWrapper } from "./sandbox-wrapper.js";
53
53
  export { ACP_CAPABILITIES };
54
54
  /** The ACP major version sweny speaks (`InitializeRequest.protocolVersion`). */
55
55
  export declare const ACP_PROTOCOL_VERSION = 1;
56
- export interface AcpHarnessOptions {
56
+ export interface AcpHarnessOptions extends HarnessEnvOptions {
57
57
  /**
58
58
  * The agent to run: a command line (`"opencode acp"`, the part after `acp:`)
59
59
  * or an explicit `{command, args}`. Never run through a shell.
@@ -139,6 +139,7 @@ export declare class AcpHarness implements AgentHarness {
139
139
  private envPassthrough;
140
140
  private authVars;
141
141
  private envScope;
142
+ private envOverlay;
142
143
  private sandboxMode;
143
144
  private sandboxWrapper;
144
145
  private policyMode;
@@ -167,6 +168,8 @@ export declare class AcpHarness implements AgentHarness {
167
168
  * vars the operator named. Skill credentials are dropped either way
168
169
  * ({@link finishAgentEnv}) unless the node granted them with `agent_env`; an
169
170
  * operator-named auth var stays, and the gate counts it if it is a write token.
171
+ * The caller's `envRemove` / `env` are applied after scoping
172
+ * ({@link applyEnvOverlay}), before that credential rule.
170
173
  */
171
174
  private buildEnv;
172
175
  /** The node policy, with the harness policy mode and the watchdog budget applied. */
@@ -45,14 +45,13 @@
45
45
  * - Timeout: `session/cancel`, a grace period, then the process is killed.
46
46
  */
47
47
  import { spawn } from "node:child_process";
48
- import * as fs from "node:fs";
49
48
  import * as path from "node:path";
50
49
  import { consoleLogger } from "../types.js";
51
- import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
50
+ import { agentEnvOverlay, applyEnvOverlay, finishAgentEnv, heldCredentials, overlaidProcessEnv, resolveExecutable, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
52
51
  import { ACP_CAPABILITIES } from "./capabilities.js";
53
52
  import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
54
53
  import { gitCredentialPolicy } from "../git-credentials.js";
55
- import { buildNodePrompt } from "./prompts.js";
54
+ import { buildNodePrompt, reportCompletionUsage } from "./prompts.js";
56
55
  import { makeAbort } from "./abort.js";
57
56
  import { parseToolResultContent, schemaMismatches, summarizeToolError, tryParseJSON } from "./parse.js";
58
57
  import { startToolBridge } from "./tool-bridge/server.js";
@@ -148,25 +147,6 @@ export function toAcpMcpServers(servers, agentHttp) {
148
147
  }
149
148
  return { list, unsupported };
150
149
  }
151
- function resolveOnPath(command, env) {
152
- const ok = (p) => {
153
- try {
154
- fs.accessSync(p, fs.constants.X_OK);
155
- return fs.statSync(p).isFile();
156
- }
157
- catch {
158
- return false;
159
- }
160
- };
161
- if (command.includes(path.sep) || command.includes("/"))
162
- return ok(command) ? command : undefined;
163
- for (const dir of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
164
- const p = path.join(dir, command);
165
- if (ok(p))
166
- return p;
167
- }
168
- return undefined;
169
- }
170
150
  // ─── Process registry (crash cleanup) ────────────────────────────
171
151
  const liveChildren = new Set();
172
152
  let exitHookInstalled = false;
@@ -207,6 +187,7 @@ export class AcpHarness {
207
187
  envPassthrough;
208
188
  authVars;
209
189
  envScope;
190
+ envOverlay;
210
191
  sandboxMode;
211
192
  sandboxWrapper;
212
193
  policyMode;
@@ -238,6 +219,7 @@ export class AcpHarness {
238
219
  this.envPassthrough = opts.envPassthrough;
239
220
  this.authVars = opts.authVars ?? [];
240
221
  this.envScope = opts.envScope;
222
+ this.envOverlay = agentEnvOverlay(opts);
241
223
  this.sandboxMode = opts.sandbox;
242
224
  this.sandboxWrapper = opts.sandboxWrapper;
243
225
  this.policyMode = opts.policy ?? resolveHarnessPolicy(process.env, undefined, this.logger);
@@ -256,7 +238,7 @@ export class AcpHarness {
256
238
  * first run (`agentInfo.version`).
257
239
  */
258
240
  async preflight() {
259
- const found = resolveOnPath(this.agent.command, process.env);
241
+ const found = resolveExecutable(this.agent.command, overlaidProcessEnv(this.envOverlay, this.authVars), this.cwd);
260
242
  if (!found) {
261
243
  const example = [this.agent.command, ...this.agent.args].join(" ");
262
244
  return {
@@ -271,6 +253,8 @@ export class AcpHarness {
271
253
  * vars the operator named. Skill credentials are dropped either way
272
254
  * ({@link finishAgentEnv}) unless the node granted them with `agent_env`; an
273
255
  * operator-named auth var stays, and the gate counts it if it is a write token.
256
+ * The caller's `envRemove` / `env` are applied after scoping
257
+ * ({@link applyEnvOverlay}), before that credential rule.
274
258
  */
275
259
  buildEnv(access) {
276
260
  const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
@@ -287,6 +271,7 @@ export class AcpHarness {
287
271
  reportWithheldEnv(scoped.withheld, this.logger);
288
272
  env = scoped.env;
289
273
  }
274
+ env = applyEnvOverlay(env, this.envOverlay, this.authVars);
290
275
  return finishAgentEnv(env, { access, keep: this.authVars, passthrough, logger: this.logger }).env;
291
276
  }
292
277
  /** The node policy, with the harness policy mode and the watchdog budget applied. */
@@ -952,6 +937,9 @@ export class AcpHarness {
952
937
  }
953
938
  if (conv.agentVersion)
954
939
  this.agentVersion = conv.agentVersion;
940
+ // A completion that failed was still billed: report before the outcome.
941
+ if (conv.costUsd !== undefined)
942
+ reportCompletionUsage(req.onUsage, { costUsd: conv.costUsd }, this.logger);
955
943
  if (conv.spawnError)
956
944
  return failClosed(`the ACP agent could not start: ${conv.spawnError}`);
957
945
  if (conv.abortReason === "timeout")
@@ -30,7 +30,8 @@ function truthy(v) {
30
30
  */
31
31
  export function detectClaudeCodeLogin(opts = {}) {
32
32
  const env = opts.env ?? process.env;
33
- const home = opts.home ?? os.homedir();
33
+ // The env's HOME, not the process's: a harness `env` overlay may give the run its own.
34
+ const home = opts.home ?? (env.HOME || os.homedir());
34
35
  const platform = opts.platform ?? process.platform;
35
36
  const configDir = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
36
37
  try {