@asrunjs/asrun 0.2.0 → 0.5.3
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.
- package/.claude/commands/explore-case.md +18 -15
- package/.claude/commands/gen-spec.md +16 -13
- package/.claude/commands/heal-spec.md +26 -23
- package/.claude/commands/make-case.md +11 -8
- package/.claude/skills/exploring-cases/SKILL.md +146 -132
- package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
- package/.claude/skills/exploring-cases/references/readiness.md +146 -137
- package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
- package/.claude/skills/generating-specs/SKILL.md +219 -207
- package/.claude/skills/generating-specs/references/conventions.md +148 -148
- package/.claude/skills/making-cases/SKILL.md +72 -64
- package/.claude/skills/making-cases/references/normalizing.md +39 -38
- package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
- package/AGENTS.md +169 -154
- package/CLAUDE.md +2 -1
- package/README.md +221 -410
- package/dist/lib/build-artifacts.d.ts +8 -0
- package/dist/lib/build-artifacts.js +10 -0
- package/dist/lib/case-doc.d.ts +311 -0
- package/dist/lib/case-doc.js +1117 -0
- package/dist/lib/cases.d.ts +2 -15
- package/dist/lib/cases.js +5 -78
- package/dist/lib/command.d.ts +24 -0
- package/dist/lib/command.js +90 -0
- package/dist/lib/fixtures.js +2 -2
- package/dist/lib/login-card.d.ts +22 -0
- package/dist/lib/login-card.js +183 -0
- package/dist/lib/node-cli.d.ts +6 -2
- package/dist/lib/node-cli.js +16 -6
- package/dist/lib/profiles.d.ts +25 -1
- package/dist/lib/profiles.js +54 -1
- package/dist/lib/run-filters.js +10 -2
- package/dist/lib/session.d.ts +13 -1
- package/dist/lib/session.js +73 -7
- package/dist/lib/targets.d.ts +5 -13
- package/dist/lib/targets.js +4 -29
- package/dist/lib/values.js +2 -2
- package/dist/scripts/build.js +2 -1
- package/dist/scripts/check-cases.js +74 -6
- package/dist/scripts/doctor.js +115 -4
- package/dist/scripts/explore.js +22 -1
- package/dist/scripts/go.mjs +188 -188
- package/dist/scripts/init.d.ts +7 -1
- package/dist/scripts/init.js +62 -14
- package/dist/scripts/lint-specs.js +4 -1
- package/dist/scripts/login.js +30 -2
- package/dist/ui/server/actions.d.ts +4 -0
- package/dist/ui/server/actions.js +25 -12
- package/dist/ui/server/ai-models.d.ts +3 -0
- package/dist/ui/server/ai-models.js +347 -0
- package/dist/ui/server/ai-stream.d.ts +34 -9
- package/dist/ui/server/ai-stream.js +283 -77
- package/dist/ui/server/app.js +74 -12
- package/dist/ui/server/jobs.d.ts +7 -1
- package/dist/ui/server/jobs.js +69 -15
- package/dist/ui/server/profiles.d.ts +20 -1
- package/dist/ui/server/profiles.js +214 -38
- package/dist/ui/shared/ai-commands.d.ts +16 -6
- package/dist/ui/shared/ai-commands.js +35 -12
- package/dist/ui/shared/types.d.ts +114 -0
- package/docs/agents/case-format.md +193 -0
- package/docs/agents/framework-dev.md +167 -0
- package/docs/ai-workflow.md +235 -0
- package/docs/cases.md +189 -0
- package/docs/commands.md +175 -0
- package/docs/install.md +174 -0
- package/docs/profiles.md +139 -0
- package/docs/theme-guide.html +922 -692
- package/docs/troubleshooting.md +101 -0
- package/docs/web-ui.md +139 -0
- package/package.json +20 -1
- package/templates/.claude/commands/explore-case.md +22 -21
- package/templates/.claude/commands/gen-spec.md +21 -20
- package/templates/.claude/commands/heal-spec.md +31 -30
- package/templates/.claude/commands/make-case.md +13 -12
- package/templates/.mcp.json +8 -8
- package/templates/AGENTS.md +66 -45
- package/templates/cases/_template.md +57 -63
- package/templates/opencode.jsonc +16 -11
- package/templates/profiles.yaml +42 -29
- package/ui/dist/assets/{dist-D_I45xgJ.js → dist-BudNAjOv.js} +1 -1
- package/ui/dist/assets/{dist-CBV7_vDN.js → dist-C-eCSFja.js} +1 -1
- package/ui/dist/assets/{dist-gF2bFn-5.js → dist-CO4heSzh.js} +1 -1
- package/ui/dist/assets/{dist-DX8knLVr.js → dist-CPLhRzom.js} +1 -1
- package/ui/dist/assets/{dist-BqAROxzI.js → dist-DEPJZ3Me.js} +1 -1
- package/ui/dist/assets/{dist-BwYRbe5s.js → dist-DZ7RiqmA.js} +1 -1
- package/ui/dist/assets/{dist-BHaIOZdW.js → dist-LixzGniF.js} +1 -1
- package/ui/dist/assets/{dist-DHCY9KpV.js → dist-O_RVi4tP.js} +1 -1
- package/ui/dist/assets/{dist-BiE4suo9.js → dist-jPS6HCJ5.js} +1 -1
- package/ui/dist/assets/{dist-BSJMM_jp.js → dist-vmf3fnkM.js} +1 -1
- package/ui/dist/assets/index-BIGt8Hfz.js +338 -0
- package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
- package/ui/dist/help/ai-tools.png +0 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/help/case-generate.png +0 -0
- package/ui/dist/help/cases-memo.png +0 -0
- package/ui/dist/help/profile-form.png +0 -0
- package/ui/dist/help/profiles-login.png +0 -0
- package/ui/dist/help/reports.png +0 -0
- package/ui/dist/help/run-tests.png +0 -0
- package/ui/dist/index.html +22 -16
- package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
- 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
|
-
|
|
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
|
-
/**
|
|
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 = [
|
|
18
|
-
|
|
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 (!
|
|
132
|
+
if (!input || typeof input !== 'object')
|
|
33
133
|
return null;
|
|
34
134
|
const args = input;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
104
|
-
|
|
105
|
-
?
|
|
106
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
127
|
-
return
|
|
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
|
-
|
|
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
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
.
|
|
155
|
-
|
|
156
|
-
|
|
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),
|
|
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
|
-
.
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
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
|
|
188
|
-
const id = String(part.callID ?? part.id ??
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/ui/server/app.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
.
|
|
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());
|
package/dist/ui/server/jobs.d.ts
CHANGED
|
@@ -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;
|