@asrunjs/asrun 0.2.0 → 0.5.2

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 (94) hide show
  1. package/.claude/commands/explore-case.md +18 -15
  2. package/.claude/commands/gen-spec.md +16 -13
  3. package/.claude/commands/heal-spec.md +26 -23
  4. package/.claude/commands/make-case.md +11 -8
  5. package/.claude/skills/exploring-cases/SKILL.md +146 -132
  6. package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
  7. package/.claude/skills/exploring-cases/references/readiness.md +146 -137
  8. package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
  9. package/.claude/skills/generating-specs/SKILL.md +219 -207
  10. package/.claude/skills/generating-specs/references/conventions.md +148 -148
  11. package/.claude/skills/making-cases/SKILL.md +72 -64
  12. package/.claude/skills/making-cases/references/normalizing.md +39 -38
  13. package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
  14. package/AGENTS.md +159 -154
  15. package/CLAUDE.md +2 -1
  16. package/README.md +512 -410
  17. package/dist/lib/build-artifacts.d.ts +8 -0
  18. package/dist/lib/build-artifacts.js +10 -0
  19. package/dist/lib/case-doc.d.ts +311 -0
  20. package/dist/lib/case-doc.js +1117 -0
  21. package/dist/lib/cases.d.ts +2 -15
  22. package/dist/lib/cases.js +5 -78
  23. package/dist/lib/command.d.ts +24 -0
  24. package/dist/lib/command.js +90 -0
  25. package/dist/lib/fixtures.js +2 -2
  26. package/dist/lib/login-card.d.ts +22 -0
  27. package/dist/lib/login-card.js +183 -0
  28. package/dist/lib/node-cli.d.ts +6 -2
  29. package/dist/lib/node-cli.js +16 -6
  30. package/dist/lib/profiles.d.ts +25 -1
  31. package/dist/lib/profiles.js +54 -1
  32. package/dist/lib/run-filters.js +10 -2
  33. package/dist/lib/session.d.ts +13 -1
  34. package/dist/lib/session.js +73 -7
  35. package/dist/lib/targets.d.ts +5 -13
  36. package/dist/lib/targets.js +4 -29
  37. package/dist/lib/values.js +2 -2
  38. package/dist/scripts/build.js +2 -1
  39. package/dist/scripts/check-cases.js +74 -6
  40. package/dist/scripts/doctor.js +115 -4
  41. package/dist/scripts/explore.js +22 -1
  42. package/dist/scripts/init.d.ts +7 -1
  43. package/dist/scripts/init.js +61 -13
  44. package/dist/scripts/lint-specs.js +4 -1
  45. package/dist/scripts/login.js +30 -2
  46. package/dist/ui/server/actions.d.ts +4 -0
  47. package/dist/ui/server/actions.js +25 -12
  48. package/dist/ui/server/ai-models.d.ts +3 -0
  49. package/dist/ui/server/ai-models.js +347 -0
  50. package/dist/ui/server/ai-stream.d.ts +34 -9
  51. package/dist/ui/server/ai-stream.js +283 -77
  52. package/dist/ui/server/app.js +74 -12
  53. package/dist/ui/server/jobs.d.ts +7 -1
  54. package/dist/ui/server/jobs.js +69 -15
  55. package/dist/ui/server/profiles.d.ts +20 -1
  56. package/dist/ui/server/profiles.js +214 -38
  57. package/dist/ui/shared/ai-commands.d.ts +16 -6
  58. package/dist/ui/shared/ai-commands.js +35 -12
  59. package/dist/ui/shared/types.d.ts +114 -0
  60. package/docs/agents/framework-dev.md +79 -0
  61. package/docs/theme-guide.html +922 -692
  62. package/package.json +12 -1
  63. package/templates/.claude/commands/explore-case.md +22 -21
  64. package/templates/.claude/commands/gen-spec.md +21 -20
  65. package/templates/.claude/commands/heal-spec.md +31 -30
  66. package/templates/.claude/commands/make-case.md +13 -12
  67. package/templates/.mcp.json +8 -8
  68. package/templates/AGENTS.md +64 -45
  69. package/templates/cases/_template.md +57 -63
  70. package/templates/opencode.jsonc +16 -11
  71. package/templates/profiles.yaml +42 -29
  72. package/ui/dist/assets/{dist-BHaIOZdW.js → dist-BGbe0-Aa.js} +1 -1
  73. package/ui/dist/assets/{dist-CBV7_vDN.js → dist-Ba-HC662.js} +1 -1
  74. package/ui/dist/assets/{dist-BiE4suo9.js → dist-BtMLMthJ.js} +1 -1
  75. package/ui/dist/assets/{dist-gF2bFn-5.js → dist-C-Oyo1ZT.js} +1 -1
  76. package/ui/dist/assets/{dist-DX8knLVr.js → dist-C8MdhqgG.js} +1 -1
  77. package/ui/dist/assets/{dist-DHCY9KpV.js → dist-CG1pHn5m.js} +1 -1
  78. package/ui/dist/assets/{dist-BwYRbe5s.js → dist-CmpteMYY.js} +1 -1
  79. package/ui/dist/assets/{dist-D_I45xgJ.js → dist-DSy3dVf4.js} +1 -1
  80. package/ui/dist/assets/{dist-BqAROxzI.js → dist-Dq6sW5Ur.js} +1 -1
  81. package/ui/dist/assets/{dist-BSJMM_jp.js → dist-jjxQutEm.js} +1 -1
  82. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  83. package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
  84. package/ui/dist/help/ai-tools.png +0 -0
  85. package/ui/dist/help/case-explore.png +0 -0
  86. package/ui/dist/help/case-generate.png +0 -0
  87. package/ui/dist/help/cases-memo.png +0 -0
  88. package/ui/dist/help/profile-form.png +0 -0
  89. package/ui/dist/help/profiles-login.png +0 -0
  90. package/ui/dist/help/reports.png +0 -0
  91. package/ui/dist/help/run-tests.png +0 -0
  92. package/ui/dist/index.html +22 -16
  93. package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
  94. package/ui/dist/assets/index-I7uU93kk.js +0 -294
