@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.
Files changed (51) 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 +9 -3
  20. package/dist/ui/server/ai-stream.js +216 -37
  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/log-text.d.ts +13 -0
  25. package/dist/ui/shared/log-text.js +45 -0
  26. package/dist/ui/shared/types.d.ts +8 -1
  27. package/docs/agents/case-format.md +2 -1
  28. package/docs/ai-workflow.md +11 -7
  29. package/docs/cases.md +5 -5
  30. package/docs/commands.md +20 -1
  31. package/docs/install.md +1 -1
  32. package/docs/troubleshooting.md +17 -0
  33. package/docs/web-ui.md +25 -8
  34. package/package.json +4 -2
  35. package/templates/.claude/commands/explore-case.md +3 -2
  36. package/templates/package.json +1 -1
  37. package/ui/dist/assets/{dist-CJnqQMvX.js → dist-BlFnpAOk.js} +1 -1
  38. package/ui/dist/assets/{dist-D8TUm3Ai.js → dist-BwXTr_k4.js} +1 -1
  39. package/ui/dist/assets/{dist-Clj0wGFy.js → dist-BzDmxZ9V.js} +1 -1
  40. package/ui/dist/assets/{dist-BjlbPf3t.js → dist-C1uAVsF-.js} +1 -1
  41. package/ui/dist/assets/{dist-CaAcCNRS.js → dist-CjFzi6xl.js} +1 -1
  42. package/ui/dist/assets/{dist-DkAx5a-Y.js → dist-D7YBl9nc.js} +1 -1
  43. package/ui/dist/assets/{dist-Bfv586Or.js → dist-DxeJFPaO.js} +1 -1
  44. package/ui/dist/assets/{dist-DUDqh_3e.js → dist-DxlXH2E-.js} +1 -1
  45. package/ui/dist/assets/{dist-BX88bMYN.js → dist-fpvlcqX7.js} +1 -1
  46. package/ui/dist/assets/{dist-cuboTVh-.js → dist-jGvx4PBr.js} +1 -1
  47. package/ui/dist/assets/index-Bo4XKJvP.css +2 -0
  48. package/ui/dist/assets/index-Djsj43vv.js +376 -0
  49. package/ui/dist/index.html +2 -2
  50. package/ui/dist/assets/index-DaUQORCy.js +0 -353
  51. package/ui/dist/assets/index-j3W4BBCj.css +0 -2
@@ -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. */
@@ -53,8 +59,8 @@ export declare class AiStreamFormatter {
53
59
  /** A call the agent has started and not finished yet (Claude reports both ends). */
54
60
  private start;
55
61
  /**
56
- * The end of a call, plus the first lines of what it returned. `fallback` is for a CLI that reports a call only when
57
- * it is over (OpenCode), which still knows when it started.
62
+ * The end of a call, plus what it returned. `fallback` is for a CLI that reports a call only when it is over
63
+ * (OpenCode), which still knows when it started.
58
64
  */
59
65
  private finishCall;
60
66
  /** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */