@asrunjs/asrun 0.2.0 → 0.5.3

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 (103) 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 +169 -154
  15. package/CLAUDE.md +2 -1
  16. package/README.md +221 -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/go.mjs +188 -188
  43. package/dist/scripts/init.d.ts +7 -1
  44. package/dist/scripts/init.js +62 -14
  45. package/dist/scripts/lint-specs.js +4 -1
  46. package/dist/scripts/login.js +30 -2
  47. package/dist/ui/server/actions.d.ts +4 -0
  48. package/dist/ui/server/actions.js +25 -12
  49. package/dist/ui/server/ai-models.d.ts +3 -0
  50. package/dist/ui/server/ai-models.js +347 -0
  51. package/dist/ui/server/ai-stream.d.ts +34 -9
  52. package/dist/ui/server/ai-stream.js +283 -77
  53. package/dist/ui/server/app.js +74 -12
  54. package/dist/ui/server/jobs.d.ts +7 -1
  55. package/dist/ui/server/jobs.js +69 -15
  56. package/dist/ui/server/profiles.d.ts +20 -1
  57. package/dist/ui/server/profiles.js +214 -38
  58. package/dist/ui/shared/ai-commands.d.ts +16 -6
  59. package/dist/ui/shared/ai-commands.js +35 -12
  60. package/dist/ui/shared/types.d.ts +114 -0
  61. package/docs/agents/case-format.md +193 -0
  62. package/docs/agents/framework-dev.md +167 -0
  63. package/docs/ai-workflow.md +235 -0
  64. package/docs/cases.md +189 -0
  65. package/docs/commands.md +175 -0
  66. package/docs/install.md +174 -0
  67. package/docs/profiles.md +139 -0
  68. package/docs/theme-guide.html +922 -692
  69. package/docs/troubleshooting.md +101 -0
  70. package/docs/web-ui.md +139 -0
  71. package/package.json +20 -1
  72. package/templates/.claude/commands/explore-case.md +22 -21
  73. package/templates/.claude/commands/gen-spec.md +21 -20
  74. package/templates/.claude/commands/heal-spec.md +31 -30
  75. package/templates/.claude/commands/make-case.md +13 -12
  76. package/templates/.mcp.json +8 -8
  77. package/templates/AGENTS.md +66 -45
  78. package/templates/cases/_template.md +57 -63
  79. package/templates/opencode.jsonc +16 -11
  80. package/templates/profiles.yaml +42 -29
  81. package/ui/dist/assets/{dist-D_I45xgJ.js → dist-BudNAjOv.js} +1 -1
  82. package/ui/dist/assets/{dist-CBV7_vDN.js → dist-C-eCSFja.js} +1 -1
  83. package/ui/dist/assets/{dist-gF2bFn-5.js → dist-CO4heSzh.js} +1 -1
  84. package/ui/dist/assets/{dist-DX8knLVr.js → dist-CPLhRzom.js} +1 -1
  85. package/ui/dist/assets/{dist-BqAROxzI.js → dist-DEPJZ3Me.js} +1 -1
  86. package/ui/dist/assets/{dist-BwYRbe5s.js → dist-DZ7RiqmA.js} +1 -1
  87. package/ui/dist/assets/{dist-BHaIOZdW.js → dist-LixzGniF.js} +1 -1
  88. package/ui/dist/assets/{dist-DHCY9KpV.js → dist-O_RVi4tP.js} +1 -1
  89. package/ui/dist/assets/{dist-BiE4suo9.js → dist-jPS6HCJ5.js} +1 -1
  90. package/ui/dist/assets/{dist-BSJMM_jp.js → dist-vmf3fnkM.js} +1 -1
  91. package/ui/dist/assets/index-BIGt8Hfz.js +338 -0
  92. package/ui/dist/assets/index-CTmnF0Wp.css +2 -0
  93. package/ui/dist/help/ai-tools.png +0 -0
  94. package/ui/dist/help/case-explore.png +0 -0
  95. package/ui/dist/help/case-generate.png +0 -0
  96. package/ui/dist/help/cases-memo.png +0 -0
  97. package/ui/dist/help/profile-form.png +0 -0
  98. package/ui/dist/help/profiles-login.png +0 -0
  99. package/ui/dist/help/reports.png +0 -0
  100. package/ui/dist/help/run-tests.png +0 -0
  101. package/ui/dist/index.html +22 -16
  102. package/ui/dist/assets/index-DmasQ6Rq.css +0 -2
  103. package/ui/dist/assets/index-I7uU93kk.js +0 -294