@@ -1,42 +1,145 @@
1
1
  const MAX_DETAIL = 180;
2
2
  const MAX_ERROR = 300;
3
+ const MAX_REASONING = 2000;
4
+ /** A call's output is a preview, not a transcript: the first few lines say what it did without flooding the log. */
5
+ const PREVIEW_LINES = 6;
6
+ const PREVIEW_LINE = 200;
7
+ const PREVIEW_CHARS = 500;
8
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
3
9
  const SANDBOX_ERROR = /listen EPERM|outside of the sandbox|sandbox/i;
4
- const clock = (at = Date.now()) => new Date(at).toTimeString().slice(0, 8);
10
+ /** A saved login is a password (AGENTS.md F5): never copy what a call read from `.auth` into the log. */
11
+ const SECRET_PATH = /(^|[\\/])\.auth([\\/]|$)/;
5
12
  const oneLine = (value, max) => {
6
13
  const text = String(value ?? '').replace(/\s+/g, ' ').trim();
7
14
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
8
15
  };
16
+ const cap = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
9
17
  const seconds = (ms) => (ms < 90_000 ? `${Math.round(ms / 1000)} s` : `${(ms / 60_000).toFixed(1)} min`);
18
+ /** The first thing a piece of code does, for a tool whose only argument is the code (`execute`). */
19
+ function firstStatement(code) {
20
+ const line = code
21
+ .split('\n')
22
+ .map((text) => text.trim())
23
+ .find((text) => text !== '' && !text.startsWith('//') && !text.startsWith('#'));
24
+ return line ?? '';
25
+ }
10
26
  /** `mcp__playwright-test__browser_click` -> `browser_click`. */
11
27
  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. */
28
+ /**
29
+ * What the call is about, in a few words: the intent the agent gave, or the argument that names the thing it touched.
30
+ * The names differ per CLI and per tool (`file_path` for Claude, `path` for OpenCode; `pattern` for glob and grep;
31
+ * `code` for a code-mode `execute`), so every one of them is looked at.
32
+ */
13
33
  function describe(input) {
14
34
  if (!input || typeof input !== 'object')
15
35
  return '';
16
36
  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) : '';
37
+ const key = [
38
+ 'intent',
39
+ 'description',
40
+ 'skill',
41
+ 'name',
42
+ 'command',
43
+ 'path',
44
+ 'file_path',
45
+ 'filePath',
46
+ 'filename',
47
+ 'pattern',
48
+ 'query',
49
+ 'url',
50
+ 'element',
51
+ ].find((name) => typeof args[name] === 'string' && args[name].trim() !== '');
52
+ if (key)
53
+ return oneLine(args[key], MAX_DETAIL);
54
+ if (typeof args.code === 'string')
55
+ return oneLine(firstStatement(args.code), MAX_DETAIL);
56
+ // Nothing known: the longest string still tells more than the bare tool name.
57
+ const longest = Object.values(args)
58
+ .filter((value) => typeof value === 'string')
59
+ .sort((a, b) => b.length - a.length)[0];
60
+ return longest ? oneLine(longest, MAX_DETAIL) : '';
61
+ }
62
+ const callOf = (name, input) => ({
63
+ name: toolName(name),
64
+ detail: describe(input),
65
+ ok: true,
66
+ pending: true,
67
+ });
68
+ const labelText = (call) => (call.detail ? `${call.name}: ${call.detail}` : call.name);
69
+ /**
70
+ * The first lines of what a call returned, with the colors a shell put in it. Long output keeps its head only: the
71
+ * point is to see what happened, not to copy a file into the log.
72
+ */
73
+ function previewText(value) {
74
+ const text = (typeof value === 'string' ? value : value == null ? '' : String(value))
75
+ .replace(/\r\n?/g, '\n')
76
+ .replace(/\t/g, ' ')
77
+ .trim();
78
+ if (!text)
79
+ return null;
80
+ const lines = text.split('\n');
81
+ const kept = [];
82
+ let chars = 0;
83
+ for (const line of lines) {
84
+ if (kept.length >= PREVIEW_LINES || chars >= PREVIEW_CHARS)
85
+ break;
86
+ // A cut line loses its colors, so a half escape sequence cannot bleed into the rest of the block.
87
+ const shown = line.length > PREVIEW_LINE ? oneLine(line.replace(ANSI, ''), PREVIEW_LINE) : line;
88
+ kept.push(shown);
89
+ chars += shown.length;
90
+ }
91
+ const rest = lines.length - kept.length;
92
+ return kept.join('\n') + (rest > 0 ? `\n… (${rest} more line${rest === 1 ? '' : 's'})` : '');
19
93
  }
20
- const callLabel = (name, input) => {
21
- const detail = describe(input);
22
- return detail ? `${toolName(name)}: ${detail}` : toolName(name);
23
- };
24
94
  const HANDOFF_CFG = '__handoffCfg';
25
- const HANDOFF_CARD = /handoff-card\.js$/;
95
+ const HANDOFF_CARD = /handoff-card\.js/;
96
+ /** What the card returns when it stops waiting (`{outcome: 'done' | 'skip' | 'condition' | 'timeout'}`). */
97
+ const HANDOFF_RESULT = /\\?"outcome\\?"\s*:\s*\\?"(?:done|skip|condition|timeout)\\?"/;
98
+ /**
99
+ * Said when a card came back that the UI was never told about: only a call of its own that sets the options lets the
100
+ * UI know a person is needed, so a card that ran with the options of an earlier handoff (or with the options set inside
101
+ * the call that runs the card) has no badge and no notification.
102
+ */
103
+ const MISSED_HANDOFF_HINT = 'HINT: this handoff was not announced: the web UI learns a handoff from the call that sets __handoffCfg, and this ' +
104
+ 'card ran without one (see user-handoff.md, "Two calls, every time"), so there was no "Your turn" badge and no ' +
105
+ 'notification.';
106
+ /** The browser tool a handoff runs through: `browser_run_code`, `browser_run_code_unsafe`, ... */
107
+ const BROWSER_RUN_CODE = /browser_run_code/;
108
+ /**
109
+ * How long "the options are set" survives calls that are not part of the handoff. The agent sets the options and then
110
+ * runs the card, and in between it may take a screenshot or read a file; the badge (and a notification) must not blink
111
+ * off and on for that. Long enough for those gaps, short enough that options never followed by a card do not leave the
112
+ * badge on.
113
+ */
114
+ const HANDOFF_CFG_TTL_MS = 300_000;
26
115
  /** `message: '...'` of the options call that comes right before the card. */
