@asrunjs/asrun 0.12.0 → 0.14.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.
- package/.claude/skills/exploring-cases/SKILL.md +26 -11
- package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
- package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
- package/.claude/skills/exploring-cases/references/readiness.md +6 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +60 -27
- package/AGENTS.md +3 -2
- package/README.md +1 -1
- package/dist/lib/desktop-notify.d.ts +38 -0
- package/dist/lib/desktop-notify.js +158 -0
- package/dist/lib/paths.d.ts +9 -2
- package/dist/lib/paths.js +32 -5
- package/dist/lib/pdf.js +23 -3
- package/dist/scripts/check-cases.js +3 -3
- package/dist/scripts/cli.js +9 -2
- package/dist/scripts/doctor.js +39 -1
- package/dist/scripts/notify-test.d.ts +1 -0
- package/dist/scripts/notify-test.js +48 -0
- package/dist/ui/server/actions.js +4 -2
- package/dist/ui/server/ai-stream.d.ts +9 -3
- package/dist/ui/server/ai-stream.js +216 -37
- package/dist/ui/server/jobs.d.ts +10 -0
- package/dist/ui/server/jobs.js +41 -1
- package/dist/ui/shared/ai-commands.js +4 -2
- package/dist/ui/shared/log-text.d.ts +13 -0
- package/dist/ui/shared/log-text.js +45 -0
- package/dist/ui/shared/types.d.ts +8 -1
- package/docs/agents/case-format.md +2 -1
- package/docs/ai-workflow.md +11 -7
- package/docs/cases.md +5 -5
- package/docs/commands.md +20 -1
- package/docs/install.md +1 -1
- package/docs/troubleshooting.md +17 -0
- package/docs/web-ui.md +25 -8
- package/package.json +4 -2
- package/templates/.claude/commands/explore-case.md +3 -2
- package/templates/package.json +1 -1
- package/ui/dist/assets/{dist-CJnqQMvX.js → dist-BlFnpAOk.js} +1 -1
- package/ui/dist/assets/{dist-D8TUm3Ai.js → dist-BwXTr_k4.js} +1 -1
- package/ui/dist/assets/{dist-Clj0wGFy.js → dist-BzDmxZ9V.js} +1 -1
- package/ui/dist/assets/{dist-BjlbPf3t.js → dist-C1uAVsF-.js} +1 -1
- package/ui/dist/assets/{dist-CaAcCNRS.js → dist-CjFzi6xl.js} +1 -1
- package/ui/dist/assets/{dist-DkAx5a-Y.js → dist-D7YBl9nc.js} +1 -1
- package/ui/dist/assets/{dist-Bfv586Or.js → dist-DxeJFPaO.js} +1 -1
- package/ui/dist/assets/{dist-DUDqh_3e.js → dist-DxlXH2E-.js} +1 -1
- package/ui/dist/assets/{dist-BX88bMYN.js → dist-fpvlcqX7.js} +1 -1
- package/ui/dist/assets/{dist-cuboTVh-.js → dist-jGvx4PBr.js} +1 -1
- package/ui/dist/assets/index-Bo4XKJvP.css +2 -0
- package/ui/dist/assets/index-Djsj43vv.js +376 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-DaUQORCy.js +0 -353
- package/ui/dist/assets/index-j3W4BBCj.css +0 -2
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { expandEscapedText } from '../shared/log-text.js';
|
|
1
2
|
const MAX_DETAIL = 180;
|
|
2
3
|
const MAX_ERROR = 300;
|
|
3
4
|
const MAX_REASONING = 2000;
|
|
4
|
-
/**
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** The argument of a call is kept whole, within reason: the log opens it instead of a truncated summary. */
|
|
6
|
+
const MAX_INPUT = 4000;
|
|
7
|
+
/**
|
|
8
|
+
* An edit carries the snippet it replaces and the one it writes, or the whole content of a small file. Keeping more of
|
|
9
|
+
* it is what lets the log draw a diff instead of a JSON blob cut in the middle.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_INPUT_EDIT = 20_000;
|
|
12
|
+
/** Tools whose argument is a change to a file (the same family the log draws as a diff). */
|
|
13
|
+
const EDIT_TOOL = /^(edit|multiedit|write|notebookedit|patch|applypatch)$/;
|
|
14
|
+
/** What a call returned is kept up to this, so the log can show it all; a runaway dump still has an end. */
|
|
15
|
+
const MAX_RESULT_CHARS = 20_000;
|
|
16
|
+
const MAX_RESULT_LINES = 400;
|
|
17
|
+
/** One line longer than this is a minified document or a data URL: its tail says nothing more. */
|
|
18
|
+
const MAX_RESULT_LINE = 2000;
|
|
8
19
|
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
9
20
|
const SANDBOX_ERROR = /listen EPERM|outside of the sandbox|sandbox/i;
|
|
10
21
|
/** A saved login is a password (AGENTS.md F5): never copy what a call read from `.auth` into the log. */
|
|
@@ -14,8 +25,15 @@ const oneLine = (value, max) => {
|
|
|
14
25
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
15
26
|
};
|
|
16
27
|
const cap = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
28
|
+
/** Keeps a body whole up to `max`, and says how much it left out instead of cutting it silently. */
|
|
29
|
+
function capBody(text, max) {
|
|
30
|
+
if (text.length <= max)
|
|
31
|
+
return text;
|
|
32
|
+
const rest = text.length - max;
|
|
33
|
+
return `${text.slice(0, max)}\n… (${rest} more character${rest === 1 ? '' : 's'} not shown)`;
|
|
34
|
+
}
|
|
17
35
|
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
|
|
36
|
+
/** The first thing a piece of code does, for code that makes no tool call of its own (`await page.goto(...)`). */
|
|
19
37
|
function firstStatement(code) {
|
|
20
38
|
const line = code
|
|
21
39
|
.split('\n')
|
|
@@ -23,12 +41,98 @@ function firstStatement(code) {
|
|
|
23
41
|
.find((text) => text !== '' && !text.startsWith('//') && !text.startsWith('#'));
|
|
24
42
|
return line ?? '';
|
|
25
43
|
}
|
|
44
|
+
/** `tools["playwright-test"]`, `tools.playwright.mcp`, `tools['x']['y']`: one step into the tools object. */
|
|
45
|
+
const ACCESSOR = /^\s*(?:\.\s*([A-Za-z0-9_$]+)|\[\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2\s*\])/;
|
|
46
|
+
function stepInto(text) {
|
|
47
|
+
const match = ACCESSOR.exec(text);
|
|
48
|
+
if (!match)
|
|
49
|
+
return null;
|
|
50
|
+
return { value: match[1] ?? match[3], length: match[0].length };
|
|
51
|
+
}
|
|
52
|
+
/** How much of a call's arguments is read to name the thing it touched. */
|
|
53
|
+
const SUMMARY_ARGS = 600;
|
|
54
|
+
/** The argument that says most about a call, in the order a person would look for it: the place it touched, then the
|
|
55
|
+
* intent it was given for. */
|
|
56
|
+
const ARG_KEYS = [
|
|
57
|
+
'url',
|
|
58
|
+
'path',
|
|
59
|
+
'file_path',
|
|
60
|
+
'filePath',
|
|
61
|
+
'filename',
|
|
62
|
+
'element',
|
|
63
|
+
'selector',
|
|
64
|
+
'pattern',
|
|
65
|
+
'query',
|
|
66
|
+
'intent',
|
|
67
|
+
'description',
|
|
68
|
+
'name',
|
|
69
|
+
'text',
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* The calls a code-mode `execute` makes on the tools it is given
|
|
73
|
+
* (`tools["playwright-test"].browser_navigate({url: "..."})`). The code is the only thing that says what such a call
|
|
74
|
+
* did: the tool `execute` itself names nothing.
|
|
75
|
+
*/
|
|
76
|
+
function nestedCalls(code) {
|
|
77
|
+
const calls = [];
|
|
78
|
+
let from = 0;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const at = code.indexOf('tools', from);
|
|
81
|
+
if (at < 0)
|
|
82
|
+
break;
|
|
83
|
+
from = at + 'tools'.length;
|
|
84
|
+
// `prototype.tools[...]` is somebody else's object, not the code mode's own.
|
|
85
|
+
if (at > 0 && /[A-Za-z0-9_$.]/.test(code[at - 1]))
|
|
86
|
+
continue;
|
|
87
|
+
const namespace = stepInto(code.slice(from));
|
|
88
|
+
if (!namespace)
|
|
89
|
+
continue;
|
|
90
|
+
const tool = stepInto(code.slice(from + namespace.length));
|
|
91
|
+
if (!tool)
|
|
92
|
+
continue;
|
|
93
|
+
const callAt = from + namespace.length + tool.length;
|
|
94
|
+
const open = /^\s*\(/.test(code.slice(callAt)) ? code.indexOf('(', callAt) : -1;
|
|
95
|
+
if (open < 0)
|
|
96
|
+
continue;
|
|
97
|
+
calls.push({ name: tool.value, args: code.slice(open + 1, open + 1 + SUMMARY_ARGS) });
|
|
98
|
+
from = open + 1;
|
|
99
|
+
}
|
|
100
|
+
return calls;
|
|
101
|
+
}
|
|
102
|
+
/** The first argument of a call that says what it touched: the URL, the element, the file, the intent. */
|
|
103
|
+
function firstArgument(args) {
|
|
104
|
+
const found = new Map();
|
|
105
|
+
for (const match of args.matchAll(/(['"]?)([A-Za-z_$][\w$]*)\1\s*:\s*(['"`])((?:\\.|(?!\3)[^\\])*?)\3/g)) {
|
|
106
|
+
if (!found.has(match[2]))
|
|
107
|
+
found.set(match[2], match[4].replace(/\\(['"`\\])/g, '$1'));
|
|
108
|
+
}
|
|
109
|
+
for (const key of ARG_KEYS) {
|
|
110
|
+
const value = found.get(key);
|
|
111
|
+
if (value && value.trim())
|
|
112
|
+
return oneLine(value, MAX_DETAIL);
|
|
113
|
+
}
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What a code-mode call does, in one line: `browser_navigate(http://…)`, or the code's first statement when it makes no
|
|
118
|
+
* tool call. The whole code opens from the call's card when the line is not enough.
|
|
119
|
+
*/
|
|
120
|
+
function codeSummary(code) {
|
|
121
|
+
const calls = nestedCalls(code);
|
|
122
|
+
const [call] = calls;
|
|
123
|
+
const summary = call ? `${call.name}(${firstArgument(call.args)})` : oneLine(firstStatement(code), MAX_DETAIL);
|
|
124
|
+
const extra = calls.length - 1;
|
|
125
|
+
if (extra > 0)
|
|
126
|
+
return cap(`${summary} +${extra} more call${extra === 1 ? '' : 's'}`, MAX_DETAIL);
|
|
127
|
+
return cap(summary, MAX_DETAIL);
|
|
128
|
+
}
|
|
26
129
|
/** `mcp__playwright-test__browser_click` -> `browser_click`. */
|
|
27
130
|
const toolName = (name) => name.replace(/^mcp__[^_]+(?:-[^_]+)*__/, '');
|
|
28
131
|
/**
|
|
29
132
|
* What the call is about, in a few words: the intent the agent gave, or the argument that names the thing it touched.
|
|
30
133
|
* 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.
|
|
134
|
+
* `code` for a code-mode `execute`), so every one of them is looked at. This is the log's one line; the whole argument
|
|
135
|
+
* travels next to it (`fullInput`) and is opened on demand.
|
|
32
136
|
*/
|
|
33
137
|
function describe(input) {
|
|
34
138
|
if (!input || typeof input !== 'object')
|
|
@@ -51,45 +155,100 @@ function describe(input) {
|
|
|
51
155
|
].find((name) => typeof args[name] === 'string' && args[name].trim() !== '');
|
|
52
156
|
if (key)
|
|
53
157
|
return oneLine(args[key], MAX_DETAIL);
|
|
158
|
+
// A patch names its file inside the text, and the text itself would fill the whole line with diff noise.
|
|
159
|
+
const patch = args.patchText ?? args.patch_text ?? args.patch ?? args.diff ?? args.input;
|
|
160
|
+
if (typeof patch === 'string') {
|
|
161
|
+
const file = /^\+\+\+ b\/(.+)$/m.exec(patch)?.[1] ?? /^\*\*\* (?:Update|Add|Delete) File: (.+)$/m.exec(patch)?.[1];
|
|
162
|
+
if (file)
|
|
163
|
+
return oneLine(file.trim(), MAX_DETAIL);
|
|
164
|
+
}
|
|
54
165
|
if (typeof args.code === 'string')
|
|
55
|
-
return
|
|
166
|
+
return codeSummary(args.code);
|
|
56
167
|
// Nothing known: the longest string still tells more than the bare tool name.
|
|
57
168
|
const longest = Object.values(args)
|
|
58
169
|
.filter((value) => typeof value === 'string')
|
|
59
170
|
.sort((a, b) => b.length - a.length)[0];
|
|
60
171
|
return longest ? oneLine(longest, MAX_DETAIL) : '';
|
|
61
172
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The whole call, as the log opens it: the code or the command as written, else the arguments as JSON. One short
|
|
175
|
+
* argument that the summary already shows is not worth a second copy of the same words.
|
|
176
|
+
*/
|
|
177
|
+
function fullInput(input, detail, tool) {
|
|
178
|
+
if (!input || typeof input !== 'object')
|
|
179
|
+
return undefined;
|
|
180
|
+
const args = input;
|
|
181
|
+
const edit = EDIT_TOOL.test(tool.toLowerCase().replace(/[_-]/g, ''));
|
|
182
|
+
const max = edit ? MAX_INPUT_EDIT : MAX_INPUT;
|
|
183
|
+
if (typeof args.code === 'string' && args.code.trim())
|
|
184
|
+
return capBody(args.code.trimEnd(), max);
|
|
185
|
+
if (typeof args.command === 'string' && args.command.trim())
|
|
186
|
+
return capBody(args.command.trimEnd(), max);
|
|
187
|
+
// One short argument that the summary already shows is not worth a second copy of the same words; anything longer
|
|
188
|
+
// than the summary (a patch, a file's content) or anything more (a window, a flag) is worth opening. An edit always
|
|
189
|
+
// keeps its argument: that is what the log draws as a diff.
|
|
190
|
+
const named = Object.entries(args).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '');
|
|
191
|
+
const only = named.length === 1 ? named[0][1] : undefined;
|
|
192
|
+
if (!edit && typeof only === 'string' && only.trim().length <= MAX_DETAIL && oneLine(only, MAX_DETAIL) === detail) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const json = JSON.stringify(args, null, 2);
|
|
197
|
+
return json && json !== '{}' ? capBody(json, max) : undefined;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const callOf = (name, input) => {
|
|
204
|
+
const detail = describe(input);
|
|
205
|
+
const full = fullInput(input, detail, name);
|
|
206
|
+
return {
|
|
207
|
+
name: toolName(name),
|
|
208
|
+
detail,
|
|
209
|
+
...(full && full !== detail && { input: full }),
|
|
210
|
+
ok: true,
|
|
211
|
+
pending: true,
|
|
212
|
+
};
|
|
213
|
+
};
|
|
68
214
|
const labelText = (call) => (call.detail ? `${call.name}: ${call.detail}` : call.name);
|
|
69
215
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
216
|
+
* A JSON result is kept with the indentation a person reads: a minified document is one endless line, which no reader
|
|
217
|
+
* and no line cap can handle. Anything that is not JSON comes back as it was.
|
|
72
218
|
*/
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.
|
|
219
|
+
function prettyJsonText(text) {
|
|
220
|
+
if (!text.startsWith('{') && !text.startsWith('['))
|
|
221
|
+
return text;
|
|
222
|
+
try {
|
|
223
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return text;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* What a call returned, whole: the log opens one call and reads all of it. With the colors the shell put in it. A
|
|
231
|
+
* runaway dump is still cut at a point a person can come back from, and the line left out says how much it was.
|
|
232
|
+
*/
|
|
233
|
+
function resultText(value) {
|
|
234
|
+
const raw = (typeof value === 'string' ? value : value == null ? '' : String(value)).replace(/\r\n?/g, '\n');
|
|
235
|
+
// A result that is a JSON string of its own text is decoded first: nobody reads `\n` as a line break in their head.
|
|
236
|
+
const text = prettyJsonText(expandEscapedText(raw).replace(/\t/g, ' ').trim());
|
|
78
237
|
if (!text)
|
|
79
238
|
return null;
|
|
80
239
|
const lines = text.split('\n');
|
|
81
240
|
const kept = [];
|
|
82
241
|
let chars = 0;
|
|
83
242
|
for (const line of lines) {
|
|
84
|
-
if (kept.length >=
|
|
243
|
+
if (kept.length >= MAX_RESULT_LINES || chars >= MAX_RESULT_CHARS)
|
|
85
244
|
break;
|
|
86
245
|
// A cut line loses its colors, so a half escape sequence cannot bleed into the rest of the block.
|
|
87
|
-
const shown = line.length >
|
|
246
|
+
const shown = line.length > MAX_RESULT_LINE ? oneLine(line.replace(ANSI, ''), MAX_RESULT_LINE) : line;
|
|
88
247
|
kept.push(shown);
|
|
89
248
|
chars += shown.length;
|
|
90
249
|
}
|
|
91
250
|
const rest = lines.length - kept.length;
|
|
92
|
-
return kept.join('\n') + (rest > 0 ? `\n… (${rest} more line${rest === 1 ? '' : 's'})` : '');
|
|
251
|
+
return kept.join('\n') + (rest > 0 ? `\n… (${rest} more line${rest === 1 ? '' : 's'} not shown)` : '');
|
|
93
252
|
}
|
|
94
253
|
const HANDOFF_CFG = '__handoffCfg';
|
|
95
254
|
const HANDOFF_CARD = /handoff-card\.js/;
|
|
@@ -112,11 +271,15 @@ const BROWSER_RUN_CODE = /browser_run_code/;
|
|
|
112
271
|
* badge on.
|
|
113
272
|
*/
|
|
114
273
|
const HANDOFF_CFG_TTL_MS = 300_000;
|
|
115
|
-
/**
|
|
116
|
-
function
|
|
117
|
-
const match =
|
|
274
|
+
/** A plain string literal of the options call that comes right before the card (`message: '...'`, `finish: '...'`). */
|
|
275
|
+
function handoffText(code, key) {
|
|
276
|
+
const match = new RegExp(`(?:^|[^\\w$])${key}\\s*:\\s*(['"\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1`).exec(code);
|
|
118
277
|
return match ? match[2].replace(/\\(['"`\\])/g, '$1').trim() || null : null;
|
|
119
278
|
}
|
|
279
|
+
/** `message: '...'` of the options call that comes right before the card. */
|
|
280
|
+
const handoffMessage = (code) => handoffText(code, 'message');
|
|
281
|
+
/** `finish: '...'` of the options call that comes right before the card. */
|
|
282
|
+
const handoffFinish = (code) => handoffText(code, 'finish');
|
|
120
283
|
const codeOf = (input) => {
|
|
121
284
|
if (!input || typeof input !== 'object')
|
|
122
285
|
return '';
|
|
@@ -147,8 +310,12 @@ export class AiStreamFormatter {
|
|
|
147
310
|
pending = new Map();
|
|
148
311
|
cardCalls = new Set();
|
|
149
312
|
waitingForUser = null;
|
|
150
|
-
/**
|
|
313
|
+
/**
|
|
314
|
+
* The message and finish condition of the last options call, kept so a card that runs after unrelated calls still
|
|
315
|
+
* carries them.
|
|
316
|
+
*/
|
|
151
317
|
lastHandoffMessage = null;
|
|
318
|
+
lastHandoffFinish = null;
|
|
152
319
|
lastEventAt = Date.now();
|
|
153
320
|
lastWaitAt = 0;
|
|
154
321
|
/** The last call the agent finished, so a quiet spell can say what it is doing instead of only that nothing came. */
|
|
@@ -189,18 +356,29 @@ export class AiStreamFormatter {
|
|
|
189
356
|
this.waitingForUser = null;
|
|
190
357
|
else {
|
|
191
358
|
this.cardCalls.add(id);
|
|
192
|
-
// `??=` on purpose: the options call before it already knows the message and
|
|
193
|
-
this.waitingForUser ??= {
|
|
359
|
+
// `??=` on purpose: the options call before it already knows the message, the finish condition and the start.
|
|
360
|
+
this.waitingForUser ??= {
|
|
361
|
+
message: handoffMessage(codeOf(input)) ?? this.lastHandoffMessage,
|
|
362
|
+
finish: handoffFinish(codeOf(input)) ?? this.lastHandoffFinish,
|
|
363
|
+
since: Date.now(),
|
|
364
|
+
};
|
|
194
365
|
}
|
|
195
366
|
return null;
|
|
196
367
|
}
|
|
197
368
|
if (kind === 'options') {
|
|
198
369
|
const message = handoffMessage(codeOf(input));
|
|
370
|
+
const finish = handoffFinish(codeOf(input));
|
|
199
371
|
if (message)
|
|
200
372
|
this.lastHandoffMessage = message;
|
|
373
|
+
if (finish)
|
|
374
|
+
this.lastHandoffFinish = finish;
|
|
201
375
|
// Keep the start time when the wait is already set: a second report of the same handoff must not announce it twice.
|
|
202
376
|
const since = this.waitingForUser?.since ?? Date.now();
|
|
203
|
-
this.waitingForUser = {
|
|
377
|
+
this.waitingForUser = {
|
|
378
|
+
message: message ?? this.lastHandoffMessage,
|
|
379
|
+
finish: finish ?? this.lastHandoffFinish,
|
|
380
|
+
since,
|
|
381
|
+
};
|
|
204
382
|
return null;
|
|
205
383
|
}
|
|
206
384
|
// Anything else ends the wait, unless it was set too recently to be an abandoned one: the card runs right after it.
|
|
@@ -251,12 +429,12 @@ export class AiStreamFormatter {
|
|
|
251
429
|
/** A call the agent has started and not finished yet (Claude reports both ends). */
|
|
252
430
|
start(id, call) {
|
|
253
431
|
const now = Date.now();
|
|
254
|
-
this.pending.set(id, { name: call.name, detail: call.detail, startedAt: now });
|
|
432
|
+
this.pending.set(id, { name: call.name, detail: call.detail, input: call.input, startedAt: now });
|
|
255
433
|
return [{ kind: 'tool', at: now, data: `>> ${labelText(call)}`, tool: call }];
|
|
256
434
|
}
|
|
257
435
|
/**
|
|
258
|
-
* The end of a call, plus
|
|
259
|
-
*
|
|
436
|
+
* The end of a call, plus what it returned. `fallback` is for a CLI that reports a call only when it is over
|
|
437
|
+
* (OpenCode), which still knows when it started.
|
|
260
438
|
*/
|
|
261
439
|
finishCall(id, ok, output, fallback) {
|
|
262
440
|
const now = Date.now();
|
|
@@ -266,6 +444,7 @@ export class AiStreamFormatter {
|
|
|
266
444
|
this.waitingForUser = null;
|
|
267
445
|
const name = call?.name ?? fallback?.name ?? 'tool';
|
|
268
446
|
const detail = call?.detail ?? fallback?.detail ?? '';
|
|
447
|
+
const input = call?.input ?? fallback?.input;
|
|
269
448
|
const startedAt = call?.startedAt ?? fallback?.startedAt;
|
|
270
449
|
const took = startedAt ? seconds(now - startedAt) : undefined;
|
|
271
450
|
const label = labelText({ name, detail });
|
|
@@ -275,7 +454,7 @@ export class AiStreamFormatter {
|
|
|
275
454
|
kind: 'tool',
|
|
276
455
|
at: now,
|
|
277
456
|
data: `${ok ? 'ok' : 'FAILED'}: ${label}${took ? ` after ${took}` : ''}`,
|
|
278
|
-
tool: { name, detail, ok, ...(took && { took }) },
|
|
457
|
+
tool: { name, detail, ...(input && { input }), ok, ...(took && { took }) },
|
|
279
458
|
},
|
|
280
459
|
];
|
|
281
460
|
if (!ok) {
|
|
@@ -285,9 +464,9 @@ export class AiStreamFormatter {
|
|
|
285
464
|
return parts;
|
|
286
465
|
}
|
|
287
466
|
// A saved login is a password: what a call read from `.auth` never goes into the log.
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
290
|
-
parts.push({ kind: 'result', at: now, data:
|
|
467
|
+
const result = SECRET_PATH.test(detail) ? null : resultText(output);
|
|
468
|
+
if (result)
|
|
469
|
+
parts.push({ kind: 'result', at: now, data: result });
|
|
291
470
|
return parts;
|
|
292
471
|
}
|
|
293
472
|
/** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */
|
package/dist/ui/server/jobs.d.ts
CHANGED
|
@@ -102,6 +102,16 @@ declare class JobManager {
|
|
|
102
102
|
private verifyLeftoverStopped;
|
|
103
103
|
/** Publishes a change of "waiting for the person" (a handoff card) like any other status change. */
|
|
104
104
|
private syncAwaiting;
|
|
105
|
+
/**
|
|
106
|
+
* Shows a desktop notification now. Neither the agent (whose shell the CLI may refuse) nor the page (not open, not
|
|
107
|
+
* focused, not allowed) can be relied on for this, so the server shows it itself.
|
|
108
|
+
*/
|
|
109
|
+
private notifyWaiting;
|
|
110
|
+
/**
|
|
111
|
+
* Why no notification appeared, written where the person reads next: never a reason to fail the job, and the next
|
|
112
|
+
* run of the same kind leaves no line at all when the notifier works.
|
|
113
|
+
*/
|
|
114
|
+
private pushNotificationFailure;
|
|
105
115
|
/**
|
|
106
116
|
* Stops a run whose agent has said nothing for `STALL_MS`: a tool call that never returns leaves the CLI waiting for
|
|
107
117
|
* ever, and the log would only keep repeating "still waiting for ...". The block above the cancel names what to do.
|
package/dist/ui/server/jobs.js
CHANGED
|
@@ -5,6 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import { StringDecoder } from 'node:string_decoder';
|
|
6
6
|
import { ROOT } from '../../lib/paths.js';
|
|
7
7
|
import { spawnTarget } from '../../lib/command.js';
|
|
8
|
+
import { notifyDesktop } from '../../lib/desktop-notify.js';
|
|
8
9
|
import { AiStreamFormatter } from './ai-stream.js';
|
|
9
10
|
import { parseResultState } from './result-state.js';
|
|
10
11
|
import { appendChunkNow, chunksFile, ensureJobDir, logFile, pruneJobs, readChunks, readJobs, writeRecord, } from './job-store.js';
|
|
@@ -244,6 +245,11 @@ class JobManager {
|
|
|
244
245
|
finished = true;
|
|
245
246
|
this.finish(record, code, signal);
|
|
246
247
|
});
|
|
248
|
+
// A login waits for a person at the browser window it just opened: say so on the desktop, where they are looking.
|
|
249
|
+
if (spec.kind === 'signin') {
|
|
250
|
+
this.notifyWaiting(record, 'AsRun — log in', `Log in to ${[spec.profile, spec.env].filter(Boolean).join(' @ ')}, then press "Done, save the session" on ` +
|
|
251
|
+
'the card in the browser window.');
|
|
252
|
+
}
|
|
247
253
|
this.events.emit('job', record.info);
|
|
248
254
|
return record.info;
|
|
249
255
|
}
|
|
@@ -476,9 +482,39 @@ class JobManager {
|
|
|
476
482
|
return;
|
|
477
483
|
if (now && before && before.since === new Date(now.since).toISOString())
|
|
478
484
|
return;
|
|
479
|
-
info.awaitingUser = now
|
|
485
|
+
info.awaitingUser = now
|
|
486
|
+
? { message: now.message, finish: now.finish, since: new Date(now.since).toISOString() }
|
|
487
|
+
: null;
|
|
480
488
|
this.persist(record);
|
|
481
489
|
this.events.emit('job', info);
|
|
490
|
+
// A handoff that starts right now: the person is told on the desktop, because the browser window is somewhere else
|
|
491
|
+
// and the page may be closed, focused or not allowed to notify. The dedupe keeps one handoff to one notification.
|
|
492
|
+
if (!before && now) {
|
|
493
|
+
this.notifyWaiting(record, 'AsRun — your turn', `${info.caseId ? `${info.caseId}: ` : ''}${now.message ?? 'A step in the browser window waits for you.'}` +
|
|
494
|
+
(now.finish ? ` Done when ${now.finish}.` : ''));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Shows a desktop notification now. Neither the agent (whose shell the CLI may refuse) nor the page (not open, not
|
|
499
|
+
* focused, not allowed) can be relied on for this, so the server shows it itself.
|
|
500
|
+
*/
|
|
501
|
+
notifyWaiting(record, title, body) {
|
|
502
|
+
notifyDesktop({ title, body, sound: true }, (reason) => this.pushNotificationFailure(record, reason));
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Why no notification appeared, written where the person reads next: never a reason to fail the job, and the next
|
|
506
|
+
* run of the same kind leaves no line at all when the notifier works.
|
|
507
|
+
*/
|
|
508
|
+
pushNotificationFailure(record, reason) {
|
|
509
|
+
const line = `\n[no desktop notification shown: ${reason}. "pnpm asrun notify-test" checks the notifier of this machine]\n`;
|
|
510
|
+
// The job may have ended while the notifier was still running: its log stream is closed then, so the files get the
|
|
511
|
+
// line directly (a running job is told through its stream, so the page shows it at once).
|
|
512
|
+
if (record.info.status === 'running' && record.logStream) {
|
|
513
|
+
this.pushPart(record, { kind: 'hint', at: Date.now(), data: line });
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
this.pushRecovered(record, line);
|
|
517
|
+
}
|
|
482
518
|
}
|
|
483
519
|
/**
|
|
484
520
|
* Stops a run whose agent has said nothing for `STALL_MS`: a tool call that never returns leaves the CLI waiting for
|
|
@@ -586,6 +622,10 @@ class JobManager {
|
|
|
586
622
|
: `[process exited with code ${code}]`);
|
|
587
623
|
if (record.spec.ai && info.status === 'succeeded')
|
|
588
624
|
info.resultState = parseResultState(resultText(record));
|
|
625
|
+
// The agent stopped with questions in the case file: that waits for a person exactly like a handoff card does.
|
|
626
|
+
if (info.resultState === 'needs-input') {
|
|
627
|
+
this.notifyWaiting(record, 'AsRun — answers needed', `${info.caseId ? `${info.caseId}: ` : 'The case: '}the agent wrote open questions into the case file.`);
|
|
628
|
+
}
|
|
589
629
|
this.push(record, 'system', `\n${summary}\n`);
|
|
590
630
|
record.logStream?.end();
|
|
591
631
|
record.chunkStream?.end();
|
|
@@ -134,8 +134,10 @@ const ATTENDED = 'Attended run: a person is at the machine and the browser windo
|
|
|
134
134
|
'need a choice to them and wait, as the skill describes. Start every handoff (also a second one in the same run) ' +
|
|
135
135
|
'with its own call that sets __handoffCfg, before the call that runs handoff-card.js, and never change those options ' +
|
|
136
136
|
'inside the call that runs the card: the web UI sees that a person is needed only from that call, so without it ' +
|
|
137
|
-
'there is no "Your turn" badge and no notification.
|
|
138
|
-
`
|
|
137
|
+
'there is no "Your turn" badge and no notification. Put one action per string in `steps` (the card numbers them), ' +
|
|
138
|
+
'keep `message` to one sentence, write the finish condition into `finish` as plain text, and set `doneText` to the ' +
|
|
139
|
+
'exact text that appears when the step is done so the card ends by itself. Nobody can answer ' +
|
|
140
|
+
`questions in the chat, so write open questions into the case file as the skill describes. ${RESULT_LINE_RULE}`;
|
|
139
141
|
/** The prompt of a one-shot run. Every command may leave open questions in the case, so every one ends with a RESULT line. */
|
|
140
142
|
export function headlessPrompt(command, caseId, options) {
|
|
141
143
|
const slash = slashCommand(command, caseId, options);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text of a tool call, as the log can show it. A tool that answers with a JSON string of its own text (`"…\n…"`, which
|
|
3
|
+
* is what an MCP result arrives as) is unreadable as escapes: the line is decoded here, once, for both the server that
|
|
4
|
+
* stores it and the page that renders what an older run already stored.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* One line of a result. If the whole line is a JSON string literal with escapes in it, the text it holds comes back —
|
|
8
|
+
* `"a\nb"` becomes two lines; a plain `"OK"` or a sentence in quotes keeps its quotes. Two rounds cover a value that
|
|
9
|
+
* was serialized twice, which is as deep as tools go.
|
|
10
|
+
*/
|
|
11
|
+
export declare function expandEscapedLine(line: string): string;
|
|
12
|
+
/** The whole result, line by line (`resultText` writes it this way on the server). */
|
|
13
|
+
export declare function expandEscapedText(text: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text of a tool call, as the log can show it. A tool that answers with a JSON string of its own text (`"…\n…"`, which
|
|
3
|
+
* is what an MCP result arrives as) is unreadable as escapes: the line is decoded here, once, for both the server that
|
|
4
|
+
* stores it and the page that renders what an older run already stored.
|
|
5
|
+
*/
|
|
6
|
+
/** A string literal that carries an escape: `\n`, `\t`, `\"`, `\\`, `\u001b`. Plain quotes are left alone. */
|
|
7
|
+
const ESCAPED = /\\(?:[nrtbfv"'\\/]|u[0-9a-fA-F]{4})/;
|
|
8
|
+
/**
|
|
9
|
+
* One line of a result. If the whole line is a JSON string literal with escapes in it, the text it holds comes back —
|
|
10
|
+
* `"a\nb"` becomes two lines; a plain `"OK"` or a sentence in quotes keeps its quotes. Two rounds cover a value that
|
|
11
|
+
* was serialized twice, which is as deep as tools go.
|
|
12
|
+
*/
|
|
13
|
+
export function expandEscapedLine(line) {
|
|
14
|
+
let text = line;
|
|
15
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
16
|
+
const trimmed = text.trim();
|
|
17
|
+
if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"'))
|
|
18
|
+
break;
|
|
19
|
+
if (!ESCAPED.test(trimmed))
|
|
20
|
+
break;
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(trimmed);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (typeof parsed !== 'string')
|
|
29
|
+
break;
|
|
30
|
+
// The indentation a log line arrived with stays in front of what it turned into.
|
|
31
|
+
const indent = /^\s*/.exec(text)?.[0] ?? '';
|
|
32
|
+
text = parsed
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((part) => `${indent}${part}`)
|
|
35
|
+
.join('\n');
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
/** The whole result, line by line (`resultText` writes it this way on the server). */
|
|
40
|
+
export function expandEscapedText(text) {
|
|
41
|
+
return text
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((line) => expandEscapedLine(line))
|
|
44
|
+
.join('\n');
|
|
45
|
+
}
|
|
@@ -60,6 +60,8 @@ export interface JobInfo {
|
|
|
60
60
|
export interface AwaitingUserInfo {
|
|
61
61
|
/** What the person is asked to do, as the agent wrote it on the card; null when unknown. */
|
|
62
62
|
message: string | null;
|
|
63
|
+
/** The finish condition the card shows in its "DONE WHEN" block; null when the agent gave none. */
|
|
64
|
+
finish?: string | null;
|
|
63
65
|
since: string;
|
|
64
66
|
}
|
|
65
67
|
/** `needs-input`: the agent wrote open questions into the case file and cannot go on without answers. */
|
|
@@ -67,7 +69,7 @@ export type ResultState = 'ready' | 'needs-input';
|
|
|
67
69
|
/**
|
|
68
70
|
* How one block of an AI job's log is presented. Absent for the plain output of every other job, which stays raw.
|
|
69
71
|
* `markdown`: what the agent said; `reasoning`: what it was thinking (not a result); `tool`: one call, started or
|
|
70
|
-
* finished; `result`:
|
|
72
|
+
* finished; `result`: what a call returned (whole, up to a cap); `error` / `hint`: what failed and what to do about it;
|
|
71
73
|
* `meta`: anything else a person should read (the waiting line, noise the CLI printed).
|
|
72
74
|
*/
|
|
73
75
|
export type JobChunkKind = 'tool' | 'result' | 'reasoning' | 'markdown' | 'error' | 'hint' | 'meta';
|
|
@@ -76,6 +78,11 @@ export interface JobChunkTool {
|
|
|
76
78
|
name: string;
|
|
77
79
|
/** What the call is about (the file, the command, the intent); empty when the CLI gave nothing telling. */
|
|
78
80
|
detail: string;
|
|
81
|
+
/**
|
|
82
|
+
* The whole argument the call was made with (its code, its command, the rest as JSON), so the log can open one call
|
|
83
|
+
* and show it in full. Absent when the call gave nothing beyond `detail`, which a saved login's path never is.
|
|
84
|
+
*/
|
|
85
|
+
input?: string;
|
|
79
86
|
ok: boolean;
|
|
80
87
|
/** Still running: Claude reports the start of a call, OpenCode only its end. */
|
|
81
88
|
pending?: boolean;
|
|
@@ -94,7 +94,8 @@ Wrong: `- Create a draft named ... and delete it at the end.` (a write → a tag
|
|
|
94
94
|
- A question block is indented under the sentence it belongs to: 4 spaces under a numbered step, 2 under a bullet.
|
|
95
95
|
The parent line is the suggestion, written as a finished sentence; the `TODO` line holds one question and optional
|
|
96
96
|
`(evidence)`; the block ends with `- ✍️ Answer:` at one level deeper and that line is the last line of the block.
|
|
97
|
-
Hang it under the immediate sentence, never under a broader
|
|
97
|
+
Hang it under the immediate sentence, never under a broader sentence that merely contains it, and never inside the
|
|
98
|
+
`## Values` yaml.
|
|
98
99
|
Details: `.claude/skills/exploring-cases/references/open-questions.md`.
|
|
99
100
|
- A marker (`🔍 to be confirmed: <what a person must decide>`) is the alternative to a question, not a companion: it
|
|
100
101
|
says nobody has to decide, only to check. `(seen on <profile>)` is a plain note the run checks by itself. Its
|
package/docs/ai-workflow.md
CHANGED
|
@@ -88,10 +88,13 @@ publishes, shares or deletes anything, in staging or in production. It:
|
|
|
88
88
|
data-call paths it saw. `/gen-spec` reads it to verify instead of rediscovering, and a page that only exists after a
|
|
89
89
|
write step is written from it. Delete it at any time: without it, generation replays the case.
|
|
90
90
|
|
|
91
|
-
With many profiles, `--profiles a,b` opens only those (ids from `profiles.yaml`, comma-separated)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
With many profiles, `--profiles a,b` opens only those (ids from `profiles.yaml`, comma-separated), each in its own
|
|
92
|
+
session; without the flag the agent opens one logged-in profile per domain. Two different hosts are two domains even
|
|
93
|
+
when they serve the same product, so the list is never collapsed into one profile. The profiles the list leaves out are
|
|
94
|
+
not opened and nothing is written about them, so a case can be explored in passes: A and B first, C later. Runs add up;
|
|
95
|
+
the case keeps what the earlier runs recorded. In the web UI the profile picker of the toolbar does the same: every
|
|
96
|
+
profile that is logged in in that environment is ticked, and the list you tick is passed to the run and remembered for
|
|
97
|
+
the next one there.
|
|
95
98
|
|
|
96
99
|
**To keep what the case says, delete the whole block.** **To answer, write after `Answer:`.** Then run the command
|
|
97
100
|
again. In the web UI the panel calls the first action **Keep what the case says**. The run's **What to do next** bar
|
|
@@ -104,9 +107,10 @@ restarted.
|
|
|
104
107
|
Use it for a case that creates or edits data, or needs a choice (an existing document, items). Stay at the computer:
|
|
105
108
|
the browser window opens and the agent waits while you do those steps.
|
|
106
109
|
|
|
107
|
-
- The window shows a small draggable card in the bottom-left corner
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
- The window shows a small draggable card in the bottom-left corner: one sentence about the step, the steps numbered,
|
|
111
|
+
the texts to copy, and a green **DONE WHEN** block with the finish condition. **Done** hands control back, and
|
|
112
|
+
**Cannot do this** gives the step up. The agent also continues by itself when the finish condition shows up (for
|
|
113
|
+
example a "Saved" message); the card turns green when control is back. It waits up to 10 minutes per step.
|
|
110
114
|
- Recording starts and stops by itself. If Playwright's own recording toolbar shows at the top of the page, leave it
|
|
111
115
|
alone: it can stop the recording.
|
|
112
116
|
- The card records what you did, and the agent writes it into the case under the step as **general recipe lines**
|
package/docs/cases.md
CHANGED
|
@@ -113,11 +113,11 @@ report and the trace read like the case.
|
|
|
113
113
|
exploration: positions instead of names of changeable data (`select items #1 to #3`), created names (project prefix
|
|
114
114
|
plus run stamp), control labels but no selectors. Several lines per step are normal. A tagged step with no recipe is
|
|
115
115
|
not generated: `/gen-spec` stops with `needs-input`.
|
|
116
|
-
- **Open questions** go in a `🚫 TODO [blocking]` or `❓ TODO [non-blocking]` block under the sentence they are about
|
|
117
|
-
|
|
118
|
-
whole block; answering means writing after `Answer:`. In the web panel the two actions are **Keep what
|
|
119
|
-
says** and **Write your answer**. Hang the block under the sentence that makes the claim, not under a
|
|
120
|
-
|
|
116
|
+
- **Open questions** go in a `🚫 TODO [blocking]` or `❓ TODO [non-blocking]` block under the sentence they are about:
|
|
117
|
+
that sentence is the agent's suggestion, and the block's last line is `- ✍️ Answer:`. Accepting a question means
|
|
118
|
+
deleting the whole block; answering means writing after `Answer:`. In the web panel the two actions are **Keep what
|
|
119
|
+
the case says** and **Write your answer**. Hang the block under the sentence that makes the claim, not under a
|
|
120
|
+
broader sentence that merely contains it, and one claim gets one block.
|
|
121
121
|
- **What the AI saw** is marked by what happens if it is wrong: `(seen on <profile>)` is a plain note the run checks by
|
|
122
122
|
itself, and `(observed on <profile>, 🔍 to be confirmed: <what a person must decide>)` is a line that would pass even
|
|
123
123
|
if it were wrong, so a person settles it. A marker may name a screenshot (`evidence: .explore/.../x.png`) so nobody
|