@asrunjs/asrun 0.2.0 → 0.5.2

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 (94) hide show
  1. package/.claude/commands/explore-case.md +18 -15
  2. package/.claude/commands/gen-spec.md +16 -13
  3. package/.claude/commands/heal-spec.md +26 -23
  4. package/.claude/commands/make-case.md +11 -8
  5. package/.claude/skills/exploring-cases/SKILL.md +146 -132
  6. package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
  7. package/.claude/skills/exploring-cases/references/readiness.md +146 -137
  8. package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
  9. package/.claude/skills/generating-specs/SKILL.md +219 -207
  10. package/.claude/skills/generating-specs/references/conventions.md +148 -148
  11. package/.claude/skills/making-cases/SKILL.md +72 -64
  12. package/.claude/skills/making-cases/references/normalizing.md +39 -38
  13. package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
  14. package/AGENTS.md +159 -154
  15. package/CLAUDE.md +2 -1
  16. package/README.md +512 -410
  17. package/dist/lib/build-artifacts.d.ts +8 -0
  18. package/dist/lib/build-artifacts.js +10 -0
  19. package/dist/lib/case-doc.d.ts +311 -0
  20. package/dist/lib/case-doc.js +1117 -0
  21. package/dist/lib/cases.d.ts +2 -15
  22. package/dist/lib/cases.js +5 -78
  23. package/dist/lib/command.d.ts +24 -0
  24. package/dist/lib/command.js +90 -0
  25. package/dist/lib/fixtures.js +2 -2
  26. package/dist/lib/login-card.d.ts +22 -0
  27. package/dist/lib/login-card.js +183 -0
  28. package/dist/lib/node-cli.d.ts +6 -2
  29. package/dist/lib/node-cli.js +16 -6
  30. package/dist/lib/profiles.d.ts +25 -1
  31. package/dist/lib/profiles.js +54 -1
  32. package/dist/lib/run-filters.js +10 -2
  33. package/dist/lib/session.d.ts +13 -1
  34. package/dist/lib/session.js +73 -7
  35. package/dist/lib/targets.d.ts +5 -13
  36. package/dist/lib/targets.js +4 -29
  37. package/dist/lib/values.js +2 -2
  38. package/dist/scripts/build.js +2 -1
  39. package/dist/scripts/check-cases.js +74 -6
  40. package/dist/scripts/doctor.js +115 -4
  41. package/dist/scripts/explore.js +22 -1
  42. package/dist/scripts/init.d.ts +7 -1
  43. package/dist/scripts/init.js +61 -13
  44. package/dist/scripts/lint-specs.js +4 -1
  45. package/dist/scripts/login.js +30 -2
  46. package/dist/ui/server/actions.d.ts +4 -0
  47. package/dist/ui/server/actions.js +25 -12
  48. package/dist/ui/server/ai-models.d.ts +3 -0
  49. package/dist/ui/server/ai-models.js +347 -0
  50. package/dist/ui/server/ai-stream.d.ts +34 -9
  51. package/dist/ui/server/ai-stream.js +283 -77
  52. package/dist/ui/server/app.js +74 -12
  53. package/dist/ui/server/jobs.d.ts +7 -1
  54. package/dist/ui/server/jobs.js +69 -15
  55. package/dist/ui/server/profiles.d.ts +20 -1
  56. package/dist/ui/server/profiles.js +214 -38
  57. package/dist/ui/shared/ai-commands.d.ts +16 -6
  58. package/dist/ui/shared/ai-commands.js +35 -12
  59. package/dist/ui/shared/types.d.ts +114 -0
  60. package/docs/agents/framework-dev.md +79 -0
  61. package/docs/theme-guide.html +922 -692
  62. package/package.json +12 -1
  63. package/templates/.claude/commands/explore-case.md +22 -21
  64. package/templates/.claude/commands/gen-spec.md +21 -20
  65. package/templates/.claude/commands/heal-spec.md +31 -30
  66. package/templates/.claude/commands/make-case.md +13 -12
  67. package/templates/.mcp.json +8 -8
  68. package/templates/AGENTS.md +64 -45
  69. package/templates/cases/_template.md +57 -63
  70. package/templates/opencode.jsonc +16 -11
  71. package/templates/profiles.yaml +42 -29
  72. package/ui/dist/assets/{dist-BHaIOZdW.js → dist-BGbe0-Aa.js} +1 -1
  73. package/ui/dist/assets/{dist-CBV7_vDN.js → dist-Ba-HC662.js} +1 -1
  74. package/ui/dist/assets/{dist-BiE4suo9.js → dist-BtMLMthJ.js} +1 -1
  75. package/ui/dist/assets/{dist-gF2bFn-5.js → dist-C-Oyo1ZT.js} +1 -1
  76. package/ui/dist/assets/{dist-DX8knLVr.js → dist-C8MdhqgG.js} +1 -1
  77. package/ui/dist/assets/{dist-DHCY9KpV.js → dist-CG1pHn5m.js} +1 -1
  78. package/ui/dist/assets/{dist-BwYRbe5s.js → dist-CmpteMYY.js} +1 -1
  79. package/ui/dist/assets/{dist-D_I45xgJ.js → dist-DSy3dVf4.js} +1 -1
  80. package/ui/dist/assets/{dist-BqAROxzI.js → dist-Dq6sW5Ur.js} +1 -1
  81. package/ui/dist/assets/{dist-BSJMM_jp.js → dist-jjxQutEm.js} +1 -1
  82. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  83. package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
  84. package/ui/dist/help/ai-tools.png +0 -0
  85. package/ui/dist/help/case-explore.png +0 -0
  86. package/ui/dist/help/case-generate.png +0 -0
  87. package/ui/dist/help/cases-memo.png +0 -0
  88. package/ui/dist/help/profile-form.png +0 -0
  89. package/ui/dist/help/profiles-login.png +0 -0
  90. package/ui/dist/help/reports.png +0 -0
  91. package/ui/dist/help/run-tests.png +0 -0
  92. package/ui/dist/index.html +22 -16
  93. package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
  94. package/ui/dist/assets/index-I7uU93kk.js +0 -294
@@ -6,12 +6,14 @@ import { FRAMEWORK_ROOT, TEMPLATES_DIR } from '../lib/paths.js';
6
6
  * Creates the files a workspace needs. It is safe to run many times: it never overwrites a file the project owns, and
7
7
  * `--update` refreshes only the files generated from the framework templates.
8
8
  */
9
- const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>] [--no-env] [--install [spec]]
10
-
11
- --root <dir> the workspace to initialize (default: the current folder)
12
- --update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*)
13
- --force <path> overwrite one file, e.g. --force playwright.config.ts (may be repeated)
14
- --no-env do not write .env (ASRUN_ROOT)
9
+ const USAGE = `Usage: pnpm asrun init [--root <dir>] [--update] [--force <path>] [--no-env] [--install [spec]]
10
+
11
+ --root <dir> the workspace to initialize (default: the current folder)
12
+ --update refresh the generated files (playwright.config.ts, tests/seed.spec.ts, .claude/commands/*,
13
+ .opencode/commands/*)
14
+ --force <path> overwrite one file this tool writes, e.g. --force playwright.config.ts (may be repeated)
15
+ --reset-rules allow --force on AGENTS.md, CLAUDE.md or .claude/skills/** (project-owned rules)
16
+ --no-env do not write .env (ASRUN_ROOT)
15
17
  --install [spec] also add the dependency (default "asrun"; e.g. --install github:wizcas/asrun#v0.1.0)`;
16
18
  /** Files `init` owns: `--update` rewrites exactly these, and a project never edits them. */