27
116
  function handoffMessage(code) {
28
117
  const match = /message\s*:\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/.exec(code);
29
118
  return match ? match[2].replace(/\\(['"`\\])/g, '$1').trim() || null : null;
30
119
  }
120
+ const codeOf = (input) => {
121
+ if (!input || typeof input !== 'object')
122
+ return '';
123
+ const code = input.code;
124
+ return typeof code === 'string' ? code : '';
125
+ };
126
+ /**
127
+ * Which handoff call this is, if any. The browser tool is called either directly or from a CLI's code mode: OpenCode's
128
+ * `execute` tool takes a `code` string that holds `tools["playwright-test"].browser_run_code_unsafe({...})`, so the
129
+ * nested call is only text inside it and the tool name says nothing about the browser.
130
+ */
31
131
  function handoffCall(name, input) {
32
- if (!name.includes('browser_run_code') || !input || typeof input !== 'object')
132
+ if (!input || typeof input !== 'object')
33
133
  return null;
34
134
  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))
135
+ const code = codeOf(input);
136
+ const filename = typeof args.filename === 'string' ? args.filename : '';
137
+ // A code-mode call is a handoff only when the browser tool is inside it: it also runs everything else.
138
+ if (!BROWSER_RUN_CODE.test(name) && !BROWSER_RUN_CODE.test(code))
139
+ return null;
140
+ if (HANDOFF_CARD.test(filename) || (HANDOFF_CARD.test(code) && /filename\s*:/.test(code)))
38
141
  return 'card';
39
- return null;
142
+ return code.includes(HANDOFF_CFG) ? 'options' : null;
40
143
  }
41
144
  export class AiStreamFormatter {
42
145
  tool;
@@ -44,159 +147,262 @@ export class AiStreamFormatter {
44
147
  pending = new Map();
45
148
  cardCalls = new Set();
46
149
  waitingForUser = null;
150
+ /** The message of the last options call, kept so a card that runs after unrelated calls still carries it. */
151
+ lastHandoffMessage = null;
47
152
  lastEventAt = Date.now();
48
153
  lastWaitAt = 0;
154
+ /** The last call the agent finished, so a quiet spell can say what it is doing instead of only that nothing came. */
155
+ lastCall = '';
49
156
  sandboxHinted = false;
50
157
  constructor(tool) {
51
158
  this.tool = tool;
52
159
  }
53
- /** Lines for a piece of stdout. Incomplete trailing lines wait for the next piece. */
160
+ /** Blocks for a piece of stdout. Incomplete trailing lines wait for the next piece. */
54
161
  feed(text) {
55
162
  this.buffer += text;
56
163
  const lines = this.buffer.split('\n');
57
164
  this.buffer = lines.pop() ?? '';
58
- return lines.map((line) => this.line(line)).join('');
165
+ return lines.flatMap((line) => this.line(line));
59
166
  }
60
167
  /**
61
168
  * Set while a handoff card waits for the person. OpenCode reports a tool call only when it has finished, so the card
62
169
  * 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.
170
+ * of the card call, or a later call, ends it. The object changes on every new handoff, so `===` detects a change.
64
171
  */
65
172
  get awaiting() {
66
173
  return this.waitingForUser;
67
174
  }
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() };
175
+ /**
176
+ * Called once for every tool call the agent makes, with the id the end of that call will carry. Returns a line for the
177
+ * log when a card came back that nobody was told about (`MISSED_HANDOFF_HINT`).
178
+ */
179
+ watchHandoff(id, name, input, ended, output) {
180
+ // The card answers `{outcome: ...}` when it stops waiting: that ends the handoff whatever ran it.
181
+ if (ended && HANDOFF_RESULT.test(String(output ?? ''))) {
182
+ const announced = this.waitingForUser !== null;
183
+ this.waitingForUser = null;
184
+ return announced ? null : { kind: 'hint', at: Date.now(), data: MISSED_HANDOFF_HINT };
74
185
  }
75
- else if (kind === 'card') {
186
+ const kind = handoffCall(name, input);
187
+ if (kind === 'card') {
76
188
  if (ended)
77
189
  this.waitingForUser = null;
78
190
  else {
79
191
  this.cardCalls.add(id);
80
- this.waitingForUser ??= { message: null, since: Date.now() };
192
+ // `??=` on purpose: the options call before it already knows the message and when the wait started.
193
+ this.waitingForUser ??= { message: handoffMessage(codeOf(input)) ?? this.lastHandoffMessage, since: Date.now() };
81
194
  }
195
+ return null;
82
196
  }
83
- else {
84
- this.waitingForUser = null;
197
+ if (kind === 'options') {
198
+ const message = handoffMessage(codeOf(input));
199
+ if (message)
200
+ this.lastHandoffMessage = message;
201
+ // Keep the start time when the wait is already set: a second report of the same handoff must not announce it twice.
202
+ const since = this.waitingForUser?.since ?? Date.now();
203
+ this.waitingForUser = { message: message ?? this.lastHandoffMessage, since };
204
+ return null;
85
205
  }
206
+ // Anything else ends the wait, unless it was set too recently to be an abandoned one: the card runs right after it.
207
+ if (this.waitingForUser && Date.now() - this.waitingForUser.since > HANDOFF_CFG_TTL_MS)
208
+ this.waitingForUser = null;
209
+ return null;
86
210
  }
87
211
  /** What is left when the process ends. */
88
212
  end() {
89
213
  const rest = this.buffer;
90
214
  this.buffer = '';
91
- return rest.trim() ? this.line(rest) : '';
215
+ return rest.trim() ? this.line(rest) : [];
92
216
  }
93
217
  /**
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).
218
+ * A block about the oldest tool call that has not returned, when nothing has happened for `quietMs` (and again every
219
+ * `quietMs` after that). It names the last call that did finish, so a long run never looks stuck.
96
220
  */
97
221
  waiting(quietMs) {
98
222
  const now = Date.now();
99
223
  if (now - Math.max(this.lastEventAt, this.lastWaitAt) < quietMs)
100
- return '';
224
+ return null;
101
225
  this.lastWaitAt = now;
102
226
  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`;
227
+ const last = this.lastCall ? ` (last: ${this.lastCall})` : '';
228
+ const data = first
229
+ ? `... still waiting for ${labelText(first)} (running ${seconds(now - first.startedAt)})`
230
+ : `... no output from the agent for ${seconds(now - this.lastEventAt)}${last}`;
231
+ return { kind: 'meta', at: now, data };
107
232
  }
108
233
  line(raw) {
109
234
  const trimmed = raw.trim();
110
235
  if (!trimmed)
111
- return '';
236
+ return [];
112
237
  let event;
113
238
  try {
114
239
  event = JSON.parse(trimmed);
115
240
  }
116
241
  catch {
117
- return `${raw}\n`;
242
+ // Not one of the CLI's events: keep the line as it came, it is the only trace of it.
243
+ return [{ kind: 'meta', at: Date.now(), data: raw.trimEnd() }];
244
+ }
245
+ if (!event || typeof event !== 'object' || typeof event.type !== 'string') {
246
+ return [{ kind: 'meta', at: Date.now(), data: raw.trimEnd() }];
118
247
  }
119
- if (!event || typeof event !== 'object' || typeof event.type !== 'string')
120
- return `${raw}\n`;
121
248
  this.lastEventAt = Date.now();
122
249
  return this.tool === 'claude' ? this.claude(event) : this.opencode(event);
123
250
  }
124
- start(id, label) {
251
+ /** A call the agent has started and not finished yet (Claude reports both ends). */
252
+ start(id, call) {
125
253
  const now = Date.now();
126
- this.pending.set(id, { label, startedAt: now });
127
- return `[${clock(now)}] >> ${label}\n`;
254
+ this.pending.set(id, { name: call.name, detail: call.detail, startedAt: now });
255
+ return [{ kind: 'tool', at: now, data: `>> ${labelText(call)}`, tool: call }];
128
256
  }
129
- end1(id, ok, error, fallbackLabel) {
257
+ /**
258
+ * The end of a call, plus the first lines of what it returned. `fallback` is for a CLI that reports a call only when
259
+ * it is over (OpenCode), which still knows when it started.
260
+ */
261
+ finishCall(id, ok, output, fallback) {
130
262
  const now = Date.now();
131
263
  const call = this.pending.get(id);
132
264
  this.pending.delete(id);
133
265
  if (this.cardCalls.delete(id))
134
266
  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;
267
+ const name = call?.name ?? fallback?.name ?? 'tool';
268
+ const detail = call?.detail ?? fallback?.detail ?? '';
269
+ const startedAt = call?.startedAt ?? fallback?.startedAt;
270
+ const took = startedAt ? seconds(now - startedAt) : undefined;
271
+ const label = labelText({ name, detail });
272
+ this.lastCall = label;
273
+ const parts = [
274
+ {
275
+ kind: 'tool',
276
+ at: now,
277
+ data: `${ok ? 'ok' : 'FAILED'}: ${label}${took ? ` after ${took}` : ''}`,
278
+ tool: { name, detail, ok, ...(took && { took }) },
279
+ },
280
+ ];
281
+ if (!ok) {
282
+ const error = oneLine(output, MAX_ERROR);
283
+ if (error)
284
+ parts.push({ kind: 'error', at: now, data: error });
285
+ return parts;
286
+ }
287
+ // A saved login is a password: what a call read from `.auth` never goes into the log.
288
+ const preview = SECRET_PATH.test(detail) ? null : previewText(output);
289
+ if (preview)
290
+ parts.push({ kind: 'result', at: now, data: preview });
291
+ return parts;
139
292
  }
140
293
  /** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */
141
294
  sandboxHint(isError, body) {
142
295
  if (this.sandboxHinted || !isError || !SANDBOX_ERROR.test(String(body ?? '')))
143
- return '';
296
+ return null;
144
297
  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');
298
+ return {
299
+ kind: 'hint',
300
+ at: Date.now(),
301
+ data: "HINT: Claude Code's sandbox blocked a command and a one-shot run cannot approve it. Options:\n" +
302
+ ' - Switch the AI agent to OpenCode in the top bar and run again (it does not use this sandbox).\n' +
303
+ ' - Or run the command yourself in a terminal and paste the output into the agent.\n' +
304
+ ' - Or check that the command matches "sandbox.excludedCommands" in .claude/settings.json (every command of a chained call must match).',
305
+ };
149
306
  }
150
307
  claude(event) {
151
308
  const content = event.message?.content;
152
309
  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`;
310
+ return content.flatMap((block) => {
311
+ if (block?.type === 'text' && block.text?.trim()) {
312
+ return [{ kind: 'markdown', at: Date.now(), data: block.text.trimEnd() }];
313
+ }
314
+ // A thinking block: worth reading while it waits, but never mistaken for the answer (see `--thinking`).
315
+ if (block?.type === 'thinking' && block.thinking?.trim()) {
316
+ return [{ kind: 'reasoning', at: Date.now(), data: cap(block.thinking.trim(), MAX_REASONING) }];
317
+ }
157
318
  if (block?.type === 'tool_use') {
158
319
  this.watchHandoff(String(block.id), String(block.name), block.input, false);
159
- return this.start(String(block.id), callLabel(String(block.name), block.input));
320
+ return this.start(String(block.id), callOf(String(block.name), block.input));
160
321
  }
161
- return '';
162
- })
163
- .join('');
322
+ return [];
323
+ });
164
324
  }
165
325
  if (event.type === 'user' && Array.isArray(content)) {
166
326
  return content
167
327
  .filter((block) => block?.type === 'tool_result')
168
- .map((block) => {
328
+ .flatMap((block) => {
169
329
  const body = Array.isArray(block.content)
170
330
  ? block.content.map((part) => part?.text ?? '').join(' ')
171
331
  : block.content;
172
- return this.end1(String(block.tool_use_id), !block.is_error, body) + this.sandboxHint(block.is_error, body);
173
- })
174
- .join('');
332
+ const hint = this.sandboxHint(block.is_error, body);
333
+ return [...this.finishCall(String(block.tool_use_id), !block.is_error, body), ...(hint ? [hint] : [])];
334
+ });
175
335
  }
176
336
  if (event.type === 'result' && event.is_error) {
177
- return `[${clock()}] FAILED: ${oneLine(event.result ?? event.subtype, MAX_ERROR)}\n`;
337
+ return [{ kind: 'error', at: Date.now(), data: oneLine(event.result ?? event.subtype, MAX_ERROR) }];
178
338
  }
179
- return '';
339
+ return [];
180
340
  }
181
341
  opencode(event) {
182
342
  const part = event.part ?? {};
183
- if (event.type === 'text' && typeof part.text === 'string' && part.text.trim())
184
- return `${part.text.trimEnd()}\n`;
343
+ // The CLI reports a failed turn (bad model, no credentials, a usage limit, a provider outage) as an error event,
344
+ // not on stderr: without this branch the log of a run that died at once showed nothing but the exit code.
345
+ if (event.type === 'error')
346
+ return this.opencodeError(event.error);
347
+ // `--thinking` is what makes OpenCode ask the model to think; without it these blocks are never sent.
348
+ if (event.type === 'reasoning' && typeof part.text === 'string' && part.text.trim()) {
349
+ return [{ kind: 'reasoning', at: Date.now(), data: cap(part.text.trim(), MAX_REASONING) }];
350
+ }
351
+ if (event.type === 'text' && typeof part.text === 'string' && part.text.trim()) {
352
+ return [{ kind: 'markdown', at: Date.now(), data: part.text.trimEnd() }];
353
+ }
185
354
  if (event.type === 'tool_use') {
186
355
  const state = part.state ?? {};
187
- const label = callLabel(String(part.tool ?? 'tool'), state.input);
188
- const id = String(part.callID ?? part.id ?? label);
356
+ const call = callOf(String(part.tool ?? 'tool'), state.input);
357
+ const id = String(part.callID ?? part.id ?? labelText(call));
189
358
  const finished = state.status === 'completed' || state.status === 'error';
190
- this.watchHandoff(id, String(part.tool ?? ''), state.input, finished);
359
+ const hint = this.watchHandoff(id, String(part.tool ?? ''), state.input, finished, finished ? (state.error ?? state.output) : undefined);
191
360
  if (finished) {
192
361
  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);
362
+ const parts = this.finishCall(id, state.status === 'completed', state.error ?? state.output, {
363
+ ...call,
364
+ ...(typeof time.start === 'number' && { startedAt: time.start }),
365
+ });
366
+ return hint ? [...parts, hint] : parts;
197
367
  }
198
- return this.start(id, label);
368
+ const parts = this.start(id, call);
369
+ return hint ? [...parts, hint] : parts;
199
370
  }
200
- return '';
371
+ return [];
201
372
  }
