@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.
- package/.claude/commands/explore-case.md +18 -15
- package/.claude/commands/gen-spec.md +16 -13
- package/.claude/commands/heal-spec.md +26 -23
- package/.claude/commands/make-case.md +11 -8
- package/.claude/skills/exploring-cases/SKILL.md +146 -132
- package/.claude/skills/exploring-cases/references/open-questions.md +116 -112
- package/.claude/skills/exploring-cases/references/readiness.md +146 -137
- package/.claude/skills/exploring-cases/references/user-handoff.md +157 -147
- package/.claude/skills/generating-specs/SKILL.md +219 -207
- package/.claude/skills/generating-specs/references/conventions.md +148 -148
- package/.claude/skills/making-cases/SKILL.md +72 -64
- package/.claude/skills/making-cases/references/normalizing.md +39 -38
- package/.claude/skills/making-cases/references/writes-and-email.md +37 -36
- package/AGENTS.md +159 -154
- package/CLAUDE.md +2 -1
- package/README.md +512 -410
- package/dist/lib/build-artifacts.d.ts +8 -0
- package/dist/lib/build-artifacts.js +10 -0
- package/dist/lib/case-doc.d.ts +311 -0
- package/dist/lib/case-doc.js +1117 -0
- package/dist/lib/cases.d.ts +2 -15
- package/dist/lib/cases.js +5 -78
- package/dist/lib/command.d.ts +24 -0
- package/dist/lib/command.js +90 -0
- package/dist/lib/fixtures.js +2 -2
- package/dist/lib/login-card.d.ts +22 -0
- package/dist/lib/login-card.js +183 -0
- package/dist/lib/node-cli.d.ts +6 -2
- package/dist/lib/node-cli.js +16 -6
- package/dist/lib/profiles.d.ts +25 -1
- package/dist/lib/profiles.js +54 -1
- package/dist/lib/run-filters.js +10 -2
- package/dist/lib/session.d.ts +13 -1
- package/dist/lib/session.js +73 -7
- package/dist/lib/targets.d.ts +5 -13
- package/dist/lib/targets.js +4 -29
- package/dist/lib/values.js +2 -2
- package/dist/scripts/build.js +2 -1
- package/dist/scripts/check-cases.js +74 -6
- package/dist/scripts/doctor.js +115 -4
- package/dist/scripts/explore.js +22 -1
- package/dist/scripts/init.d.ts +7 -1
- package/dist/scripts/init.js +61 -13
- package/dist/scripts/lint-specs.js +4 -1
- package/dist/scripts/login.js +30 -2
- package/dist/ui/server/actions.d.ts +4 -0
- package/dist/ui/server/actions.js +25 -12
- package/dist/ui/server/ai-models.d.ts +3 -0
- package/dist/ui/server/ai-models.js +347 -0
- package/dist/ui/server/ai-stream.d.ts +34 -9
- package/dist/ui/server/ai-stream.js +283 -77
- package/dist/ui/server/app.js +74 -12
- package/dist/ui/server/jobs.d.ts +7 -1
- package/dist/ui/server/jobs.js +69 -15
- package/dist/ui/server/profiles.d.ts +20 -1
- package/dist/ui/server/profiles.js +214 -38
- package/dist/ui/shared/ai-commands.d.ts +16 -6
- package/dist/ui/shared/ai-commands.js +35 -12
- package/dist/ui/shared/types.d.ts +114 -0
- package/docs/agents/framework-dev.md +79 -0
- package/docs/theme-guide.html +922 -692
- package/package.json +12 -1
- package/templates/.claude/commands/explore-case.md +22 -21
- package/templates/.claude/commands/gen-spec.md +21 -20
- package/templates/.claude/commands/heal-spec.md +31 -30
- package/templates/.claude/commands/make-case.md +13 -12
- package/templates/.mcp.json +8 -8
- package/templates/AGENTS.md +64 -45
- package/templates/cases/_template.md +57 -63
- package/templates/opencode.jsonc +16 -11
- package/templates/profiles.yaml +42 -29
- package/ui/dist/assets/{dist-BHaIOZdW.js → dist-BGbe0-Aa.js} +1 -1
- package/ui/dist/assets/{dist-CBV7_vDN.js → dist-Ba-HC662.js} +1 -1
- package/ui/dist/assets/{dist-BiE4suo9.js → dist-BtMLMthJ.js} +1 -1
- package/ui/dist/assets/{dist-gF2bFn-5.js → dist-C-Oyo1ZT.js} +1 -1
- package/ui/dist/assets/{dist-DX8knLVr.js → dist-C8MdhqgG.js} +1 -1
- package/ui/dist/assets/{dist-DHCY9KpV.js → dist-CG1pHn5m.js} +1 -1
- package/ui/dist/assets/{dist-BwYRbe5s.js → dist-CmpteMYY.js} +1 -1
- package/ui/dist/assets/{dist-D_I45xgJ.js → dist-DSy3dVf4.js} +1 -1
- package/ui/dist/assets/{dist-BqAROxzI.js → dist-Dq6sW5Ur.js} +1 -1
- package/ui/dist/assets/{dist-BSJMM_jp.js → dist-jjxQutEm.js} +1 -1
- package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
- package/ui/dist/assets/index-P_Yo0ZZn.js +338 -0
- package/ui/dist/help/ai-tools.png +0 -0
- package/ui/dist/help/case-explore.png +0 -0
- package/ui/dist/help/case-generate.png +0 -0
- package/ui/dist/help/cases-memo.png +0 -0
- package/ui/dist/help/profile-form.png +0 -0
- package/ui/dist/help/profiles-login.png +0 -0
- package/ui/dist/help/reports.png +0 -0
- package/ui/dist/help/run-tests.png +0 -0
- package/ui/dist/index.html +22 -16
- package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
- package/ui/dist/assets/index-I7uU93kk.js +0 -294
package/dist/scripts/init.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
--
|
|
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}}`
|
|
82
|
+
/** Fills the `{{framework}}` and `{{playwright}}` placeholders of a template. */
|
|
75
83
|
export function renderTemplate(name, root) {
|
|
76
|
-
return readTemplate(name)
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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}`);
|
package/dist/scripts/login.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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:
|
|
173
|
+
command: ['pnpm explore', request.profile, startPath].filter(Boolean).join(' '),
|
|
169
174
|
file: process.execPath,
|
|
170
|
-
args: [...scriptArgs('explore.ts'), request.profile,
|
|
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
|
-
|
|
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,
|
|
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'],
|