claude-simple 0.2.23 → 0.2.25

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,10 +10,64 @@ 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
  }
16
40
 
41
+ // JSON-RPC 2.0 error.code must be an integer. Node fs/spawn errors use string
42
+ // codes like "ENOENT"; Grok's Rust client rejects those and logs
43
+ // `invalid type: string "ENOENT", expected i32`.
44
+ export function jsonRpcErrorCode(error) {
45
+ const code = error?.code;
46
+ if (typeof code === 'number' && Number.isInteger(code)) return code;
47
+ return -32000;
48
+ }
49
+
50
+ /** ACP paths must be absolute; resolve relatives against the session cwd, not the edge process cwd. */
51
+ export function resolveFsPath(filePath, cwd) {
52
+ const raw = String(filePath || '').trim();
53
+ if (!raw) {
54
+ const err = new Error('path is required');
55
+ err.code = -32602;
56
+ throw err;
57
+ }
58
+ return path.resolve(cwd || process.cwd(), raw);
59
+ }
60
+
61
+ export function selectPermissionOption(options) {
62
+ const list = Array.isArray(options) ? options : [];
63
+ const allow = list.find(o => o?.kind === 'allow_always')
64
+ || list.find(o => o?.kind === 'allow_once')
65
+ || list.find(o => typeof o?.kind === 'string' && String(o.kind).startsWith('allow'))
66
+ || list[0];
67
+ if (!allow?.optionId) return { outcome: 'cancelled' };
68
+ return { outcome: 'selected', optionId: allow.optionId };
69
+ }
70
+
17
71
  function envFromParams(envList) {
18
72
  const env = { ...process.env };
19
73
  if (!Array.isArray(envList)) return env;
@@ -164,6 +218,7 @@ class GrokACPClient {
164
218
  this.child = spawn(cliPath || 'grok', args, { stdio: ['pipe', 'pipe', 'pipe'] });
165
219
  this.pending = new Map();
166
220
  this.listeners = new Map();
221
+ this.sessionCwd = new Map();
167
222
  // High ids avoid colliding with agent-originated request ids (often 0..N).
168
223
  this.nextID = 100000;
169
224
  this.terminals = new TerminalHost();
@@ -198,13 +253,19 @@ class GrokACPClient {
198
253
  return init;
199
254
  }
200
255
 
256
+ fsCwd(params) {
257
+ return (params?.sessionId && this.sessionCwd.get(params.sessionId)) || process.cwd();
258
+ }
259
+
201
260
  async handleClientRequest(msg) {
202
261
  const method = msg.method;
203
262
  const params = msg.params || {};
204
263
  try {
205
264
  let result = {};
206
- if (method === 'fs/read_text_file') {
207
- const filePath = String(params.path || '');
265
+ if (method === 'session/request_permission') {
266
+ result = { outcome: selectPermissionOption(params.options) };
267
+ } else if (method === 'fs/read_text_file') {
268
+ const filePath = resolveFsPath(params.path, this.fsCwd(params));
208
269
  const content = await fs.promises.readFile(filePath, 'utf8');
209
270
  const limit = Number.isFinite(params.limit) ? params.limit : null;
210
271
  const line = Number.isFinite(params.line) ? params.line : null;
@@ -217,11 +278,13 @@ class GrokACPClient {
217
278
  result = { content };
218
279
  }
219
280
  } else if (method === 'fs/write_text_file') {
220
- const filePath = String(params.path || '');
281
+ const filePath = resolveFsPath(params.path, this.fsCwd(params));
282
+ const content = params.content == null ? '' : String(params.content);
221
283
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
222
- await fs.promises.writeFile(filePath, params.content ?? '', 'utf8');
284
+ await fs.promises.writeFile(filePath, content, 'utf8');
223
285
  result = {};
224
286
  } else if (method === 'terminal/create') {
287
+ if (!params.cwd) params = { ...params, cwd: this.fsCwd(params) };
225
288
  result = this.terminals.create(params);
226
289
  } else if (method === 'terminal/output') {
227
290
  result = this.terminals.output(params.terminalId);
@@ -244,7 +307,10 @@ class GrokACPClient {
244
307
  this.write({
245
308
  jsonrpc: '2.0',
246
309
  id: msg.id,
247
- error: { code: error.code || -32000, message: error.message || 'Client request failed' },
310
+ error: {
311
+ code: jsonRpcErrorCode(error),
312
+ message: error.message || 'Client request failed',
313
+ },
248
314
  });
249
315
  }
250
316
  }
@@ -291,20 +357,23 @@ class GrokACPClient {
291
357
 
292
358
  async ensureSession(options) {
293
359
  await this.ready;
360
+ const cwd = options.cwd || process.cwd();
294
361
  if (options.sessionId) {
295
362
  try {
296
- await this.request('session/load', { sessionId: options.sessionId, cwd: options.cwd, mcpServers: [] });
363
+ await this.request('session/load', { sessionId: options.sessionId, cwd, mcpServers: [] });
297
364
  } catch {
298
- await this.request('session/resume', { sessionId: options.sessionId, cwd: options.cwd, mcpServers: [] });
365
+ await this.request('session/resume', { sessionId: options.sessionId, cwd, mcpServers: [] });
299
366
  }
367
+ this.sessionCwd.set(options.sessionId, cwd);
300
368
  return options.sessionId;
301
369
  }
302
370
  const result = await this.request('session/new', {
303
- cwd: options.cwd || process.cwd(),
371
+ cwd,
304
372
  mcpServers: [],
305
373
  _meta: { yoloMode: true },
306
374
  });
307
375
  if (!result.sessionId) throw new Error('Grok ACP did not return a session id');
376
+ this.sessionCwd.set(result.sessionId, cwd);
308
377
  return result.sessionId;
309
378
  }
310
379
  }
@@ -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 }));
@@ -910,12 +910,16 @@ function geminiJournalPath(sessionId, geminiHome) {
910
910
  return path.join(geminiUiSessionsRoot(geminiHome), `${sessionId}.jsonl`);
911
911
  }
912
912
 
913
- // agy keeps the actual native transcript here. This is plain JSONL (unlike
914
- // its conversation SQLite database) and is available for conversations made
915
- // in the Antigravity app as well as print-mode conversations created by us.
916
- function geminiNativeTranscriptPath(sessionId, geminiHome) {
913
+ // agy keeps both a compact transport log and a lossless transcript. The
914
+ // compact log replaces large fields with `<truncated N bytes>`; always prefer
915
+ // the full file when it is available so chat history never loses message text.
916
+ function geminiNativeTranscriptPaths(sessionId, geminiHome) {
917
917
  const home = geminiHome || path.join(os.homedir(), '.gemini');
918
- return path.join(home, 'antigravity-cli', 'brain', sessionId, '.system_generated', 'logs', 'transcript.jsonl');
918
+ const logs = path.join(home, 'antigravity-cli', 'brain', sessionId, '.system_generated', 'logs');
919
+ return [
920
+ path.join(logs, 'transcript_full.jsonl'),
921
+ path.join(logs, 'transcript.jsonl'),
922
+ ];
919
923
  }
920
924
 
921
925
  function geminiPathFromUri(uri) {
@@ -1039,8 +1043,10 @@ async function findGeminiJournal(sessionId, geminiHome) {
1039
1043
  }
1040
1044
 
1041
1045
  async function findGeminiNativeTranscript(sessionId, geminiHome) {
1042
- const file = geminiNativeTranscriptPath(sessionId, geminiHome);
1043
- try { await fs.access(file); return file; } catch { return null; }
1046
+ for (const file of geminiNativeTranscriptPaths(sessionId, geminiHome)) {
1047
+ try { await fs.access(file); return file; } catch { /* try the compact fallback */ }
1048
+ }
1049
+ return null;
1044
1050
  }
1045
1051
 
1046
1052
  function geminiUserText(content) {
@@ -1571,7 +1577,8 @@ export async function loadSessionJsonl(sessionId, agent, { claudeConfigDirs, cod
1571
1577
  let file;
1572
1578
  if (a === 'codex') file = await findCodexSessionFile(sessionId, codexHome);
1573
1579
  else if (a === 'cursor') file = await findCursorJournal(sessionId, cursorHome);
1574
- else if (a === 'gemini') file = await findGeminiJournal(sessionId, geminiHome);
1580
+ else if (a === 'gemini') file = await findGeminiNativeTranscript(sessionId, geminiHome)
1581
+ || await findGeminiJournal(sessionId, geminiHome);
1575
1582
  else if (a === 'grok') {
1576
1583
  const dir = await findGrokSessionDir(sessionId, grokHome);
1577
1584
  file = dir ? path.join(dir, 'chat_history.jsonl') : null;
@@ -1637,14 +1644,24 @@ export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codex
1637
1644
  return { cwd, display };
1638
1645
  }
1639
1646
 
1640
- 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 } = {}) {
1641
1654
  // 1. Get meta and messages from source session
1642
1655
  const meta = await getSessionMeta(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome });
1643
1656
  const cwd = meta.cwd || null;
1644
1657
  const display = meta.display || 'Converted Session';
1645
1658
 
1646
1659
  const bundle = await loadSessionMessages(sourceSessionId, sourceAgent, { claudeConfigDirs, codexHome, grokHome, geminiHome, cursorHome });
1647
- 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');
1648
1665
 
1649
1666
  // 2. Generate target session ID
1650
1667
  const targetSessionId = crypto.randomUUID();