373
+ /** `FAILED: <why>`, plus what to do about the two failures users actually hit. */
374
+ opencodeError(error) {
375
+ const body = (error ?? {});
376
+ const now = Date.now();
377
+ const message = oneLine(body.message ?? body.type ?? 'the agent failed', MAX_ERROR);
378
+ const type = String(body.type ?? '');
379
+ const parts = [{ kind: 'error', at: now, data: `FAILED: ${message}` }];
380
+ if (type === 'provider.free-limit') {
381
+ const wait = typeof body.retryAfterMs === 'number' ? ` (it comes back in about ${waitFor(body.retryAfterMs)})` : '';
382
+ parts.push({
383
+ kind: 'hint',
384
+ at: now,
385
+ data: 'HINT: this run used a free OpenCode model, and its free usage limit is spent' +
386
+ `${wait}. Pick a model in the top bar (it has to belong to a provider you are signed in to), ` +
387
+ 'or switch the AI agent to Claude. A model can also be set as "model" in opencode.jsonc.',
388
+ });
389
+ }
390
+ else if (type === 'provider.no-route' || type === 'provider.no-key' || type === 'provider.auth') {
391
+ parts.push({
392
+ kind: 'hint',
393
+ at: now,
394
+ data: 'HINT: the model is not available to this OpenCode install. Pick another one in the top bar, ' +
395
+ 'or sign the provider in with "opencode auth login".',
396
+ });
397
+ }
398
+ return parts;
399
+ }
400
+ }
401
+ /** A retry delay as a person reads it: `40 s`, `12.5 min`, `15.3 h`. */
402
+ function waitFor(ms) {
403
+ if (ms < 90_000)
404
+ return `${Math.max(1, Math.round(ms / 1000))} s`;
405
+ if (ms < 90 * 60_000)
406
+ return `${(ms / 60_000).toFixed(1)} min`;
407
+ return `${(ms / 3_600_000).toFixed(1)} h`;
202
408
  }
