@tangle-network/agent-bench 0.3.5 → 0.3.7

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 (262) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +13 -1
  3. package/dist/adapters.d.ts +15 -0
  4. package/dist/adapters.js +43 -0
  5. package/dist/adapters.js.map +1 -0
  6. package/dist/benchmarks/_harness.d.ts +125 -0
  7. package/dist/benchmarks/_harness.js +33 -0
  8. package/dist/benchmarks/_harness.js.map +1 -0
  9. package/dist/benchmarks/aec-bench.d.ts +27 -0
  10. package/dist/benchmarks/aec-bench.js +8 -0
  11. package/dist/benchmarks/aec-bench.js.map +1 -0
  12. package/dist/benchmarks/agentbench.d.ts +16 -0
  13. package/dist/benchmarks/agentbench.js +10 -0
  14. package/dist/benchmarks/agentbench.js.map +1 -0
  15. package/dist/benchmarks/appworld.d.ts +37 -0
  16. package/dist/benchmarks/appworld.js +14 -0
  17. package/dist/benchmarks/appworld.js.map +1 -0
  18. package/dist/benchmarks/bfcl.d.ts +18 -0
  19. package/dist/benchmarks/bfcl.js +10 -0
  20. package/dist/benchmarks/bfcl.js.map +1 -0
  21. package/dist/benchmarks/cad-design.d.ts +45 -0
  22. package/dist/benchmarks/cad-design.js +7 -0
  23. package/dist/benchmarks/cad-design.js.map +1 -0
  24. package/dist/benchmarks/cadbench.d.ts +19 -0
  25. package/dist/benchmarks/cadbench.js +8 -0
  26. package/dist/benchmarks/cadbench.js.map +1 -0
  27. package/dist/benchmarks/cadgenbench.d.ts +22 -0
  28. package/dist/benchmarks/cadgenbench.js +8 -0
  29. package/dist/benchmarks/cadgenbench.js.map +1 -0
  30. package/dist/benchmarks/commit0.d.ts +31 -0
  31. package/dist/benchmarks/commit0.js +10 -0
  32. package/dist/benchmarks/commit0.js.map +1 -0
  33. package/dist/benchmarks/crag.d.ts +14 -0
  34. package/dist/benchmarks/crag.js +9 -0
  35. package/dist/benchmarks/crag.js.map +1 -0
  36. package/dist/benchmarks/dabstep.d.ts +18 -0
  37. package/dist/benchmarks/dabstep.js +10 -0
  38. package/dist/benchmarks/dabstep.js.map +1 -0
  39. package/dist/benchmarks/enterpriseops-gym.d.ts +38 -0
  40. package/dist/benchmarks/enterpriseops-gym.js +10 -0
  41. package/dist/benchmarks/enterpriseops-gym.js.map +1 -0
  42. package/dist/benchmarks/finresearchbench.d.ts +15 -0
  43. package/dist/benchmarks/finresearchbench.js +8 -0
  44. package/dist/benchmarks/finresearchbench.js.map +1 -0
  45. package/dist/benchmarks/finsearchcomp.d.ts +49 -0
  46. package/dist/benchmarks/finsearchcomp.js +7 -0
  47. package/dist/benchmarks/finsearchcomp.js.map +1 -0
  48. package/dist/benchmarks/frames.d.ts +59 -0
  49. package/dist/benchmarks/frames.js +13 -0
  50. package/dist/benchmarks/frames.js.map +1 -0
  51. package/dist/benchmarks/hotpotqa.d.ts +48 -0
  52. package/dist/benchmarks/hotpotqa.js +15 -0
  53. package/dist/benchmarks/hotpotqa.js.map +1 -0
  54. package/dist/benchmarks/humaneval.d.ts +62 -0
  55. package/dist/benchmarks/humaneval.js +17 -0
  56. package/dist/benchmarks/humaneval.js.map +1 -0
  57. package/dist/benchmarks/mind2web.d.ts +41 -0
  58. package/dist/benchmarks/mind2web.js +9 -0
  59. package/dist/benchmarks/mind2web.js.map +1 -0
  60. package/dist/benchmarks/nomiracl.d.ts +15 -0
  61. package/dist/benchmarks/nomiracl.js +9 -0
  62. package/dist/benchmarks/nomiracl.js.map +1 -0
  63. package/dist/benchmarks/open-rag-bench.d.ts +14 -0
  64. package/dist/benchmarks/open-rag-bench.js +9 -0
  65. package/dist/benchmarks/open-rag-bench.js.map +1 -0
  66. package/dist/benchmarks/programbench.d.ts +38 -0
  67. package/dist/benchmarks/programbench.js +10 -0
  68. package/dist/benchmarks/programbench.js.map +1 -0
  69. package/dist/benchmarks/rag-shared.d.ts +42 -0
  70. package/dist/benchmarks/rag-shared.js +39 -0
  71. package/dist/benchmarks/rag-shared.js.map +1 -0
  72. package/dist/benchmarks/ragbench.d.ts +16 -0
  73. package/dist/benchmarks/ragbench.js +9 -0
  74. package/dist/benchmarks/ragbench.js.map +1 -0
  75. package/dist/benchmarks/simpleqa.d.ts +64 -0
  76. package/dist/benchmarks/simpleqa.js +11 -0
  77. package/dist/benchmarks/simpleqa.js.map +1 -0
  78. package/dist/benchmarks/swe-bench.d.ts +56 -0
  79. package/dist/benchmarks/swe-bench.js +14 -0
  80. package/dist/benchmarks/swe-bench.js.map +1 -0
  81. package/dist/benchmarks/t2-ragbench.d.ts +14 -0
  82. package/dist/benchmarks/t2-ragbench.js +9 -0
  83. package/dist/benchmarks/t2-ragbench.js.map +1 -0
  84. package/dist/benchmarks/tau-bench-shared.d.ts +26 -0
  85. package/dist/benchmarks/tau-bench-shared.js +10 -0
  86. package/dist/benchmarks/tau-bench-shared.js.map +1 -0
  87. package/dist/benchmarks/tau2-bench.d.ts +7 -0
  88. package/dist/benchmarks/tau2-bench.js +11 -0
  89. package/dist/benchmarks/tau2-bench.js.map +1 -0
  90. package/dist/benchmarks/tau3-banking.d.ts +15 -0
  91. package/dist/benchmarks/tau3-banking.js +9 -0
  92. package/dist/benchmarks/tau3-banking.js.map +1 -0
  93. package/dist/benchmarks/terminal-bench.d.ts +24 -0
  94. package/dist/benchmarks/terminal-bench.js +8 -0
  95. package/dist/benchmarks/terminal-bench.js.map +1 -0
  96. package/dist/benchmarks/toollm.d.ts +16 -0
  97. package/dist/benchmarks/toollm.js +10 -0
  98. package/dist/benchmarks/toollm.js.map +1 -0
  99. package/dist/benchmarks/trata-hedge.d.ts +32 -0
  100. package/dist/benchmarks/trata-hedge.js +7 -0
  101. package/dist/benchmarks/trata-hedge.js.map +1 -0
  102. package/dist/benchmarks/types.d.ts +107 -0
  103. package/dist/benchmarks/types.js +1 -0
  104. package/dist/benchmarks/types.js.map +1 -0
  105. package/dist/benchmarks/webarena-verified.d.ts +16 -0
  106. package/dist/benchmarks/webarena-verified.js +10 -0
  107. package/dist/benchmarks/webarena-verified.js.map +1 -0
  108. package/dist/chunk-2PVVP7GN.js +197 -0
  109. package/dist/chunk-2PVVP7GN.js.map +1 -0
  110. package/dist/chunk-2XU6OGEN.js +170 -0
  111. package/dist/chunk-2XU6OGEN.js.map +1 -0
  112. package/dist/chunk-53UPUNBZ.js +325 -0
  113. package/dist/chunk-53UPUNBZ.js.map +1 -0
  114. package/dist/chunk-5H5XV76F.js +240 -0
  115. package/dist/chunk-5H5XV76F.js.map +1 -0
  116. package/dist/chunk-7WSD27QQ.js +118 -0
  117. package/dist/chunk-7WSD27QQ.js.map +1 -0
  118. package/dist/chunk-C7T7WEK2.js +103 -0
  119. package/dist/chunk-C7T7WEK2.js.map +1 -0
  120. package/dist/chunk-CKUVRZ2T.js +251 -0
  121. package/dist/chunk-CKUVRZ2T.js.map +1 -0
  122. package/dist/chunk-HBSWHQNJ.js +30 -0
  123. package/dist/chunk-HBSWHQNJ.js.map +1 -0
  124. package/dist/chunk-HHXFIHXC.js +116 -0
  125. package/dist/chunk-HHXFIHXC.js.map +1 -0
  126. package/dist/chunk-IFAV6KEM.js +276 -0
  127. package/dist/chunk-IFAV6KEM.js.map +1 -0
  128. package/dist/chunk-INNOYXCP.js +387 -0
  129. package/dist/chunk-INNOYXCP.js.map +1 -0
  130. package/dist/chunk-J3KDJNX2.js +182 -0
  131. package/dist/chunk-J3KDJNX2.js.map +1 -0
  132. package/dist/chunk-JRWWGMK7.js +148 -0
  133. package/dist/chunk-JRWWGMK7.js.map +1 -0
  134. package/dist/chunk-JTHWEDEW.js +32 -0
  135. package/dist/chunk-JTHWEDEW.js.map +1 -0
  136. package/dist/chunk-KDIKRJGB.js +120 -0
  137. package/dist/chunk-KDIKRJGB.js.map +1 -0
  138. package/dist/chunk-LRRD7NAG.js +301 -0
  139. package/dist/chunk-LRRD7NAG.js.map +1 -0
  140. package/dist/chunk-ODT47UAY.js +221 -0
  141. package/dist/chunk-ODT47UAY.js.map +1 -0
  142. package/dist/chunk-PA2ZKHJC.js +230 -0
  143. package/dist/chunk-PA2ZKHJC.js.map +1 -0
  144. package/dist/chunk-PUIRNYI7.js +189 -0
  145. package/dist/chunk-PUIRNYI7.js.map +1 -0
  146. package/dist/chunk-PWQVGAJB.js +144 -0
  147. package/dist/chunk-PWQVGAJB.js.map +1 -0
  148. package/dist/chunk-R36V2VP7.js +169 -0
  149. package/dist/chunk-R36V2VP7.js.map +1 -0
  150. package/dist/chunk-R67DFVLO.js +142 -0
  151. package/dist/chunk-R67DFVLO.js.map +1 -0
  152. package/dist/chunk-SEVJPLZC.js +260 -0
  153. package/dist/chunk-SEVJPLZC.js.map +1 -0
  154. package/dist/chunk-SYDW647C.js +318 -0
  155. package/dist/chunk-SYDW647C.js.map +1 -0
  156. package/dist/chunk-TBKU5XQI.js +228 -0
  157. package/dist/chunk-TBKU5XQI.js.map +1 -0
  158. package/dist/chunk-TSWPNOYM.js +147 -0
  159. package/dist/chunk-TSWPNOYM.js.map +1 -0
  160. package/dist/chunk-UAIOHCUK.js +27 -0
  161. package/dist/chunk-UAIOHCUK.js.map +1 -0
  162. package/dist/chunk-UPAMRDX4.js +233 -0
  163. package/dist/chunk-UPAMRDX4.js.map +1 -0
  164. package/dist/chunk-VQRS7VUC.js +342 -0
  165. package/dist/chunk-VQRS7VUC.js.map +1 -0
  166. package/dist/chunk-X3BTXCJ4.js +262 -0
  167. package/dist/chunk-X3BTXCJ4.js.map +1 -0
  168. package/dist/chunk-X5YKXC6V.js +211 -0
  169. package/dist/chunk-X5YKXC6V.js.map +1 -0
  170. package/dist/chunk-Y6O2OCUO.js +130 -0
  171. package/dist/chunk-Y6O2OCUO.js.map +1 -0
  172. package/dist/chunk-YCGY7UIZ.js +208 -0
  173. package/dist/chunk-YCGY7UIZ.js.map +1 -0
  174. package/dist/chunk-Z7ML6L77.js +162 -0
  175. package/dist/chunk-Z7ML6L77.js.map +1 -0
  176. package/dist/chunk-ZEWMTR5M.js +136 -0
  177. package/dist/chunk-ZEWMTR5M.js.map +1 -0
  178. package/dist/index.d.ts +355 -0
  179. package/dist/index.js +1908 -0
  180. package/dist/index.js.map +1 -0
  181. package/package.json +26 -9
  182. package/scripts/run-package-tests.mjs +30 -8
  183. package/scripts/verify-packed-consumer.mjs +12 -1
  184. package/scripts/verify-pier-agent.mts +1 -0
  185. package/src/benchmarks/humaneval.test.mts +122 -0
  186. package/src/benchmarks/humaneval.ts +100 -27
  187. package/src/david-attribution.mts +78 -0
  188. package/src/david-goliath.mts +149 -0
  189. package/src/hev-improve.mts +25 -6
  190. package/src/humaneval-object-ablation.mts +201 -0
  191. package/src/live-improve-campaign-mbpp.mts +641 -0
  192. package/src/live-improve-campaign.mts +500 -0
  193. package/src/mbpp-structural.mts +12 -7
  194. package/src/stream-observe.py +45 -0
  195. package/src/stream-observe.tpl.html +247 -0
  196. package/src/supervisor-arena.mts +816 -0
  197. package/src/swe-arena/analyze.ts +211 -0
  198. package/src/swe-arena/arms.ts +788 -0
  199. package/src/swe-arena/bootstrap-meta.mts +188 -0
  200. package/src/swe-arena/bootstrap-meta.test.mts +51 -0
  201. package/src/swe-arena/calibrate.ts +116 -0
  202. package/src/swe-arena/capabilities.mts +76 -0
  203. package/src/swe-arena/capabilities.test.mts +57 -0
  204. package/src/swe-arena/capacity.ts +194 -0
  205. package/src/swe-arena/cell-evidence.mts +405 -0
  206. package/src/swe-arena/cell-evidence.test.mts +248 -0
  207. package/src/swe-arena/diagnosis-ensemble.test.mts +210 -0
  208. package/src/swe-arena/diagnosis-ensemble.ts +520 -0
  209. package/src/swe-arena/execution.test.mts +1170 -0
  210. package/src/swe-arena/fixtures/analyze.py +80 -0
  211. package/src/swe-arena/fixtures/excludes.txt +8 -0
  212. package/src/swe-arena/fixtures/gen1-salvage/README.md +45 -0
  213. package/src/swe-arena/fixtures/gen1-salvage/cand0-e6d7361.diff +116 -0
  214. package/src/swe-arena/fixtures/gen1-salvage/cand1-76a8590.diff +293 -0
  215. package/src/swe-arena/fixtures/holdout-preregister.log +12 -0
  216. package/src/swe-arena/fixtures/holdout.json +44 -0
  217. package/src/swe-arena/fixtures/instances.json +146 -0
  218. package/src/swe-arena/fixtures/ledger.jsonl +12 -0
  219. package/src/swe-arena/fixtures/patches/pallets__flask-5014.solo.patch +36 -0
  220. package/src/swe-arena/fixtures/patches/pydata__xarray-4687.sup.patch +33 -0
  221. package/src/swe-arena/fixtures/rejudge.jsonl +15 -0
  222. package/src/swe-arena/fixtures/rematch.jsonl +3 -0
  223. package/src/swe-arena/fixtures/rematch2.jsonl +3 -0
  224. package/src/swe-arena/fixtures/rematch3.jsonl +3 -0
  225. package/src/swe-arena/fixtures/sup-journal-true.json +19 -0
  226. package/src/swe-arena/fixtures/verify/astropy__astropy-13033.sh +48 -0
  227. package/src/swe-arena/fixtures/verify/django__django-11532.sh +50 -0
  228. package/src/swe-arena/fixtures/verify/matplotlib__matplotlib-20826.sh +76 -0
  229. package/src/swe-arena/fixtures/verify/pydata__xarray-4687.sh +44 -0
  230. package/src/swe-arena/fixtures/verify/pytest-dev__pytest-6197.sh +32 -0
  231. package/src/swe-arena/fixtures/verify/sphinx-doc__sphinx-9658.sh +51 -0
  232. package/src/swe-arena/fixtures/worker-tokens.json +42 -0
  233. package/src/swe-arena/fixtures.ts +104 -0
  234. package/src/swe-arena/holdout-certify.mts +408 -0
  235. package/src/swe-arena/holdout-certify.test.mts +160 -0
  236. package/src/swe-arena/judge-child.mts +37 -0
  237. package/src/swe-arena/manifest.mts +293 -0
  238. package/src/swe-arena/manifest.test.mts +169 -0
  239. package/src/swe-arena/materialize.ts +142 -0
  240. package/src/swe-arena/outer-loop.mts +2145 -0
  241. package/src/swe-arena/outer-loop.test.mts +696 -0
  242. package/src/swe-arena/parity.test.mts +87 -0
  243. package/src/swe-arena/proc.test.mts +174 -0
  244. package/src/swe-arena/proc.ts +260 -0
  245. package/src/swe-arena/profiles/default-author.profile.json +4 -0
  246. package/src/swe-arena/proposer-fanout.mts +489 -0
  247. package/src/swe-arena/proposer-fanout.test.mts +372 -0
  248. package/src/swe-arena/reconcile.ts +0 -0
  249. package/src/swe-arena/replay.mts +183 -0
  250. package/src/swe-arena/replay.test.mts +300 -0
  251. package/src/swe-arena/run-experiment.mts +361 -0
  252. package/src/swe-arena/run-supervisor.mjs +297 -0
  253. package/src/swe-arena/run-supervisor.test.mts +498 -0
  254. package/src/swe-arena/serialized-judge.ts +414 -0
  255. package/src/swe-arena/types.ts +166 -0
  256. package/src/swe-code-improve.mts +328 -0
  257. package/src/swe-emit-patch.mts +104 -0
  258. package/src/swe-improve.mts +232 -0
  259. package/src/swe-jail.ts +2 -2
  260. package/src/swe-local-proof.mts +169 -0
  261. package/src/swe-repro-calibrate.mts +446 -0
  262. package/src/swe-stream.mts +1497 -0
