claude-simple 0.2.24 → 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,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
  }
@@ -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();