@@ -8,10 +8,11 @@ import { listEnvs } from '../../lib/profiles.js';
8
8
  import { listSuites } from '../../lib/cases.js';
9
9
  import { FRAMEWORK_ROOT, ROOT } from '../../lib/paths.js';
10
10
  import { ActionError, buildJobSpec, planRunCases, previewRun, previewSchema, startJobSchema } from './actions.js';
11
- import { addProfile, listProfileRows, ProfileError, verifyProfiles } from './profiles.js';
11
+ import { addProfile, deleteProfile, editProfile, forgetSession, listProfileRows, ProfileError, verifyProfiles } from './profiles.js';
12
12
  import { CaseError, caseImpact, createCase, deleteCase, isValidCaseId, listCaseIds, listCases, openSpec, readCase, readSpec, renameCase, writeCase, writeSpec, } from './cases.js';
13
13
  import { resolveInside, sendFile } from './files.js';
14
14
  import { JobConflictError, jobs } from './jobs.js';
15
+ import { listAiModels } from './ai-models.js';
15
16
  import { fileEvents } from './watch-files.js';
16
17
  const REPORTS_DIR = path.join(ROOT, 'reports');
17
18
  // Built by `prepack` and shipped with the package; the framework repository builds it with `pnpm ui`.
@@ -80,21 +81,56 @@ export function createApp({ port }) {
80
81
  return c.json({ root: ROOT, envs: listEnvs(), suites, caseIds: listCaseIds() });
81
82
  });