@@ -0,0 +1,788 @@
1
+ /**
2
+ * ArmSpec execution — the typed port of the experiment's `solo.sh` and
3
+ * `sup4.sh`, plus the cost-recovery pipeline (`true_sup_spend.py` + the
4
+ * opencode sqlite join that produced worker-tokens.json).
5
+ *
6
+ * Secrets discipline: every model-touching child runs under
7
+ * `dotenvx run -f <env files> --` with cwd = the secrets dir, resolved at CALL
8
+ * time. This module handles env NAMES and file names only — never key values;
9
+ * keys are expanded inside the dotenvx child, not in this process.
10
+ *
11
+ * The supervisor arm WRAPS the real loops driver: the tracked
12
+ * `run-supervisor.mjs` shim registers the loops pi extension's actual tools
13
+ * and invokes `spawn_supervisor` — no supervisor logic is reimplemented here.
14
+ */
15
+
16
+ import { chmod, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
17
+ import { join, resolve } from 'node:path'
18
+ import { homedir } from 'node:os'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { run, runOk, type RunResult, shq } from './proc'
21
+ import { materializeWorkspace } from './materialize'
22
+ import type { ArmSpec, SoloUsage } from './types'
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Shared pieces: excludes, prompt suffix, patch extraction, dotenvx spawning.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const fixturesDir = fileURLToPath(new URL('./fixtures', import.meta.url))
29
+ /** The tracked driver shim (was untracked in the experiment scratchpad). */
30
+ export const runSupervisorShim = fileURLToPath(new URL('./run-supervisor.mjs', import.meta.url))
31
+
32
+ /**
33
+ * The exact worker prompt suffix from solo.sh — the SOLO arm gets the same
34
+ * closing instruction the supervisor's workers get, so prompt content is
35
+ * never a confound between arms.
36
+ */
37
+ export const WORKER_PROMPT_SUFFIX =
38
+ '\n\nWork only in your current working directory. Implement the change completely and correctly — do not stub, weaken, or special-case anything, and keep the repository\'s existing tests passing. An automated check verifies your work after you finish.'
39
+
40
+ /** Test-file exclude pathspecs (fixtures/excludes.txt, vendored from hh/excludes.txt). */
41
+ export async function loadExcludes(): Promise<string[]> {
42
+ const raw = await readFile(join(fixturesDir, 'excludes.txt'), 'utf8')
43
+ const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
44
+ if (lines.length === 0) throw new Error('fixtures/excludes.txt is empty')
45
+ return lines
46
+ }
47
+
48
+ const GIT_COMMAND_TIMEOUT_MS = 2 * 60_000
49
+
50
+ async function runArenaGitOk(argv: string[], signal?: AbortSignal): Promise<Awaited<ReturnType<typeof runOk>>> {
51
+ signal?.throwIfAborted()
52
+ const result = await runOk('git', argv, { signal, timeoutMs: GIT_COMMAND_TIMEOUT_MS })
53
+ signal?.throwIfAborted()
54
+ return result
55
+ }
56
+
57
+ /**
58
+ * Patch extraction — identical git invocation to solo.sh/sup4.sh: stage
59
+ * everything, diff the index against base_commit, excluding test files and the
60
+ * supervisor's own .loops state.
61
+ */
62
+ export async function extractPatch(
63
+ ws: string,
64
+ baseCommit: string,
65
+ excludes: string[],
66
+ signal?: AbortSignal,
67
+ ): Promise<string> {
68
+ await runArenaGitOk(['-C', ws, 'add', '-A'], signal)
69
+ const res = await runArenaGitOk([
70
+ '-C', ws,
71
+ 'diff', '--cached', baseCommit,
72
+ '--', '.',
73
+ ...excludes,
74
+ ':(exclude,glob).loops/**',
75
+ ':(exclude,glob)**/.loops/**',
76
+ ], signal)
77
+ return res.stdout
78
+ }
79
+
80
+ export interface SecretsEnv {
81
+ /** Directory dotenvx runs from (e.g. /home/drew/company/devops/secrets). */
82
+ secretsDir: string
83
+ /** Env file names passed as `-f` flags, resolved relative to secretsDir. */
84
+ envFiles: string[]
85
+ }
86
+
87
+ /** `cd <secretsDir> && dotenvx run -f … -- bash -c <script>` — the arms' env wrapper. */
88
+ export function dotenvxBash(
89
+ secrets: SecretsEnv,
90
+ script: string,
91
+ opts: { timeoutMs?: number; killGraceMs?: number; env?: NodeJS.ProcessEnv; signal?: AbortSignal } = {},
92
+ ): ReturnType<typeof run> {
93
+ const authBootstrap = [
94
+ 'if [ -n "${SWE_ARENA_OPENCODE_AUTH_FILE:-}" ] && [ -r "$SWE_ARENA_OPENCODE_AUTH_FILE" ]; then',
95
+ ' export OPENCODE_AUTH_CONTENT="$(cat -- "$SWE_ARENA_OPENCODE_AUTH_FILE")"',
96
+ 'fi',
97
+ ].join('\n')
98
+ const argv = [
99
+ 'run',
100
+ ...secrets.envFiles.flatMap((f) => ['-f', f]),
101
+ '--',
102
+ 'bash',
103
+ '-c',
104
+ `${authBootstrap}\n${script}`,
105
+ ]
106
+ return run('dotenvx', argv, {
107
+ cwd: secrets.secretsDir,
108
+ timeoutMs: opts.timeoutMs,
109
+ killGraceMs: opts.killGraceMs,
110
+ env: opts.env ?? process.env,
111
+ signal: opts.signal,
112
+ })
113
+ }
114
+
115
+ export interface IsolatedCellEnvironment {
116
+ env: NodeJS.ProcessEnv
117
+ opencodeDb: string
118
+ }
119
+
120
+ /** Give one cell private CLI state, caches, temp files, and Python installs. */
121
+ export async function prepareIsolatedCellEnvironment(
122
+ runDir: string,
123
+ baseEnv: NodeJS.ProcessEnv = process.env,
124
+ ): Promise<IsolatedCellEnvironment> {
125
+ const ambientHome = baseEnv.HOME?.trim() || homedir()
126
+ const cellRoot = resolve(runDir)
127
+ const home = join(cellRoot, 'home')
128
+ const xdgRoot = join(cellRoot, 'xdg')
129
+ const configHome = join(xdgRoot, 'config')
130
+ const dataHome = join(xdgRoot, 'data')
131
+ const cacheHome = join(xdgRoot, 'cache')
132
+ const stateHome = join(xdgRoot, 'state')
133
+ const runtimeDir = join(xdgRoot, 'runtime')
134
+ const tmp = join(cellRoot, 'tmp')
135
+ const pythonUserBase = join(cellRoot, 'python-user')
136
+ const opencodeConfigDir = join(configHome, 'opencode')
137
+ const opencodeConfig = join(opencodeConfigDir, 'opencode.json')
138
+ const opencodeDb = join(dataHome, 'opencode', 'opencode.db')
139
+ const managedRoots = [home, xdgRoot, tmp, pythonUserBase]
140
+ await Promise.all(managedRoots.map((dir) => rm(dir, { recursive: true, force: true })))
141
+
142
+ const privateDirs = [home, configHome, dataHome, cacheHome, stateHome, runtimeDir, tmp, pythonUserBase, opencodeConfigDir]
143
+ await Promise.all(privateDirs.map((dir) => mkdir(dir, { recursive: true })))
144
+ await Promise.all(privateDirs.map((dir) => chmod(dir, 0o700)))
145
+
146
+ const ambientConfigHome = baseEnv.XDG_CONFIG_HOME?.trim() || join(ambientHome, '.config')
147
+ const configCandidates = [
148
+ baseEnv.OPENCODE_CONFIG?.trim(),
149
+ join(ambientConfigHome, 'opencode', 'opencode.json'),
150
+ join(ambientConfigHome, 'opencode', 'opencode.jsonc'),
151
+ ].filter((path): path is string => Boolean(path))
152
+ let configText = baseEnv.OPENCODE_CONFIG_CONTENT
153
+ if (configText === undefined) {
154
+ for (const candidate of configCandidates) {
155
+ configText = await readFile(candidate, 'utf8').catch(() => undefined)
156
+ if (configText !== undefined) break
157
+ }
158
+ }
159
+ await writeFile(opencodeConfig, configText ?? '{}\n', { mode: 0o600 })
160
+
161
+ const env: NodeJS.ProcessEnv = {
162
+ ...baseEnv,
163
+ HOME: home,
164
+ XDG_CONFIG_HOME: configHome,
165
+ XDG_DATA_HOME: dataHome,
166
+ XDG_CACHE_HOME: cacheHome,
167
+ XDG_STATE_HOME: stateHome,
168
+ XDG_RUNTIME_DIR: runtimeDir,
169
+ TMPDIR: tmp,
170
+ OPENCODE_CONFIG_DIR: opencodeConfigDir,
171
+ OPENCODE_CONFIG: opencodeConfig,
172
+ OPENCODE_DB: opencodeDb,
173
+ SWE_ARENA_OPENCODE_AUTH_FILE:
174
+ baseEnv.SWE_ARENA_OPENCODE_AUTH_FILE?.trim() ||
175
+ join(baseEnv.XDG_DATA_HOME?.trim() || join(ambientHome, '.local', 'share'), 'opencode', 'auth.json'),
176
+ PYTHONUSERBASE: pythonUserBase,
177
+ }
178
+ delete env.OPENCODE_CONFIG_CONTENT
179
+ delete env.OPENCODE_AUTH_CONTENT
180
+ delete env.PYTHONHOME
181
+ delete env.PYTHONPATH
182
+ delete env.PYTHONNOUSERSITE
183
+ return { env, opencodeDb }
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Arm specs. `ArmSpec` (types.ts) is the declarative identity M1 pinned;
188
+ // these are the executable forms, convertible back via `toArmIdentity`.
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export interface SoloArmSpec {
192
+ kind: 'solo'
193
+ /** Ledger/run-dir label, e.g. 'SOLO'. */
194
+ name: string
195
+ /** opencode model id, e.g. 'zai-coding-plan/glm-5.2'. */
196
+ model: string
197
+ /** Appended to the problem statement. Default: the shared worker suffix. */
198
+ promptSuffix?: string
199
+ /** Whole-run ceiling. solo.sh used `timeout 1000` (s). */
200
+ timeoutMs?: number
201
+ }
202
+
203
+ export interface SupervisorArmSpec {
204
+ kind: 'supervisor'
205
+ /** Ledger/run-dir label, e.g. 'SUP4'. */
206
+ name: string
207
+ workerModel: string
208
+ driverModel: string
209
+ /** params.json knobs — defaults are sup4.sh's values. */
210
+ budget?: number
211
+ maxSandboxes?: number
212
+ maxUsd?: number
213
+ maxDepth?: number
214
+ /**
215
+ * Env knobs exported inside the dotenvx child. Defaults are sup4.sh's:
216
+ * WORKER_BACKEND=cli, LOOPS_WORKER_HARNESS=opencode, LOOPS_BRAIN_RETRIES=30,
217
+ * DRIVER_DEADLINE_MS=2600000. LOOPS_BRAIN_LOG is always run-dir-derived.
218
+ */
219
+ envKnobs?: Record<string, string>
220
+ /** The loops checkout the driver runs from. */
221
+ loopsRepo?: string
222
+ /** The pi extension entry. Default: <loopsRepo>/extensions/pi/loops.ts. */
223
+ extensionPath?: string
224
+ /** Whole-run ceiling. sup4.sh used `timeout 2800` (s). */
225
+ timeoutMs?: number
226
+ }
227
+
228
+ export type ExecutableArmSpec = SoloArmSpec | SupervisorArmSpec
229
+
230
+ export const DEFAULT_LOOPS_REPO = '/home/drew/code/loops'
231
+
232
+ export const SUP_DEFAULT_ENV_KNOBS: Record<string, string> = {
233
+ WORKER_BACKEND: 'cli',
234
+ LOOPS_WORKER_HARNESS: 'opencode',
235
+ LOOPS_BRAIN_RETRIES: '30',
236
+ DRIVER_DEADLINE_MS: '2600000',
237
+ }
238
+
239
+ export const DEFAULT_DRIVER_CANCEL_TIMEOUT_MS = 30_000
240
+ export const DRIVER_CANCELLATION_SETTLEMENT_BUFFER_MS = 10_000
241
+
242
+ /** Leave the driver more time than its own cancellation deadline to settle workers. */
243
+ export function supervisorDriverKillGraceMs(envKnobs: Record<string, string>): number {
244
+ const raw = envKnobs.DRIVER_CANCEL_TIMEOUT_MS
245
+ const cancelTimeoutMs = raw === undefined ? DEFAULT_DRIVER_CANCEL_TIMEOUT_MS : Number(raw)
246
+ if (!Number.isFinite(cancelTimeoutMs) || cancelTimeoutMs <= 0) {
247
+ throw new Error('DRIVER_CANCEL_TIMEOUT_MS must be a positive number')
248
+ }
249
+ return cancelTimeoutMs + DRIVER_CANCELLATION_SETTLEMENT_BUFFER_MS
250
+ }
251
+
252
+ export interface ArmProvenance {
253
+ repo: string
254
+ commit: string
255
+ envKnobs: Record<string, string>
256
+ }
257
+
258
+ /** Runtime provenance: which loops commit actually sat in the supervisor seat. */
259
+ export async function armProvenance(
260
+ loopsRepo: string,
261
+ envKnobs: Record<string, string>,
262
+ signal?: AbortSignal,
263
+ ): Promise<ArmProvenance> {
264
+ const head = await runArenaGitOk(['-C', loopsRepo, 'rev-parse', 'HEAD'], signal)
265
+ return { repo: loopsRepo, commit: head.stdout.trim(), envKnobs }
266
+ }
267
+
268
+ /** Collapse an executable spec to the M1 declarative identity for the record. */
269
+ export async function toArmIdentity(spec: ExecutableArmSpec): Promise<ArmSpec> {
270
+ if (spec.kind === 'solo') {
271
+ return {
272
+ name: spec.name,
273
+ kind: 'solo',
274
+ env: { model: spec.model },
275
+ provenance: { repo: 'opencode', commit: 'cli' },
276
+ }
277
+ }
278
+ const loopsRepo = spec.loopsRepo ?? DEFAULT_LOOPS_REPO
279
+ const envKnobs = { ...SUP_DEFAULT_ENV_KNOBS, ...spec.envKnobs }
280
+ const prov = await armProvenance(loopsRepo, envKnobs)
281
+ return {
282
+ name: spec.name,
283
+ kind: 'supervisor',
284
+ env: envKnobs,
285
+ provenance: { repo: prov.repo, commit: prov.commit },
286
+ }
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // opencode usage parse (oc_usage.py port).
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /** Sum opencode `--format json` stream token parts into the ledger's SoloUsage. */
294
+ export function parseOcUsage(jsonl: string): SoloUsage {
295
+ let steps = 0
296
+ let inp = 0
297
+ let out = 0
298
+ let rea = 0
299
+ let cw = 0
300
+ let cr = 0
301
+ let maxTot = 0
302
+ let cost = 0
303
+ for (const rawLine of jsonl.split('\n')) {
304
+ const line = rawLine.trim()
305
+ if (!line.startsWith('{')) continue
306
+ let o: unknown
307
+ try {
308
+ o = JSON.parse(line)
309
+ } catch {
310
+ continue
311
+ }
312
+ const part = (o as { part?: unknown }).part
313
+ if (typeof part !== 'object' || part === null) continue
314
+ const tk = (part as { tokens?: unknown }).tokens
315
+ if (typeof tk !== 'object' || tk === null) continue
316
+ const t = tk as Record<string, unknown>
317
+ const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
318
+ steps += 1
319
+ inp += num(t.input)
320
+ out += num(t.output)
321
+ rea += num(t.reasoning)
322
+ const cache = (t.cache ?? {}) as Record<string, unknown>
323
+ cw += num(cache.write)
324
+ cr += num(cache.read)
325
+ maxTot = Math.max(maxTot, num(t.total))
326
+ cost += num((part as Record<string, unknown>).cost)
327
+ }
328
+ return {
329
+ steps,
330
+ in: inp,
331
+ out,
332
+ reasoning: rea,
333
+ cache_w: cw,
334
+ cache_r: cr,
335
+ max_ctx: maxTot,
336
+ oc_cost: Number(cost.toFixed(6)),
337
+ total_io: inp + out + rea,
338
+ }
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Arm execution.
343
+ // ---------------------------------------------------------------------------
344
+
345
+ export interface ArmRunContext {
346
+ instanceId: string
347
+ image: string
348
+ baseCommit: string
349
+ problemStatement: string
350
+ /** Self-repro verify command (bash -c, cwd = ws) — the MEASUREMENT gate. */
351
+ verifyCmd: string
352
+ /** Root for runs/<iid>/<ARM>/ and patches/. */
353
+ outDir: string
354
+ secrets: SecretsEnv
355
+ excludes: string[]
356
+ signal?: AbortSignal
357
+ }
358
+
359
+ export interface SoloArmResult {
360
+ arm: string
361
+ iid: string
362
+ oc_rc: number
363
+ wall_s: number
364
+ patch_lines: number
365
+ verify_rc: number
366
+ verify_pass: boolean
367
+ usage: SoloUsage
368
+ patchPath: string
369
+ ws: string
370
+ }
371
+
372
+ export interface SupervisorArmResult {
373
+ arm: string
374
+ iid: string
375
+ driver_rc: number
376
+ wall_s: number
377
+ patch_lines: number
378
+ verify_rc: number
379
+ verify_pass: boolean
380
+ sup_status: string | null
381
+ sup_verdict: string | null
382
+ delivered: boolean | null
383
+ spentTokens: number | null
384
+ spentUsd: number | null
385
+ spawned: number
386
+ workers: number
387
+ settled: number
388
+ subtasks: string[]
389
+ patchPath: string
390
+ ws: string
391
+ provenance: ArmProvenance
392
+ recoveredSpend: SupSpend | null
393
+ }
394
+
395
+ const patchLineCount = (patch: string): number => (patch.length === 0 ? 0 : patch.split('\n').length - (patch.endsWith('\n') ? 1 : 0))
396
+
397
+ type ProcessCompletion = Pick<RunResult, 'code' | 'timedOut' | 'aborted'>
398
+
399
+ /** Reject a solo process outcome before its partial workspace can be scored. */
400
+ export function assertSoloProcessCompleted(result: ProcessCompletion, label = 'runSoloArm'): void {
401
+ if (result.timedOut) {
402
+ throw new Error(`${label}: opencode timed out (exit ${result.code})`)
403
+ }
404
+ if (result.aborted) {
405
+ throw new Error(`${label}: opencode was cancelled (exit ${result.code})`)
406
+ }
407
+ if (result.code !== 0) {
408
+ throw new Error(`${label}: opencode exited ${result.code}`)
409
+ }
410
+ }
411
+
412
+ /** Reject a driver or unfinished supervisor state before its partial workspace can be scored. */
413
+ export function assertSupervisorProcessCompleted(
414
+ driver: ProcessCompletion,
415
+ status: string | null,
416
+ label = 'runSupervisorArm',
417
+ ): void {
418
+ const state = status ?? 'missing'
419
+ if (driver.timedOut) {
420
+ throw new Error(`${label}: driver timed out (exit ${driver.code}, supervisor state ${state})`)
421
+ }
422
+ if (driver.aborted) {
423
+ throw new Error(`${label}: driver was cancelled (exit ${driver.code}, supervisor state ${state})`)
424
+ }
425
+ if (driver.code !== 0) {
426
+ throw new Error(`${label}: driver exited ${driver.code} (supervisor state ${state})`)
427
+ }
428
+ if (status !== 'completed') {
429
+ throw new Error(`${label}: supervisor state ${state}; expected completed`)
430
+ }
431
+ }
432
+
433
+ async function verifyWorkspace(
434
+ verifyCmd: string,
435
+ ws: string,
436
+ logPath: string,
437
+ env: NodeJS.ProcessEnv,
438
+ signal?: AbortSignal,
439
+ ): Promise<number> {
440
+ const res = await run('bash', ['-c', verifyCmd], { cwd: ws, timeoutMs: 600_000, env, signal })
441
+ await writeFile(logPath, res.stdout + res.stderr)
442
+ return res.code
443
+ }
444
+
445
+ /** solo.sh port: materialize → prompt → opencode run → patch extract → verify. */
446
+ export async function runSoloArm(spec: SoloArmSpec, ctx: ArmRunContext): Promise<SoloArmResult> {
447
+ const runDir = join(ctx.outDir, 'runs', ctx.instanceId, spec.name)
448
+ const ws = join(runDir, 'ws')
449
+ await mkdir(runDir, { recursive: true })
450
+ const cell = await prepareIsolatedCellEnvironment(runDir)
451
+ await materializeWorkspace({
452
+ instanceId: ctx.instanceId,
453
+ image: ctx.image,
454
+ baseCommit: ctx.baseCommit,
455
+ dest: ws,
456
+ signal: ctx.signal,
457
+ })
458
+
459
+ const promptFile = join(runDir, 'prompt.txt')
460
+ await writeFile(promptFile, ctx.problemStatement + (spec.promptSuffix ?? WORKER_PROMPT_SUFFIX))
461
+
462
+ const t0 = Date.now()
463
+ const oc = await dotenvxBash(
464
+ ctx.secrets,
465
+ `cd ${shq(ws)} && opencode run "$(cat ${shq(promptFile)})" -m ${shq(spec.model)} --format json`,
466
+ { timeoutMs: spec.timeoutMs ?? 1_000_000, env: cell.env, signal: ctx.signal },
467
+ )
468
+ const wall_s = Math.round((Date.now() - t0) / 1000)
469
+ await writeFile(join(runDir, 'oc.jsonl'), oc.stdout)
470
+ await writeFile(join(runDir, 'oc.err'), oc.stderr)
471
+ assertSoloProcessCompleted(oc, `runSoloArm ${ctx.instanceId}/${spec.name}`)
472
+ if (ctx.signal?.aborted) throw ctx.signal.reason
473
+
474
+ const patch = await extractPatch(ws, ctx.baseCommit, ctx.excludes, ctx.signal)
475
+ const patchPath = join(ctx.outDir, 'patches', `${ctx.instanceId}.${spec.name.toLowerCase()}.patch`)
476
+ await mkdir(join(ctx.outDir, 'patches'), { recursive: true })
477
+ await writeFile(patchPath, patch)
478
+
479
+ const verify_rc = await verifyWorkspace(ctx.verifyCmd, ws, join(runDir, 'verify.log'), cell.env, ctx.signal)
480
+ return {
481
+ arm: spec.name,
482
+ iid: ctx.instanceId,
483
+ oc_rc: oc.code,
484
+ wall_s,
485
+ patch_lines: patchLineCount(patch),
486
+ verify_rc,
487
+ verify_pass: verify_rc === 0,
488
+ usage: parseOcUsage(oc.stdout),
489
+ patchPath,
490
+ ws,
491
+ }
492
+ }
493
+
494
+ interface SupervisorRunArtifacts {
495
+ status: string | null
496
+ verdict: string | null
497
+ delivered: boolean | null
498
+ spentTokens: number | null
499
+ spentUsd: number | null
500
+ spawned: number
501
+ workers: number
502
+ settled: number
503
+ subtasks: string[]
504
+ supRunDir: string | null
505
+ }
506
+
507
+ /** Locate the (single) supervisor run dir under <ws>/.loops/supervisor. */
508
+ export async function findSupervisorRunDir(ws: string): Promise<string | null> {
509
+ const root = join(ws, '.loops', 'supervisor')
510
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
511
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(root, e.name))
512
+ return dirs[0] ?? null
513
+ }
514
+
515
+ /** sup4.sh's state.json + journal.jsonl summary (spawned/settled/workers/goals). */
516
+ export async function parseSupervisorArtifacts(ws: string): Promise<SupervisorRunArtifacts> {
517
+ const supRunDir = await findSupervisorRunDir(ws)
518
+ let status: string | null = null
519
+ let verdict: string | null = null
520
+ let delivered: boolean | null = null
521
+ let spentTokens: number | null = null
522
+ let spentUsd: number | null = null
523
+ let spawned = 0
524
+ let workers = 0
525
+ let settled = 0
526
+ const subtasks: string[] = []
527
+ if (supRunDir) {
528
+ let state: Record<string, unknown> = {}
529
+ try {
530
+ state = JSON.parse(await readFile(join(supRunDir, 'state.json'), 'utf8')) as Record<string, unknown>
531
+ } catch {
532
+ // Absent/corrupt state.json (driver killed mid-write) = all-null summary.
533
+ }
534
+ status = typeof state.status === 'string' ? state.status : null
535
+ verdict = typeof state.verdict === 'string' ? state.verdict : null
536
+ const result = (state.result ?? {}) as Record<string, unknown>
537
+ delivered = typeof result.delivered === 'boolean' ? result.delivered : null
538
+ spentTokens = typeof result.spentTokens === 'number' ? result.spentTokens : null
539
+ spentUsd = typeof result.spentUsd === 'number' ? result.spentUsd : null
540
+ const journal = await readFile(join(supRunDir, 'journal.jsonl'), 'utf8').catch(() => '')
541
+ for (const line of journal.split('\n')) {
542
+ if (!line.trim()) continue
543
+ let o: Record<string, unknown>
544
+ try {
545
+ o = JSON.parse(line) as Record<string, unknown>
546
+ } catch {
547
+ continue
548
+ }
549
+ const kind = o.kind
550
+ const label = typeof o.label === 'string' ? o.label : ''
551
+ if (kind === 'spawned') {
552
+ spawned += 1
553
+ if (label && label !== 'root') {
554
+ workers += 1
555
+ subtasks.push(label.slice(0, 100))
556
+ }
557
+ } else if (kind === 'settled') {
558
+ settled += 1
559
+ }
560
+ }
561
+ }
562
+ return { status, verdict, delivered, spentTokens, spentUsd, spawned, workers, settled, subtasks, supRunDir }
563
+ }
564
+
565
+ /**
566
+ * sup4.sh port: materialize → params.json → loops driver via the tracked shim
567
+ * (env knobs incl. LOOPS_BRAIN_RETRIES / DRIVER_DEADLINE_MS) → patch extract →
568
+ * verify → cost recovery. The driver runs with cwd = the loops repo, exactly
569
+ * like the bash (`cd /home/drew/code/loops && node --import tsx …`).
570
+ */
571
+ export async function runSupervisorArm(spec: SupervisorArmSpec, ctx: ArmRunContext): Promise<SupervisorArmResult> {
572
+ const loopsRepo = spec.loopsRepo ?? DEFAULT_LOOPS_REPO
573
+ const extensionPath = spec.extensionPath ?? join(loopsRepo, 'extensions', 'pi', 'loops.ts')
574
+ const envKnobs = { ...SUP_DEFAULT_ENV_KNOBS, ...spec.envKnobs }
575
+ const provenance = await armProvenance(loopsRepo, envKnobs, ctx.signal)
576
+
577
+ const runDir = join(ctx.outDir, 'runs', ctx.instanceId, spec.name)
578
+ const ws = join(runDir, 'ws')
579
+ await mkdir(runDir, { recursive: true })
580
+ const cell = await prepareIsolatedCellEnvironment(runDir)
581
+ await materializeWorkspace({
582
+ instanceId: ctx.instanceId,
583
+ image: ctx.image,
584
+ baseCommit: ctx.baseCommit,
585
+ dest: ws,
586
+ signal: ctx.signal,
587
+ })
588
+
589
+ const paramsFile = join(runDir, 'params.json')
590
+ await writeFile(
591
+ paramsFile,
592
+ JSON.stringify(
593
+ {
594
+ task: ctx.problemStatement,
595
+ workspaceDir: ws,
596
+ budget: spec.budget ?? 40,
597
+ verifyCmd: ctx.verifyCmd,
598
+ workerModel: spec.workerModel,
599
+ driverModel: spec.driverModel,
600
+ maxSandboxes: spec.maxSandboxes ?? 4,
601
+ maxUsd: spec.maxUsd ?? 8,
602
+ maxDepth: spec.maxDepth ?? 3,
603
+ },
604
+ null,
605
+ 1,
606
+ ),
607
+ )
608
+ await rm(join(ws, '.loops'), { recursive: true, force: true })
609
+
610
+ const knobExports = Object.entries({ ...envKnobs, LOOPS_BRAIN_LOG: join(runDir, 'brain.jsonl') })
611
+ .map(([k, v]) => {
612
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(k)) throw new Error(`invalid env knob name: ${k}`)
613
+ return `export ${k}=${shq(v)}`
614
+ })
615
+ .join('\n')
616
+ const script = [
617
+ knobExports,
618
+ `cd ${shq(loopsRepo)}`,
619
+ `node --import tsx ${shq(runSupervisorShim)} ${shq(extensionPath)} ${shq(ws)} ${shq(paramsFile)}`,
620
+ ].join('\n')
621
+
622
+ const t0 = Date.now()
623
+ const driver = await dotenvxBash(ctx.secrets, script, {
624
+ timeoutMs: spec.timeoutMs ?? 2_800_000,
625
+ killGraceMs: supervisorDriverKillGraceMs(envKnobs),
626
+ env: cell.env,
627
+ signal: ctx.signal,
628
+ })
629
+ const wall_s = Math.round((Date.now() - t0) / 1000)
630
+ await writeFile(join(runDir, 'driver.log'), driver.stdout + driver.stderr)
631
+
632
+ const artifacts = await parseSupervisorArtifacts(ws)
633
+ assertSupervisorProcessCompleted(
634
+ driver,
635
+ artifacts.status,
636
+ `runSupervisorArm ${ctx.instanceId}/${spec.name}`,
637
+ )
638
+ if (ctx.signal?.aborted) throw ctx.signal.reason
639
+
640
+ const patch = await extractPatch(ws, ctx.baseCommit, ctx.excludes, ctx.signal)
641
+ const patchPath = join(ctx.outDir, 'patches', `${ctx.instanceId}.${spec.name.toLowerCase()}.patch`)
642
+ await mkdir(join(ctx.outDir, 'patches'), { recursive: true })
643
+ await writeFile(patchPath, patch)
644
+
645
+ const verify_rc = await verifyWorkspace(ctx.verifyCmd, ws, join(runDir, 'verify.log'), cell.env, ctx.signal)
646
+
647
+ const recoveredSpend = artifacts.supRunDir
648
+ ? await recoverSupSpend(artifacts.supRunDir, { opencodeDb: cell.opencodeDb }).catch(() => null)
649
+ : null
650
+
651
+ return {
652
+ arm: spec.name,
653
+ iid: ctx.instanceId,
654
+ driver_rc: driver.code,
655
+ wall_s,
656
+ patch_lines: patchLineCount(patch),
657
+ verify_rc,
658
+ verify_pass: verify_rc === 0,
659
+ sup_status: artifacts.status,
660
+ sup_verdict: artifacts.verdict,
661
+ delivered: artifacts.delivered,
662
+ spentTokens: artifacts.spentTokens,
663
+ spentUsd: artifacts.spentUsd,
664
+ spawned: artifacts.spawned,
665
+ workers: artifacts.workers,
666
+ settled: artifacts.settled,
667
+ subtasks: artifacts.subtasks,
668
+ patchPath,
669
+ ws,
670
+ provenance,
671
+ recoveredSpend,
672
+ }
673
+ }
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // Cost recovery — true_sup_spend.py port + the opencode sqlite worker join.
677
+ // ---------------------------------------------------------------------------
678
+
679
+ export interface SupSpend {
680
+ /** Worker tokens the journal itself captured (`settled` spend) — often 0. */
681
+ workerTokJournal: number
682
+ /** Worker clone cwds found in workers/*.ndjson `started` events. */
683
+ workerCwds: string[]
684
+ /**
685
+ * Worker-session spend recovered from the opencode sqlite store by joining
686
+ * ndjson cwd → session.directory (the provenance of worker-tokens.json).
687
+ * `null` = the store was unavailable — a telemetry gap, never a zero.
688
+ * `workerTokIn`/`workerTokOut` split the same sessions so the campaign
689
+ * CostLedger receipt carries a real input/output usage, not a lump.
690
+ */
691
+ workerSessions: number | null
692
+ workerTokSqlite: number | null
693
+ workerTokIn: number | null
694
+ workerTokOut: number | null
695
+ }
696
+
697
+ export const DEFAULT_OPENCODE_DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
698
+
699
+ /**
700
+ * Recover the WORKER-side SUP-arm spend from a supervisor run dir
701
+ * (<ws>/.loops/supervisor/<id>): ndjson settled spend where captured, plus the
702
+ * opencode sqlite session totals for each worker clone cwd. CLI-backend worker
703
+ * sessions never meter into the journal, so the sqlite join is genuinely
704
+ * custom recovery. Brain/driver spend is NO LONGER re-parsed from journal
705
+ * `metered` events: the supervise runtime's spend tree now reaches state.json
706
+ * on BOTH result arms (loops extensions/pi/loops.ts writes
707
+ * `result.spentTokens`/`spentUsd` from `SupervisedResult.spentTotal`, winner
708
+ * AND no-winner), so `parseSupervisorArtifacts` already carries it — the old
709
+ * no-winner zeroing this parsing compensated for is fixed upstream.
710
+ */
711
+ export async function recoverSupSpend(
712
+ supRunDir: string,
713
+ opts: { opencodeDb?: string } = {},
714
+ ): Promise<SupSpend> {
715
+ let workerTokJournal = 0
716
+ const workerCwds: string[] = []
717
+ const workersDir = join(supRunDir, 'workers')
718
+ const workerFiles = (await readdir(workersDir).catch(() => [])).filter((f) => f.endsWith('.ndjson'))
719
+ for (const f of workerFiles) {
720
+ const nd = await readFile(join(workersDir, f), 'utf8').catch(() => '')
721
+ for (const line of nd.split('\n')) {
722
+ if (!line.trim()) continue
723
+ let o: Record<string, unknown>
724
+ try {
725
+ o = JSON.parse(line) as Record<string, unknown>
726
+ } catch {
727
+ continue
728
+ }
729
+ if (o.kind === 'settled') {
730
+ const tokens = ((o.spend ?? {}) as Record<string, unknown>).tokens as Record<string, unknown> | undefined
731
+ workerTokJournal +=
732
+ (typeof tokens?.input === 'number' ? tokens.input : 0) +
733
+ (typeof tokens?.output === 'number' ? tokens.output : 0)
734
+ } else if (o.kind === 'started' && typeof o.cwd === 'string') {
735
+ workerCwds.push(o.cwd)
736
+ }
737
+ }
738
+ }
739
+
740
+ let workerSessions: number | null = null
741
+ let workerTokSqlite: number | null = null
742
+ let workerTokIn: number | null = null
743
+ let workerTokOut: number | null = null
744
+ if (workerCwds.length > 0) {
745
+ try {
746
+ // Deferred import: node:sqlite is only needed on the cost-recovery path.
747
+ const { DatabaseSync } = await import('node:sqlite')
748
+ const db = new DatabaseSync(opts.opencodeDb ?? DEFAULT_OPENCODE_DB, { readOnly: true })
749
+ try {
750
+ const placeholders = workerCwds.map(() => '?').join(',')
751
+ const row = db
752
+ .prepare(
753
+ `SELECT COUNT(*) AS n,
754
+ COALESCE(SUM(tokens_input), 0) AS tin,
755
+ COALESCE(SUM(tokens_output + tokens_reasoning), 0) AS tout
756
+ FROM session WHERE directory IN (${placeholders})`,
757
+ )
758
+ .get(...workerCwds) as { n: number | bigint; tin: number | bigint; tout: number | bigint }
759
+ workerSessions = Number(row.n)
760
+ workerTokIn = Number(row.tin)
761
+ workerTokOut = Number(row.tout)
762
+ workerTokSqlite = workerTokIn + workerTokOut
763
+ } finally {
764
+ db.close()
765
+ }
766
+ } catch {
767
+ // Store unavailable/corrupt → telemetry gap (null), never a silent 0.
768
+ workerSessions = null
769
+ workerTokSqlite = null
770
+ workerTokIn = null
771
+ workerTokOut = null
772
+ }
773
+ } else {
774
+ workerSessions = 0
775
+ workerTokSqlite = 0
776
+ workerTokIn = 0
777
+ workerTokOut = 0
778
+ }
779
+
780
+ return {
781
+ workerTokJournal,
782
+ workerCwds,
783
+ workerSessions,
784
+ workerTokSqlite,
785
+ workerTokIn,
786
+ workerTokOut,
787
+ }
788
+ }