@asrunjs/asrun 0.0.0-stage → 0.2.0

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 (286) hide show
  1. package/.claude/commands/explore-case.md +15 -0
  2. package/.claude/commands/gen-spec.md +13 -0
  3. package/.claude/commands/heal-spec.md +23 -0
  4. package/.claude/commands/make-case.md +8 -0
  5. package/.claude/settings.json +5 -0
  6. package/.claude/skills/exploring-cases/SKILL.md +132 -0
  7. package/.claude/skills/exploring-cases/references/assertion-targets.md +103 -0
  8. package/.claude/skills/exploring-cases/references/handoff-card.js +128 -0
  9. package/.claude/skills/exploring-cases/references/open-questions.md +112 -0
  10. package/.claude/skills/exploring-cases/references/readiness.md +137 -0
  11. package/.claude/skills/exploring-cases/references/user-handoff.md +147 -0
  12. package/.claude/skills/generating-specs/SKILL.md +207 -0
  13. package/.claude/skills/generating-specs/references/conventions.md +148 -0
  14. package/.claude/skills/making-cases/SKILL.md +64 -0
  15. package/.claude/skills/making-cases/references/gaps.md +36 -0
  16. package/.claude/skills/making-cases/references/normalizing.md +38 -0
  17. package/.claude/skills/making-cases/references/splitting.md +28 -0
  18. package/.claude/skills/making-cases/references/writes-and-email.md +36 -0
  19. package/AGENTS.md +154 -0
  20. package/CLAUDE.md +1 -0
  21. package/LICENSE +21 -0
  22. package/README.md +410 -3
  23. package/bin/asrun.mjs +49 -0
  24. package/dist/lib/account-lock.d.ts +6 -0
  25. package/dist/lib/account-lock.js +52 -0
  26. package/dist/lib/assertions.d.ts +14 -0
  27. package/dist/lib/assertions.js +39 -0
  28. package/dist/lib/baselines.d.ts +43 -0
  29. package/dist/lib/baselines.js +108 -0
  30. package/dist/lib/case-id.d.ts +11 -0
  31. package/dist/lib/case-id.js +17 -0
  32. package/dist/lib/case-ops.d.ts +26 -0
  33. package/dist/lib/case-ops.js +110 -0
  34. package/dist/lib/cases.d.ts +29 -0
  35. package/dist/lib/cases.js +124 -0
  36. package/dist/lib/cli.d.ts +10 -0
  37. package/dist/lib/cli.js +18 -0
  38. package/dist/lib/downloads.d.ts +42 -0
  39. package/dist/lib/downloads.js +84 -0
  40. package/dist/lib/email.d.ts +10 -0
  41. package/dist/lib/email.js +19 -0
  42. package/dist/lib/ffmpeg.d.ts +8 -0
  43. package/dist/lib/ffmpeg.js +50 -0
  44. package/dist/lib/fixtures.d.ts +40 -0
  45. package/dist/lib/fixtures.js +149 -0
  46. package/dist/lib/node-cli.d.ts +11 -0
  47. package/dist/lib/node-cli.js +26 -0
  48. package/dist/lib/paths.d.ts +25 -0
  49. package/dist/lib/paths.js +88 -0
  50. package/dist/lib/pdf.d.ts +71 -0
  51. package/dist/lib/pdf.js +140 -0
  52. package/dist/lib/profiles.d.ts +38 -0
  53. package/dist/lib/profiles.js +121 -0
  54. package/dist/lib/ready.d.ts +36 -0
  55. package/dist/lib/ready.js +53 -0
  56. package/dist/lib/records.d.ts +33 -0
  57. package/dist/lib/records.js +61 -0
  58. package/dist/lib/run-filters.d.ts +19 -0
  59. package/dist/lib/run-filters.js +30 -0
  60. package/dist/lib/run-summary.d.ts +90 -0
  61. package/dist/lib/run-summary.js +35 -0
  62. package/dist/lib/session.d.ts +18 -0
  63. package/dist/lib/session.js +45 -0
  64. package/dist/lib/summary-reporter.d.ts +16 -0
  65. package/dist/lib/summary-reporter.js +146 -0
  66. package/dist/lib/targets.d.ts +27 -0
  67. package/dist/lib/targets.js +51 -0
  68. package/dist/lib/ui-build.d.ts +10 -0
  69. package/dist/lib/ui-build.js +30 -0
  70. package/dist/lib/values.d.ts +29 -0
  71. package/dist/lib/values.js +85 -0
  72. package/dist/lib/write-gate.d.ts +41 -0
  73. package/dist/lib/write-gate.js +129 -0
  74. package/dist/playwright.config.d.ts +2 -0
  75. package/dist/playwright.config.js +92 -0
  76. package/dist/scripts/build.d.ts +1 -0
  77. package/dist/scripts/build.js +20 -0
  78. package/dist/scripts/check-cases.d.ts +1 -0
  79. package/dist/scripts/check-cases.js +81 -0
  80. package/dist/scripts/cleanup.d.ts +1 -0
  81. package/dist/scripts/cleanup.js +25 -0
  82. package/dist/scripts/cli.d.ts +1 -0
  83. package/dist/scripts/cli.js +28 -0
  84. package/dist/scripts/doctor.d.ts +1 -0
  85. package/dist/scripts/doctor.js +185 -0
  86. package/dist/scripts/explore.d.ts +1 -0
  87. package/dist/scripts/explore.js +54 -0
  88. package/dist/scripts/go.mjs +188 -0
  89. package/dist/scripts/heal-evidence.mjs +143 -0
  90. package/dist/scripts/init.d.ts +16 -0
  91. package/dist/scripts/init.js +284 -0
  92. package/dist/scripts/lint-specs.d.ts +1 -0
  93. package/dist/scripts/lint-specs.js +138 -0
  94. package/dist/scripts/login.d.ts +1 -0
  95. package/dist/scripts/login.js +47 -0
  96. package/dist/scripts/new-case.d.ts +1 -0
  97. package/dist/scripts/new-case.js +19 -0
  98. package/dist/scripts/render-pdf.d.ts +1 -0
  99. package/dist/scripts/render-pdf.js +44 -0
  100. package/dist/scripts/report.d.ts +1 -0
  101. package/dist/scripts/report.js +14 -0
  102. package/dist/scripts/review-baselines.d.ts +1 -0
  103. package/dist/scripts/review-baselines.js +30 -0
  104. package/dist/scripts/run.d.ts +1 -0
  105. package/dist/scripts/run.js +423 -0
  106. package/dist/scripts/status.d.ts +1 -0
  107. package/dist/scripts/status.js +31 -0
  108. package/dist/scripts/ui-build.d.ts +1 -0
  109. package/dist/scripts/ui-build.js +7 -0
  110. package/dist/scripts/ui-dev.d.ts +1 -0
  111. package/dist/scripts/ui-dev.js +3 -0
  112. package/dist/scripts/ui.d.ts +1 -0
  113. package/dist/scripts/ui.js +13 -0
  114. package/dist/ui/dev.d.ts +1 -0
  115. package/dist/ui/dev.js +36 -0
  116. package/dist/ui/server/actions.d.ts +91 -0
  117. package/dist/ui/server/actions.js +352 -0
  118. package/dist/ui/server/ai-stream.d.ts +41 -0
  119. package/dist/ui/server/ai-stream.js +202 -0
  120. package/dist/ui/server/app.d.ts +4 -0
  121. package/dist/ui/server/app.js +333 -0
  122. package/dist/ui/server/cases.d.ts +23 -0
  123. package/dist/ui/server/cases.js +170 -0
  124. package/dist/ui/server/files.d.ts +5 -0
  125. package/dist/ui/server/files.js +67 -0
  126. package/dist/ui/server/index.d.ts +1 -0
  127. package/dist/ui/server/index.js +33 -0
  128. package/dist/ui/server/jobs.d.ts +74 -0
  129. package/dist/ui/server/jobs.js +263 -0
  130. package/dist/ui/server/profiles.d.ts +16 -0
  131. package/dist/ui/server/profiles.js +195 -0
  132. package/dist/ui/server/watch-files.d.ts +4 -0
  133. package/dist/ui/server/watch-files.js +36 -0
  134. package/dist/ui/shared/ai-commands.d.ts +78 -0
  135. package/dist/ui/shared/ai-commands.js +154 -0
  136. package/dist/ui/shared/case-id.d.ts +1 -0
  137. package/dist/ui/shared/case-id.js +1 -0
  138. package/dist/ui/shared/jobs.d.ts +6 -0
  139. package/dist/ui/shared/jobs.js +7 -0
  140. package/dist/ui/shared/product.d.ts +3 -0
  141. package/dist/ui/shared/product.js +3 -0
  142. package/dist/ui/shared/run-filters.d.ts +1 -0
  143. package/dist/ui/shared/run-filters.js +1 -0
  144. package/dist/ui/shared/site-address.d.ts +6 -0
  145. package/dist/ui/shared/site-address.js +11 -0
  146. package/dist/ui/shared/types.d.ts +257 -0
  147. package/dist/ui/shared/types.js +1 -0
  148. package/docs/theme-guide.html +692 -0
  149. package/package.json +98 -3
  150. package/templates/.claude/commands/explore-case.md +21 -0
  151. package/templates/.claude/commands/gen-spec.md +20 -0
  152. package/templates/.claude/commands/heal-spec.md +30 -0
  153. package/templates/.claude/commands/make-case.md +12 -0
  154. package/templates/.claude/settings.json +5 -0
  155. package/templates/.mcp.json +8 -0
  156. package/templates/AGENTS.md +45 -0
  157. package/templates/CLAUDE.md +1 -0
  158. package/templates/cases/_template.md +63 -0
  159. package/templates/gitignore +13 -0
  160. package/templates/opencode.jsonc +11 -0
  161. package/templates/package.json +31 -0
  162. package/templates/playwright.config.ts +3 -0
  163. package/templates/pnpm-workspace.yaml +5 -0
  164. package/templates/profiles.yaml +29 -0
  165. package/templates/seed.spec.ts +9 -0
  166. package/templates/tsconfig.json +14 -0
  167. package/ui/dist/apple-touch-icon.png +0 -0
  168. package/ui/dist/assets/apl-gfMMiTNf.js +1 -0
  169. package/ui/dist/assets/asciiarmor-C_eisQCp.js +1 -0
  170. package/ui/dist/assets/asn1-DCl_8MCW.js +1 -0
  171. package/ui/dist/assets/asterisk-BEn0Cwpx.js +1 -0
  172. package/ui/dist/assets/brainfuck-D5EjA2JK.js +1 -0
  173. package/ui/dist/assets/clike-xvONpD0Z.js +1 -0
  174. package/ui/dist/assets/clojure-BflJGmDX.js +1 -0
  175. package/ui/dist/assets/cmake-84nbDH0I.js +1 -0
  176. package/ui/dist/assets/cobol-DHLQUbN7.js +1 -0
  177. package/ui/dist/assets/coffeescript-De_zI4J0.js +1 -0
  178. package/ui/dist/assets/commonlisp-DgAKBe0r.js +1 -0
  179. package/ui/dist/assets/crystal-Bl6YKCG_.js +1 -0
  180. package/ui/dist/assets/css-CnaYoQib.js +1 -0
  181. package/ui/dist/assets/cypher-p9eYesGn.js +1 -0
  182. package/ui/dist/assets/d-DWkQyNnU.js +1 -0
  183. package/ui/dist/assets/diff-ChtP43wD.js +1 -0
  184. package/ui/dist/assets/dist-BHaIOZdW.js +1 -0
  185. package/ui/dist/assets/dist-BSJMM_jp.js +1 -0
  186. package/ui/dist/assets/dist-BiE4suo9.js +1 -0
  187. package/ui/dist/assets/dist-BqAROxzI.js +1 -0
  188. package/ui/dist/assets/dist-Bw5-uxs9.js +1 -0
  189. package/ui/dist/assets/dist-BwYRbe5s.js +9 -0
  190. package/ui/dist/assets/dist-BzulKuec.js +1 -0
  191. package/ui/dist/assets/dist-CBV7_vDN.js +2 -0
  192. package/ui/dist/assets/dist-CgIQYY1o.js +1 -0
  193. package/ui/dist/assets/dist-DDJNvivM.js +1 -0
  194. package/ui/dist/assets/dist-DF3F_I8N.js +1 -0
  195. package/ui/dist/assets/dist-DHCY9KpV.js +1 -0
  196. package/ui/dist/assets/dist-DX8knLVr.js +1 -0
  197. package/ui/dist/assets/dist-D_I45xgJ.js +11 -0
  198. package/ui/dist/assets/dist-Dlorc3Sx.js +1 -0
  199. package/ui/dist/assets/dist-Dr9crbBz.js +13 -0
  200. package/ui/dist/assets/dist-gF2bFn-5.js +1 -0
  201. package/ui/dist/assets/dockerfile-DJkHQgkl.js +1 -0
  202. package/ui/dist/assets/dtd-DTF72YNO.js +1 -0
  203. package/ui/dist/assets/dylan-S7ucR4pR.js +1 -0
  204. package/ui/dist/assets/ebnf-D4c0_ac3.js +1 -0
  205. package/ui/dist/assets/ecl-BVIeWmwY.js +1 -0
  206. package/ui/dist/assets/eiffel-BEjRio4Q.js +1 -0
  207. package/ui/dist/assets/elm-Cshzl8qu.js +1 -0
  208. package/ui/dist/assets/erlang-BV0c4Hdn.js +1 -0
  209. package/ui/dist/assets/factor-LKH5gN8L.js +1 -0
  210. package/ui/dist/assets/fcl-ClOhbFR7.js +1 -0
  211. package/ui/dist/assets/forth-D6RzAl64.js +1 -0
  212. package/ui/dist/assets/fortran-Bt6PBEDR.js +1 -0
  213. package/ui/dist/assets/gas-BHEdbvp9.js +1 -0
  214. package/ui/dist/assets/gherkin-oBAE_ms0.js +1 -0
  215. package/ui/dist/assets/groovy-CUrWKPia.js +1 -0
  216. package/ui/dist/assets/haskell-BRsoo5mP.js +1 -0
  217. package/ui/dist/assets/haxe-DjVOVvNP.js +1 -0
  218. package/ui/dist/assets/http-CLVgA2GD.js +1 -0
  219. package/ui/dist/assets/idl-BUZw3wgd.js +1 -0
  220. package/ui/dist/assets/index-DmasQ6Rq.css +2 -0
  221. package/ui/dist/assets/index-I7uU93kk.js +294 -0
  222. package/ui/dist/assets/javascript-lS2r2ifg.js +1 -0
  223. package/ui/dist/assets/julia-CQfgDRfP.js +1 -0
  224. package/ui/dist/assets/livescript-BXxG0Yva.js +1 -0
  225. package/ui/dist/assets/lua-DPCEkfD_.js +1 -0
  226. package/ui/dist/assets/mathematica-u8YMmbU0.js +1 -0
  227. package/ui/dist/assets/mbox-BEMVcqjs.js +1 -0
  228. package/ui/dist/assets/mirc-C9zpzAZU.js +1 -0
  229. package/ui/dist/assets/mllike-B45xgp2S.js +1 -0
  230. package/ui/dist/assets/modelica-CqHI_q-D.js +1 -0
  231. package/ui/dist/assets/mscgen-qgSLujhx.js +1 -0
  232. package/ui/dist/assets/mumps-BSzj6Xyz.js +1 -0
  233. package/ui/dist/assets/nginx-C-ORUuei.js +1 -0
  234. package/ui/dist/assets/nsis-viPvXFiq.js +1 -0
  235. package/ui/dist/assets/ntriples-CzMBulkp.js +1 -0
  236. package/ui/dist/assets/octave-D2Q8cUp1.js +1 -0
  237. package/ui/dist/assets/oz-BwTct_5T.js +1 -0
  238. package/ui/dist/assets/pascal-Dp_KdFgX.js +1 -0
  239. package/ui/dist/assets/perl-BkSkiI9m.js +1 -0
  240. package/ui/dist/assets/pig-AF4Hwhju.js +1 -0
  241. package/ui/dist/assets/powershell-D8WpQPaX.js +1 -0
  242. package/ui/dist/assets/properties-C357ku5U.js +1 -0
  243. package/ui/dist/assets/protobuf-D1ij_kNL.js +1 -0
  244. package/ui/dist/assets/pug-BqCHZ5xT.js +1 -0
  245. package/ui/dist/assets/puppet-BZyAS7I4.js +1 -0
  246. package/ui/dist/assets/python-B_1Jd8HM.js +1 -0
  247. package/ui/dist/assets/q-B9NhS7L_.js +1 -0
  248. package/ui/dist/assets/r-CxDmU1I4.js +1 -0
  249. package/ui/dist/assets/rpm-gpDK5sbt.js +1 -0
  250. package/ui/dist/assets/ruby-ChfNwlCY.js +1 -0
  251. package/ui/dist/assets/sas-C2OyD2Y6.js +1 -0
  252. package/ui/dist/assets/scheme-D9qYN0V5.js +1 -0
  253. package/ui/dist/assets/shell-DwuoZtxw.js +1 -0
  254. package/ui/dist/assets/sieve-tLWDfEkZ.js +1 -0
  255. package/ui/dist/assets/simple-mode-DXiLM1-G.js +1 -0
  256. package/ui/dist/assets/smalltalk-BLp5-jwC.js +1 -0
  257. package/ui/dist/assets/solr-B78wSQJQ.js +1 -0
  258. package/ui/dist/assets/sparql-Dz-mlCAC.js +1 -0
  259. package/ui/dist/assets/spreadsheet-CTIncYxj.js +1 -0
  260. package/ui/dist/assets/sql-DuQMFDvy.js +1 -0
  261. package/ui/dist/assets/stex-DNzd62Q_.js +1 -0
  262. package/ui/dist/assets/stylus-BOVgKnng.js +1 -0
  263. package/ui/dist/assets/swift-DKB6_1j6.js +1 -0
  264. package/ui/dist/assets/tcl-BGfruO1u.js +1 -0
  265. package/ui/dist/assets/textile-C4jVoHG1.js +1 -0
  266. package/ui/dist/assets/tiddlywiki-VlUiK86J.js +1 -0
  267. package/ui/dist/assets/tiki-CvUEFusT.js +1 -0
  268. package/ui/dist/assets/toml-BPTmHmyx.js +1 -0
  269. package/ui/dist/assets/troff-C2dAgh7P.js +1 -0
  270. package/ui/dist/assets/ttcn-PPSTk1ow.js +1 -0
  271. package/ui/dist/assets/ttcn-cfg-2b4Gb5sE.js +1 -0
  272. package/ui/dist/assets/turtle-Cx3rYX2g.js +1 -0
  273. package/ui/dist/assets/vb-BthdM_4N.js +1 -0
  274. package/ui/dist/assets/vbscript-D9f6rSsU.js +1 -0
  275. package/ui/dist/assets/velocity-CqftRsjg.js +1 -0
  276. package/ui/dist/assets/verilog-CjI9Y6RI.js +1 -0
  277. package/ui/dist/assets/vhdl-kS471soi.js +1 -0
  278. package/ui/dist/assets/webidl-D5jiJvOU.js +1 -0
  279. package/ui/dist/assets/xquery-BKJx4mf9.js +1 -0
  280. package/ui/dist/assets/yacas-CXXgtw0p.js +1 -0
  281. package/ui/dist/assets/z80-Co3PbHdE.js +1 -0
  282. package/ui/dist/favicon.ico +0 -0
  283. package/ui/dist/favicon.svg +11 -0
  284. package/ui/dist/index.html +17 -0
  285. package/ui/dist/logos/claude.svg +1 -0
  286. package/ui/dist/logos/opencode.svg +1 -0