@@ -1,10 +1,11 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { StringDecoder } from 'node:string_decoder';
7
7
  import { ROOT } from '../../lib/paths.js';
8
+ import { spawnTarget } from '../../lib/command.js';
8
9
  import { AiStreamFormatter } from './ai-stream.js';
9
10
  const LOG_DIR = path.join(ROOT, '.tmp', 'ui-jobs');
10
11
  const MAX_BUFFERED_CHARS = 4_000_000;
@@ -17,16 +18,36 @@ const HEARTBEAT_MS = 15_000;
17
18
  export class JobConflictError extends Error {
18
19
  }
19
20
  const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
20
- /** The last `RESULT: ready|needs-input` line of what the agent printed (markdown emphasis around it is fine). */
21
+ /** The last `RESULT: ready|needs-input` line the agent said (markdown emphasis around it is fine). */
21
22
  function parseResultState(record) {
22
23
  const text = record.chunks
23
- .filter((chunk) => chunk.stream !== 'system')
24
+ // Only what the agent said counts: a file it read or a page it dumped may quote the same words.
25
+ .filter((chunk) => (chunk.kind ? chunk.kind === 'markdown' : chunk.stream !== 'system'))
24
26
  .map((chunk) => chunk.data)
25
- .join('')
27
+ .join('\n')
26
28
  .replace(ANSI, '');
27
29
  const matches = [...text.matchAll(/^[\s*`>_-]*RESULT:\s*(ready|needs-input)\b/gm)];
28
30
  return matches.at(-1)?.[1] ?? null;
29
31
  }
32
+ /**
33
+ * A block of an AI run as the log file keeps it: the same text the UI shows, with the timestamp the UI styles written
34
+ * out, and the output of a call indented under the call it belongs to. The file is read in a terminal or an editor, so
35
+ * it has to make sense without the colors.
36
+ */
37
+ function logFileText(part) {
38
+ const stamp = `[${new Date(part.at).toTimeString().slice(0, 8)}] `;
39
+ // What the agent said is separated by a blank line: it is the answer, not another line of the action above it.
40
+ if (part.kind === 'markdown')
41
+ return `\n${part.data}\n`;
42
+ if (part.kind === 'result' || part.kind === 'reasoning') {
43
+ const mark = part.kind === 'reasoning' ? '~ ' : '';
44
+ return part.data
45
+ .split('\n')
46
+ .map((line, index) => ` ${index === 0 ? mark : ''}${line}\n`)
47
+ .join('');
48
+ }
49
+ return `${stamp}${part.data}\n`;
50
+ }
30
51
  class JobManager {
31
52
  jobs = new Map();
32
53
  /** Emits `job` with a JobInfo on every status change of any job. */
@@ -71,6 +92,7 @@ class JobManager {
71
92
  caseId: spec.ai.caseId,
72
93
  aiCommand: spec.ai.command,
73
94
  sessionId: spec.ai.sessionId,
95
+ ...(spec.ai.model && { aiModel: spec.ai.model }),
74
96
  resultState: null,
75
97
  }),
76
98
  ...(spec.continuesJobId && { continuesJobId: spec.continuesJobId }),
@@ -89,13 +111,23 @@ class JobManager {
89
111
  record.emitter.setMaxListeners(0);
90
112
  this.jobs.set(id, record);
91
113
  this.push(record, 'system', `$ ${spec.command}\n`);
114
+ // A CLI installed globally is a `.cmd` shim on Windows, which spawn cannot start on its own (see lib/command.ts).
115
+ const target = spawnTarget(spec.file, spec.args);
116
+ if (target === null) {
117
+ this.finish(record, null, null, `Failed to start: "${spec.file}" was not found on PATH. Install it, or choose the other tool.`);
118
+ return record.info;
119
+ }
92
120
  let child;
93
121
  try {
94
- child = spawn(spec.file, spec.args, {
122
+ child = spawn(target.file, target.args, {
95
123
  cwd: ROOT,
96
- detached: true,
124
+ // Off Windows the group is what `cancel` signals. On Windows a detached child loses the output of a `.cmd`
125
+ // shim, and the tree is ended with taskkill instead (see signalGroup).
126
+ detached: process.platform !== 'win32',
97
127
  stdio: [spec.acceptsInput ? 'pipe' : 'ignore', 'pipe', 'pipe'],
98
128
  env: { ...process.env, FORCE_COLOR: '1', NO_COLOR: undefined },
129
+ windowsHide: true,
130
+ ...(target.throughShell && { windowsVerbatimArguments: true }),
99
131
  });
100
132
  }
101
133
  catch (error) {
@@ -107,9 +139,10 @@ class JobManager {
107
139
  const decoder = new StringDecoder('utf8');
108
140
  const formatted = stream === 'stdout' ? record.formatter : null;
109
141
  const emit = (text) => {
110
- const out = formatted ? formatted.feed(text) : text;
111
- if (out)
112
- this.push(record, stream, out);
142
+ if (formatted)
143
+ formatted.feed(text).forEach((part) => this.pushPart(record, part));
144
+ else
145
+ this.push(record, stream, text);
113
146
  this.syncAwaiting(record);
114
147
  };
115
148
  child[stream]?.on('data', (buffer) => {
@@ -121,9 +154,7 @@ class JobManager {
121
154
  const rest = decoder.end();
122
155
  if (rest)
123
156
  emit(rest);
124
- const tail = formatted?.end();
125
- if (tail)
126
- this.push(record, stream, tail);
157
+ formatted?.end().forEach((part) => this.pushPart(record, part));
127
158
  });
128
159
  }
129
160
  if (record.formatter) {
@@ -131,7 +162,7 @@ class JobManager {
131
162
  record.heartbeat = setInterval(() => {
132
163
  const line = formatter.waiting(QUIET_MS);
133
164
  if (line)
134
- this.push(record, 'system', line);
165
+ this.pushPart(record, line);
135
166
  }, HEARTBEAT_MS);
136
167
  record.heartbeat.unref();
137
168
  }
@@ -194,6 +225,12 @@ class JobManager {
194
225
  this.events.emit('job', info);
195
226
  }
196
227
  signalGroup(pid, signal) {
228
+ if (process.platform === 'win32') {
229
+ // Windows has no process groups (a negative pid throws ESRCH), so the whole tree is ended at once:
230
+ // cmd.exe, the agent CLI it started, and anything below them.
231
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
232
+ return;
233
+ }
197
234
  try {
198
235
  process.kill(-pid, signal);
199
236
  }
@@ -201,14 +238,31 @@ class JobManager {
201
238
  // Already gone.
202
239
  }
203
240
  }
241
+ /** A block of an AI run's log, with the time it was produced and how the UI should present it. */
242
+ pushPart(record, part) {
243
+ const stream = part.kind === 'error' ? 'stderr' : part.kind === 'markdown' || part.kind === 'result' ? 'stdout' : 'system';
244
+ const chunk = {
245
+ seq: record.nextSeq++,
246
+ stream,
247
+ data: part.data,
248
+ kind: part.kind,
249
+ at: new Date(part.at).toISOString(),
250
+ ...(part.tool && { tool: part.tool }),
251
+ };
252
+ this.store(record, chunk, logFileText(part));
253
+ }
204
254
  push(record, stream, data) {
205
255
  const chunk = { seq: record.nextSeq++, stream, data };
256
+ this.store(record, chunk, stream === 'stderr' ? `[stderr] ${data}` : data);
257
+ }
258
+ /** Keeps the chunk for the browser and writes it to the job's log file, which is what is left after the run. */
259
+ store(record, chunk, fileText) {
206
260
  record.chunks.push(chunk);
207
- record.bufferedChars += data.length;
261
+ record.bufferedChars += chunk.data.length;
208
262
  while (record.bufferedChars > MAX_BUFFERED_CHARS && record.chunks.length > 1) {
209
263
  record.bufferedChars -= record.chunks.shift().data.length;
210
264
  }
211
- record.logStream.write(stream === 'stderr' ? `[stderr] ${data}` : data);
265
+ record.logStream.write(fileText);
212
266
  record.emitter.emit('chunk', chunk);
213
267
  }
214
268
  finish(record, code, signal, note) {
@@ -1,4 +1,4 @@
1
- import type { AddProfileRequest, ProfileInfo, ProfilesResponse } from '../shared/types.js';
1
+ import type { AddProfileRequest, EditProfileRequest, EditProfileResult, ProfileInfo, ProfilesResponse } from '../shared/types.js';
2
2
  export declare function listProfileRows(): ProfilesResponse;
3
3
  /** Opens each saved session on the real site (read-only) and records whether it is still logged in. */
4
4
  export declare function verifyProfiles(only?: {
@@ -14,3 +14,22 @@ export declare class ProfileError extends Error {
14
14
  * existing entries stay as they are.
15
15
  */
16
16
  export declare function addProfile(request: AddProfileRequest, file?: string): ProfileInfo;
17
+ /**
18
+ * Deletes the saved login of one profile, so it has to log in again. The file holds the session cookies
19
+ * and is never read here, only removed.
20
+ */
21
+ export declare function forgetSession(id: string, file?: string): ProfileInfo;
22
+ /**
23
+ * Removes one profile from profiles.yaml and deletes its saved login. Only that entry is written, so the
24
+ * comments and the other entries stay in the file. Screenshot baselines named after the id stay on disk:
25
+ * they are files of the cases, not of the profile.
26
+ */
27
+ export declare function deleteProfile(id: string, file?: string): void;
28
+ /**
29
+ * Changes the fields of one existing profile. Only the fields that actually changed are written, so
30
+ * comments on the others stay in the file. When the site address changes, the saved login belongs to
31
+ * the old site and is deleted: the profile has to log in again on the new one.
32
+ */
33
+ export declare function editProfile(request: EditProfileRequest & {
34
+ id: string;
35
+ }, file?: string): EditProfileResult;
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { chromium } from '@playwright/test';
3
3
  import { isMap, isSeq, parseDocument } from 'yaml';
4
- import { listEnvs, listProfiles, loadProfiles, PROFILES_FILE } from '../../lib/profiles.js';
4
+ import { DEFAULT_SESSION_CHECK_PATH, DEFAULT_SESSION_CHECK, listEnvs, listProfiles, loadProfiles, PROFILES_FILE, validateSessionCheck } from '../../lib/profiles.js';
5
5
  import { authFile } from '../../lib/paths.js';
6
6
  import { checkSession } from '../../lib/session.js';
7
7
  import { withScheme } from '../shared/site-address.js';
@@ -16,10 +16,13 @@ function savedAt(profileId) {
16
16
  const toInfo = (profile) => {
17
17
  const saved = savedAt(profile.id);
18
18
  const verification = verifications.get(profile.id);
19
+ // A session file saved after the check (a new login from the CLI, another browser tab) makes the old
20
+ // result stale, so the row shows "unverified" until it is checked again.
21
+ const stale = Boolean(saved && verification && saved > verification.checkedAt);
19
22
  let status;
20
23
  if (!saved)
21
24
  status = 'missing';
22
- else if (!verification)
25
+ else if (!verification || stale)
23
26
  status = 'unverified';
24
27
  else
25
28
  status = verification.status === 'missing' ? 'unverified' : verification.status;
@@ -30,6 +33,10 @@ const toInfo = (profile) => {
30
33
  baseURL: profile.baseURL,
31
34
  account: profile.account,
32
35
  tags: profile.tags,
36
+ startPath: profile.startPath,
37
+ sessionCheckPath: profile.sessionCheckPath,
38
+ sessionCheck: profile.sessionCheck,
39
+ email: profile.email,
33
40
  status,
34
41
  verifying: verifying.has(profile.id),
35
42
  savedAt: saved,
@@ -113,20 +120,14 @@ function parseSiteAddress(text, label) {
113
120
  return url;
114
121
  }
115
122
  const normalTags = (tags) => [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
116
- /**
117
- * Appends one profile to profiles.yaml. The file is edited as a YAML document, so comments and the
118
- * existing entries stay as they are.
119
- */
120
- export function addProfile(request, file = PROFILES_FILE) {
121
- const id = request.id.trim();
123
+ function readFields(request) {
122
124
  const name = request.name.trim();
123
125
  const env = request.env.trim();
124
126
  const account = request.account?.trim() || undefined;
127
+ const startPath = request.startPath?.trim() || undefined;
125
128
  const sessionCheckPath = request.sessionCheckPath?.trim() || undefined;
126
- const tags = normalTags(request.tags);
127
- if (!PROFILE_ID.test(id)) {
128
- throw new ProfileError('Profile id must be lowercase letters and digits, separated by single dashes (e.g. "example-staging").', 400);
129
- }
129
+ // Same rules as the loader, so the file the form writes is one `pnpm asrun` accepts.
130
+ const sessionCheck = validateSessionCheck('Session check', request.sessionCheck ?? null);
130
131
  if (!name)
131
132
  throw new ProfileError('Name is required.', 400);
132
133
  if (!ENV_NAME.test(env)) {
@@ -136,9 +137,68 @@ export function addProfile(request, file = PROFILES_FILE) {
136
137
  throw new ProfileError('email: true is not allowed. Tests never send email in production.', 400);
137
138
  }
138
139
  const baseURL = parseSiteAddress(request.baseURL, 'Base URL').origin;
140
+ if (startPath && (!startPath.startsWith('/') || startPath.startsWith('//') || /\s/.test(startPath))) {
141
+ throw new ProfileError('Start path must be empty (the site root) or a path on the site that starts with a single "/" and contains no spaces.', 400);
142
+ }
139
143
  if (sessionCheckPath && (!sessionCheckPath.startsWith('/') || /\s/.test(sessionCheckPath))) {
140
144
  throw new ProfileError('Session check path must start with "/" and contain no spaces.', 400);
141
145
  }
146
+ return {
147
+ name,
148
+ env,
149
+ baseURL,
150
+ account,
151
+ tags: normalTags(request.tags),
152
+ startPath,
153
+ sessionCheckPath,
154
+ sessionCheck,
155
+ email: request.email === true,
156
+ };
157
+ }
158
+ /** The `sessionCheck` keys that differ from the defaults, or null when the profile can rely on them. */
159
+ function sessionCheckEntry(check) {
160
+ const entry = {};
161
+ if (check.loggedOutUrl !== DEFAULT_SESSION_CHECK.loggedOutUrl)
162
+ entry.loggedOutUrl = check.loggedOutUrl;
163
+ if (check.loggedInSelector)
164
+ entry.loggedInSelector = check.loggedInSelector;
165
+ if (check.loggedOutSelector)
166
+ entry.loggedOutSelector = check.loggedOutSelector;
167
+ if (check.timeoutMs !== DEFAULT_SESSION_CHECK.timeoutMs)
168
+ entry.timeoutMs = check.timeoutMs;
169
+ return Object.keys(entry).length > 0 ? entry : null;
170
+ }
171
+ const sameSessionCheck = (a, b) => a.loggedOutUrl === b.loggedOutUrl &&
172
+ a.loggedInSelector === b.loggedInSelector &&
173
+ a.loggedOutSelector === b.loggedOutSelector &&
174
+ a.timeoutMs === b.timeoutMs;
175
+ /**
176
+ * Writes the edited YAML document and restores the old file when the result cannot be loaded, so a
177
+ * failed save never leaves a profiles.yaml behind that the test runner cannot read.
178
+ */
179
+ function saveDocument(file, doc, original, action) {
180
+ const temp = `${file}.${process.pid}.tmp`;
181
+ fs.writeFileSync(temp, doc.toString());
182
+ fs.renameSync(temp, file);
183
+ try {
184
+ loadProfiles(file);
185
+ }
186
+ catch (error) {
187
+ fs.writeFileSync(file, original);
188
+ throw new ProfileError(`Could not ${action}: ${error.message}`, 400);
189
+ }
190
+ }
191
+ const sameList = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]);
192
+ /**
193
+ * Appends one profile to profiles.yaml. The file is edited as a YAML document, so comments and the
194
+ * existing entries stay as they are.
195
+ */
196
+ export function addProfile(request, file = PROFILES_FILE) {
197
+ const id = request.id.trim();
198
+ const fields = readFields(request);
199
+ if (!PROFILE_ID.test(id)) {
200
+ throw new ProfileError('Profile id must be lowercase letters and digits, separated by single dashes (e.g. "example-staging").', 400);
201
+ }
142
202
  if (loadProfiles(file).some((profile) => profile.id === id)) {
143
203
  throw new ProfileError(`Profile "${id}" already exists.`, 409);
144
204
  }
@@ -153,43 +213,159 @@ export function addProfile(request, file = PROFILES_FILE) {
153
213
  // `profiles: []` is a flow sequence; adding a map entry to it would render `[ { ... } ]`. Keep the file block-style.
154
214
  profiles.flow = false;
155
215
  const hadEntries = profiles.items.length > 0;
216
+ const check = sessionCheckEntry(fields.sessionCheck);
156
217
  const entry = doc.createNode({
157
218
  id,
158
- name,
159
- env,
160
- baseURL,
161
- ...(account && { account }),
162
- ...(tags.length > 0 && { tags }),
163
- ...(sessionCheckPath && { sessionCheckPath }),
219
+ name: fields.name,
220
+ env: fields.env,
221
+ baseURL: fields.baseURL,
222
+ ...(fields.account && { account: fields.account }),
223
+ ...(fields.tags.length > 0 && { tags: fields.tags }),
224
+ ...(fields.startPath && { startPath: fields.startPath }),
225
+ ...(fields.sessionCheckPath && { sessionCheckPath: fields.sessionCheckPath }),
226
+ ...(check && { sessionCheck: check }),
164
227
  // Never send email from a new profile until a human has checked that a mailbox is connected.
165
- email: request.email === true ? true : false,
228
+ email: fields.email,
166
229
  });
167
230
  if (isMap(entry)) {
168
231
  entry.spaceBefore = hadEntries;
169
232
  entry.flow = false;
170
233
  }
171
234
  profiles.add(entry);
172
- const temp = `${file}.${process.pid}.tmp`;
173
- fs.writeFileSync(temp, doc.toString());
174
- fs.renameSync(temp, file);
175
- // Never leave a file behind that the test runner cannot load.
176
- try {
177
- loadProfiles(file);
235
+ saveDocument(file, doc, original, 'add the profile');
236
+ return toInfo(loadProfiles(file).find((profile) => profile.id === id));
237
+ }
238
+ /**
239
+ * Deletes the saved login of one profile, so it has to log in again. The file holds the session cookies
240
+ * and is never read here, only removed.
241
+ */
242
+ export function forgetSession(id, file = PROFILES_FILE) {
243
+ const profile = loadProfiles(file).find((candidate) => candidate.id === id);
244
+ if (!profile)
245
+ throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
246
+ fs.rmSync(authFile(id), { force: true });
247
+ verifications.delete(id);
248
+ return toInfo(profile);
249
+ }
250
+ /**
251
+ * Removes one profile from profiles.yaml and deletes its saved login. Only that entry is written, so the
252
+ * comments and the other entries stay in the file. Screenshot baselines named after the id stay on disk:
253
+ * they are files of the cases, not of the profile.
254
+ */
255
+ export function deleteProfile(id, file = PROFILES_FILE) {
256
+ const profile = loadProfiles(file).find((candidate) => candidate.id === id);
257
+ if (!profile)
258
+ throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
259
+ const original = fs.readFileSync(file, 'utf8');
260
+ const doc = parseDocument(original, { merge: true });
261
+ if (doc.errors.length > 0) {
262
+ throw new ProfileError('profiles.yaml has syntax errors; fix it by hand first.', 400);
178
263
  }
179
- catch (error) {
180
- fs.writeFileSync(file, original);
181
- throw new ProfileError(`Could not add the profile: ${error.message}`, 400);
264
+ const profiles = doc.get('profiles');
265
+ if (!isSeq(profiles))
266
+ throw new ProfileError('profiles.yaml has no "profiles" list.', 400);
267
+ const index = profiles.items.findIndex((item) => isMap(item) && item.get('id') === id);
268
+ if (index < 0)
269
+ throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
270
+ profiles.delete(index);
271
+ // The entry that is first now keeps the blank line of the one before it; drop it so `profiles:` is followed
272
+ // directly by the entry, like a hand-written file.
273
+ const first = profiles.items[0];
274
+ if (isMap(first))
275
+ first.spaceBefore = false;
276
+ // A `profiles:` with nothing under it is null; the loader needs a list (it may be empty while the suite is
277
+ // set up), so an emptied list is written as `[]`.
278
+ if (profiles.items.length === 0)
279
+ profiles.flow = true;
280
+ saveDocument(file, doc, original, 'delete the profile');
281
+ // The saved login belonged to this profile. The file is never read here, only removed.
282
+ fs.rmSync(authFile(id), { force: true });
283
+ verifications.delete(id);
284
+ }
285
+ /**
286
+ * Changes the fields of one existing profile. Only the fields that actually changed are written, so
287
+ * comments on the others stay in the file. When the site address changes, the saved login belongs to
288
+ * the old site and is deleted: the profile has to log in again on the new one.
289
+ */
290
+ export function editProfile(request, file = PROFILES_FILE) {
291
+ const id = request.id.trim();
292
+ if (!PROFILE_ID.test(id)) {
293
+ throw new ProfileError('Profile id must be lowercase letters and digits, separated by single dashes (e.g. "example-staging").', 400);
294
+ }
295
+ const fields = readFields(request);
296
+ const before = loadProfiles(file).find((profile) => profile.id === id);
297
+ if (!before)
298
+ throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
299
+ const original = fs.readFileSync(file, 'utf8');
300
+ const doc = parseDocument(original, { merge: true });
301
+ if (doc.errors.length > 0) {
302
+ throw new ProfileError('profiles.yaml has syntax errors; fix it by hand first.', 400);
303
+ }
304
+ const profiles = doc.get('profiles');
305
+ if (!isSeq(profiles))
306
+ throw new ProfileError('profiles.yaml has no "profiles" list.', 400);
307
+ const entry = profiles.items.find((item) => isMap(item) && item.get('id') === id);
308
+ if (!isMap(entry))
309
+ throw new ProfileError(`Profile "${id}" is not in profiles.yaml any more. Reload the page.`, 409);
310
+ if (fields.name !== before.name)
311
+ entry.set('name', fields.name);
312
+ if (fields.env !== before.env)
313
+ entry.set('env', fields.env);
314
+ if (fields.baseURL !== before.baseURL)
315
+ entry.set('baseURL', fields.baseURL);
316
+ if (fields.account) {
317
+ if (fields.account !== before.account)
318
+ entry.set('account', fields.account);
319
+ }
320
+ else if (entry.has('account')) {
321
+ entry.delete('account');
322
+ }
323
+ if (!sameList(fields.tags, before.tags)) {
324
+ if (fields.tags.length > 0)
325
+ entry.set('tags', fields.tags);
326
+ else
327
+ entry.delete('tags');
328
+ }
329
+ // An empty path means the site root; drop an explicit key instead of writing an empty one.
330
+ if (fields.startPath) {
331
+ if (fields.startPath !== before.startPath || !entry.has('startPath')) {
332
+ entry.set('startPath', fields.startPath);
333
+ }
334
+ }
335
+ else if (entry.has('startPath')) {
336
+ entry.delete('startPath');
337
+ }
338
+ // An omitted path means the default; drop an explicit key instead of writing the default out.
339
+ if (fields.sessionCheckPath && fields.sessionCheckPath !== DEFAULT_SESSION_CHECK_PATH) {
340
+ if (fields.sessionCheckPath !== before.sessionCheckPath || !entry.has('sessionCheckPath')) {
341
+ entry.set('sessionCheckPath', fields.sessionCheckPath);
342
+ }
343
+ }
344
+ else if (entry.has('sessionCheckPath')) {
345
+ entry.delete('sessionCheckPath');
346
+ }
347
+ // The same for the session check signals: write only what differs from the defaults, so the entry
348
+ // stays readable (and a comment above it stays in place when nothing about it changed).
349
+ if (!sameSessionCheck(fields.sessionCheck, before.sessionCheck)) {
350
+ const check = sessionCheckEntry(fields.sessionCheck);
351
+ if (check)
352
+ entry.set('sessionCheck', check);
353
+ else
354
+ entry.delete('sessionCheck');
355
+ }
356
+ if (fields.email !== before.email)
357
+ entry.set('email', fields.email);
358
+ saveDocument(file, doc, original, 'save the profile');
359
+ const siteChanged = fields.baseURL !== before.baseURL;
360
+ const hadSession = siteChanged && fs.existsSync(authFile(id));
361
+ if (siteChanged) {
362
+ // The saved login holds cookies of the old site. Deleting the file (never reading it) makes the
363
+ // profile log in again on the new address.
364
+ fs.rmSync(authFile(id), { force: true });
365
+ verifications.delete(id);
182
366
  }
183
367
  return {
184
- id,
185
- name,
186
- env,
187
- baseURL,
188
- account: account ?? null,
189
- tags,
190
- status: 'missing',
191
- verifying: false,
192
- savedAt: null,
193
- checkedAt: null,
368
+ profile: toInfo(loadProfiles(file).find((profile) => profile.id === id)),
369
+ sessionCleared: hadSession,
194
370
  };
195
371
  }
@@ -21,10 +21,17 @@ export declare const AI_ENV_HINT: Record<AiCommand, string>;
21
21
  export interface AiRunOptions {
22
22
  /** The environment to explore. There is no default. */
23
23
  env: string;
24
+ /**
25
+ * The model to run the agent with, as `provider/model` (`#variant` for OpenCode) or a Claude alias/name.
26
+ * Omitted = whatever the CLI would pick by itself, which for OpenCode is its built-in free model.
27
+ */
28
+ model?: string;
24
29
  /** `gen-spec` only: regenerate and overwrite an existing spec instead of leaving it alone. */
25
30
  force?: boolean;
26
31
  /** `explore-case` only: a person is at the machine, so steps that write data are handed to them in the browser. */
27
32
  attended?: boolean;
33
+ /** Show the agent's reasoning in the log (`--thinking` for OpenCode). Off by default: it costs tokens and time. */
34
+ thinking?: boolean;
28
35
  /** `heal-spec` only: the profiles to heal. Omitted = every profile that is logged in in `env`. */
29
36
  profiles?: string[];
30
37
  /** `heal-spec` only: file (relative to the project root) with the user's description and clues. */
@@ -53,26 +60,29 @@ export declare function headlessCommandLine(tool: AiTool, command: AiCommand, ca
53
60
  /** Folder (relative to the AsRun root, git-ignored) where the clues typed into the Heal dialog are stored. */
54
61
  export declare const HINTS_DIR = ".tmp/hints";
55
62
  export declare const HINTS_MAX_CHARS = 8000;
63
+ /** Folder (relative to the AsRun root, git-ignored) where the answers typed into the Continue box are stored. */
64
+ export declare const REPLY_DIR = ".tmp/replies";
56
65
  export declare const REPLY_MAX_CHARS = 4000;
57
66
  export declare const USE_DEFAULTS_REPLY = "Accept the suggestion of every open question: delete its TODO block and keep the sentence above it as confirmed text.";
58
67
  /**
59
68
  * The next turn of a run that ended with `RESULT: needs-input`: the user's answers. They go into the case file, which
60
- * stays the record of truth, and the task of the original command is finished.
69
+ * stays the record of truth, and the task of the original command is finished. The answers come from a file
70
+ * (`.tmp/replies/…md`) rather than from the prompt, so a line break or a quote in them cannot break the command line.
61
71
  */
62
- export declare function replyPrompt(command: AiCommand, reply: string): string;
72
+ export declare function replyPrompt(command: AiCommand, replyFile: string): string;
63
73
  /** The next turn of an earlier run, in its session and with exactly the permissions of the first turn. */
64
- export declare function continueInvocation(tool: AiTool, command: AiCommand, reply: string, sessionId: string): {
74
+ export declare function continueInvocation(tool: AiTool, command: AiCommand, replyFile: string, sessionId: string, model?: string, thinking?: boolean): {
65
75
  file: string;
66
76
  args: string[];
67
77
  };
68
- export declare const continueCommandLine: (tool: AiTool, command: AiCommand, reply: string, sessionId: string) => string;
78
+ export declare const continueCommandLine: (tool: AiTool, command: AiCommand, replyFile: string, sessionId: string, model?: string, thinking?: boolean) => string;
69
79
  /** Folder (relative to the AsRun root, git-ignored) where memos from the Cases page are stored. */
70
80
  export declare const MEMO_DIR = ".tmp/memos";
71
81
  export declare const MEMO_MAX_CHARS = 100000;
72
82
  /** /make-case is interactive by design; a one-shot run cannot answer questions, so it must use the defaults. */
73
83
  export declare const makeCasePrompt: (memoPath: string) => string;
74
- export declare const makeCaseInvocation: (tool: AiTool, memoPath: string) => {
84
+ export declare const makeCaseInvocation: (tool: AiTool, memoPath: string, model?: string, thinking?: boolean) => {
75
85
  file: string;
76
86
  args: string[];
77
87
  };
78
- export declare const makeCaseCommandLine: (tool: AiTool, memoPath: string) => string;
88
+ export declare const makeCaseCommandLine: (tool: AiTool, memoPath: string, model?: string, thinking?: boolean) => string;