82
83
  api.get('/profiles', (c) => c.json(listProfileRows()));
83
- api.post('/profiles', async (c) => {
84
- const body = z
84
+ const profileFields = {
85
+ name: z.string().max(100),
86
+ env: z.string().max(40),
87
+ baseURL: z.string().max(200),
88
+ account: z.string().max(200).optional(),
89
+ tags: z.array(z.string().max(40)).max(20).optional(),
90
+ startPath: z.string().max(300).optional(),
91
+ sessionCheckPath: z.string().max(200).optional(),
92
+ // Optional keys: an omitted one keeps its default. The ranges are checked by lib/profiles.ts, so the
93
+ // form and a hand-edited profiles.yaml fail with the same message.
94
+ sessionCheck: z
85
95
  .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(),
96
+ loggedOutUrl: z.string().max(300).optional(),
97
+ loggedInSelector: z.string().max(300).optional(),
98
+ loggedOutSelector: z.string().max(300).optional(),
99
+ timeoutMs: z.number().int().optional(),
94
100
  })
95
- .parse(await c.req.json());
101
+ .optional(),
102
+ email: z.boolean().optional(),
103
+ };
104
+ api.post('/profiles', async (c) => {
105
+ const body = z.object({ id: z.string().max(60), ...profileFields }).parse(await c.req.json());
96
106
  return c.json(addProfile(body), 201);
97
107
  });