@@ -0,0 +1,202 @@
1
+ const MAX_DETAIL = 180;
2
+ const MAX_ERROR = 300;
3
+ const SANDBOX_ERROR = /listen EPERM|outside of the sandbox|sandbox/i;
4
+ const clock = (at = Date.now()) => new Date(at).toTimeString().slice(0, 8);
5
+ const oneLine = (value, max) => {
6
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
7
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
8
+ };
9
+ const seconds = (ms) => (ms < 90_000 ? `${Math.round(ms / 1000)} s` : `${(ms / 60_000).toFixed(1)} min`);
10
+ /** `mcp__playwright-test__browser_click` -> `browser_click`. */
11
+ const toolName = (name) => name.replace(/^mcp__[^_]+(?:-[^_]+)*__/, '');
12
+ /** What the call is about, in a few words: the intent the agent gave, or the most telling argument. */
13
+ function describe(input) {
14
+ if (!input || typeof input !== 'object')
15
+ return '';
16
+ const args = input;
17
+ const key = ['intent', 'description', 'skill', 'command', 'file_path', 'filename', 'url', 'element', 'query'].find((name) => typeof args[name] === 'string' && args[name]);
18
+ return key ? oneLine(args[key], MAX_DETAIL) : '';
19
+ }
20
+ const callLabel = (name, input) => {
21
+ const detail = describe(input);
22
+ return detail ? `${toolName(name)}: ${detail}` : toolName(name);
23
+ };
24
+ const HANDOFF_CFG = '__handoffCfg';
25
+ const HANDOFF_CARD = /handoff-card\.js$/;
26
+ /** `message: '...'` of the options call that comes right before the card. */
27
+ function handoffMessage(code) {
28
+ const match = /message\s*:\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/.exec(code);
29
+ return match ? match[2].replace(/\\(['"`\\])/g, '$1').trim() || null : null;
30
+ }
31
+ function handoffCall(name, input) {
32
+ if (!name.includes('browser_run_code') || !input || typeof input !== 'object')
33
+ return null;
34
+ const args = input;
35
+ if (typeof args.code === 'string' && args.code.includes(HANDOFF_CFG))
36
+ return 'options';
37
+ if (typeof args.filename === 'string' && HANDOFF_CARD.test(args.filename))
38
+ return 'card';
39
+ return null;
40
+ }
41
+ export class AiStreamFormatter {
42
+ tool;
43
+ buffer = '';
44
+ pending = new Map();
45
+ cardCalls = new Set();
46
+ waitingForUser = null;
47
+ lastEventAt = Date.now();
48
+ lastWaitAt = 0;
49
+ sandboxHinted = false;
50
+ constructor(tool) {
51
+ this.tool = tool;
52
+ }
53
+ /** Lines for a piece of stdout. Incomplete trailing lines wait for the next piece. */
54
+ feed(text) {
55
+ this.buffer += text;
56
+ const lines = this.buffer.split('\n');
57
+ this.buffer = lines.pop() ?? '';
58
+ return lines.map((line) => this.line(line)).join('');
59
+ }
60
+ /**
61
+ * Set while a handoff card waits for the person. OpenCode reports a tool call only when it has finished, so the card
62
+ * call itself is not visible while it waits: the options call before it (`__handoffCfg`) starts the state and the end
63
+ * of the card call, or any other call, ends it. The object changes on every new handoff, so `===` detects a change.
64
+ */
65
+ get awaiting() {
66
+ return this.waitingForUser;
67
+ }
68
+ /** Called once for every tool call the agent makes, with the id the end of that call will carry. */
69
+ watchHandoff(id, name, input, ended) {
70
+ const kind = handoffCall(name, input);
71
+ if (kind === 'options') {
72
+ const code = String(input.code);
73
+ this.waitingForUser = { message: handoffMessage(code), since: Date.now() };
74
+ }
75
+ else if (kind === 'card') {
76
+ if (ended)
77
+ this.waitingForUser = null;
78
+ else {
79
+ this.cardCalls.add(id);
80
+ this.waitingForUser ??= { message: null, since: Date.now() };
81
+ }
82
+ }
83
+ else {
84
+ this.waitingForUser = null;
85
+ }
86
+ }
87
+ /** What is left when the process ends. */
88
+ end() {
89
+ const rest = this.buffer;
90
+ this.buffer = '';
91
+ return rest.trim() ? this.line(rest) : '';
92
+ }
93
+ /**
94
+ * A line about the oldest tool call that has not returned, when nothing has happened for `quietMs` (and again every
95
+ * `quietMs` after that).
96
+ */
97
+ waiting(quietMs) {
98
+ const now = Date.now();
99
+ if (now - Math.max(this.lastEventAt, this.lastWaitAt) < quietMs)
100
+ return '';
101
+ this.lastWaitAt = now;
102
+ const [first] = [...this.pending.values()].sort((a, b) => a.startedAt - b.startedAt);
103
+ const quiet = seconds(now - this.lastEventAt);
104
+ return first
105
+ ? `[${clock(now)}] ... still waiting for ${first.label} (running ${seconds(now - first.startedAt)})\n`
106
+ : `[${clock(now)}] ... no output from the agent for ${quiet}\n`;
107
+ }
108
+ line(raw) {
109
+ const trimmed = raw.trim();
110
+ if (!trimmed)
111
+ return '';
112
+ let event;
113
+ try {
114
+ event = JSON.parse(trimmed);
115
+ }
116
+ catch {
117
+ return `${raw}\n`;
118
+ }
119
+ if (!event || typeof event !== 'object' || typeof event.type !== 'string')
120
+ return `${raw}\n`;
121
+ this.lastEventAt = Date.now();
122
+ return this.tool === 'claude' ? this.claude(event) : this.opencode(event);
123
+ }
124
+ start(id, label) {
125
+ const now = Date.now();
126
+ this.pending.set(id, { label, startedAt: now });
127
+ return `[${clock(now)}] >> ${label}\n`;
128
+ }
129
+ end1(id, ok, error, fallbackLabel) {
130
+ const now = Date.now();
131
+ const call = this.pending.get(id);
132
+ this.pending.delete(id);
133
+ if (this.cardCalls.delete(id))
134
+ this.waitingForUser = null;
135
+ const label = call?.label ?? fallbackLabel ?? 'tool call';
136
+ const took = call ? ` after ${seconds(now - call.startedAt)}` : '';
137
+ const head = `[${clock(now)}] ${ok ? 'ok' : 'FAILED'}: ${label}${took}\n`;
138
+ return !ok && error ? `${head} ${oneLine(error, MAX_ERROR)}\n` : head;
139
+ }
140
+ /** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */
141
+ sandboxHint(isError, body) {
142
+ if (this.sandboxHinted || !isError || !SANDBOX_ERROR.test(String(body ?? '')))
143
+ return '';
144
+ this.sandboxHinted = true;
145
+ return (`[${clock()}] HINT: Claude Code's sandbox blocked a command and a one-shot run cannot approve it. Options:\n` +
146
+ ' - Switch the AI agent to OpenCode in the top bar and run again (it does not use this sandbox).\n' +
147
+ ' - Or run the command yourself in a terminal and paste the output into the agent.\n' +
148
+ ' - Or check that the command matches "sandbox.excludedCommands" in .claude/settings.json (every command of a chained call must match).\n');
149
+ }
150
+ claude(event) {
151
+ const content = event.message?.content;
152
+ if (event.type === 'assistant' && Array.isArray(content)) {
153
+ return content
154
+ .map((block) => {
155
+ if (block?.type === 'text' && block.text?.trim())
156
+ return `${block.text.trimEnd()}\n`;
157
+ if (block?.type === 'tool_use') {
158
+ this.watchHandoff(String(block.id), String(block.name), block.input, false);
159
+ return this.start(String(block.id), callLabel(String(block.name), block.input));
160
+ }
161
+ return '';
162
+ })
163
+ .join('');
164
+ }
165
+ if (event.type === 'user' && Array.isArray(content)) {
166
+ return content
167
+ .filter((block) => block?.type === 'tool_result')
168
+ .map((block) => {
169
+ const body = Array.isArray(block.content)
170
+ ? block.content.map((part) => part?.text ?? '').join(' ')
171
+ : block.content;
172
+ return this.end1(String(block.tool_use_id), !block.is_error, body) + this.sandboxHint(block.is_error, body);
173
+ })
174
+ .join('');
175
+ }
176
+ if (event.type === 'result' && event.is_error) {
177
+ return `[${clock()}] FAILED: ${oneLine(event.result ?? event.subtype, MAX_ERROR)}\n`;
178
+ }
179
+ return '';
180
+ }
181
+ opencode(event) {
182
+ const part = event.part ?? {};
183
+ if (event.type === 'text' && typeof part.text === 'string' && part.text.trim())
184
+ return `${part.text.trimEnd()}\n`;
185
+ if (event.type === 'tool_use') {
186
+ const state = part.state ?? {};
187
+ const label = callLabel(String(part.tool ?? 'tool'), state.input);
188
+ const id = String(part.callID ?? part.id ?? label);
189
+ const finished = state.status === 'completed' || state.status === 'error';
190
+ this.watchHandoff(id, String(part.tool ?? ''), state.input, finished);
191
+ if (finished) {
192
+ const time = state.time ?? {};
193
+ if (!this.pending.has(id) && typeof time.start === 'number') {
194
+ this.pending.set(id, { label, startedAt: time.start });
195
+ }
196
+ return this.end1(id, state.status === 'completed', state.error ?? state.output, label);
197
+ }
198
+ return this.start(id, label);
199
+ }
200
+ return '';
201
+ }
202
+ }
@@ -0,0 +1,4 @@
1
+ import { Hono } from 'hono';
2
+ export declare function createApp({ port }: {
3
+ port: number;
4
+ }): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,333 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Hono } from 'hono';
4
+ import { streamSSE } from 'hono/streaming';
5
+ import { z } from 'zod';
6
+ import { listBaselines, snapshotsDir } from '../../lib/baselines.js';
7
+ import { listEnvs } from '../../lib/profiles.js';
8
+ import { listSuites } from '../../lib/cases.js';
9
+ import { FRAMEWORK_ROOT, ROOT } from '../../lib/paths.js';
10
+ import { ActionError, buildJobSpec, planRunCases, previewRun, previewSchema, startJobSchema } from './actions.js';
11
+ import { addProfile, listProfileRows, ProfileError, verifyProfiles } from './profiles.js';
12
+ import { CaseError, caseImpact, createCase, deleteCase, isValidCaseId, listCaseIds, listCases, openSpec, readCase, readSpec, renameCase, writeCase, writeSpec, } from './cases.js';
13
+ import { resolveInside, sendFile } from './files.js';
14
+ import { JobConflictError, jobs } from './jobs.js';
15
+ import { fileEvents } from './watch-files.js';
16
+ const REPORTS_DIR = path.join(ROOT, 'reports');
17
+ // Built by `prepack` and shipped with the package; the framework repository builds it with `pnpm ui`.
18
+ const WEB_DIST = path.join(FRAMEWORK_ROOT, 'ui', 'dist');
19
+ // `2026-10-27T16-47-54` (before environments existed, always production) or `2026-10-27T16-47-54-staging`.
20
+ const REPORT_ID = /^(latest|\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-[a-z0-9]+(-[a-z0-9]+)*)?)$/;
21
+ const BASELINE_FILE = /^[\w.@-]+\.png$/i;
22
+ const CSRF_HEADER = 'x-asrun-ui';
23
+ function readSummary(id) {
24
+ const file = resolveInside(REPORTS_DIR, path.join(id, 'summary.json'));
25
+ if (!file)
26
+ return null;
27
+ try {
28
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ export function createApp({ port }) {
35
+ const app = new Hono();
36
+ const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
37
+ const allowedOrigins = new Set([...allowedHosts].map((host) => `http://${host}`));
38
+ // Vite dev server proxies /api to us and keeps its own origin.
39
+ const devOrigin = process.env.ASRUN_UI_DEV_ORIGIN;
40
+ if (devOrigin)
41
+ allowedOrigins.add(devOrigin);
42
+ // This server can start local processes, so only accept requests that really come from our own pages.
43
+ app.use('*', async (c, next) => {
44
+ const host = c.req.header('host') ?? '';
45
+ if (!allowedHosts.has(host) && !(devOrigin && new URL(devOrigin).host === host)) {
46
+ return c.text('Forbidden host', 403);
47
+ }
48
+ const origin = c.req.header('origin');
49
+ if (origin && !allowedOrigins.has(origin))
50
+ return c.text('Forbidden origin', 403);
51
+ if (!['GET', 'HEAD'].includes(c.req.method)) {
52
+ if (c.req.header(CSRF_HEADER) !== '1')
53
+ return c.text('Missing CSRF header', 403);
54
+ }
55
+ await next();
56
+ });
57
+ app.onError((error, c) => {
58
+ if (error instanceof ActionError)
59
+ return c.json({ error: error.message }, 400);
60
+ if (error instanceof JobConflictError)
61
+ return c.json({ error: error.message }, 409);
62
+ if (error instanceof ProfileError)
63
+ return c.json({ error: error.message }, error.status);
64
+ if (error instanceof CaseError)
65
+ return c.json({ error: error.message }, error.status);
66
+ if (error instanceof z.ZodError)
67
+ return c.json({ error: 'Invalid request body.' }, 400);
68
+ console.error(error);
69
+ return c.json({ error: error instanceof Error ? error.message : 'Internal error' }, 500);
70
+ });
71
+ const api = new Hono();
72
+ api.get('/info', (c) => {
73
+ let suites = [];
74
+ try {
75
+ suites = listSuites();
76
+ }
77
+ catch {
78
+ // A case with broken front matter; the cases page shows it.
79
+ }
80
+ return c.json({ root: ROOT, envs: listEnvs(), suites, caseIds: listCaseIds() });
81
+ });
82
+ api.get('/profiles', (c) => c.json(listProfileRows()));
83
+ api.post('/profiles', async (c) => {
84
+ const body = z
85
+ .object({
86
+ id: z.string().max(60),
87
+ name: z.string().max(100),
88
+ env: z.string().max(40),
89
+ baseURL: z.string().max(200),
90
+ account: z.string().max(200).optional(),
91
+ tags: z.array(z.string().max(40)).max(20).optional(),
92
+ sessionCheckPath: z.string().max(200).optional(),
93
+ email: z.boolean().optional(),
94
+ })
95
+ .parse(await c.req.json());
96
+ return c.json(addProfile(body), 201);
97
+ });
98
+ api.post('/profiles/verify', async (c) => {
99
+ const body = z
100
+ .object({ profile: z.string().optional(), env: z.string().optional() })
101
+ .parse(await c.req.json().catch(() => ({})));
102
+ void verifyProfiles({ ids: body.profile ? [body.profile] : undefined, env: body.env });
103
+ // Give verifyProfiles a tick to mark the profiles as "verifying".
104
+ await new Promise((resolve) => setTimeout(resolve, 20));
105
+ return c.json(listProfileRows());
106
+ });
107
+ api.get('/cases', (c) => c.json(listCases()));
108
+ api.post('/cases', async (c) => {
109
+ const { id } = z.object({ id: z.string() }).parse(await c.req.json());
110
+ return c.json(await createCase(id), 201);
111
+ });
112
+ // A case id may contain one `/` (`documents/landing-page`), so the id is the whole rest of the path.
113
+ api.get('/case-impact/:id{.+}', (c) => c.json(caseImpact(c.req.param('id'))));
114
+ // Moving or deleting files under a running agent or test run would break it.
115
+ const assertNoRunningWork = () => {
116
+ const busy = jobs.list().find((job) => job.status === 'running' && (job.kind === 'ai' || job.kind === 'run'));
117
+ if (busy)
118
+ throw new CaseError(`Wait for "${busy.command}" to finish first.`, 409);
119
+ };
120
+ api.post('/case-rename', async (c) => {
121
+ const body = z.object({ id: z.string().max(200), to: z.string().max(200) }).parse(await c.req.json());
122
+ assertNoRunningWork();
123
+ return c.json(renameCase(body.id, body.to));
124
+ });
125
+ api.get('/specs/:id{.+}', (c) => c.json(readSpec(c.req.param('id'))));
126
+ api.put('/specs/:id{.+}', async (c) => {
127
+ const body = z.object({ content: z.string(), mtimeMs: z.number() }).parse(await c.req.json());
128
+ return c.json(writeSpec(c.req.param('id'), body.content, body.mtimeMs));
129
+ });
130
+ // Only the spec of a known case, never a path from the browser.
131
+ api.post('/spec-open', async (c) => {
132
+ const { id } = z.object({ id: z.string().max(200) }).parse(await c.req.json());
133
+ await openSpec(id);
134
+ return c.json({ ok: true });
135
+ });
136
+ api.get('/cases/:id{.+}', (c) => c.json(readCase(c.req.param('id'))));
137
+ api.put('/cases/:id{.+}', async (c) => {
138
+ const body = z.object({ content: z.string(), mtimeMs: z.number() }).parse(await c.req.json());
139
+ return c.json(writeCase(c.req.param('id'), body.content, body.mtimeMs));
140
+ });
141
+ api.delete('/cases/:id{.+}', (c) => {
142
+ assertNoRunningWork();
143
+ return c.json(deleteCase(c.req.param('id')));
144
+ });
145
+ // The screenshot baselines of one case, with what each must show. Read-only.
146
+ api.get('/baselines', (c) => {
147
+ const caseId = c.req.query('case') ?? '';
148
+ if (!isValidCaseId(caseId) || !listCaseIds().includes(caseId))
149
+ return c.json({ error: 'Unknown case.' }, 404);
150
+ const items = listBaselines([caseId]).map(({ caseId, file, profile, env, changed, mtimeMs, targetId, card, problem }) => ({
151
+ caseId,
152
+ file,
153
+ profile,
154
+ env,
155
+ changed,
156
+ mtimeMs,
157
+ targetId,
158
+ card,
159
+ problem,
160
+ }));
161
+ return c.json(items);
162
+ });
163
+ // One baseline image. Only a .png that sits directly in the snapshot folder of a known case.
164
+ app.get('/baseline-files', (c) => {
165
+ const caseId = c.req.query('case') ?? '';
166
+ const file = c.req.query('file') ?? '';
167
+ if (!isValidCaseId(caseId) || !listCaseIds().includes(caseId) || !BASELINE_FILE.test(file))
168
+ return c.notFound();
169
+ const dir = snapshotsDir(caseId);
170
+ if (!fs.existsSync(dir))
171
+ return c.notFound();
172
+ const image = resolveInside(dir, file);
173
+ return image ? sendFile(c, image) : c.notFound();
174
+ });
175
+ api.get('/reports', (c) => {
176
+ if (!fs.existsSync(REPORTS_DIR))
177
+ return c.json([]);
178
+ const latestTarget = resolveInside(REPORTS_DIR, 'latest');
179
+ const reports = fs
180
+ .readdirSync(REPORTS_DIR)
181
+ .filter((name) => REPORT_ID.test(name) && name !== 'latest')
182
+ .filter((name) => fs.existsSync(path.join(REPORTS_DIR, name, 'html', 'index.html')))
183
+ .sort()
184
+ .reverse()
185
+ .map((id) => {
186
+ const summary = readSummary(id);
187
+ return {
188
+ id,
189
+ // Runs from before environments existed have no env in the id or the summary; they all ran against production.
190
+ env: summary?.env ?? /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$/.exec(id)?.[1] ?? null,
191
+ createdAt: new Date(id.slice(0, 10) + 'T' + id.slice(11, 19).replace(/-/g, ':') + 'Z').toISOString(),
192
+ isLatest: latestTarget === path.join(fs.realpathSync(REPORTS_DIR), id),
193
+ counts: summary?.counts ?? null,
194
+ };
195
+ });
196
+ return c.json(reports);
197
+ });
198
+ // Per-test results with the error already split into a headline and technical details.
199
+ api.get('/reports/:id/summary', (c) => {
200
+ const id = c.req.param('id');
201
+ if (!REPORT_ID.test(id))
202
+ return c.json({ error: 'Unknown report.' }, 404);
203
+ const summary = readSummary(id);
204
+ return summary ? c.json(summary) : c.json({ error: 'This run has no summary.' }, 404);
205
+ });
206
+ api.post('/runs/preview', async (c) => c.json(previewRun(previewSchema.parse(await c.req.json()))));
207
+ api.post('/runs/plan', async (c) => c.json(await planRunCases(previewSchema.parse(await c.req.json()))));
208
+ api.get('/jobs', (c) => c.json(jobs.list()));
209
+ api.post('/jobs', async (c) => {
210
+ const request = startJobSchema.parse(await c.req.json());
211
+ return c.json(jobs.start(buildJobSpec(request)), 201);
212
+ });
213
+ api.post('/jobs/:id/input', async (c) => {
214
+ const { text } = z.object({ text: z.string().max(100) }).parse(await c.req.json());
215
+ return c.json({ ok: jobs.write(c.req.param('id'), text) });
216
+ });
217
+ api.post('/jobs/:id/cancel', (c) => c.json({ ok: jobs.cancel(c.req.param('id')) }));
218
+ // Status changes of any job, so every open page can refresh what depends on them.
219
+ api.get('/events', (c) => streamSSE(c, async (stream) => {
220
+ let open = true;
221
+ const listener = (info) => {
222
+ if (open)
223
+ void stream.writeSSE({ event: 'job', data: JSON.stringify(info) }).catch(() => { });
224
+ };
225
+ const filesListener = (change) => {
226
+ if (open)
227
+ void stream.writeSSE({ event: 'files', data: JSON.stringify(change) }).catch(() => { });
228
+ };
229
+ jobs.events.on('job', listener);
230
+ fileEvents.on('files', filesListener);
231
+ stream.onAbort(() => {
232
+ open = false;
233
+ jobs.events.off('job', listener);
234
+ fileEvents.off('files', filesListener);
235
+ });
236
+ while (open) {
237
+ await stream.writeSSE({ event: 'ping', data: '' });
238
+ await stream.sleep(15_000);
239
+ }
240
+ }));
241
+ // Output of one job. Reconnecting with Last-Event-ID replays only what was missed.
242
+ api.get('/jobs/:id/events', (c) => {
243
+ const record = jobs.get(c.req.param('id'));
244
+ if (!record)
245
+ return c.json({ error: 'Unknown job.' }, 404);
246
+ const lastId = Number(c.req.header('last-event-id') ?? c.req.query('after') ?? -1);
247
+ return streamSSE(c, async (stream) => {
248
+ const queue = [];
249
+ let wake = null;
250
+ let aborted = false;
251
+ const notify = () => wake?.();
252
+ const onChunk = (chunk) => {
253
+ queue.push({ event: 'chunk', id: String(chunk.seq), data: JSON.stringify(chunk) });
254
+ notify();
255
+ };
256
+ const onStatus = (info) => {
257
+ queue.push({ event: 'status', data: JSON.stringify(info) });
258
+ notify();
259
+ };
260
+ // Replay and subscribe in one synchronous step so no chunk can fall in between.
261
+ for (const chunk of record.chunks)
262
+ if (chunk.seq > lastId)
263
+ onChunk(chunk);
264
+ record.emitter.on('chunk', onChunk);
265
+ record.emitter.on('status', onStatus);
266
+ if (record.info.status !== 'running')
267
+ onStatus(record.info);
268
+ stream.onAbort(() => {
269
+ aborted = true;
270
+ notify();
271
+ });
272
+ try {
273
+ let done = false;
274
+ while (!aborted && !done) {
275
+ while (queue.length > 0) {
276
+ const message = queue.shift();
277
+ await stream.writeSSE(message);
278
+ if (message.event === 'status' && JSON.parse(message.data).status !== 'running')
279
+ done = true;
280
+ }
281
+ if (done || aborted)
282
+ break;
283
+ await new Promise((resolve) => {
284
+ wake = resolve;
285
+ setTimeout(resolve, 15_000);
286
+ });
287
+ wake = null;
288
+ }
289
+ }
290
+ finally {
291
+ record.emitter.off('chunk', onChunk);
292
+ record.emitter.off('status', onStatus);
293
+ }
294
+ });
295
+ });
296
+ api.all('*', (c) => c.json({ error: 'Not found' }, 404));
297
+ app.route('/api', api);
298
+ // The Playwright HTML report, same origin so it can be embedded in an iframe.
299
+ app.get('/report-files/:id', (c) => c.redirect(`/report-files/${c.req.param('id')}/`));
300
+ app.get('/report-files/:id/*', (c) => {
301
+ const id = c.req.param('id');
302
+ if (!REPORT_ID.test(id))
303
+ return c.notFound();
304
+ const prefix = `/report-files/${id}/`;
305
+ const relative = decodeURIComponent(c.req.path.slice(prefix.length)) || 'index.html';
306
+ const htmlDir = path.join(REPORTS_DIR, id, 'html');
307
+ if (!fs.existsSync(htmlDir))
308
+ return c.notFound();
309
+ // Joined videos live next to the HTML report (it owns its folder), but are served under the same prefix.
310
+ const videosDir = path.join(REPORTS_DIR, id, 'videos');
311
+ if (relative.startsWith('videos/') && fs.existsSync(videosDir)) {
312
+ const video = resolveInside(videosDir, relative.slice('videos/'.length));
313
+ return video ? sendFile(c, video) : c.notFound();
314
+ }
315
+ let file = resolveInside(htmlDir, relative);
316
+ if (file && fs.statSync(file).isDirectory())
317
+ file = resolveInside(htmlDir, path.join(relative, 'index.html'));
318
+ return file ? sendFile(c, file) : c.notFound();
319
+ });
320
+ // The static Inkstone theme guide, linked from the top bar of the UI.
321
+ app.get('/docs/theme-guide.html', (c) => sendFile(c, path.join(FRAMEWORK_ROOT, 'docs', 'theme-guide.html')));
322
+ // Built web app (production mode). In dev, Vite serves it and proxies /api here.
323
+ if (fs.existsSync(WEB_DIST)) {
324
+ app.get('*', (c) => {
325
+ const relative = decodeURIComponent(c.req.path).replace(/^\/+/, '') || 'index.html';
326
+ const file = resolveInside(WEB_DIST, relative);
327
+ if (file && fs.statSync(file).isFile())
328
+ return sendFile(c, file);
329
+ return sendFile(c, path.join(WEB_DIST, 'index.html'));
330
+ });
331
+ }
332
+ return app;
333
+ }
@@ -0,0 +1,23 @@
1
+ import { isValidCaseId } from '../../lib/case-id.js';
2
+ import { CaseError } from '../../lib/case-ops.js';
3
+ import { listCaseFileIds } from '../../lib/cases.js';
4
+ import type { CaseDetail, CaseImpact, CaseSummary, SpecDetail } from '../shared/types.js';
5
+ export { CaseError, isValidCaseId };
6
+ export declare const listCaseIds: typeof listCaseFileIds;
7
+ export declare function listCases(): CaseSummary[];
8
+ export declare function readCase(id: string): CaseDetail;
9
+ export declare function readSpec(id: string): SpecDetail;
10
+ /** Writes only if the file is unchanged since the editor loaded it (an agent or an external editor may change it too). */
11
+ export declare function writeSpec(id: string, content: string, expectedMtimeMs: number): SpecDetail;
12
+ /**
13
+ * Opens the spec of a case in the default application of the system. Resolves once the opener has handed the file over
14
+ * (or is still running after a moment, as `xdg-open` can be); rejects when there is no opener or it fails.
15
+ */
16
+ export declare function openSpec(id: string): Promise<void>;
17
+ /** Writes only if the file is unchanged since the editor loaded it (an agent may be editing it too). */
18
+ export declare function writeCase(id: string, content: string, expectedMtimeMs: number): CaseDetail;
19
+ /** Reuses `pnpm case:new` so the template stays in one place. */
20
+ export declare function createCase(id: string): Promise<CaseDetail>;
21
+ export declare const caseImpact: (id: string) => CaseImpact;
22
+ export declare const renameCase: (id: string, to: string) => CaseDetail;
23
+ export declare const deleteCase: (id: string) => CaseImpact;