@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.
- 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 +169 -154
- package/CLAUDE.md +2 -1
- package/README.md +221 -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/go.mjs +188 -188
- package/dist/scripts/init.d.ts +7 -1
- package/dist/scripts/init.js +62 -14
- 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/case-format.md +193 -0
- package/docs/agents/framework-dev.md +167 -0
- package/docs/ai-workflow.md +235 -0
- package/docs/cases.md +189 -0
- package/docs/commands.md +175 -0
- package/docs/install.md +174 -0
- package/docs/profiles.md +139 -0
- package/docs/theme-guide.html +922 -692
- package/docs/troubleshooting.md +101 -0
- package/docs/web-ui.md +139 -0
- package/package.json +20 -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 +66 -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-D_I45xgJ.js → dist-BudNAjOv.js} +1 -1
- package/ui/dist/assets/{dist-CBV7_vDN.js → dist-C-eCSFja.js} +1 -1
- package/ui/dist/assets/{dist-gF2bFn-5.js → dist-CO4heSzh.js} +1 -1
- package/ui/dist/assets/{dist-DX8knLVr.js → dist-CPLhRzom.js} +1 -1
- package/ui/dist/assets/{dist-BqAROxzI.js → dist-DEPJZ3Me.js} +1 -1
- package/ui/dist/assets/{dist-BwYRbe5s.js → dist-DZ7RiqmA.js} +1 -1
- package/ui/dist/assets/{dist-BHaIOZdW.js → dist-LixzGniF.js} +1 -1
- package/ui/dist/assets/{dist-DHCY9KpV.js → dist-O_RVi4tP.js} +1 -1
- package/ui/dist/assets/{dist-BiE4suo9.js → dist-jPS6HCJ5.js} +1 -1
- package/ui/dist/assets/{dist-BSJMM_jp.js → dist-vmf3fnkM.js} +1 -1
- package/ui/dist/assets/index-BIGt8Hfz.js +338 -0
- package/ui/dist/assets/index-CTmnF0Wp.css +2 -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
|
@@ -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'],
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnTarget } from '../../lib/command.js';
|
|
6
|
+
import { ROOT } from '../../lib/paths.js';
|
|
7
|
+
/**
|
|
8
|
+
* The models each AI CLI can be run with on this machine, read from the CLI itself rather than from a list we ship:
|
|
9
|
+
* OpenCode answers `opencode models`; Claude Code has no such command, so its rows come from its own configuration
|
|
10
|
+
* (the model settings, `modelPicker`, the custom-option and default-model variables) and, for a gateway, from the
|
|
11
|
+
* `/v1/models` endpoint the CLI itself would query when its model discovery is enabled.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here reads or prints a credential: the environment is only used to build request headers.
|
|
14
|
+
*/
|
|
15
|
+
/** A provider added or signed in after the list was read changes it, so it is read again after this long. */
|
|
16
|
+
const CACHE_MS = 30_000;
|
|
17
|
+
const COMMAND_TIMEOUT_MS = 20_000;
|
|
18
|
+
/** Claude Code's own discovery timeout is 3 s; an asrun page can wait a little longer. */
|
|
19
|
+
const GATEWAY_TIMEOUT_MS = 5_000;
|
|
20
|
+
const CACHE = new Map();
|
|
21
|
+
export async function listAiModels(tool, refresh = false) {
|
|
22
|
+
const cached = CACHE.get(tool);
|
|
23
|
+
if (!refresh && cached && Date.now() - cached.at < CACHE_MS)
|
|
24
|
+
return cached.value;
|
|
25
|
+
const value = tool === 'opencode' ? await opencodeModels() : await claudeModels();
|
|
26
|
+
CACHE.set(tool, { at: Date.now(), value });
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
const nothingToDo = (tool, error) => ({
|
|
30
|
+
tool,
|
|
31
|
+
models: [],
|
|
32
|
+
clientDefault: null,
|
|
33
|
+
suggested: null,
|
|
34
|
+
notes: [],
|
|
35
|
+
error,
|
|
36
|
+
});
|
|
37
|
+
const firstLine = (text) => text.replace(/\s+/g, ' ').trim();
|
|
38
|
+
/** Runs a CLI and collects its output; never rejects. */
|
|
39
|
+
function run(file, args, timeoutMs) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const target = spawnTarget(file, args);
|
|
42
|
+
if (target === null) {
|
|
43
|
+
resolve({ code: null, stdout: '', stderr: `"${file}" was not found on PATH` });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let stdout = '';
|
|
47
|
+
let stderr = '';
|
|
48
|
+
let done = false;
|
|
49
|
+
const finish = (code) => {
|
|
50
|
+
if (done)
|
|
51
|
+
return;
|
|
52
|
+
done = true;
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve({ code, stdout, stderr });
|
|
55
|
+
};
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
child.kill();
|
|
58
|
+
finish(null);
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
const child = spawn(target.file, target.args, {
|
|
61
|
+
windowsHide: true,
|
|
62
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
63
|
+
...(target.throughShell && { windowsVerbatimArguments: true }),
|
|
64
|
+
});
|
|
65
|
+
child.stdout?.on('data', (buffer) => (stdout += buffer.toString('utf8')));
|
|
66
|
+
child.stderr?.on('data', (buffer) => (stderr += buffer.toString('utf8')));
|
|
67
|
+
child.on('error', (error) => {
|
|
68
|
+
stderr += error.message;
|
|
69
|
+
finish(null);
|
|
70
|
+
});
|
|
71
|
+
child.on('close', (code) => finish(code));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* `opencode models` prints one `provider/model` (or `provider/model@variant`) per line, which is exactly what
|
|
76
|
+
* `--model` takes apart from the variant separator: the CLI reads `#variant` there.
|
|
77
|
+
*/
|
|
78
|
+
async function opencodeModels() {
|
|
79
|
+
const result = await run('opencode', ['models'], COMMAND_TIMEOUT_MS);
|
|
80
|
+
if (result.code !== 0) {
|
|
81
|
+
const why = result.code === null
|
|
82
|
+
? firstLine(result.stderr) || 'the command did not finish'
|
|
83
|
+
: `the command exited with code ${result.code}${firstLine(result.stderr) ? ` (${firstLine(result.stderr)})` : ''}`;
|
|
84
|
+
return nothingToDo('opencode', `Cannot read the OpenCode model list: ${why}.`);
|
|
85
|
+
}
|
|
86
|
+
const models = [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
for (const raw of result.stdout.split(/\r?\n/)) {
|
|
89
|
+
const line = raw.trim();
|
|
90
|
+
if (line === '' || line.startsWith('#') || !line.includes('/'))
|
|
91
|
+
continue;
|
|
92
|
+
const slash = line.indexOf('/');
|
|
93
|
+
const provider = line.slice(0, slash);
|
|
94
|
+
const rest = line.slice(slash + 1);
|
|
95
|
+
const at = rest.indexOf('@');
|
|
96
|
+
const name = at === -1 ? rest : rest.slice(0, at);
|
|
97
|
+
const variant = at === -1 ? '' : rest.slice(at + 1);
|
|
98
|
+
// `provider/model#variant` is what the CLI's own --model flag expects; a run without one gets the default variant.
|
|
99
|
+
const id = `${provider}/${name}${variant ? `#${variant}` : ''}`;
|
|
100
|
+
if (seen.has(id))
|
|
101
|
+
continue;
|
|
102
|
+
seen.add(id);
|
|
103
|
+
models.push({
|
|
104
|
+
id,
|
|
105
|
+
label: variant ? `${name} (${variant})` : name,
|
|
106
|
+
group: provider,
|
|
107
|
+
...(variant && { detail: `variant ${variant}` }),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (models.length === 0)
|
|
111
|
+
return nothingToDo('opencode', 'OpenCode listed no models. Add a provider with "opencode auth login".');
|
|
112
|
+
models.sort((a, b) => (a.group === b.group ? a.label.localeCompare(b.label) : a.group.localeCompare(b.group)));
|
|
113
|
+
// OpenCode keeps nothing that a one-shot run reads: without --model it takes a built-in free model. Its TUI does
|
|
114
|
+
// remember the model of the last session, which is a better guess than the free one for the first run.
|
|
115
|
+
const lastUsed = opencodeLastUsed();
|
|
116
|
+
return {
|
|
117
|
+
tool: 'opencode',
|
|
118
|
+
models,
|
|
119
|
+
clientDefault: null,
|
|
120
|
+
suggested: lastUsed && models.some((model) => model.id === lastUsed || model.id.startsWith(`${lastUsed}#`)) ? lastUsed : null,
|
|
121
|
+
notes: [],
|
|
122
|
+
error: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** `~/.local/state/opencode/model.json`, where the OpenCode TUI remembers its recent models. */
|
|
126
|
+
function opencodeLastUsed() {
|
|
127
|
+
try {
|
|
128
|
+
const file = path.join(os.homedir(), '.local', 'state', 'opencode', 'model.json');
|
|
129
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
130
|
+
const first = data.recent?.[0];
|
|
131
|
+
return first?.providerID && first?.modelID ? `${first.providerID}/${first.modelID}` : null;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Every settings file Claude Code reads, lowest precedence first; the managed one comes last and wins. */
|
|
138
|
+
function claudeSettingsFiles() {
|
|
139
|
+
const programData = process.env.ProgramData ?? 'C:\\ProgramData';
|
|
140
|
+
const managed = process.platform === 'win32'
|
|
141
|
+
? path.join(programData, 'ClaudeCode', 'managed-settings.json')
|
|
142
|
+
: process.platform === 'darwin'
|
|
143
|
+
? '/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
144
|
+
: '/etc/claude-code/managed-settings.json';
|
|
145
|
+
return [
|
|
146
|
+
{ path: path.join(os.homedir(), '.claude', 'settings.json'), label: 'user settings' },
|
|
147
|
+
{ path: path.join(ROOT, '.claude', 'settings.json'), label: 'project settings' },
|
|
148
|
+
{ path: path.join(ROOT, '.claude', 'settings.local.json'), label: 'local settings' },
|
|
149
|
+
{ path: managed, label: 'managed settings' },
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
/** The aliases Claude Code always accepts, whatever the provider. */
|
|
153
|
+
const CLAUDE_ALIASES = [
|
|
154
|
+
{ id: 'sonnet', env: 'ANTHROPIC_DEFAULT_SONNET_MODEL', what: 'the latest Sonnet for everyday coding' },
|
|
155
|
+
{ id: 'opus', env: 'ANTHROPIC_DEFAULT_OPUS_MODEL', what: 'the latest Opus for complex reasoning' },
|
|
156
|
+
{ id: 'haiku', env: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', what: 'the fast and cheap model' },
|
|
157
|
+
{ id: 'fable', env: 'ANTHROPIC_DEFAULT_FABLE_MODEL', what: 'the model for the hardest, longest tasks' },
|
|
158
|
+
{ id: 'opusplan', env: null, what: 'opus in plan mode, then sonnet' },
|
|
159
|
+
];
|
|
160
|
+
/**
|
|
161
|
+
* Claude Code does not enumerate the models of a cloud provider (Vertex, Bedrock, Foundry): its picker shows a
|
|
162
|
+
* built-in lineup plus whatever the settings pin, and it sends a model id it does not know as it is. So the rows here
|
|
163
|
+
* are the models that are actually configured on this machine, not an invented catalogue.
|
|
164
|
+
*/
|
|
165
|
+
async function claudeModels() {
|
|
166
|
+
const notes = [];
|
|
167
|
+
const settings = [];
|
|
168
|
+
for (const file of claudeSettingsFiles()) {
|
|
169
|
+
try {
|
|
170
|
+
if (!fs.existsSync(file.path))
|
|
171
|
+
continue;
|
|
172
|
+
const data = JSON.parse(fs.readFileSync(file.path, 'utf8'));
|
|
173
|
+
if (data && typeof data === 'object')
|
|
174
|
+
settings.push({ ...file, data });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
notes.push(`Cannot read ${file.label} (${file.path}): ${firstLine(error.message)}.`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// `env` blocks of the settings files, then the real environment on top: that is the order the CLI ends up with.
|
|
181
|
+
const env = {};
|
|
182
|
+
for (const file of settings) {
|
|
183
|
+
const block = file.data.env;
|
|
184
|
+
if (!block || typeof block !== 'object')
|
|
185
|
+
continue;
|
|
186
|
+
for (const [key, value] of Object.entries(block)) {
|
|
187
|
+
if (typeof value === 'string')
|
|
188
|
+
env[key] = value;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
192
|
+
if (value !== undefined && (key.startsWith('ANTHROPIC_') || key.startsWith('CLAUDE_CODE_')))
|
|
193
|
+
env[key] = value;
|
|
194
|
+
}
|
|
195
|
+
const models = [];
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
const add = (option) => {
|
|
198
|
+
if (option.id === '' || seen.has(option.id))
|
|
199
|
+
return;
|
|
200
|
+
seen.add(option.id);
|
|
201
|
+
models.push(option);
|
|
202
|
+
};
|
|
203
|
+
// The lineup the user wrote for the picker (user or managed settings only, as the CLI reads it). The highest of
|
|
204
|
+
// those that sets the key supplies the whole lineup, so the last file wins.
|
|
205
|
+
for (const file of [...settings].reverse()) {
|
|
206
|
+
const options = file.data.modelPicker?.options;
|
|
207
|
+
if (!Array.isArray(options))
|
|
208
|
+
continue;
|
|
209
|
+
for (const row of options) {
|
|
210
|
+
if (!row || typeof row.model !== 'string' || row.model === '')
|
|
211
|
+
continue;
|
|
212
|
+
add({
|
|
213
|
+
id: row.model,
|
|
214
|
+
label: typeof row.label === 'string' && row.label !== '' ? row.label : row.model,
|
|
215
|
+
group: 'settings',
|
|
216
|
+
detail: typeof row.description === 'string' && row.description !== '' ? row.description : 'from modelPicker',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
const defaultModel = [...settings].reverse().find((file) => typeof file.data.model === 'string')?.data.model;
|
|
222
|
+
if (typeof defaultModel === 'string' && defaultModel !== '') {
|
|
223
|
+
add({ id: defaultModel, label: defaultModel, group: 'settings', detail: 'the model new sessions start on' });
|
|
224
|
+
}
|
|
225
|
+
if (env.ANTHROPIC_CUSTOM_MODEL_OPTION) {
|
|
226
|
+
add({
|
|
227
|
+
id: env.ANTHROPIC_CUSTOM_MODEL_OPTION,
|
|
228
|
+
label: env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME || env.ANTHROPIC_CUSTOM_MODEL_OPTION,
|
|
229
|
+
group: 'settings',
|
|
230
|
+
detail: env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION || 'from ANTHROPIC_CUSTOM_MODEL_OPTION',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
for (const alias of CLAUDE_ALIASES) {
|
|
234
|
+
const pinned = alias.env ? env[alias.env] : undefined;
|
|
235
|
+
if (!pinned)
|
|
236
|
+
continue;
|
|
237
|
+
add({ id: pinned, label: pinned, group: 'settings', detail: `what ${alias.id} runs here` });
|
|
238
|
+
}
|
|
239
|
+
const provider = claudeProvider(env);
|
|
240
|
+
if (provider === 'cloud') {
|
|
241
|
+
notes.push('Claude Code does not list the models of Vertex, Bedrock or Foundry: only the models configured in its settings ' +
|
|
242
|
+
'are listed here (modelPicker, ANTHROPIC_CUSTOM_MODEL_OPTION, ANTHROPIC_DEFAULT_*_MODEL), plus the aliases.');
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const discovered = await gatewayModels(env);
|
|
246
|
+
for (const option of discovered.models)
|
|
247
|
+
add(option);
|
|
248
|
+
notes.push(...discovered.notes);
|
|
249
|
+
}
|
|
250
|
+
for (const alias of CLAUDE_ALIASES) {
|
|
251
|
+
const pinned = alias.env ? env[alias.env] : undefined;
|
|
252
|
+
add({
|
|
253
|
+
id: alias.id,
|
|
254
|
+
label: alias.id,
|
|
255
|
+
group: 'alias',
|
|
256
|
+
detail: pinned ? `→ ${pinned}` : alias.what,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const clientDefault = env.ANTHROPIC_MODEL || defaultModel || env.ANTHROPIC_DEFAULT_MODEL || null;
|
|
260
|
+
return {
|
|
261
|
+
tool: 'claude',
|
|
262
|
+
models,
|
|
263
|
+
clientDefault,
|
|
264
|
+
suggested: clientDefault,
|
|
265
|
+
notes,
|
|
266
|
+
error: models.length === 0 ? 'Claude Code listed no models: no model is configured for it here.' : null,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** `cloud` when a `CLAUDE_CODE_USE_*` provider is selected, `gateway`/`api` otherwise. */
|
|
270
|
+
function claudeProvider(env) {
|
|
271
|
+
if (env.CLAUDE_CODE_USE_VERTEX || env.CLAUDE_CODE_USE_BEDROCK || env.CLAUDE_CODE_USE_FOUNDRY || env.CLAUDE_CODE_USE_MANTLE) {
|
|
272
|
+
return 'cloud';
|
|
273
|
+
}
|
|
274
|
+
return env.ANTHROPIC_BASE_URL ? 'gateway' : 'api';
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The `/v1/models?limit=1000` request Claude Code makes when its gateway model discovery is on, with the same
|
|
278
|
+
* credentials and custom headers. Every id is kept: a gateway may serve models whose name says nothing about Claude,
|
|
279
|
+
* and those are exactly the ones a person cannot guess.
|
|
280
|
+
*/
|
|
281
|
+
async function gatewayModels(env) {
|
|
282
|
+
const base = (env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/+$/, '');
|
|
283
|
+
const host = (() => {
|
|
284
|
+
try {
|
|
285
|
+
return new URL(base).host;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return base;
|
|
289
|
+
}
|
|
290
|
+
})();
|
|
291
|
+
const headers = {};
|
|
292
|
+
if (env.ANTHROPIC_AUTH_TOKEN)
|
|
293
|
+
headers.Authorization = `Bearer ${env.ANTHROPIC_AUTH_TOKEN}`;
|
|
294
|
+
if (env.ANTHROPIC_API_KEY)
|
|
295
|
+
headers['x-api-key'] = env.ANTHROPIC_API_KEY;
|
|
296
|
+
for (const raw of (env.ANTHROPIC_CUSTOM_HEADERS ?? '').split(/\r?\n/)) {
|
|
297
|
+
const match = /^([^:]+):\s*(.*)$/.exec(raw.trim());
|
|
298
|
+
if (match)
|
|
299
|
+
headers[match[1].trim()] = match[2].trim();
|
|
300
|
+
}
|
|
301
|
+
if (!headers.Authorization && !headers['x-api-key']) {
|
|
302
|
+
return {
|
|
303
|
+
models: [],
|
|
304
|
+
notes: [
|
|
305
|
+
`${host} was not asked for its models: Claude Code authenticates it without ANTHROPIC_AUTH_TOKEN or ` +
|
|
306
|
+
'ANTHROPIC_API_KEY, so there is no credential to send.',
|
|
307
|
+
],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const timeout = Number(env.CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS) || GATEWAY_TIMEOUT_MS;
|
|
311
|
+
try {
|
|
312
|
+
const response = await fetch(`${base}/v1/models?limit=1000`, {
|
|
313
|
+
headers,
|
|
314
|
+
// A redirect is a failure: the credential must not travel to another host.
|
|
315
|
+
redirect: 'error',
|
|
316
|
+
signal: AbortSignal.timeout(timeout),
|
|
317
|
+
});
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
const body = firstLine(await response.text().catch(() => '')).slice(0, 200);
|
|
320
|
+
return { models: [], notes: [`${host} answered ${response.status} for its model list${body ? `: ${body}` : ''}.`] };
|
|
321
|
+
}
|
|
322
|
+
const data = (await response.json());
|
|
323
|
+
const rows = Array.isArray(data?.data) ? data.data : [];
|
|
324
|
+
const models = [];
|
|
325
|
+
for (const row of rows) {
|
|
326
|
+
if (!row || typeof row.id !== 'string' || row.id === '')
|
|
327
|
+
continue;
|
|
328
|
+
models.push({
|
|
329
|
+
id: row.id,
|
|
330
|
+
label: typeof row.display_name === 'string' && row.display_name !== '' ? row.display_name : row.id,
|
|
331
|
+
group: host,
|
|
332
|
+
...(typeof row.description === 'string' && row.description !== '' && { detail: firstLine(row.description) }),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
models,
|
|
337
|
+
notes: models.length === 0 ? [`${host} listed no models.`] : [],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
const message = firstLine(error.message);
|
|
342
|
+
return {
|
|
343
|
+
models: [],
|
|
344
|
+
notes: [`${host} could not be asked for its model list (${message}).`],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { AiTool } from '../shared/ai-commands.js';
|
|
2
|
+
import type { JobChunkKind, JobChunkTool } from '../shared/types.js';
|
|
3
|
+
/** One block of the job log; the server turns it into a `JobChunk` (`jobs.ts`). */
|
|
4
|
+
export interface LogPart {
|
|
5
|
+
kind: JobChunkKind;
|
|
6
|
+
/** The block as a person reads it; also what Copy log and the log file hold. */
|
|
7
|
+
data: string;
|
|
8
|
+
/** When it was produced, in ms. */
|
|
9
|
+
at: number;
|
|
10
|
+
/** Set on a tool block: the UI colors the name, the outcome and the time it took. */
|
|
11
|
+
tool?: JobChunkTool;
|
|
12
|
+
}
|
|
2
13
|
/** The agent has put a handoff card in the browser window and waits for the person (see user-handoff.md). */
|
|
3
14
|
export interface AwaitingUser {
|
|
4
15
|
message: string | null;
|
|
@@ -10,32 +21,46 @@ export declare class AiStreamFormatter {
|
|
|
10
21
|
private pending;
|
|
11
22
|
private cardCalls;
|
|
12
23
|
private waitingForUser;
|
|
24
|
+
/** The message of the last options call, kept so a card that runs after unrelated calls still carries it. */
|
|
25
|
+
private lastHandoffMessage;
|
|
13
26
|
private lastEventAt;
|
|
14
27
|
private lastWaitAt;
|
|
28
|
+
/** The last call the agent finished, so a quiet spell can say what it is doing instead of only that nothing came. */
|
|
29
|
+
private lastCall;
|
|
15
30
|
private sandboxHinted;
|
|
16
31
|
constructor(tool: AiTool);
|
|
17
|
-
/**
|
|
18
|
-
feed(text: string):
|
|
32
|
+
/** Blocks for a piece of stdout. Incomplete trailing lines wait for the next piece. */
|
|
33
|
+
feed(text: string): LogPart[];
|
|
19
34
|
/**
|
|
20
35
|
* Set while a handoff card waits for the person. OpenCode reports a tool call only when it has finished, so the card
|
|
21
36
|
* call itself is not visible while it waits: the options call before it (`__handoffCfg`) starts the state and the end
|
|
22
|
-
* of the card call, or
|
|
37
|
+
* of the card call, or a later call, ends it. The object changes on every new handoff, so `===` detects a change.
|
|
23
38
|
*/
|
|
24
39
|
get awaiting(): AwaitingUser | null;
|
|
25
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Called once for every tool call the agent makes, with the id the end of that call will carry. Returns a line for the
|
|
42
|
+
* log when a card came back that nobody was told about (`MISSED_HANDOFF_HINT`).
|
|
43
|
+
*/
|
|
26
44
|
private watchHandoff;
|
|
27
45
|
/** What is left when the process ends. */
|
|
28
|
-
end():
|
|
46
|
+
end(): LogPart[];
|
|
29
47
|
/**
|
|
30
|
-
* A
|
|
31
|
-
* `quietMs` after that).
|
|
48
|
+
* A block about the oldest tool call that has not returned, when nothing has happened for `quietMs` (and again every
|
|
49
|
+
* `quietMs` after that). It names the last call that did finish, so a long run never looks stuck.
|
|
32
50
|
*/
|
|
33
|
-
waiting(quietMs: number):
|
|
51
|
+
waiting(quietMs: number): LogPart | null;
|
|
34
52
|
private line;
|
|
53
|
+
/** A call the agent has started and not finished yet (Claude reports both ends). */
|
|
35
54
|
private start;
|
|
36
|
-
|
|
55
|
+
/**
|
|
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.
|
|
58
|
+
*/
|
|
59
|
+
private finishCall;
|
|
37
60
|
/** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */
|
|
38
61
|
private sandboxHint;
|
|
39
62
|
private claude;
|
|
40
63
|
private opencode;
|
|
64
|
+
/** `FAILED: <why>`, plus what to do about the two failures users actually hit. */
|
|
65
|
+
private opencodeError;
|
|
41
66
|
}
|