108
+ // A running job read profiles.yaml at its start and uses the session files while it runs, so a profile
109
+ // cannot change underneath it.
110
+ const assertProfileIdle = (id) => {
111
+ const busy = jobs
112
+ .list()
113
+ .find((job) => job.status === 'running' &&
114
+ (job.kind === 'run' ||
115
+ job.kind === 'ai' ||
116
+ (job.profile === id && (job.kind === 'signin' || job.kind === 'visit'))));
117
+ if (busy)
118
+ throw new ProfileError(`Wait for "${busy.command}" to finish first.`, 409);
119
+ };
120
+ // The id is fixed: commands, session files and baseline names use it. Changing the site address logs the profile out.
121
+ api.put('/profiles/:id', async (c) => {
122
+ const id = c.req.param('id');
123
+ assertProfileIdle(id);
124
+ const body = z.object(profileFields).parse(await c.req.json());
125
+ return c.json(editProfile({ ...body, id }));
126
+ });
127
+ // Removes the entry from profiles.yaml and deletes the saved login. Never reads the session file.
128
+ api.delete('/profiles/:id', (c) => {
129
+ const id = c.req.param('id');
130
+ assertProfileIdle(id);
131
+ deleteProfile(id);
132
+ return c.json(listProfileRows());
133
+ });
98
134
  api.post('/profiles/verify', async (c) => {
99
135
  const body = z
100
136
  .object({ profile: z.string().optional(), env: z.string().optional() })
@@ -104,6 +140,13 @@ export function createApp({ port }) {
104
140
  await new Promise((resolve) => setTimeout(resolve, 20));
105
141
  return c.json(listProfileRows());
106
142
  });
143
+ // Forgets the saved login of one profile. Never reads it: the file is only deleted.
144
+ api.post('/profiles/logout', async (c) => {
145
+ const { profile: id } = z.object({ profile: z.string().min(1).max(60) }).parse(await c.req.json());
146
+ assertProfileIdle(id);
147
+ forgetSession(id);
148
+ return c.json(listProfileRows());
149
+ });
107
150
  api.get('/cases', (c) => c.json(listCases()));
108
151
  api.post('/cases', async (c) => {
109
152
  const { id } = z.object({ id: z.string() }).parse(await c.req.json());
@@ -172,6 +215,19 @@ export function createApp({ port }) {
172
215
  const image = resolveInside(dir, file);
173
216
  return image ? sendFile(c, image) : c.notFound();
174
217
  });
218
+ // A reference screenshot of a target card: the case names it as a project-relative path under `.explore/`, which is
219
+ // local state and git-ignored, so the file may be missing on this machine. Only images, only inside `.explore/`.
220
+ const REFERENCE_IMAGE = /\.(png|jpe?g|webp)$/i;
221
+ app.get('/explore-files', (c) => {
222
+ const file = (c.req.query('file') ?? '').replace(/\\/g, '/');
223
+ if (!file.startsWith('.explore/') || file.includes('..') || !REFERENCE_IMAGE.test(file))
224
+ return c.notFound();
225
+ const base = path.join(ROOT, '.explore');
226
+ if (!fs.existsSync(base))
227
+ return c.notFound();
228
+ const image = resolveInside(base, file.slice('.explore/'.length));
229
+ return image ? sendFile(c, image) : c.notFound();
230
+ });
175
231
  api.get('/reports', (c) => {
176
232
  if (!fs.existsSync(REPORTS_DIR))
177
233
  return c.json([]);
@@ -205,6 +261,12 @@ export function createApp({ port }) {
205
261
  });
206
262
  api.post('/runs/preview', async (c) => c.json(previewRun(previewSchema.parse(await c.req.json()))));
207
263
  api.post('/runs/plan', async (c) => c.json(await planRunCases(previewSchema.parse(await c.req.json()))));
264
+ // The models the chosen AI CLI can be run with here (read from the CLI or its configuration, never from a list
265
+ // this app ships). `?refresh=1` reads them again instead of using the short cache.
266
+ api.get('/ai/models', async (c) => {
267
+ const tool = c.req.query('tool') === 'claude' ? 'claude' : 'opencode';
268
+ return c.json(await listAiModels(tool, c.req.query('refresh') === '1'));
269
+ });
208
270
  api.get('/jobs', (c) => c.json(jobs.list()));
209
271
  api.post('/jobs', async (c) => {
210
272
  const request = startJobSchema.parse(await c.req.json());
@@ -12,7 +12,7 @@ export interface JobSpec {
12
12
  env?: string;
13
13
  /** Shown to the user. */
14
14
  command: string;
15
- /** Executable and arguments, spawned without a shell. */
15
+ /** Executable and arguments: looked up on PATH, spawned without a shell (a Windows `.cmd` shim goes through cmd.exe). */
16
16
  file: string;
17
17
  args: string[];
18
18
  /** Jobs holding the same lock cannot run at the same time. */
@@ -31,6 +31,8 @@ export interface JobSpec {
31
31
  caseId: string;
32
32
  env: string;
33
33
  sessionId: string;
34
+ model?: string;
35
+ thinking?: boolean;
34
36
  };
35
37
  /** The job whose conversation this one continues. */
36
38
  continuesJobId?: string;
@@ -65,7 +67,11 @@ declare class JobManager {
65
67
  /** Publishes a change of "waiting for the person" (a handoff card) like any other status change. */
66
68
  private syncAwaiting;
67
69
  private signalGroup;
70
+ /** A block of an AI run's log, with the time it was produced and how the UI should present it. */
71
+ private pushPart;
68
72
  private push;
73
+ /** Keeps the chunk for the browser and writes it to the job's log file, which is what is left after the run. */
74
+ private store;
69
75
  private finish;
70
76
  private pruneFinished;
71
77
  private pruneOldLogs;