@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.
Files changed (46) hide show
  1. package/.claude/skills/exploring-cases/SKILL.md +26 -11
  2. package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
  3. package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
  4. package/.claude/skills/exploring-cases/references/readiness.md +6 -0
  5. package/.claude/skills/exploring-cases/references/user-handoff.md +60 -27
  6. package/AGENTS.md +3 -2
  7. package/README.md +1 -1
  8. package/dist/lib/desktop-notify.d.ts +38 -0
  9. package/dist/lib/desktop-notify.js +158 -0
  10. package/dist/lib/paths.d.ts +9 -2
  11. package/dist/lib/paths.js +32 -5
  12. package/dist/lib/pdf.js +23 -3
  13. package/dist/scripts/check-cases.js +3 -3
  14. package/dist/scripts/cli.js +9 -2
  15. package/dist/scripts/doctor.js +39 -1
  16. package/dist/scripts/notify-test.d.ts +1 -0
  17. package/dist/scripts/notify-test.js +48 -0
  18. package/dist/ui/server/actions.js +4 -2
  19. package/dist/ui/server/ai-stream.d.ts +7 -1
  20. package/dist/ui/server/ai-stream.js +26 -7
  21. package/dist/ui/server/jobs.d.ts +10 -0
  22. package/dist/ui/server/jobs.js +41 -1
  23. package/dist/ui/shared/ai-commands.js +4 -2
  24. package/dist/ui/shared/types.d.ts +2 -0
  25. package/docs/agents/case-format.md +2 -1
  26. package/docs/ai-workflow.md +11 -7
  27. package/docs/cases.md +5 -5
  28. package/docs/commands.md +20 -1
  29. package/docs/install.md +1 -1
  30. package/docs/troubleshooting.md +17 -0
  31. package/docs/web-ui.md +7 -4
  32. package/package.json +2 -2
  33. package/templates/.claude/commands/explore-case.md +3 -2
  34. package/templates/package.json +1 -1
  35. package/ui/dist/assets/{dist-C46EmZuI.js → dist-BlFnpAOk.js} +1 -1
  36. package/ui/dist/assets/{dist-DrFzCwMc.js → dist-BwXTr_k4.js} +1 -1
  37. package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BzDmxZ9V.js} +1 -1
  38. package/ui/dist/assets/{dist-_0g6iC9K.js → dist-C1uAVsF-.js} +1 -1
  39. package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-CjFzi6xl.js} +1 -1
  40. package/ui/dist/assets/{dist-D9GmSkjm.js → dist-D7YBl9nc.js} +1 -1
  41. package/ui/dist/assets/{dist-BB2cF3XN.js → dist-DxeJFPaO.js} +1 -1
  42. package/ui/dist/assets/{dist-P9roUi1O.js → dist-DxlXH2E-.js} +1 -1
  43. package/ui/dist/assets/{dist-Dy45nXf8.js → dist-fpvlcqX7.js} +1 -1
  44. package/ui/dist/assets/{dist-Db_XhDui.js → dist-jGvx4PBr.js} +1 -1
  45. package/ui/dist/assets/{index-BN4ucmmn.js → index-Djsj43vv.js} +11 -11
  46. package/ui/dist/index.html +1 -1
@@ -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 `node_modules/<package>` folder of the framework's own install, or the workspace's (a hoisted or linked
34
- * install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use this.
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 `node_modules/<package>` folder of the framework's own install, or the workspace's (a hoisted or linked
91
- * install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use this.
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
- // The package's own install, the pnpm virtual store that holds its dependencies, or a hoisted install in the workspace.
95
- for (const base of [FRAMEWORK_ROOT, path.dirname(path.dirname(FRAMEWORK_ROOT)), ROOT]) {
96
- const dir = path.join(base, 'node_modules', packageName);
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
- // pdf.js loads its fonts, cmaps and wasm files from the package folder, not from the consuming project.
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
- const dirUrl = (dir) => pathToFileURL(path.join((pdfjsDir ??= packageDir('pdfjs-dist')), dir)).href + '/';
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 text above it: accepting it would delete a claim nobody sees`);
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 above = splitLines(doc.text).find((line) => line.number === anchor)?.text.trim() ?? '';
71
- warnings.push(`the question on line ${question.line} hangs under "${above.slice(0, 80)}", but the panel shows "${question.parent.text.slice(0, 80)}" as the text above it; indent it under that sentence or lift that sentence to a top-level bullet`);
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) {
@@ -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();
@@ -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
- // Naming every profile of the environment is the same as leaving it out: the agent then takes all that are logged in.
263
- profiles: request.profiles && request.profiles.length < profilesInEnv.length ? request.profiles : undefined,
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
- /** The message of the last options call, kept so a card that runs after unrelated calls still carries it. */
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
- /** `message: '...'` of the options call that comes right before the card. */
275
- function handoffMessage(code) {
276
- const match = /message\s*:\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/.exec(code);
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
- /** The message of the last options call, kept so a card that runs after unrelated calls still carries it. */
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 when the wait started.
352
- this.waitingForUser ??= { message: handoffMessage(codeOf(input)) ?? this.lastHandoffMessage, since: Date.now() };
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 = { message: message ?? this.lastHandoffMessage, since };
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.
@@ -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.
@@ -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 ? { message: now.message, since: new Date(now.since).toISOString() } : null;
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. Nobody can answer questions in the chat, so write open ' +
138
- `questions into the case file as the skill describes. ${RESULT_LINE_RULE}`;
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 one above it, and never inside the `## Values` yaml.
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
@@ -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). The others are not
92
- opened and nothing is written about them, so a case can be explored in passes: A and B first, C later. Runs add up; the
93
- case keeps what the earlier runs recorded. In the web UI the profile picker of the toolbar does the same: every profile
94
- that is logged in in that environment is ticked, and the list you tick is remembered for the next run there.
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 with what to do, **Done** (hand control back) and
108
- **Cannot do this**. The agent also continues by itself when the finish condition it named shows up (for example a
109
- "Saved" message); the card turns green when control is back. It waits up to 10 minutes per step.
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
- with the agent's suggestion written above and a last line `- ✍️ Answer:`. Accepting a question means deleting the
118
- whole block; answering means writing after `Answer:`. In the web panel the two actions are **Keep what the case
119
- says** and **Write your answer**. Hang the block under the sentence that makes the claim, not under a broader one
120
- above it, and one claim gets one block.
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 and the workspace migrations. `upgrade` and `migrate` are described above.
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