claude-simple 0.2.24 → 0.2.26

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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Allowlisted env for Claude Code (and Anthropic-compatible gateways
3
+ * such as DeepSeek). LaunchAgents do not inherit shell exports, so we
4
+ * persist a profile and merge ~/.claude/settings.json `env` at spawn time.
5
+ */
6
+ import { promises as fs } from 'fs';
7
+ import path from 'path';
8
+ import os from 'os';
9
+
10
+ export const CLAUDE_ENV_KEYS = [
11
+ 'ANTHROPIC_AUTH_TOKEN',
12
+ 'ANTHROPIC_API_KEY',
13
+ 'ANTHROPIC_BASE_URL',
14
+ 'ANTHROPIC_MODEL',
15
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
16
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
17
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
18
+ 'ANTHROPIC_SMALL_FAST_MODEL',
19
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
20
+ 'CLAUDE_CODE_EFFORT_LEVEL',
21
+ 'CLAUDE_MODEL',
22
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
23
+ 'API_TIMEOUT_MS',
24
+ 'ANTHROPIC_BETAS',
25
+ ];
26
+
27
+ const SECRET_RE = /TOKEN|KEY|SECRET|PASSWORD/i;
28
+
29
+ export function isSecretEnvKey(key) {
30
+ return SECRET_RE.test(key);
31
+ }
32
+
33
+ export function pickAllowedEnv(src) {
34
+ const out = {};
35
+ if (!src || typeof src !== 'object') return out;
36
+ for (const key of CLAUDE_ENV_KEYS) {
37
+ const v = src[key];
38
+ if (typeof v === 'string' && v.trim()) out[key] = v.trim();
39
+ }
40
+ return out;
41
+ }
42
+
43
+ export function mergeClaudeEnv(...layers) {
44
+ const out = {};
45
+ for (const layer of layers) {
46
+ if (!layer) continue;
47
+ Object.assign(out, pickAllowedEnv(layer));
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Apply allowlisted keys onto a full process.env clone. */
53
+ export function withClaudeEnv(processEnv, extra) {
54
+ return { ...processEnv, ...pickAllowedEnv(extra) };
55
+ }
56
+
57
+ const PLACEHOLDER_MODELS = new Set([
58
+ 'default', 'auto', '', 'claude-sonnet-4-5', 'claude-sonnet-4-6',
59
+ ]);
60
+
61
+ export function resolveClaudeModel(options, env) {
62
+ const requested = typeof options?.model === 'string' ? options.model.trim() : '';
63
+ const fromEnv = env?.ANTHROPIC_MODEL || env?.CLAUDE_MODEL || '';
64
+ const customGateway = !!(env?.ANTHROPIC_BASE_URL);
65
+ if (!requested || requested === 'default' || requested === 'auto') {
66
+ return fromEnv || undefined;
67
+ }
68
+ // Official fallback ids against a third-party Anthropic-compatible URL
69
+ // (DeepSeek etc.) would 404; prefer the env model.
70
+ if (customGateway && fromEnv && PLACEHOLDER_MODELS.has(requested)) {
71
+ return fromEnv;
72
+ }
73
+ return requested;
74
+ }
75
+
76
+ export function resolveClaudeEffort(options, env) {
77
+ const requested = typeof options?.effort === 'string' ? options.effort.trim() : '';
78
+ if (requested) return requested;
79
+ const fromEnv = env?.CLAUDE_CODE_EFFORT_LEVEL;
80
+ return fromEnv || undefined;
81
+ }
82
+
83
+ export function redactEnv(env) {
84
+ const out = {};
85
+ for (const [k, v] of Object.entries(env || {})) {
86
+ if (isSecretEnvKey(k)) out[k] = { set: true, preview: maskSecret(v) };
87
+ else out[k] = v;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function maskSecret(v) {
93
+ const s = String(v);
94
+ if (s.length <= 8) return '••••';
95
+ return `${s.slice(0, 3)}…${s.slice(-4)}`;
96
+ }
97
+
98
+ export async function readSettingsJsonEnv(configDir) {
99
+ if (!configDir) return {};
100
+ const dir = String(configDir).replace(/^~/, os.homedir());
101
+ let acc = {};
102
+ for (const name of ['settings.json', 'settings.local.json']) {
103
+ try {
104
+ const raw = JSON.parse(await fs.readFile(path.join(dir, name), 'utf8'));
105
+ if (raw?.env && typeof raw.env === 'object') {
106
+ acc = { ...acc, ...pickAllowedEnv(raw.env) };
107
+ }
108
+ } catch { /* missing is fine */ }
109
+ }
110
+ return acc;
111
+ }
112
+
113
+ export function defaultAgentEnvPath() {
114
+ if (process.env.AGENT_ENV_FILE) return process.env.AGENT_ENV_FILE;
115
+ return path.join(os.homedir(), '.claude-simple-edge', 'agent-env.json');
116
+ }
117
+
118
+ export function normalizeProfileName(name) {
119
+ return String(name || '').trim();
120
+ }
121
+
122
+ export function parseAgentEnvDoc(raw) {
123
+ if (!raw || typeof raw !== 'object') return { env: {}, profiles: {} };
124
+ const hasProfiles = raw.profiles && typeof raw.profiles === 'object';
125
+ const env = pickAllowedEnv(raw.env || (!hasProfiles ? raw : {}));
126
+ const profiles = {};
127
+ if (hasProfiles) {
128
+ for (const [k, v] of Object.entries(raw.profiles)) {
129
+ const n = normalizeProfileName(k);
130
+ if (n) profiles[n] = pickAllowedEnv(v);
131
+ }
132
+ }
133
+ return { env, profiles };
134
+ }
135
+
136
+ export async function loadAgentEnvDoc(filePath = defaultAgentEnvPath()) {
137
+ try {
138
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf8'));
139
+ return parseAgentEnvDoc(raw);
140
+ } catch {
141
+ return { env: {}, profiles: {} };
142
+ }
143
+ }
144
+
145
+ export async function saveAgentEnvDoc(doc, filePath = defaultAgentEnvPath()) {
146
+ const env = pickAllowedEnv(doc?.env);
147
+ const profiles = {};
148
+ for (const [k, v] of Object.entries(doc?.profiles || {})) {
149
+ const n = normalizeProfileName(k);
150
+ const picked = pickAllowedEnv(v);
151
+ if (n && Object.keys(picked).length) profiles[n] = picked;
152
+ }
153
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
154
+ const tmp = `${filePath}.tmp`;
155
+ await fs.writeFile(tmp, JSON.stringify({ env, profiles }, null, 2), { mode: 0o600 });
156
+ await fs.rename(tmp, filePath);
157
+ return { env, profiles };
158
+ }
159
+
160
+ /** Default (unnamed) allowlist. Named profiles live under `profiles`. */
161
+ export async function loadAgentEnvFile(filePath = defaultAgentEnvPath()) {
162
+ return (await loadAgentEnvDoc(filePath)).env;
163
+ }
164
+
165
+ export async function saveAgentEnvFile(env, filePath = defaultAgentEnvPath()) {
166
+ const doc = await loadAgentEnvDoc(filePath);
167
+ doc.env = pickAllowedEnv(env);
168
+ await saveAgentEnvDoc(doc, filePath);
169
+ return doc.env;
170
+ }
171
+
172
+ /** Default layer, then workspace-named profile (same name as Settings workspace). */
173
+ export function envForProfile(doc, name) {
174
+ const n = normalizeProfileName(name);
175
+ const named = n && doc?.profiles?.[n] ? doc.profiles[n] : {};
176
+ return mergeClaudeEnv(doc?.env, named);
177
+ }
178
+
179
+ /** Merge incoming PATCH: omit key to keep, empty string to delete, string to set. */
180
+ export function patchEnv(current, patch) {
181
+ const out = { ...pickAllowedEnv(current) };
182
+ if (!patch || typeof patch !== 'object') return out;
183
+ for (const key of CLAUDE_ENV_KEYS) {
184
+ if (!(key in patch)) continue;
185
+ const v = patch[key];
186
+ if (v == null || v === '' || (typeof v === 'object' && v.set === true && !v.value)) {
187
+ if (v && typeof v === 'object' && v.set === true) continue; // keep secret
188
+ delete out[key];
189
+ continue;
190
+ }
191
+ if (typeof v === 'string') out[key] = v.trim();
192
+ else if (typeof v === 'object' && typeof v.value === 'string') out[key] = v.value.trim();
193
+ }
194
+ return out;
195
+ }
196
+
197
+ export async function applyAgentEnvToProcess(processEnv = process.env) {
198
+ const fileEnv = await loadAgentEnvFile();
199
+ const settingsEnv = await readSettingsJsonEnv(path.join(os.homedir(), '.claude'));
200
+ const merged = mergeClaudeEnv(processEnv, settingsEnv, fileEnv);
201
+ for (const [k, v] of Object.entries(merged)) processEnv[k] = v;
202
+ return merged;
203
+ }
204
+
205
+ export async function buildClaudeSpawnEnv({ processEnv = process.env, configDir, extra, profile } = {}) {
206
+ const settingsEnv = await readSettingsJsonEnv(configDir || path.join(os.homedir(), '.claude'));
207
+ const doc = await loadAgentEnvDoc();
208
+ const fileEnv = envForProfile(doc, profile);
209
+ const allow = mergeClaudeEnv(processEnv, settingsEnv, fileEnv, extra);
210
+ return { ...processEnv, ...allow };
211
+ }
@@ -2,6 +2,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import crypto from 'crypto';
3
3
  import { mapAgentEmit, compactToolInput } from '../runtime/protocol.js';
4
4
  import { waitForApproval, cancelApprovalsForSession } from '../runtime/approvals.js';
5
+ import { buildClaudeSpawnEnv, resolveClaudeModel, resolveClaudeEffort } from '../agent-env.js';
5
6
 
6
7
  /**
7
8
  * Run a Claude Code turn via the agent SDK.
@@ -20,7 +21,11 @@ export async function runClaude(command, options, send, { cliPath, activeSession
20
21
  sessionId: options.sessionId || null,
21
22
  });
22
23
 
23
- const env = { ...process.env };
24
+ const env = await buildClaudeSpawnEnv({
25
+ processEnv: process.env,
26
+ configDir: options.configDir,
27
+ profile: options.workspaceName || options.profile,
28
+ });
24
29
  if (options.configDir) env.CLAUDE_CONFIG_DIR = options.configDir;
25
30
 
26
31
  const permissionMode = options.permissionMode || 'bypassPermissions';
@@ -40,12 +45,14 @@ export async function runClaude(command, options, send, { cliPath, activeSession
40
45
  tools: { type: 'preset', preset: 'claude_code' },
41
46
  systemPrompt: { type: 'preset', preset: 'claude_code' },
42
47
  settingSources: ['project', 'user', 'local'],
43
- model: options.model || 'claude-sonnet-4-5',
44
48
  abortSignal: controller.signal,
45
49
  };
50
+ const model = resolveClaudeModel(options, env);
51
+ if (model) sdkOptions.model = model;
52
+ const effort = resolveClaudeEffort(options, env);
53
+ if (effort) sdkOptions.effort = effort;
46
54
  if (options.cwd) sdkOptions.cwd = options.cwd;
47
55
  if (options.sessionId) sdkOptions.resume = options.sessionId;
48
- if (options.effort) sdkOptions.effort = options.effort;
49
56
  if (permissionMode && permissionMode !== 'default') {
50
57
  sdkOptions.permissionMode = permissionMode;
51
58
  }
@@ -10,6 +10,30 @@ const clients = new Map();
10
10
  const queues = new Map();
11
11
  const queueAliases = new Map();
12
12
 
13
+ /** Read-only queue snapshot used when a browser reconnects mid-turn. The ACP
14
+ * process owns the real queue; exposing it lets the UI restore the user's
15
+ * already-accepted follow-up prompts after a page refresh. */
16
+ export function getGrokQueueState(sessionId) {
17
+ const key = (sessionId && queueAliases.get(sessionId)) || sessionId;
18
+ const queue = key ? queues.get(key) : null;
19
+ if (!queue) return { running: false, items: [] };
20
+ return {
21
+ running: !!queue.running,
22
+ items: queue.items.map(item => String(item?.command || '')).filter(Boolean),
23
+ };
24
+ }
25
+
26
+ /** Drop follow-ups that have not started yet. The active ACP prompt is aborted
27
+ * by app.js through its active-session entry. */
28
+ export function cancelGrokQueue(sessionId) {
29
+ const key = (sessionId && queueAliases.get(sessionId)) || sessionId;
30
+ const queue = key ? queues.get(key) : null;
31
+ if (!queue) return 0;
32
+ const count = queue.items.length;
33
+ queue.items.length = 0;
34
+ return count;
35
+ }
36
+
13
37
  function profileKey(options) {
14
38
  return `${options.model || ''}\u001f${options.effort || ''}`;
15
39
  }
@@ -28,8 +28,8 @@ import crypto from 'crypto';
28
28
  import { spawn } from 'child_process';
29
29
 
30
30
  export const AI_DEFAULTS = {
31
- url: 'https://api.deepseek.com/chat/completions',
32
- model: 'deepseek-chat',
31
+ url: 'https://api.xiaomimimo.com/v1/chat/completions',
32
+ model: 'mimo-v2.5',
33
33
  vlm_url: '',
34
34
  vlm_model: '',
35
35
  temperature: 0.3,
@@ -51,7 +51,8 @@ export const DEFAULT_SYSTEM = `你是 Claude Simple UI 的内置工作助手(M
51
51
  - 同一批图的追问:传入上次返回的 thread_id + 新 prompt,可复用图片与历史问答,不必重复传路径(也可继续传 paths 以补图)。
52
52
  - 不要假装已经看到图片;没调用工具就说「根据路径无法看见内容」。
53
53
  6. 需要验证时:write_file 写代码 → run_command 执行 → 根据输出迭代。
54
- 7. 回复简洁有结构,中文优先(用户用中文时)。`;
54
+ 7. 查找项目文件时优先用 find_files;先缩小范围再 read_file,不要用 run_command 代替只读检索。
55
+ 8. 回复简洁有结构,中文优先(用户用中文时)。`;
55
56
 
56
57
  export const REPORT_SYSTEM = `你是工作复盘助手。根据提供的 JSON 活动快照,写一份清晰的工作报告(Markdown)。
57
58
  结构建议:
@@ -292,6 +293,23 @@ const TOOLS = [
292
293
  },
293
294
  },
294
295
  },
296
+ {
297
+ type: 'function',
298
+ function: {
299
+ name: 'find_files',
300
+ description: '按文件名在目录树中查找文件;适合先定位代码、配置或文档,再用 read_file 读取内容',
301
+ parameters: {
302
+ type: 'object',
303
+ required: ['path', 'query'],
304
+ properties: {
305
+ path: { type: 'string', description: '要搜索的根目录(绝对路径)' },
306
+ query: { type: 'string', description: '文件名关键词,不区分大小写' },
307
+ max: { type: 'integer', description: '最多返回条数,默认 80,上限 200' },
308
+ maxDepth: { type: 'integer', description: '最大目录深度,默认 5,上限 10' },
309
+ },
310
+ },
311
+ },
312
+ },
295
313
  {
296
314
  type: 'function',
297
315
  function: {
@@ -504,12 +522,16 @@ export function createMetaAgent(deps) {
504
522
  cursorHome,
505
523
  } = deps;
506
524
 
507
- const configFile = path.join(rootDir, '.ai_config.json');
508
- const reportsFile = path.join(rootDir, '.ai_reports.json');
509
- const legacySessionsFile = path.join(rootDir, '.ai_sessions.json');
510
525
  const aiDir = process.env.AI_DATA_DIR
511
526
  ? expandPath(process.env.AI_DATA_DIR)
512
527
  : path.join(rootDir, '.ai');
528
+ // Keep credentials and reports outside an npm-installed package whenever
529
+ // AI_DATA_DIR is configured. Legacy files are read once for compatibility.
530
+ const configFile = path.join(aiDir, 'config.json');
531
+ const reportsFile = path.join(aiDir, 'reports.json');
532
+ const legacyConfigFile = path.join(rootDir, '.ai_config.json');
533
+ const legacyReportsFile = path.join(rootDir, '.ai_reports.json');
534
+ const legacySessionsFile = path.join(rootDir, '.ai_sessions.json');
513
535
  const notesFile = path.join(aiDir, 'meta-notes.json');
514
536
  const sessionBriefsFile = path.join(aiDir, 'session-briefs.json');
515
537
  const sessionsDir = path.join(aiDir, 'sessions');
@@ -1455,10 +1477,15 @@ export function createMetaAgent(deps) {
1455
1477
 
1456
1478
  async function loadConfig() {
1457
1479
  if (configCache) return configCache;
1480
+ await ensureAiDirs();
1458
1481
  try {
1459
1482
  configCache = { ...AI_DEFAULTS, ...JSON.parse(await fs.readFile(configFile, 'utf8')) };
1460
1483
  } catch {
1461
- configCache = { ...AI_DEFAULTS, key: '', vlm_key: '', system: DEFAULT_SYSTEM };
1484
+ try {
1485
+ configCache = { ...AI_DEFAULTS, ...JSON.parse(await fs.readFile(legacyConfigFile, 'utf8')) };
1486
+ } catch {
1487
+ configCache = { ...AI_DEFAULTS, key: '', vlm_key: '', system: DEFAULT_SYSTEM };
1488
+ }
1462
1489
  }
1463
1490
  if (!configCache.system) configCache.system = DEFAULT_SYSTEM;
1464
1491
  return configCache;
@@ -1492,10 +1519,15 @@ export function createMetaAgent(deps) {
1492
1519
 
1493
1520
  async function loadReports() {
1494
1521
  if (reportsCache) return reportsCache;
1522
+ await ensureAiDirs();
1495
1523
  try {
1496
1524
  reportsCache = JSON.parse(await fs.readFile(reportsFile, 'utf8'));
1497
1525
  } catch {
1498
- reportsCache = {};
1526
+ try {
1527
+ reportsCache = JSON.parse(await fs.readFile(legacyReportsFile, 'utf8'));
1528
+ } catch {
1529
+ reportsCache = {};
1530
+ }
1499
1531
  }
1500
1532
  return reportsCache;
1501
1533
  }
@@ -1622,11 +1654,26 @@ export function createMetaAgent(deps) {
1622
1654
  return res;
1623
1655
  }
1624
1656
 
1657
+ function isMimoModel(model) {
1658
+ return String(model || '').toLowerCase().includes('mimo');
1659
+ }
1660
+
1661
+ function providerOptions(body) {
1662
+ if (!isMimoModel(body?.model)) return body;
1663
+ // `extra_body` is an SDK-only Python convenience. This server uses fetch,
1664
+ // so MiMo's provider option must be sent at the JSON body top level.
1665
+ return {
1666
+ ...body,
1667
+ max_completion_tokens: body.max_completion_tokens || 2048,
1668
+ thinking: { type: 'disabled' },
1669
+ };
1670
+ }
1671
+
1625
1672
  async function chatSync(cfg, body) {
1626
1673
  const res = await chatCompletions({
1627
1674
  url: cfg.url || AI_DEFAULTS.url,
1628
1675
  apiKey: cfg.key || '',
1629
- body: { ...body, stream: false },
1676
+ body: providerOptions({ ...body, stream: false }),
1630
1677
  });
1631
1678
  return res.json();
1632
1679
  }
@@ -1635,7 +1682,7 @@ export function createMetaAgent(deps) {
1635
1682
  const res = await chatCompletions({
1636
1683
  url: cfg.url || AI_DEFAULTS.url,
1637
1684
  apiKey: cfg.key || '',
1638
- body: { ...body, stream: true },
1685
+ body: providerOptions({ ...body, stream: true }),
1639
1686
  });
1640
1687
  const reader = res.body.getReader();
1641
1688
  const dec = new TextDecoder();
@@ -1821,6 +1868,37 @@ export function createMetaAgent(deps) {
1821
1868
  path: path.join(dir, e.name),
1822
1869
  }));
1823
1870
  }
1871
+ case 'find_files': {
1872
+ const root = expandPath(args.path);
1873
+ if (!path.isAbsolute(root)) return { error: 'path must be absolute' };
1874
+ const query = String(args.query || '').trim().toLowerCase();
1875
+ if (!query) return { error: 'query required' };
1876
+ const max = Math.min(Math.max(args.max || 80, 1), 200);
1877
+ const maxDepth = Math.min(Math.max(args.maxDepth || 5, 0), 10);
1878
+ const ignored = new Set(['.git', 'node_modules', 'dist', 'build', '.next', '.dart_tool']);
1879
+ const matches = [];
1880
+ async function visit(dir, depth) {
1881
+ if (matches.length >= max || depth > maxDepth) return;
1882
+ let entries;
1883
+ try {
1884
+ entries = await fs.readdir(dir, { withFileTypes: true });
1885
+ } catch {
1886
+ return;
1887
+ }
1888
+ for (const entry of entries) {
1889
+ if (matches.length >= max) return;
1890
+ if (ignored.has(entry.name) || entry.isSymbolicLink()) continue;
1891
+ const full = path.join(dir, entry.name);
1892
+ if (entry.isDirectory()) {
1893
+ await visit(full, depth + 1);
1894
+ } else if (entry.isFile() && entry.name.toLowerCase().includes(query)) {
1895
+ matches.push(full);
1896
+ }
1897
+ }
1898
+ }
1899
+ await visit(root, 0);
1900
+ return { root, query, matches, truncated: matches.length >= max };
1901
+ }
1824
1902
  case 'read_file': {
1825
1903
  const p = expandPath(args.path);
1826
1904
  const maxBytes = Math.min(args.maxBytes || 120000, 500000);
@@ -1989,7 +2067,7 @@ export function createMetaAgent(deps) {
1989
2067
  const res = await chatCompletions({
1990
2068
  url: vlmUrl,
1991
2069
  apiKey: vlmKey,
1992
- body: { ...body, stream: false },
2070
+ body: providerOptions({ ...body, stream: false }),
1993
2071
  });
1994
2072
  const json = await res.json();
1995
2073
  const text = json.choices?.[0]?.message?.content || '';
@@ -2097,12 +2175,6 @@ export function createMetaAgent(deps) {
2097
2175
  stream: true,
2098
2176
  temperature: config.temperature ?? 0.3,
2099
2177
  };
2100
- // some providers
2101
- if (body.model && String(body.model).includes('mimo')) {
2102
- body.max_completion_tokens = 2048;
2103
- body.extra_body = { thinking: { type: 'disabled' } };
2104
- }
2105
-
2106
2178
  await streamChat(config, body, (chunk) => {
2107
2179
  const d = chunk.choices?.[0]?.delta;
2108
2180
  if (d?.content) {
@@ -6,6 +6,7 @@ import { promises as fs } from 'fs';
6
6
  import path from 'path';
7
7
  import os from 'os';
8
8
  import { execFile } from 'child_process';
9
+ import { pickAllowedEnv, loadAgentEnvFile, readSettingsJsonEnv } from '../agent-env.js';
9
10
 
10
11
  /** Static fallbacks when local cache/config is missing. */
11
12
  export const FALLBACK_MODELS = {
@@ -168,6 +169,34 @@ async function discoverClaude(claudeConfigDirs = []) {
168
169
  let defaultModel = null;
169
170
  const sources = [];
170
171
 
172
+ const envLayers = [
173
+ pickAllowedEnv(process.env),
174
+ await loadAgentEnvFile(),
175
+ ];
176
+ for (const dir of dirs) {
177
+ envLayers.push(await readSettingsJsonEnv(dir));
178
+ }
179
+ const envMerged = Object.assign({}, ...envLayers);
180
+ const envModelKeys = [
181
+ 'ANTHROPIC_MODEL',
182
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
183
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
184
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
185
+ 'ANTHROPIC_SMALL_FAST_MODEL',
186
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
187
+ 'CLAUDE_MODEL',
188
+ ];
189
+ for (const k of envModelKeys) {
190
+ const v = envMerged[k];
191
+ if (typeof v === 'string' && v.trim()) {
192
+ found.push({ value: v.trim(), label: shortLabel(v.trim()) });
193
+ if ((k === 'ANTHROPIC_MODEL' || k === 'CLAUDE_MODEL') && !defaultModel) {
194
+ defaultModel = v.trim();
195
+ }
196
+ }
197
+ }
198
+ if (found.length) sources.push('env');
199
+
171
200
  for (const dir of dirs) {
172
201
  for (const name of ['settings.json', 'settings.local.json']) {
173
202
  const data = await readJson(path.join(dir, name));
@@ -202,7 +231,7 @@ async function discoverClaude(claudeConfigDirs = []) {
202
231
  let models = uniqModels(found);
203
232
  let source = 'fallback';
204
233
  if (models.length) {
205
- source = 'settings';
234
+ source = envMerged.ANTHROPIC_BASE_URL ? 'anthropic-compat' : 'settings';
206
235
  // Custom gateway / third-party models: list only what is configured
207
236
  } else {
208
237
  models = FALLBACK_MODELS.claude.models.map(m => ({ ...m }));
@@ -1644,14 +1644,24 @@ export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codex
1644
1644
  return { cwd, display };
1645
1645
  }
1646
1646
 
1647
- export async function convertSession(sourceSessionId, sourceAgent, targetAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }) {
1647
+ /**
1648
+ * Copy a session into a new native transcript. `upTo` is an exclusive message
1649
+ * count, used by message-level forks to copy only the selected conversation
1650
+ * prefix. Keeping this here (rather than slicing in the browser) makes the
1651
+ * server-owned transcript the source of truth.
1652
+ */
1653
+ export async function convertSession(sourceSessionId, sourceAgent, targetAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome }, { upTo = null } = {}) {
1648
1654
  // 1. Get meta and messages from source session
1649
1655
  const meta = await getSessionMeta(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome });
1650
1656
  const cwd = meta.cwd || null;
1651
1657
  const display = meta.display || 'Converted Session';
1652
1658
 
1653
1659
  const bundle = await loadSessionMessages(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome });
1654
- const messages = bundle.messages || [];
1660
+ let messages = bundle.messages || [];
1661
+ if (Number.isInteger(upTo)) {
1662
+ messages = messages.slice(0, Math.max(0, Math.min(upTo, messages.length)));
1663
+ }
1664
+ if (!messages.length) throw new Error('No messages available at the selected fork point');
1655
1665
 
1656
1666
  // 2. Generate target session ID
1657
1667
  const targetSessionId = crypto.randomUUID();