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.
- package/README.md +20 -0
- package/client.js +20 -3
- package/dist/app.0.2.25.min.js +1164 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.25.min.css +1753 -0
- package/package.json +1 -1
- package/public/api.js +1 -0
- package/public/chat.js +213 -8
- package/public/router.js +5 -0
- package/public/settings.js +312 -63
- package/public/shell/notes.js +5 -2
- package/public/shell/session-list.js +50 -2
- package/public/shell/session-nav.js +45 -12
- package/public/state.js +3 -2
- package/public/style.css +126 -0
- package/public/ui.min.css +1 -1
- package/src/agent-env.js +211 -0
- package/src/agents/claude.js +10 -3
- package/src/agents/grok.js +24 -0
- package/src/agents/models.js +30 -1
- package/src/agents/sessions.js +12 -2
- package/src/app.js +450 -14
- package/src/collab.js +248 -0
- package/src/runtime/external-session-sync.js +106 -0
- package/src/runtime/protocol.js +3 -2
- package/dist/app.0.2.24.min.js +0 -1104
- package/dist/style.0.2.24.min.css +0 -1627
package/src/agent-env.js
ADDED
|
@@ -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
|
+
}
|
package/src/agents/claude.js
CHANGED
|
@@ -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 = {
|
|
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
|
}
|
package/src/agents/grok.js
CHANGED
|
@@ -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
|
}
|
package/src/agents/models.js
CHANGED
|
@@ -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 }));
|
package/src/agents/sessions.js
CHANGED
|
@@ -1644,14 +1644,24 @@ export async function getSessionMeta(sessionId, agent, { claudeConfigDirs, codex
|
|
|
1644
1644
|
return { cwd, display };
|
|
1645
1645
|
}
|
|
1646
1646
|
|
|
1647
|
-
|
|
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
|
-
|
|
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();
|