@asrunjs/asrun 0.13.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 +7 -1
- package/dist/ui/server/ai-stream.js +26 -7
- 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/types.d.ts +2 -0
- 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 +7 -4
- package/package.json +2 -2
- package/templates/.claude/commands/explore-case.md +3 -2
- package/templates/package.json +1 -1
- package/ui/dist/assets/{dist-C46EmZuI.js → dist-BlFnpAOk.js} +1 -1
- package/ui/dist/assets/{dist-DrFzCwMc.js → dist-BwXTr_k4.js} +1 -1
- package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BzDmxZ9V.js} +1 -1
- package/ui/dist/assets/{dist-_0g6iC9K.js → dist-C1uAVsF-.js} +1 -1
- package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-CjFzi6xl.js} +1 -1
- package/ui/dist/assets/{dist-D9GmSkjm.js → dist-D7YBl9nc.js} +1 -1
- package/ui/dist/assets/{dist-BB2cF3XN.js → dist-DxeJFPaO.js} +1 -1
- package/ui/dist/assets/{dist-P9roUi1O.js → dist-DxlXH2E-.js} +1 -1
- package/ui/dist/assets/{dist-Dy45nXf8.js → dist-fpvlcqX7.js} +1 -1
- package/ui/dist/assets/{dist-Db_XhDui.js → dist-jGvx4PBr.js} +1 -1
- package/ui/dist/assets/{index-BN4ucmmn.js → index-Djsj43vv.js} +11 -11
- package/ui/dist/index.html +1 -1
package/dist/lib/paths.d.ts
CHANGED
|
@@ -30,7 +30,14 @@ export declare const EXPLORE_DIR: string;
|
|
|
30
30
|
export declare const TEMPLATES_DIR: string;
|
|
31
31
|
export declare const SKILLS_DIR: string;
|
|
32
32
|
/**
|
|
33
|
-
* The
|
|
34
|
-
* install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
|
|
33
|
+
* The folder of a package the framework needs at run time: the framework's own dependency, or the workspace's (a
|
|
34
|
+
* hoisted or linked install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
|
|
35
|
+
* this.
|
|
36
|
+
*
|
|
37
|
+
* Node's own resolution comes first, because it is the same one every `import` in this package goes through: the
|
|
38
|
+
* folder and the module that is loaded can then never disagree. A hand-built path could not promise that — under
|
|
39
|
+
* pnpm's virtual store the framework sits in `<store>/node_modules/@asrunjs/asrun`, so the folder that holds its
|
|
40
|
+
* dependencies is one `node_modules`, not two, and appending a second one named the wrong folder and made every PDF
|
|
41
|
+
* check fail with "pdfjs-dist is not installed" while the package was installed.
|
|
35
42
|
*/
|
|
36
43
|
export declare function packageDir(packageName: string): string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
/**
|
|
4
5
|
* Where the framework's own code and assets live: the package folder (`node_modules/@asrunjs/asrun`). The compiled files sit in
|
|
5
6
|
* `dist/lib`, so one level up from there is the package root again.
|
|
@@ -87,13 +88,39 @@ export const EXPLORE_DIR = path.join(ROOT, '.explore');
|
|
|
87
88
|
export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
|
|
88
89
|
export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
|
|
89
90
|
/**
|
|
90
|
-
* The
|
|
91
|
-
* install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
|
|
91
|
+
* The folder of a package the framework needs at run time: the framework's own dependency, or the workspace's (a
|
|
92
|
+
* hoisted or linked install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
|
|
93
|
+
* this.
|
|
94
|
+
*
|
|
95
|
+
* Node's own resolution comes first, because it is the same one every `import` in this package goes through: the
|
|
96
|
+
* folder and the module that is loaded can then never disagree. A hand-built path could not promise that — under
|
|
97
|
+
* pnpm's virtual store the framework sits in `<store>/node_modules/@asrunjs/asrun`, so the folder that holds its
|
|
98
|
+
* dependencies is one `node_modules`, not two, and appending a second one named the wrong folder and made every PDF
|
|
99
|
+
* check fail with "pdfjs-dist is not installed" while the package was installed.
|
|
92
100
|
*/
|
|
93
101
|
export function packageDir(packageName) {
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
const dir = path.
|
|
102
|
+
// A bare name resolves to the package's entry point only; asking for the manifest is the portable way to its folder.
|
|
103
|
+
try {
|
|
104
|
+
const dir = path.dirname(createRequire(import.meta.url).resolve(`${packageName}/package.json`));
|
|
105
|
+
if (fs.existsSync(path.join(dir, 'package.json')))
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Not resolvable from here (an `exports` map without "./package.json", or a package installed elsewhere): the
|
|
110
|
+
// folders that can hold an install answer instead.
|
|
111
|
+
}
|
|
112
|
+
// pnpm's virtual store already keeps a package's dependencies inside a `node_modules`, next to its scope folder;
|
|
113
|
+
// every other layout nests that folder inside the install root.
|
|
114
|
+
const store = path.dirname(path.dirname(FRAMEWORK_ROOT));
|
|
115
|
+
const candidates = [
|
|
116
|
+
// This package's own nested dependencies (an install strategy that nests instead of hoisting).
|
|
117
|
+
path.join(FRAMEWORK_ROOT, 'node_modules', packageName),
|
|
118
|
+
// The dependencies next to it: pnpm's store, or the root of a hoisted install.
|
|
119
|
+
path.basename(store) === 'node_modules' ? path.join(store, packageName) : path.join(store, 'node_modules', packageName),
|
|
120
|
+
// The workspace's own install: a peer dependency (`@playwright/test`) lives here.
|
|
121
|
+
path.join(ROOT, 'node_modules', packageName),
|
|
122
|
+
];
|
|
123
|
+
for (const dir of candidates) {
|
|
97
124
|
if (fs.existsSync(dir))
|
|
98
125
|
return dir;
|
|
99
126
|
}
|
package/dist/lib/pdf.js
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { pathToFileURL } from 'node:url';
|
|
3
4
|
import { createCanvas } from '@napi-rs/canvas';
|
|
4
5
|
import { expect } from '@playwright/test';
|
|
5
6
|
import { packageDir } from './paths.js';
|
|
6
7
|
let pdfjs;
|
|
7
|
-
const loadPdfjs = () => (pdfjs ??= import('pdfjs-dist/legacy/build/pdf.mjs'))
|
|
8
|
-
//
|
|
8
|
+
const loadPdfjs = () => (pdfjs ??= import('pdfjs-dist/legacy/build/pdf.mjs').catch((error) => {
|
|
9
|
+
// The one dependency of this module. A raw ERR_MODULE_NOT_FOUND names this file and nothing a person can act on.
|
|
10
|
+
throw new Error(`The PDF renderer is unavailable: "pdfjs-dist" could not be loaded (${error.message}). The asrun install is ` +
|
|
11
|
+
'incomplete: run "pnpm install" in the workspace root, or install the framework again ("pnpm add -D ' +
|
|
12
|
+
'@asrunjs/asrun"). "pnpm asrun doctor" says which part is missing.');
|
|
13
|
+
}));
|
|
14
|
+
// pdf.js loads its fonts, cmaps and wasm files from the package folder, not from the consuming project. The folder is
|
|
15
|
+
// derived from the very module that is imported, so it cannot drift from the code that reads it: a hand-built path was
|
|
16
|
+
// one `node_modules` too deep under pnpm's virtual store, and every PDF check failed although the package was there.
|
|
17
|
+
// `packageDir` answers only when the module itself cannot be resolved (then the load above reports it).
|
|
9
18
|
let pdfjsDir;
|
|
10
|
-
|
|
19
|
+
function pdfjsRoot() {
|
|
20
|
+
if (pdfjsDir !== undefined)
|
|
21
|
+
return pdfjsDir;
|
|
22
|
+
try {
|
|
23
|
+
const build = createRequire(import.meta.url).resolve('pdfjs-dist/legacy/build/pdf.mjs');
|
|
24
|
+
return (pdfjsDir = path.resolve(path.dirname(build), '..', '..'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return (pdfjsDir = packageDir('pdfjs-dist'));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const dirUrl = (dir) => pathToFileURL(path.join(pdfjsRoot(), dir)).href + '/';
|
|
11
31
|
/** A corner of the page. Defaults: 30% of the width and 12% of the height, enough for a logo and nothing else. */
|
|
12
32
|
export function corner(name, size = {}) {
|
|
13
33
|
const width = size.width ?? 0.3;
|
|
@@ -58,7 +58,7 @@ function questionProblems(id) {
|
|
|
58
58
|
warnings.push(`the "✍️ Answer:" line of the question on line ${question.line} is not the last line of its block`);
|
|
59
59
|
}
|
|
60
60
|
if (!question.parent) {
|
|
61
|
-
warnings.push(`the question on line ${question.line} has no
|
|
61
|
+
warnings.push(`the question on line ${question.line} has no sentence it is about: accepting it would delete a claim nobody sees`);
|
|
62
62
|
continue;
|
|
63
63
|
}
|
|
64
64
|
const step = doc.steps.find((candidate) => candidate.line === question.parent?.line);
|
|
@@ -67,8 +67,8 @@ function questionProblems(id) {
|
|
|
67
67
|
}
|
|
68
68
|
const anchor = immediateAnchor(splitLines(doc.text), question.line, question.indent);
|
|
69
69
|
if (anchor !== null && anchor !== question.parent.line) {
|
|
70
|
-
const
|
|
71
|
-
warnings.push(`the question on line ${question.line} hangs under "${
|
|
70
|
+
const anchorText = splitLines(doc.text).find((line) => line.number === anchor)?.text.trim() ?? '';
|
|
71
|
+
warnings.push(`the question on line ${question.line} hangs under "${anchorText.slice(0, 80)}", but the panel shows "${question.parent.text.slice(0, 80)}" as the sentence it is about; indent it under that sentence or lift that sentence to a top-level bullet`);
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
for (const step of doc.steps) {
|
package/dist/scripts/cli.js
CHANGED
|
@@ -44,13 +44,18 @@ async function main() {
|
|
|
44
44
|
await import('./explore-notes.js');
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
|
+
if (command === 'notify-test') {
|
|
48
|
+
const { main: notifyTest } = await import('./notify-test.js');
|
|
49
|
+
process.exitCode = await notifyTest(rest);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
47
52
|
if (command === 'help' || command === '--help-init') {
|
|
48
53
|
console.log(USAGE);
|
|
49
54
|
return;
|
|
50
55
|
}
|
|
51
56
|
await import('./run.js');
|
|
52
57
|
}
|
|
53
|
-
const USAGE = `Usage: pnpm asrun [init|doctor|rules|reset|upgrade|migrate|explore:notes] [options]
|
|
58
|
+
const USAGE = `Usage: pnpm asrun [init|doctor|rules|reset|upgrade|migrate|explore:notes|notify-test] [options]
|
|
54
59
|
|
|
55
60
|
(no subcommand) Run the tests of one environment. See "pnpm asrun --help".
|
|
56
61
|
init Create the files a workspace needs (never overwrites; --update refreshes generated files,
|
|
@@ -65,5 +70,7 @@ const USAGE = `Usage: pnpm asrun [init|doctor|rules|reset|upgrade|migrate|explor
|
|
|
65
70
|
target release for breaking changes (see "pnpm asrun upgrade --help").
|
|
66
71
|
migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
|
|
67
72
|
--help"). "upgrade" runs it; use it after a manual install.
|
|
68
|
-
explore:notes Print or validate the local exploration note of a case (see "pnpm asrun explore:notes --help")
|
|
73
|
+
explore:notes Print or validate the local exploration note of a case (see "pnpm asrun explore:notes --help").
|
|
74
|
+
notify-test Show one desktop notification and print the command behind it, to tell a hidden notification from a
|
|
75
|
+
broken one (see "pnpm asrun notify-test --help").`;
|
|
69
76
|
await main();
|
package/dist/scripts/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
|
|
5
|
-
import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
|
|
5
|
+
import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR, packageDir } from '../lib/paths.js';
|
|
6
6
|
import { readWorkspaceConfig } from '../lib/config.js';
|
|
7
7
|
import { readProjectRules, mentionedTestDataPrefixes } from '../lib/rules.js';
|
|
8
8
|
import { spawnTarget } from '../lib/command.js';
|
|
@@ -85,6 +85,33 @@ function documentedBrowserTools() {
|
|
|
85
85
|
const section = /^## Runnable\n([\s\S]*?)^## /m.exec(text)?.[1] ?? '';
|
|
86
86
|
return [...new Set(section.match(/browser_[a-z0-9_]+/g) ?? [])];
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* The PDF renderer, which is how an exploring agent looks at a downloaded PDF and how a spec checks one: `lib/pdf.js`
|
|
90
|
+
* imports `pdfjs-dist` and `@napi-rs/canvas` at run time, and pdf.js reads its fonts and cmaps from the package folder
|
|
91
|
+
* `packageDir` names. Both are runtime dependencies of the framework, so a failure means the install (or the Node
|
|
92
|
+
* version) is broken — and it used to surface only when someone rendered a PDF, halfway through an exploration.
|
|
93
|
+
*/
|
|
94
|
+
async function pdfRenderer() {
|
|
95
|
+
let dir;
|
|
96
|
+
try {
|
|
97
|
+
dir = packageDir('pdfjs-dist');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// `packageDir` already says where it looked; the caller adds what to do about it.
|
|
101
|
+
return { ok: false, reason: 'pdfjs-dist is not installed where the framework can reach it' };
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const version = JSON.parse(read(path.join(dir, 'package.json')) ?? '{}').version ?? 'unknown';
|
|
105
|
+
// Loading both is what proves it: the module import resolves exactly like the one in `lib/pdf.js`, and the native
|
|
106
|
+
// canvas is what draws the page.
|
|
107
|
+
await import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
108
|
+
await import('@napi-rs/canvas');
|
|
109
|
+
return { ok: true, version };
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
return { ok: false, reason: error.message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
88
115
|
export async function main(args) {
|
|
89
116
|
if (args.includes('--help')) {
|
|
90
117
|
console.log('Usage: pnpm asrun doctor');
|
|
@@ -296,6 +323,17 @@ export async function main(args) {
|
|
|
296
323
|
warnings.push(`${extra.pkg} is not installed and this machine does not provide it: ${extra.provides} is skipped. Run ` +
|
|
297
324
|
'"pnpm asrun init" (or "pnpm go") to install it, or install the tool yourself and run doctor again.');
|
|
298
325
|
}
|
|
326
|
+
// The PDF renderer: "pnpm pdf:render", the exploring agent's look at a downloaded PDF, and every `expectPdf` check.
|
|
327
|
+
const pdf = await pdfRenderer();
|
|
328
|
+
if (pdf.ok) {
|
|
329
|
+
facts.push(`pdf renderer: pdfjs-dist ${pdf.version} (canvas ok)`);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
// An Error message may already end with a period; the sentence around it must not double it.
|
|
333
|
+
const reason = pdf.reason.replace(/\.\s*$/, '');
|
|
334
|
+
errors.push(`The PDF renderer does not work, so "pnpm pdf:render" and every PDF check fail (${reason}). To fix it, run ` +
|
|
335
|
+
'"pnpm install" in the workspace root (Node 22.13 or newer is required), or install the framework again.');
|
|
336
|
+
}
|
|
299
337
|
let scripts = null;
|
|
300
338
|
try {
|
|
301
339
|
const packageJson = read(path.join(ROOT, 'package.json'));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describeCommand, notificationCommand, sendNotification } from '../lib/desktop-notify.js';
|
|
2
|
+
/*
|
|
3
|
+
* `pnpm asrun notify-test`
|
|
4
|
+
*
|
|
5
|
+
* Shows one desktop notification through the same module the UI server notifies with, and prints what it ran. The
|
|
6
|
+
* point is to separate "the code is wrong" from "this machine hides notifications": a notification that never appears
|
|
7
|
+
* while this command exits 0 means the operating system dropped it, and the hint below says where to look.
|
|
8
|
+
*/
|
|
9
|
+
const USAGE = `Usage: pnpm asrun notify-test [--help]
|
|
10
|
+
|
|
11
|
+
Shows one desktop notification and prints the command that showed it. Nothing is written to a case or a report.
|
|
12
|
+
|
|
13
|
+
Use it when "your turn" produced no notification: the UI server sends those through the same command.
|
|
14
|
+
macOS: System Settings > Notifications > the app that runs this (Terminal, iTerm2, ...).
|
|
15
|
+
Windows: Settings > System > Notifications, and turn off Focus assist while you test.
|
|
16
|
+
Linux: install libnotify (the "notify-send" command).`;
|
|
17
|
+
const HINT = {
|
|
18
|
+
darwin: 'If nothing appeared, the system dropped it: open System Settings > Notifications, find the app that runs this ' +
|
|
19
|
+
'command (Terminal, iTerm2, the editor you run pnpm in) and allow notifications for it. Focus mode hides them too.',
|
|
20
|
+
win32: 'If nothing appeared, check Settings > System > Notifications (and Focus assist), and that a notification banner is ' +
|
|
21
|
+
'not turned off for Windows PowerShell.',
|
|
22
|
+
linux: 'If nothing appeared, install libnotify (the "notify-send" command) and check that a notification daemon runs in ' +
|
|
23
|
+
'this session.',
|
|
24
|
+
};
|
|
25
|
+
export async function main(args) {
|
|
26
|
+
if (args.includes('--help')) {
|
|
27
|
+
console.log(USAGE);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
const notification = {
|
|
31
|
+
title: 'AsRun — test',
|
|
32
|
+
body: 'A desktop notification: this is how "your turn in the browser window" reaches you.',
|
|
33
|
+
sound: true,
|
|
34
|
+
};
|
|
35
|
+
const command = notificationCommand(process.platform, notification);
|
|
36
|
+
if (command)
|
|
37
|
+
console.log(`Running: ${describeCommand(command)}`);
|
|
38
|
+
const outcome = await sendNotification(notification);
|
|
39
|
+
if (outcome.ok) {
|
|
40
|
+
console.log('Done: a notification should be on screen now. If it is not, the system dropped it, not asrun.');
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
console.error(`No notification: ${outcome.reason ?? 'the command failed'}`);
|
|
44
|
+
const hint = HINT[process.platform];
|
|
45
|
+
if (hint)
|
|
46
|
+
console.error(hint);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
@@ -259,8 +259,10 @@ export function buildJobSpec(request) {
|
|
|
259
259
|
thinking: request.thinking,
|
|
260
260
|
force: request.force,
|
|
261
261
|
attended: request.attended,
|
|
262
|
-
//
|
|
263
|
-
|
|
262
|
+
// Always the picked ids, even when they name every profile of the environment: without the flag explore falls back
|
|
263
|
+
// to one logged-in profile per domain, which is not the same as "the profiles you ticked". Heal runs only the
|
|
264
|
+
// profiles that are logged in anyway.
|
|
265
|
+
profiles: request.profiles,
|
|
264
266
|
hints: hintsPath,
|
|
265
267
|
report: request.report,
|
|
266
268
|
};
|
|
@@ -13,6 +13,8 @@ export interface LogPart {
|
|
|
13
13
|
/** The agent has put a handoff card in the browser window and waits for the person (see user-handoff.md). */
|
|
14
14
|
export interface AwaitingUser {
|
|
15
15
|
message: string | null;
|
|
16
|
+
/** The finish condition the card shows in its "DONE WHEN" block; null when the options did not name one. */
|
|
17
|
+
finish: string | null;
|
|
16
18
|
since: number;
|
|
17
19
|
}
|
|
18
20
|
export declare class AiStreamFormatter {
|
|
@@ -21,8 +23,12 @@ export declare class AiStreamFormatter {
|
|
|
21
23
|
private pending;
|
|
22
24
|
private cardCalls;
|
|
23
25
|
private waitingForUser;
|
|
24
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* The message and finish condition of the last options call, kept so a card that runs after unrelated calls still
|
|
28
|
+
* carries them.
|
|
29
|
+
*/
|
|
25
30
|
private lastHandoffMessage;
|
|
31
|
+
private lastHandoffFinish;
|
|
26
32
|
private lastEventAt;
|
|
27
33
|
private lastWaitAt;
|
|
28
34
|
/** The last call the agent finished, so a quiet spell can say what it is doing instead of only that nothing came. */
|
|
@@ -271,11 +271,15 @@ const BROWSER_RUN_CODE = /browser_run_code/;
|
|
|
271
271
|
* badge on.
|
|
272
272
|
*/
|
|
273
273
|
const HANDOFF_CFG_TTL_MS = 300_000;
|
|
274
|
-
/**
|
|
275
|
-
function
|
|
276
|
-
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);
|
|
277
277
|
return match ? match[2].replace(/\\(['"`\\])/g, '$1').trim() || null : null;
|
|
278
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');
|
|
279
283
|
const codeOf = (input) => {
|
|
280
284
|
if (!input || typeof input !== 'object')
|
|
281
285
|
return '';
|
|
@@ -306,8 +310,12 @@ export class AiStreamFormatter {
|
|
|
306
310
|
pending = new Map();
|
|
307
311
|
cardCalls = new Set();
|
|
308
312
|
waitingForUser = null;
|
|
309
|
-
/**
|
|
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
|
+
*/
|
|
310
317
|
lastHandoffMessage = null;
|
|
318
|
+
lastHandoffFinish = null;
|
|
311
319
|
lastEventAt = Date.now();
|
|
312
320
|
lastWaitAt = 0;
|
|
313
321
|
/** The last call the agent finished, so a quiet spell can say what it is doing instead of only that nothing came. */
|
|
@@ -348,18 +356,29 @@ export class AiStreamFormatter {
|
|
|
348
356
|
this.waitingForUser = null;
|
|
349
357
|
else {
|
|
350
358
|
this.cardCalls.add(id);
|
|
351
|
-
// `??=` on purpose: the options call before it already knows the message and
|
|
352
|
-
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
|
+
};
|
|
353
365
|
}
|
|
354
366
|
return null;
|
|
355
367
|
}
|
|
356
368
|
if (kind === 'options') {
|
|
357
369
|
const message = handoffMessage(codeOf(input));
|
|
370
|
+
const finish = handoffFinish(codeOf(input));
|
|
358
371
|
if (message)
|
|
359
372
|
this.lastHandoffMessage = message;
|
|
373
|
+
if (finish)
|
|
374
|
+
this.lastHandoffFinish = finish;
|
|
360
375
|
// Keep the start time when the wait is already set: a second report of the same handoff must not announce it twice.
|
|
361
376
|
const since = this.waitingForUser?.since ?? Date.now();
|
|
362
|
-
this.waitingForUser = {
|
|
377
|
+
this.waitingForUser = {
|
|
378
|
+
message: message ?? this.lastHandoffMessage,
|
|
379
|
+
finish: finish ?? this.lastHandoffFinish,
|
|
380
|
+
since,
|
|
381
|
+
};
|
|
363
382
|
return null;
|
|
364
383
|
}
|
|
365
384
|
// Anything else ends the wait, unless it was set too recently to be an abandoned one: the card runs right after 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);
|
|
@@ -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. */
|
|
@@ -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
|
package/docs/commands.md
CHANGED
|
@@ -16,6 +16,7 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
|
|
|
16
16
|
| Start the web UI | `pnpm go`, `pnpm ui`, `pnpm ui:dev` |
|
|
17
17
|
| See which profiles are logged in | `pnpm status [--env <env>]` |
|
|
18
18
|
| Log in a profile (a person, with 2FA) | `pnpm signin <profile-id>` |
|
|
19
|
+
| Check that desktop notifications reach you | `pnpm asrun notify-test` |
|
|
19
20
|
| Run tests | `pnpm asrun --env <env> [filters]` |
|
|
20
21
|
| Open the last report | `pnpm report` |
|
|
21
22
|
| Look around a page as a profile | `pnpm explore <profile-id> [path]` |
|
|
@@ -116,6 +117,23 @@ pnpm signin example-staging
|
|
|
116
117
|
The login is done by a person, including 2FA; the session is saved to `.auth/<profile-id>.json` and verified. See
|
|
117
118
|
[profiles.md](./profiles.md).
|
|
118
119
|
|
|
120
|
+
## Notifications
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pnpm asrun notify-test
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The UI server shows a **desktop notification** when a run waits for you: an AI run handed a step to the browser window,
|
|
127
|
+
a login opened its window, or a run ended with open questions in the case file. It is the server's own call
|
|
128
|
+
(`osascript` on macOS, `notify-send` on Linux, a PowerShell toast on Windows), so it arrives whether or not the UI page
|
|
129
|
+
is open, focused or allowed to notify. The browser's bell is separate: it is for "a run ended", while the tab is in the
|
|
130
|
+
background (see [web-ui.md](./web-ui.md)).
|
|
131
|
+
|
|
132
|
+
`notify-test` shows one through the same code and prints the command it ran. Exit code 0 with nothing on screen means
|
|
133
|
+
the operating system dropped the notification, not that asrun failed: allow the app that runs the command (Terminal,
|
|
134
|
+
iTerm2, ...) in its notifications settings. The job log says `[no desktop notification shown: ...]` when the command
|
|
135
|
+
itself failed.
|
|
136
|
+
|
|
119
137
|
## Cases and checks
|
|
120
138
|
|
|
121
139
|
```bash
|
|
@@ -284,7 +302,8 @@ init writes (a single file with `--force <file>`). `--force` still keeps `profil
|
|
|
284
302
|
adds the `allowBuilds` entries `pnpm-workspace.yaml` lacks, and installs the optional packages this machine lacks
|
|
285
303
|
(`ffmpeg-static`; `--no-extras` skips them).
|
|
286
304
|
`doctor` checks profiles, `tsconfig.json`, `playwright.config.ts`, the generated files, the installed version, the
|
|
287
|
-
project rule table, the skill overrides
|
|
305
|
+
project rule table, the skill overrides, the workspace migrations and the PDF renderer (a missing or unloadable
|
|
306
|
+
`pdfjs-dist` fails every PDF check). `upgrade` and `migrate` are described above.
|
|
288
307
|
|
|
289
308
|
`reset` removes `node_modules` and the files `init` writes, for a clean reinstall. It deletes a once-only file only
|
|
290
309
|
when it is still identical to its template, so edited files stay; `cases/`, specs, baselines, `profiles.yaml`,
|
package/docs/install.md
CHANGED
|
@@ -6,7 +6,7 @@ and is never edited: a project commits only its own test content.
|
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
|
-
- **Node.js 22 or newer** and **pnpm**.
|
|
9
|
+
- **Node.js 22.13 or newer** and **pnpm**.
|
|
10
10
|
- **ffmpeg** joins the recordings of one test (a new browser tab has its own video) into one video for the report.
|
|
11
11
|
`pnpm go` installs it with Homebrew on macOS or winget on Windows; on Linux run `sudo apt install ffmpeg`. When it is
|
|
12
12
|
missing, `pnpm go` / `pnpm asrun init` install the bundled `ffmpeg-static` package into the workspace; if neither
|