17
19
  export const GENERATED = [
@@ -21,12 +23,18 @@ export const GENERATED = [
21
23
  { file: '.claude/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
22
24
  { file: '.claude/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
23
25
  { file: '.claude/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
26
+ // OpenCode does not read `.claude/commands` (only `.claude/skills`), so the same commands live here too.
27
+ { file: '.opencode/commands/make-case.md', template: '.claude/commands/make-case.md' },
28
+ { file: '.opencode/commands/explore-case.md', template: '.claude/commands/explore-case.md' },
29
+ { file: '.opencode/commands/gen-spec.md', template: '.claude/commands/gen-spec.md' },
30
+ { file: '.opencode/commands/heal-spec.md', template: '.claude/commands/heal-spec.md' },
24
31
  ];
25
32
  /** Files `init` creates once; after that the project owns them and may edit them freely. */
26
33
  const ONCE = [
27
34
  { file: 'AGENTS.md', template: 'AGENTS.md' },
28
35
  { file: 'CLAUDE.md', template: 'CLAUDE.md' },
29
36
  { file: '.mcp.json', template: '.mcp.json' },
37
+ { file: 'opencode.jsonc', template: 'opencode.jsonc' },
30
38
  { file: '.claude/settings.json', template: '.claude/settings.json' },
31
39
  { file: 'tsconfig.json', template: 'tsconfig.json' },
32
40
  { file: 'pnpm-workspace.yaml', template: 'pnpm-workspace.yaml' },
@@ -71,9 +79,22 @@ export function frameworkPath(root) {
71
79
  export function isInstalled(root) {
72
80
  return fs.existsSync(path.join(path.resolve(root, frameworkPath(root)), 'package.json'));
73
81
  }
74
- /** Fills the `{{framework}}` placeholder of a template. */
82
+ /** Fills the `{{framework}}` and `{{playwright}}` placeholders of a template. */
75
83
  export function renderTemplate(name, root) {
76
- return readTemplate(name).replaceAll('{{framework}}', frameworkPath(root));
84
+ return readTemplate(name)
85
+ .replaceAll('{{framework}}', frameworkPath(root))
86
+ .replaceAll('{{playwright}}', playwrightBin(root));
87
+ }
88
+ /**
89
+ * The Playwright CLI the MCP server runs. The workspace's own install is the normal place; a workspace that lives
90
+ * inside the framework repository (examples/demo) has no `node_modules` of its own, so it points at the framework's.
91
+ * Kept relative: the file is committed and must not name a machine.
92
+ */
93
+ export function playwrightBin(root) {
94
+ if (fs.existsSync(path.join(root, 'node_modules', '.bin', 'playwright')))
95
+ return './node_modules/.bin/playwright';
96
+ const framework = frameworkPath(root);
97
+ return framework.startsWith('..') ? `${framework}/node_modules/.bin/playwright` : './node_modules/.bin/playwright';
77
98
  }
78
99
  export function frameworkVersion() {
79
100
  let version = '0.0.0';
@@ -183,7 +204,10 @@ export async function main(args) {
183
204
  const installAt = args.indexOf('--install');
184
205
  const install = installAt >= 0;
185
206
  const installSpec = install && args[installAt + 1] && !args[installAt + 1].startsWith('-') ? args[installAt + 1] : PACKAGE;
186
- const forced = args.flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []));
207
+ const forced = args
208
+ .flatMap((arg, index) => (arg === '--force' && args[index + 1] ? [args[index + 1]] : []))
209
+ .map((file) => file.split('\\').join('/'));
210
+ const resetRules = args.includes('--reset-rules');
187
211
  const skipEnv = args.includes('--no-env');
188
212
  if (path.resolve(root) === path.resolve(FRAMEWORK_ROOT)) {
189
213
  console.error('This folder is the framework itself, not a workspace. Run "pnpm asrun init" inside the project that uses ' +
@@ -195,6 +219,20 @@ export async function main(args) {
195
219
  const kept = [];
196
220
  const notes = [];
197
221
  const framework = frameworkPath(root);
222
+ // `--force` repairs a file this tool writes. A project's own rules are never overwritten by a framework update:
223
+ // `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` need `--reset-rules` on top.
224
+ const owned = [...GENERATED, ...ONCE].map((entry) => entry.file);
225
+ const projectRules = (file) => file === 'AGENTS.md' || file === 'CLAUDE.md' || file === '.claude/skills' || file.startsWith('.claude/skills/');
226
+ for (const file of forced) {
227
+ if (!owned.includes(file)) {
228
+ console.error(`--force ${file}: init only writes the files it owns (${owned.join(', ')}).`);
229
+ return 1;
230
+ }
231
+ if (projectRules(file) && !resetRules) {
232
+ console.error(`--force ${file}: this file belongs to the project. Add --reset-rules to replace it with the template.`);
233
+ return 1;
234
+ }
235
+ }
198
236
  const place = (entry, overwrite) => {
199
237
  const target = path.join(root, entry.file);
200
238
  if (fs.existsSync(target) && !overwrite) {
@@ -231,10 +269,20 @@ export async function main(args) {
231
269
  notes.push('pnpm-workspace.yaml lists "packages"; asrun is a dependency now, so that list can go.');
232
270
  }
233
271
  }
234
- if (fs.existsSync(path.join(root, '.mcp.json'))) {
235
- const mcp = fs.readFileSync(path.join(root, '.mcp.json'), 'utf8');
236
- if (!mcp.includes('playwright-test'))
237
- notes.push('Add the "playwright-test" MCP server to .mcp.json (see templates/.mcp.json).');
272
+ if (fs.existsSync(path.join(root, '.mcp.json')) && !fs.readFileSync(path.join(root, '.mcp.json'), 'utf8').includes('playwright-test')) {
273
+ notes.push('Add the "playwright-test" MCP server to .mcp.json (see templates/.mcp.json).');
274
+ }
275
+ if (fs.existsSync(path.join(root, 'opencode.jsonc')) && !fs.readFileSync(path.join(root, 'opencode.jsonc'), 'utf8').includes('playwright-test')) {
276
+ notes.push('Add the "playwright-test" MCP server to opencode.jsonc (see templates/opencode.jsonc).');
277
+ }
278
+ if (fs.existsSync(path.join(root, 'opencode.jsonc'))) {
279
+ const text = fs.readFileSync(path.join(root, 'opencode.jsonc'), 'utf8');
280
+ // A commented-out "model" (the template has one) does not set anything: only a real key counts.
281
+ const withoutComments = text.replace(/^\s*\/\/.*$/gm, '');
282
+ if (!/["']?model["']?\s*:/.test(withoutComments)) {
283
+ notes.push('opencode.jsonc sets no "model": a one-shot "opencode run" then uses OpenCode\'s built-in free model, which has a ' +
284
+ 'daily usage limit. Pick a model in the AsRun web UI (top bar), or set "model" there.');
285
+ }
238
286
  }
239
287
  if (fs.existsSync(path.join(root, '.claude', 'settings.json'))) {
240
288
  const settings = fs.readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8');
@@ -128,8 +128,11 @@ function lint(file) {
128
128
  TARGET_REF.lastIndex = 0;
129
129
  return findings;
130
130
  }
131
+ // A case id is written with `/` (`conduit/header-navigation`), while a path on Windows has `\`: without the
132
+ // normalization this filter matched nothing and reported "0 spec(s)" for a spec that was right there.
133
+ const casePath = (file) => file.replace(/\\/g, '/');
131
134
  const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
132
- const files = specFiles(TESTS_DIR).filter((file) => wanted.length === 0 || wanted.some((id) => file.endsWith(`${id}.spec.ts`)));
135
+ const files = specFiles(TESTS_DIR).filter((file) => wanted.length === 0 || wanted.some((id) => casePath(file).endsWith(`${casePath(id)}.spec.ts`)));
133
136
  const findings = files.flatMap(lint);
134
137
  for (const f of findings)
135
138
  console.log(`${f.level.toUpperCase().padEnd(4)} ${f.file}:${f.line} [${f.rule}] ${f.message}`);
@@ -6,6 +6,7 @@ import { getProfile, listProfiles, profileLabel } from '../lib/profiles.js';
6
6
  import { parseCliTarget } from '../lib/cli.js';
7
7
  import { authFile } from '../lib/paths.js';
8
8
  import { checkSession } from '../lib/session.js';
9
+ import { showLoginCard, showLoginCardSaved, waitForLoginCard } from '../lib/login-card.js';
9
10
  const parsed = parseCliTarget(process.argv.slice(2));
10
11
  if (!parsed.profileId || parsed.help) {
11
12
  console.log('Usage: pnpm signin <profile-id>\n\nProfiles:');
@@ -23,14 +24,41 @@ const file = authFile(profile.id);
23
24
  fs.mkdirSync(path.dirname(file), { recursive: true });
24
25
  const browser = await chromium.launch({ headless: false });
25
26
  const context = await browser.newContext({ viewport: null });
27
+ // The card goes in before the first page, so it is there on the login page and again after every navigation.
28
+ showLoginCard(context, `Log in as usual, including the 2FA code. When you can see the logged-in page, press "Done, save the session": ` +
29
+ `the session is saved and this window closes.`);
26
30
  const page = await context.newPage();
27
31
  await page.goto(new URL('/login/', profile.baseURL).href);
28
32
  console.log(`\nA browser window opened for "${profileLabel(profile)}" (${profile.baseURL}).`);
29
33
  console.log('1. Log in as usual, including the 2FA code.');
30
- console.log('2. Wait until you can see the logged-in page.');
34
+ console.log('2. When you can see the logged-in page, press "Done, save the session" on the card in that window.');
35
+ console.log(' The card sits in the bottom-left corner; drag it if it is in the way.');
31
36
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
32
- await rl.question('3. Come back here and press Enter to save the session... ');
37
+ const stopWaiting = new AbortController();
38
+ const card = waitForLoginCard(context, page, stopWaiting.signal);
39
+ const typed = rl
40
+ .question(' Or press Enter here (in the terminal, or the button on the job in the UI): ')
41
+ .then(() => 'enter');
42
+ const answer = await Promise.race([
43
+ card.then((outcome) => ({ via: 'card', outcome })),
44
+ typed.then(() => ({ via: 'enter', outcome: 'done' })),
45
+ ]);
46
+ stopWaiting.abort();
33
47
  rl.close();
48
+ if (answer.via === 'card' && answer.outcome !== 'done') {
49
+ await browser.close().catch(() => { });
50
+ console.error(answer.outcome === 'cancel'
51
+ ? `\nNothing was saved: the login was cancelled on the card. Run \`pnpm signin ${profile.id}\` again when you are ready.`
52
+ : '\nNothing was saved: the browser window was closed before the session was saved. ' +
53
+ `Run \`pnpm signin ${profile.id}\` again.`);
54
+ process.exit(1);
55
+ }
56
+ if (answer.via === 'card') {
57
+ console.log('\nSaving the session from the card...');
58
+ // The person is still looking at the window: say that the session is safe before it disappears.
59
+ await showLoginCardSaved(context, 'Session saved. Closing this window...');
60
+ await new Promise((resolve) => setTimeout(resolve, 1200));
61
+ }
34
62
  await context.storageState({ path: file });
35
63
  await browser.close();
36
64
  fs.chmodSync(file, 0o600);
@@ -24,6 +24,8 @@ export declare const startJobSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
24
24
  env: z.ZodString;
25
25
  force: z.ZodOptional<z.ZodBoolean>;
26
26
  attended: z.ZodOptional<z.ZodBoolean>;
27
+ thinking: z.ZodOptional<z.ZodBoolean>;
28
+ model: z.ZodOptional<z.ZodString>;
27
29
  profiles: z.ZodOptional<z.ZodArray<z.ZodString>>;
28
30
  hints: z.ZodOptional<z.ZodString>;
29
31
  report: z.ZodOptional<z.ZodString>;
@@ -38,6 +40,8 @@ export declare const startJobSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
38
40
  opencode: "opencode";
39
41
  }>;
40
42
  memo: z.ZodString;
43
+ model: z.ZodOptional<z.ZodString>;
44
+ thinking: z.ZodOptional<z.ZodBoolean>;
41
45
  }, z.core.$strip>, z.ZodObject<{
42
46
  action: z.ZodLiteral<"run">;
43
47
  env: z.ZodString;
@@ -12,8 +12,7 @@ import { jobs } from './jobs.js';
12
12
  import { verifyProfiles } from './profiles.js';
13
13
  import { isValidCaseId, listCaseIds } from './cases.js';
14
14
  import { snapshotDirPath, specFilePath } from '../../lib/case-ops.js';
15
- import { AI_COMMANDS, AI_COMMAND_WRITES, AI_TOOLS, continueCommandLine, continueInvocation, headlessCommandLine, headlessInvocation, HINTS_DIR, HINTS_MAX_CHARS, makeCaseCommandLine, makeCaseInvocation, MEMO_DIR, MEMO_MAX_CHARS, REPLY_MAX_CHARS, } from '../shared/ai-commands.js';
16
- const VISIT_PATH = '/app/home';
15
+ import { AI_COMMANDS, AI_COMMAND_WRITES, AI_TOOLS, continueCommandLine, continueInvocation, headlessCommandLine, headlessInvocation, HINTS_DIR, HINTS_MAX_CHARS, makeCaseCommandLine, makeCaseInvocation, MEMO_DIR, MEMO_MAX_CHARS, REPLY_DIR, REPLY_MAX_CHARS, } from '../shared/ai-commands.js';
17
16
  const REPORTS_DIR = path.join(ROOT, 'reports');
18
17
  const REPORT_ID = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-[a-z0-9]+(-[a-z0-9]+)*)?$/;
19
18
  const newSessionId = (tool) => tool === 'claude' ? crypto.randomUUID() : `ses_${crypto.randomBytes(12).toString('hex')}`;
@@ -28,6 +27,8 @@ export const startJobSchema = z.discriminatedUnion('action', [
28
27
  env: z.string().min(1),
29
28
  force: z.boolean().optional(),
30
29
  attended: z.boolean().optional(),
30
+ thinking: z.boolean().optional(),
31
+ model: z.string().min(1).max(200).optional(),
31
32
  profiles: z.array(z.string().min(1)).optional(),
32
33
  hints: z.string().max(HINTS_MAX_CHARS).optional(),
33
34
  report: z.string().min(1).max(100).optional(),
@@ -41,6 +42,8 @@ export const startJobSchema = z.discriminatedUnion('action', [
41
42
  action: z.literal('make-cases'),
42
43
  tool: z.enum(AI_TOOLS),
43
44
  memo: z.string().max(MEMO_MAX_CHARS),
45
+ model: z.string().min(1).max(200).optional(),
46
+ thinking: z.boolean().optional(),
44
47
  }),
45
48
  z.object({
46
49
  action: z.literal('run'),
@@ -159,15 +162,17 @@ export function buildJobSpec(request) {
159
162
  }
160
163
  if (request.action === 'visit') {
161
164
  checkProfile(request.profile, request.env);
165
+ // Where a login lands is a profile setting (its own home page), not something the UI knows.
166
+ const startPath = knownProfiles.find((profile) => profile.id === request.profile)?.startPath ?? '';
162
167
  // Only reads the saved session when it starts, so it does not take the auth lock
163
168
  // (that would block test runs for as long as the window stays open).
164
169
  return {
165
170
  kind: 'visit',
166
171
  profile: request.profile,
167
172
  env: request.env,
168
- command: `pnpm explore ${request.profile} ${VISIT_PATH}`,
173
+ command: ['pnpm explore', request.profile, startPath].filter(Boolean).join(' '),
169
174
  file: process.execPath,
170
- args: [...scriptArgs('explore.ts'), request.profile, VISIT_PATH],
175
+ args: [...scriptArgs('explore.ts'), request.profile, ...(startPath ? [startPath] : [])],
171
176
  locks: [`visit:${request.profile}`],
172
177
  acceptsInput: false,
173
178
  };
@@ -175,14 +180,14 @@ export function buildJobSpec(request) {
175
180
  // An AI run on a case, a first turn or a continuation: the same permissions and locks either way.
176
181
  // Generating and healing write the spec and the case values and run the tests, so they must not overlap with a run.
177
182
  // Reviewing only edits the case file and looks at pages, so it takes just the case lock.
178
- const aiSpec = (tool, command, caseId, env, sessionId, run) => ({
183
+ const aiSpec = (tool, command, caseId, env, sessionId, run, model, thinking) => ({
179
184
  kind: 'ai',
180
185
  env,
181
186
  ...run,
182
187
  locks: [`ai:${caseId}`, ...(AI_COMMAND_WRITES[command] === 'files' ? ['run', ...allLocks()] : [])],
183
188
  acceptsInput: false,
184
189
  stream: tool,
185
- ai: { tool, command, caseId, env, sessionId },
190
+ ai: { tool, command, caseId, env, sessionId, ...(model && { model }), ...(thinking && { thinking }) },
186
191
  });
187
192
  if (request.action === 'ai') {
188
193
  if (!isValidCaseId(request.caseId) || !listCaseIds().includes(request.caseId)) {
@@ -225,6 +230,8 @@ export function buildJobSpec(request) {
225
230
  }
226
231
  const options = {
227
232
  env: request.env,
233
+ model: request.model,
234
+ thinking: request.thinking,
228
235
  force: request.force,
229
236
  attended: request.attended,
230
237
  // Naming every profile is the same as leaving it out: the agent then takes all that are logged in.
@@ -238,7 +245,7 @@ export function buildJobSpec(request) {
238
245
  command: headlessCommandLine(request.tool, request.command, request.caseId, options, session),
239
246
  file,
240
247
  args,
241
- });
248
+ }, request.model, request.thinking);
242
249
  }
243
250
  if (request.action === 'continue') {
244
251
  const earlier = jobs.get(request.jobId);
@@ -253,13 +260,19 @@ export function buildJobSpec(request) {
253
260
  }
254
261
  if (!listCaseIds().includes(ai.caseId))
255
262
  throw new ActionError(`The case "${ai.caseId}" no longer exists.`);
256
- const { file, args } = continueInvocation(ai.tool, ai.command, request.reply, ai.sessionId);
263
+ // The answers are stored as a file so the agent reads them verbatim; a line break or a quote in them cannot
264
+ // break the command line of the agent CLI.
265
+ const replyPath = `${REPLY_DIR}/${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(3).toString('hex')}.md`;
266
+ fs.mkdirSync(path.join(ROOT, REPLY_DIR), { recursive: true });
267
+ fs.writeFileSync(path.join(ROOT, replyPath), request.reply.trim() + '\n');
268
+ // The next turn keeps how the first one was told to run (model and reasoning), so the log stays the same.
269
+ const { file, args } = continueInvocation(ai.tool, ai.command, replyPath, ai.sessionId, ai.model, ai.thinking);
257
270
  return aiSpec(ai.tool, ai.command, ai.caseId, ai.env, ai.sessionId, {
258
- command: continueCommandLine(ai.tool, ai.command, request.reply, ai.sessionId),
271
+ command: continueCommandLine(ai.tool, ai.command, replyPath, ai.sessionId, ai.model, ai.thinking),
259
272
  file,
260
273
  args,
261
274
  continuesJobId: earlier.info.id,
262
- });
275
+ }, ai.model, ai.thinking);
263
276
  }
264
277
  if (request.action === 'make-cases') {
265
278
  if (request.memo.trim() === '')
@@ -268,10 +281,10 @@ export function buildJobSpec(request) {
268
281
  const memoPath = `${MEMO_DIR}/${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(3).toString('hex')}.md`;
269
282
  fs.mkdirSync(path.join(ROOT, MEMO_DIR), { recursive: true });
270
283
  fs.writeFileSync(path.join(ROOT, memoPath), request.memo);
271
- const { file, args } = makeCaseInvocation(request.tool, memoPath);
284
+ const { file, args } = makeCaseInvocation(request.tool, memoPath, request.model, request.thinking);
272
285
  return {
273
286
  kind: 'ai',
274
- command: makeCaseCommandLine(request.tool, memoPath),
287
+ command: makeCaseCommandLine(request.tool, memoPath, request.model, request.thinking),
275
288
  file,
276
289
  args,
277
290
  locks: ['ai:make-cases'],
@@ -0,0 +1,3 @@
1
+ import type { AiTool } from '../shared/ai-commands.js';
2
+ import type { AiModelsResponse } from '../shared/types.js';
3
+ export declare function listAiModels(tool: AiTool, refresh?: boolean): Promise<AiModelsResponse>;