@yanzc/claude-pro 0.0.0-stage → 0.4.1

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.
Files changed (58) hide show
  1. package/.env.example +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +314 -3
  4. package/bin/claude-pro.js +32 -0
  5. package/package.json +40 -6
  6. package/src/agent.js +752 -0
  7. package/src/agents.js +186 -0
  8. package/src/api.js +529 -0
  9. package/src/cache.js +150 -0
  10. package/src/checkpoints.js +184 -0
  11. package/src/claude-code.js +60 -0
  12. package/src/codex/client.js +307 -0
  13. package/src/codex/translate-request.js +239 -0
  14. package/src/codex/translate-stream.js +328 -0
  15. package/src/commands.js +839 -0
  16. package/src/config.js +453 -0
  17. package/src/context.js +109 -0
  18. package/src/filestate.js +57 -0
  19. package/src/gateway.js +143 -0
  20. package/src/hooks.js +81 -0
  21. package/src/main.js +193 -0
  22. package/src/mcp.js +290 -0
  23. package/src/modes.js +154 -0
  24. package/src/oauth.js +287 -0
  25. package/src/peers.js +157 -0
  26. package/src/print.js +106 -0
  27. package/src/prompts.js +179 -0
  28. package/src/ratelimit.js +244 -0
  29. package/src/restart.js +115 -0
  30. package/src/runtime.js +162 -0
  31. package/src/selfcheck.js +71 -0
  32. package/src/session.js +139 -0
  33. package/src/supervisor.js +110 -0
  34. package/src/tasks.js +76 -0
  35. package/src/tools/bash.js +227 -0
  36. package/src/tools/effort.js +41 -0
  37. package/src/tools/files.js +352 -0
  38. package/src/tools/index.js +52 -0
  39. package/src/tools/interaction.js +157 -0
  40. package/src/tools/notebook.js +91 -0
  41. package/src/tools/peers.js +45 -0
  42. package/src/tools/restart.js +50 -0
  43. package/src/tools/search.js +458 -0
  44. package/src/tools/skill.js +32 -0
  45. package/src/tools/task.js +139 -0
  46. package/src/tools/web.js +283 -0
  47. package/src/ui/ansi.js +228 -0
  48. package/src/ui/dialogs.js +122 -0
  49. package/src/ui/editor.js +389 -0
  50. package/src/ui/markdown.js +249 -0
  51. package/src/ui/render.js +155 -0
  52. package/src/ui/repl.js +1460 -0
  53. package/src/ui/screen.js +259 -0
  54. package/src/updater.js +93 -0
  55. package/src/utils/clipboard.js +197 -0
  56. package/src/utils/diff.js +95 -0
  57. package/src/utils/paths.js +37 -0
  58. package/src/utils/shell.js +251 -0
package/src/config.js ADDED
@@ -0,0 +1,453 @@
1
+ // Configuration loading. Mirrors Claude Code's environment variables so the same
2
+ // settings work here. Sources, lowest precedence first:
3
+ // 1. built-in defaults
4
+ // 2. <install dir>/.env
5
+ // 3. ~/.claude-pro/settings.json ("env" block)
6
+ // 4. <project>/.claude-pro/settings.json ("env" block)
7
+ // 5. <project>/.claude-pro/settings.local.json
8
+ // 6. process environment
9
+ // 7. command line flags (--model, --effort ...)
10
+ // The model and effort chosen with /model and /effort are remembered apart from these (see
11
+ // rememberChoice) and win over 1-6.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { normalizeTtl } from './cache.js';
18
+ import { codexModels, codexConfigDefaults } from './codex/client.js';
19
+
20
+ export const INSTALL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ export const CONFIG_DIR = process.env.CLAUDE_PRO_CONFIG_DIR || path.join(os.homedir(), '.claude-pro');
22
+ export const APP_NAME = 'claude-pro';
23
+ export const PROVIDERS = ['api', 'claude-code', 'gateway', 'codex'];
24
+ export const GATEWAY_DEFAULT_URL = 'https://gw.predix.icu';
25
+ // The codex provider's model when neither CLAUDE_PRO_CODEX_MODEL nor the Codex config.toml names one.
26
+ export const CODEX_DEFAULT_MODEL = 'gpt-6.1-sol';
27
+ export const VERSION = JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'package.json'), 'utf8')).version;
28
+
29
+ // Every key we understand, with a short description (shown by /config).
30
+ export const KNOWN_KEYS = {
31
+ CLAUDE_PRO_PROVIDER: 'Authentication backend: gateway (default; api when an API endpoint or key is configured), api, claude-code (local Claude subscription login), gateway (codex-gateway account, /login) or codex (local Codex/ChatGPT subscription login)',
32
+ CLAUDE_PRO_GATEWAY_URL: `codex-gateway address for the gateway provider (default https://gw.predix.icu)`,
33
+ CLAUDE_PRO_GATEWAY_MODEL: 'Model with the gateway provider (default: opus; also sonnet, haiku, gpt-6.1-sol)',
34
+ CLAUDE_PRO_CODEX_MODEL: 'Model with the codex provider (default: the model in ~/.codex/config.toml, else gpt-6.1-sol)',
35
+ CLAUDE_PRO_CLAUDE_CONFIG_FILE: 'Existing Claude Code account metadata file (default: ~/.claude.json)',
36
+ CLAUDE_PRO_CLAUDE_CREDENTIALS_FILE: 'Existing Claude Code OAuth credentials file (default: ~/.claude/.credentials.json)',
37
+ CLAUDE_PRO_CLAUDE_CODE_VERSION: 'Claude Code version in the HTTP User-Agent (default: installed npm version)',
38
+ CLAUDE_PRO_DISABLE_TOKEN_REFRESH: 'Set to 1 to stop this program renewing the Claude Code login by itself (it then only reads what the official client keeps up to date)',
39
+ CLAUDE_PRO_AUTO_RESTART: 'Restart by itself when a turn ends and claude-pro\'s own source files changed (default 1; 0 = only on /restart or the Restart tool)',
40
+ CLAUDE_PRO_CLAUDE_CODE_MODEL: 'Model for Claude Code OAuth (default: sonnet; independent of API model settings)',
41
+ CLAUDE_CONFIG_DIR: 'Official Claude Code configuration directory, for selecting the locally signed-in account',
42
+ ANTHROPIC_BASE_URL: 'API base URL (Anthropic compatible endpoint)',
43
+ ANTHROPIC_AUTH_TOKEN: 'Bearer token sent as Authorization header',
44
+ ANTHROPIC_API_KEY: 'API key sent as x-api-key header (used if no auth token)',
45
+ ANTHROPIC_CUSTOM_HEADERS: 'Extra headers, "Name: Value" separated by newlines',
46
+ ANTHROPIC_MODEL: 'Main model (name or alias opus/sonnet/haiku; suffix [1m] = 1M context)',
47
+ ANTHROPIC_DEFAULT_OPUS_MODEL: 'Model used for the "opus" alias',
48
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'Model used for the "sonnet" alias',
49
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: 'Small/fast model (web page summarising, titles)',
50
+ ANTHROPIC_SMALL_FAST_MODEL: 'Deprecated alias of ANTHROPIC_DEFAULT_HAIKU_MODEL',
51
+ CLAUDE_CODE_SUBAGENT_MODEL: 'Model used by Task sub-agents (API providers; default: the main model)',
52
+ CLAUDE_PRO_CLAUDE_CODE_SUBAGENT_MODEL: 'Model used by Task sub-agents with the claude-code provider (default: sonnet)',
53
+ CLAUDE_CODE_EFFORT_LEVEL: 'Reasoning effort: none|minimal|low|medium|high|xhigh|max (sent as output_config.effort)',
54
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW: 'Context size (tokens) used for auto-compaction calculations',
55
+ CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: 'Percentage (1-100) of the window at which auto-compaction triggers',
56
+ DISABLE_AUTOCOMPACT: 'Set to 1 to disable auto-compaction',
57
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: 'max_tokens for each model response (default 128000; lowered automatically if the API allows less)',
58
+ MAX_THINKING_TOKENS: 'Thinking budget: 0 disables thinking, N enables with budget N',
59
+ CLAUDE_CODE_PROMPT_CACHE_TTL: 'Prompt cache TTL of the conversation itself: 5m or 1h (default: 1h with the subscription login, as in Claude Code, else 5m; 1h writes cost more, they pay off when you pause for more than 5 minutes). Also the promptCacheTtl setting',
60
+ CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL: 'Prompt cache TTL of everything else (sub-agents, compaction, side queries): 5m (default) or 1h. Also the subagentPromptCacheTtl setting',
61
+ ENABLE_PROMPT_CACHING_1H: 'Set to 1 to request the 1-hour TTL for both (the two TTL variables above win over it)',
62
+ FORCE_PROMPT_CACHING_5M: 'Set to 1 to force the 5-minute TTL for both, over every other TTL setting',
63
+ DISABLE_PROMPT_CACHING: 'Set to 1 to send no cache_control markers at all (prompt caching off for every model)',
64
+ DISABLE_PROMPT_CACHING_HAIKU: 'Set to 1 to turn prompt caching off for the model the haiku alias resolves to',
65
+ DISABLE_PROMPT_CACHING_SONNET: 'Set to 1 to turn prompt caching off for the model the sonnet alias resolves to',
66
+ DISABLE_PROMPT_CACHING_OPUS: 'Set to 1 to turn prompt caching off for the model the opus alias resolves to',
67
+ DISABLE_PROMPT_CACHING_FABLE: 'Set to 1 to turn prompt caching off for Fable models',
68
+ API_TIMEOUT_MS: 'Request timeout in milliseconds',
69
+ CLAUDE_PRO_MAX_RETRIES: 'Retries for failed API requests (0 = no retries at all, which also turns off the rate limit wait)',
70
+ CLAUDE_PRO_RATE_LIMIT_MAX_WAIT_MS: 'Longest total time to wait for a rate limit (e.g. the 5-hour window) to reset before continuing by itself (default 21600000 = 6 hours; 0 = report the error instead of waiting)',
71
+ CLAUDE_PRO_RATE_LIMIT_POLL_MS: 'When a rate limit does not say when it resets, try again at most this far apart (default 300000 = 5 minutes)',
72
+ BASH_DEFAULT_TIMEOUT_MS: 'Default timeout for Bash commands (default 600000 = 10 minutes)',
73
+ BASH_MAX_TIMEOUT_MS: 'Maximum timeout the model may request for Bash commands (default 3600000 = 1 hour)',
74
+ BASH_MAX_OUTPUT_LENGTH: 'Max characters of Bash output returned to the model; the full text is saved to a file (default 60000)',
75
+ CLAUDE_CODE_GIT_BASH_PATH: 'Windows: path to bash.exe (Git Bash)',
76
+ CLAUDE_PRO_RIPGREP_PATH: 'Path to ripgrep (rg) for Grep and @-file suggestions; "off" forces the built-in JS scanner (default: rg from PATH, else the JS scanner)',
77
+ CLAUDE_PRO_MAX_TOOL_RESULT_CHARS: 'Tool results longer than this are saved to a file and only a preview is sent (default 100000)',
78
+ CLAUDE_PRO_CLEAR_AFTER_IDLE: 'Back after more than 65 minutes away (the prompt cache has expired): clear old tool results and thinking, keeping the last 5 results, as Claude Code does. 1 = on, 0 = off (default: on for the Claude Code login and api.anthropic.com, off for other endpoints, whose cache may last for hours)',
79
+ CLAUDE_PRO_MAX_SUBAGENT_DEPTH: 'How deep sub-agents may nest: agents below this depth can start their own sub-agents (default 2; 0 = no nesting)',
80
+ CLAUDE_PRO_VERIFY_COMMAND: 'Command run after the agent edits files (e.g. "npm test"); failures are sent back to fix',
81
+ CLAUDE_PRO_VERIFY_MAX_ROUNDS: 'How many times a failing verify command is sent back before giving up (default 3)',
82
+ CLAUDE_PRO_DISABLE_AUTO_MEMORY: 'Set to 1 to turn off the persistent per-project memory',
83
+ CLAUDE_PRO_DISABLE_TOPIC_TITLE: 'Set to 1 to stop asking the small model for the current topic after each message (the tab title then shows the first message of the session)',
84
+ };
85
+
86
+ const SECRET_KEYS = new Set(['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_CUSTOM_HEADERS']);
87
+
88
+ export function parseDotEnv(text) {
89
+ const env = {};
90
+ for (let line of text.split(/\r?\n/)) {
91
+ line = line.trim();
92
+ if (!line || line.startsWith('#')) continue;
93
+ if (line.startsWith('export ')) line = line.slice(7);
94
+ const eq = line.indexOf('=');
95
+ if (eq <= 0) continue;
96
+ const key = line.slice(0, eq).trim();
97
+ let val = line.slice(eq + 1).trim();
98
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
99
+ val = val.slice(1, -1);
100
+ if (line[eq + 1] === '"') val = val.replace(/\\n/g, '\n');
101
+ } else {
102
+ val = val.replace(/\s+#.*$/, '');
103
+ }
104
+ env[key] = val;
105
+ }
106
+ return env;
107
+ }
108
+
109
+ function readJson(file) {
110
+ try {
111
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
112
+ } catch (e) {
113
+ if (e.code !== 'ENOENT') process.stderr.write(`warning: could not parse ${file}: ${e.message}\n`);
114
+ return null;
115
+ }
116
+ }
117
+
118
+ export function settingsFiles(cwd) {
119
+ return {
120
+ user: path.join(CONFIG_DIR, 'settings.json'),
121
+ project: path.join(cwd, '.claude-pro', 'settings.json'),
122
+ local: path.join(cwd, '.claude-pro', 'settings.local.json'),
123
+ };
124
+ }
125
+
126
+ // What the install's .env holds is, by its own template, the API provider's setup: an endpoint, its token and
127
+ // models, and how hard and how long that model works. The subscription provider (claude-code) does not take that
128
+ // tuning over: it starts from Claude Code's own defaults (the model's default effort, the model's whole context
129
+ // window), which is what makes it work without any setting. The same keys in a settings file, in the shell, as a
130
+ // flag, or chosen with /effort do apply there.
131
+ export const API_ONLY_DOTENV_KEYS = ['CLAUDE_CODE_EFFORT_LEVEL', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_AUTOCOMPACT_PCT_OVERRIDE', 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', 'MAX_THINKING_TOKENS'];
132
+
133
+ // The variable that holds the model, per provider.
134
+ const MODEL_KEYS = { api: 'ANTHROPIC_MODEL', 'claude-code': 'CLAUDE_PRO_CLAUDE_CODE_MODEL', gateway: 'CLAUDE_PRO_GATEWAY_MODEL', codex: 'CLAUDE_PRO_CODEX_MODEL' };
135
+
136
+ export function loadConfig({ cwd = process.cwd(), flags = {}, dotEnvFile = path.join(INSTALL_DIR, '.env') } = {}) {
137
+ const env = {};
138
+ const sources = {};
139
+ const set = (obj, source) => {
140
+ for (const [k, v] of Object.entries(obj || {})) {
141
+ if (v === undefined || v === null) continue;
142
+ env[k] = String(v);
143
+ sources[k] = source;
144
+ }
145
+ };
146
+
147
+ if (fs.existsSync(dotEnvFile)) set(parseDotEnv(fs.readFileSync(dotEnvFile, 'utf8')), dotEnvFile);
148
+
149
+ const files = settingsFiles(cwd);
150
+ // Settings files can still carry "permissions" from other tools; there are no permission rules here, so they are ignored.
151
+ const settings = { mcpServers: {}, hooks: {} };
152
+ for (const [scope, file] of Object.entries(files)) {
153
+ const json = readJson(file);
154
+ if (!json) continue;
155
+ // Claude Code's settings for the prompt cache TTL. They stay settings (the variables rank above them, whichever
156
+ // file they come from; see Config.cacheTtl); a later file's setting replaces an earlier one's.
157
+ if (json.promptCacheTtl !== undefined) settings.promptCacheTtl = json.promptCacheTtl;
158
+ if (json.subagentPromptCacheTtl !== undefined) settings.subagentPromptCacheTtl = json.subagentPromptCacheTtl;
159
+ set(json.env, file);
160
+ if (json.mcpServers) Object.assign(settings.mcpServers, json.mcpServers);
161
+ for (const [event, entries] of Object.entries(json.hooks || {})) {
162
+ if (Array.isArray(entries)) (settings.hooks[event] ||= []).push(...entries);
163
+ }
164
+ if (json.model) { env.ANTHROPIC_MODEL = json.model; sources.ANTHROPIC_MODEL = `${file} (model)`; }
165
+ settings[scope] = json;
166
+ }
167
+ // Project level .mcp.json, same format as Claude Code.
168
+ const mcpJson = readJson(path.join(cwd, '.mcp.json'));
169
+ if (mcpJson?.mcpServers) Object.assign(settings.mcpServers, mcpJson.mcpServers);
170
+
171
+ const fromProcess = {};
172
+ for (const k of Object.keys(KNOWN_KEYS)) if (process.env[k] !== undefined && process.env[k] !== '') fromProcess[k] = process.env[k];
173
+ set(fromProcess, 'process environment');
174
+
175
+ if (flags.provider) { env.CLAUDE_PRO_PROVIDER = flags.provider; sources.CLAUDE_PRO_PROVIDER = '--provider flag'; }
176
+ if (flags.model) {
177
+ const key = MODEL_KEYS[(env.CLAUDE_PRO_PROVIDER || '').trim().toLowerCase()] || 'ANTHROPIC_MODEL';
178
+ env[key] = flags.model;
179
+ sources[key] = '--model flag';
180
+ }
181
+ if (flags.effort) { env.CLAUDE_CODE_EFFORT_LEVEL = flags.effort; sources.CLAUDE_CODE_EFFORT_LEVEL = '--effort flag'; }
182
+
183
+ const ignoredFromDotEnv = [];
184
+ if (!['', 'api'].includes((env.CLAUDE_PRO_PROVIDER || '').trim().toLowerCase())) {
185
+ for (const key of API_ONLY_DOTENV_KEYS) {
186
+ if (sources[key] !== dotEnvFile) continue;
187
+ delete env[key];
188
+ delete sources[key];
189
+ ignoredFromDotEnv.push(key);
190
+ }
191
+ }
192
+ const config = new Config(env, sources, settings, cwd);
193
+ config.ignoredFromDotEnv = ignoredFromDotEnv; // shown by /config
194
+ return config;
195
+ }
196
+
197
+ const int = (v, d) => {
198
+ const n = parseInt(v, 10);
199
+ return Number.isFinite(n) ? n : d;
200
+ };
201
+ const isOn = (v) => /^(1|true|yes)$/i.test(String(v ?? '').trim());
202
+
203
+ export class Config {
204
+ constructor(env, sources, settings, cwd) {
205
+ this.env = env;
206
+ this.sources = sources;
207
+ this.settings = settings;
208
+ this.cwd = cwd;
209
+ this.modelOverride = null; // set by /model during a session
210
+ this.effortOverride = null; // set by /effort during a session
211
+ this.effortBoost = null; // set by the model with the Effort tool, for the rest of the user's current request
212
+ this.effortBoostKeepNext = false; // a restart continues the request: the next turn keeps the boost
213
+ this.verifyOverride = null; // set by /verify or --verify during a session
214
+ this.ignoredFromDotEnv = []; // .env keys that are not applied because of the provider (see API_ONLY_DOTENV_KEYS)
215
+ }
216
+
217
+ // Not set: an API endpoint, key or model that is configured (.env, settings) means the api provider, as before;
218
+ // otherwise the gateway, where /login gets an account in the browser.
219
+ get provider() {
220
+ const configuredApi = this.env.ANTHROPIC_AUTH_TOKEN || this.env.ANTHROPIC_API_KEY || this.env.ANTHROPIC_BASE_URL || this.env.ANTHROPIC_MODEL;
221
+ const value = (this.env.CLAUDE_PRO_PROVIDER || (configuredApi ? 'api' : 'gateway')).trim().toLowerCase();
222
+ if (!PROVIDERS.includes(value)) throw new Error(`Unknown CLAUDE_PRO_PROVIDER: ${value}. Use ${PROVIDERS.join(', ')}.`);
223
+ return value;
224
+ }
225
+ get usesClaudeCode() { return this.provider === 'claude-code'; }
226
+ get usesGateway() { return this.provider === 'gateway'; }
227
+ get usesCodex() { return this.provider === 'codex'; }
228
+ get gatewayUrl() { return (this.env.CLAUDE_PRO_GATEWAY_URL || GATEWAY_DEFAULT_URL).trim().replace(/\/+$/, ''); }
229
+ // Claude's own model names and context windows apply: the subscription login, or the gateway (which serves
230
+ // Claude through a subscription login too).
231
+ get claudeIds() { return this.usesClaudeCode || this.usesGateway; }
232
+ get claudeCodeConfigFile() { return path.resolve(this.cwd, this.env.CLAUDE_PRO_CLAUDE_CONFIG_FILE || path.join(os.homedir(), '.claude.json')); }
233
+ get claudeCodeCredentialsFile() {
234
+ return path.resolve(this.cwd, this.env.CLAUDE_PRO_CLAUDE_CREDENTIALS_FILE || path.join(
235
+ this.env.CLAUDE_CONFIG_DIR || path.join(path.dirname(this.claudeCodeConfigFile), '.claude'), '.credentials.json'));
236
+ }
237
+ get endpointLabel() {
238
+ if (this.usesClaudeCode) return 'https://api.anthropic.com (Claude Code OAuth)';
239
+ if (this.usesGateway) return `${this.gatewayUrl} (codex-gateway)`;
240
+ if (this.usesCodex) return 'https://chatgpt.com/backend-api/codex (Codex login)';
241
+ return this.baseURL;
242
+ }
243
+ get baseURL() { return this.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'; }
244
+ get authToken() { return this.env.ANTHROPIC_AUTH_TOKEN || null; }
245
+ get apiKey() { return this.env.ANTHROPIC_API_KEY || null; }
246
+ get customHeaders() {
247
+ const headers = {};
248
+ for (const line of (this.env.ANTHROPIC_CUSTOM_HEADERS || '').split(/\n/)) {
249
+ const i = line.indexOf(':');
250
+ if (i > 0) headers[line.slice(0, i).trim()] = line.slice(i + 1).trim();
251
+ }
252
+ return headers;
253
+ }
254
+
255
+ aliasTarget(alias) {
256
+ if (this.usesClaudeCode) {
257
+ return { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-4-5-20251001' }[alias] || null;
258
+ }
259
+ // The gateway prices models by their undated names.
260
+ if (this.usesGateway) return { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-4-5' }[alias] || null;
261
+ if (this.usesCodex) {
262
+ // No Claude here: the big aliases mean the Codex model, haiku the small one when the Codex install has it.
263
+ if (alias === 'haiku') return codexModels(this.env).models.some((m) => m.slug === 'gpt-6-luna') ? 'gpt-6-luna' : this.codexModelSpec;
264
+ return alias === 'opus' || alias === 'sonnet' ? this.codexModelSpec : null;
265
+ }
266
+ switch (alias) {
267
+ case 'opus': return this.env.ANTHROPIC_DEFAULT_OPUS_MODEL || 'claude-opus-4-5';
268
+ case 'sonnet': return this.env.ANTHROPIC_DEFAULT_SONNET_MODEL || 'claude-sonnet-4-5';
269
+ case 'haiku': return this.env.ANTHROPIC_DEFAULT_HAIKU_MODEL || this.env.ANTHROPIC_SMALL_FAST_MODEL || 'claude-haiku-4-5';
270
+ default: return null;
271
+ }
272
+ }
273
+
274
+ // Resolve a model spec (alias or name, maybe with [1m]) to {id, contextWindow, label}.
275
+ resolveModel(spec) {
276
+ let name = (spec || '').trim() || 'sonnet';
277
+ let oneM = false;
278
+ if (/\[1m\]$/i.test(name)) { oneM = true; name = name.replace(/\[1m\]$/i, ''); }
279
+ const aliased = this.aliasTarget(name.toLowerCase());
280
+ if (aliased) {
281
+ const inner = this.resolveModel(aliased);
282
+ return { ...inner, contextWindow: oneM ? 1_000_000 : inner.contextWindow, label: `${name}${oneM ? '[1m]' : ''} (${inner.label})` };
283
+ }
284
+ const nativeOneM = this.claudeIds && /^claude-(?:sonnet-5(?:-|$)|opus-(?:5(?:-|$)|4-[78](?:-|$)))/i.test(name);
285
+ // OpenAI models (the codex provider, gpt-6.1-sol on the gateway): the window the Codex install reports,
286
+ // else the 272K of the current GPT models.
287
+ if (/^gpt-/i.test(name)) {
288
+ const known = codexModels(this.env).models.find((m) => m.slug === name)?.contextWindow;
289
+ return { id: name, contextWindow: known || 272_000, label: name };
290
+ }
291
+ return { id: name, contextWindow: oneM || nativeOneM ? 1_000_000 : 200_000, label: `${name}${oneM ? '[1m]' : ''}` };
292
+ }
293
+
294
+ // The model the active provider is configured with, before any /model override.
295
+ get configuredModelSpec() {
296
+ if (this.usesGateway) return this.env.CLAUDE_PRO_GATEWAY_MODEL || 'opus';
297
+ if (this.usesCodex) return this.codexModelSpec;
298
+ return (this.usesClaudeCode ? this.env.CLAUDE_PRO_CLAUDE_CODE_MODEL : this.env.ANTHROPIC_MODEL) || 'sonnet';
299
+ }
300
+ get codexModelSpec() { return this.env.CLAUDE_PRO_CODEX_MODEL || codexConfigDefaults(this.env).model || CODEX_DEFAULT_MODEL; }
301
+ get mainModelSpec() { return this.modelOverride || this.configuredModelSpec; }
302
+ get mainModel() { return this.resolveModel(this.mainModelSpec); }
303
+ get subagentModel() {
304
+ // With the subscription login sub-agents run on Sonnet: much cheaper than Opus, and good at the searching and
305
+ // well-specified subtasks they are given. CLAUDE_PRO_CLAUDE_CODE_SUBAGENT_MODEL (or a sub-agent's own model) changes it.
306
+ if (this.usesClaudeCode) return this.resolveModel(this.env.CLAUDE_PRO_CLAUDE_CODE_SUBAGENT_MODEL || 'sonnet');
307
+ // The gateway: Sonnet under a Claude main model, as with the subscription; a GPT main model keeps its own.
308
+ if (this.usesGateway) return /^claude-/i.test(this.mainModel.id) ? this.resolveModel('sonnet') : this.mainModel;
309
+ if (this.usesCodex) return this.mainModel;
310
+ return this.env.CLAUDE_CODE_SUBAGENT_MODEL ? this.resolveModel(this.env.CLAUDE_CODE_SUBAGENT_MODEL) : this.mainModel;
311
+ }
312
+ get smallModel() { return this.resolveModel('haiku'); }
313
+
314
+ get effort() {
315
+ const v = (this.effortBoost ?? this.effortOverride ?? this.env.CLAUDE_CODE_EFFORT_LEVEL ?? '').trim().toLowerCase();
316
+ return v && v !== 'auto' && v !== 'default' ? v : null;
317
+ }
318
+
319
+ // Generous by default so large files can be written in one go; if the API turns out to allow
320
+ // less, streamMessage learns the real limit from the error and clamps (see api.js).
321
+ // The gateway holds back balance for max_tokens of output before each request, so a smaller default there (the
322
+ // 32K Claude Code uses) keeps a modest balance usable.
323
+ get maxOutputTokens() { return int(this.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, this.usesGateway ? 32_000 : 128_000); }
324
+ get thinkingBudget() {
325
+ return this.env.MAX_THINKING_TOKENS === undefined ? null : int(this.env.MAX_THINKING_TOKENS, null);
326
+ }
327
+ // Prompt cache TTL for a request bucket, by Claude Code's rules: "main" is the conversation itself, "other"
328
+ // is everything else (sub-agents, compaction, side queries). In order: FORCE_PROMPT_CACHING_5M; the bucket's
329
+ // own variable or setting; ENABLE_PROMPT_CACHING_1H; then the default: with the subscription login the
330
+ // conversation's own requests use 1 hour, which is what Claude Code asks for there (within the plan's
331
+ // usage); with an API key or another endpoint, and for everything else, 5 minutes.
332
+ cacheTtl(bucket = 'main') {
333
+ if (isOn(this.env.FORCE_PROMPT_CACHING_5M)) return '5m';
334
+ // an invalid value is ignored, and the next rule applies (as in Claude Code)
335
+ const own = bucket === 'main'
336
+ ? normalizeTtl(this.env.CLAUDE_CODE_PROMPT_CACHE_TTL) || normalizeTtl(this.settings?.promptCacheTtl)
337
+ : normalizeTtl(this.env.CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL) || normalizeTtl(this.settings?.subagentPromptCacheTtl);
338
+ if (own) return own;
339
+ if (isOn(this.env.ENABLE_PROMPT_CACHING_1H)) return '1h';
340
+ return bucket === 'main' && this.usesClaudeCode ? '1h' : '5m';
341
+ }
342
+ // DISABLE_PROMPT_CACHING turns it off for everything; the per-model variables only for the model that
343
+ // their alias resolves to (a different version pinned as the model keeps its caching), wherever it runs.
344
+ promptCachingDisabled(modelId) {
345
+ if (isOn(this.env.DISABLE_PROMPT_CACHING)) return true;
346
+ for (const alias of ['haiku', 'sonnet', 'opus']) {
347
+ if (!isOn(this.env[`DISABLE_PROMPT_CACHING_${alias.toUpperCase()}`])) continue;
348
+ const target = this.aliasTarget(alias);
349
+ if (target && this.resolveModel(target).id === modelId) return true;
350
+ }
351
+ return isOn(this.env.DISABLE_PROMPT_CACHING_FABLE) && /fable/i.test(modelId || '');
352
+ }
353
+ get apiTimeout() { return int(this.env.API_TIMEOUT_MS, 600_000); }
354
+ get maxRetries() { return int(this.env.CLAUDE_PRO_MAX_RETRIES, 6); }
355
+ get rateLimitMaxWait() { return Math.max(0, int(this.env.CLAUDE_PRO_RATE_LIMIT_MAX_WAIT_MS, 6 * 3_600_000)); }
356
+ get rateLimitPoll() { return Math.max(1000, int(this.env.CLAUDE_PRO_RATE_LIMIT_POLL_MS, 300_000)); }
357
+
358
+ // Effective window for compaction: the configured auto-compact window capped by the model window.
359
+ compactWindow(model = this.mainModel) {
360
+ const configured = int(this.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, 0);
361
+ return configured > 0 ? Math.min(configured, model.contextWindow) : model.contextWindow;
362
+ }
363
+ get autoCompactEnabled() { return !/^(1|true|yes)$/i.test(this.env.DISABLE_AUTOCOMPACT || ''); }
364
+ autoCompactThreshold(model = this.mainModel) {
365
+ const window = this.compactWindow(model);
366
+ const pct = int(this.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, 0);
367
+ if (pct > 0 && pct <= 100) return Math.floor(window * pct / 100);
368
+ // Leave room for the summary itself and the next response (replies rarely come close to the
369
+ // max_tokens ceiling, so the reserve is capped; an overflow is handled by compacting on the error).
370
+ return Math.floor(window - Math.min(window * 0.2, Math.max(20_000, Math.min(this.maxOutputTokens, 64_000))));
371
+ }
372
+
373
+ get bashDefaultTimeout() { return int(this.env.BASH_DEFAULT_TIMEOUT_MS, 600_000); }
374
+ get bashMaxTimeout() { return int(this.env.BASH_MAX_TIMEOUT_MS, 3_600_000); }
375
+ get bashMaxOutput() { return int(this.env.BASH_MAX_OUTPUT_LENGTH, 60_000); }
376
+ get maxToolResultChars() { return int(this.env.CLAUDE_PRO_MAX_TOOL_RESULT_CHARS, 100_000); }
377
+ // Clearing old tool results after a long pause (see Agent.clearAfterIdle): on where the cache is Anthropic's,
378
+ // which has expired by then. Another endpoint may keep its cache for hours, and clearing would make it miss.
379
+ get clearAfterIdle() {
380
+ const v = String(this.env.CLAUDE_PRO_CLEAR_AFTER_IDLE ?? '').trim();
381
+ if (v) return isOn(v);
382
+ return this.usesClaudeCode || this.usesGateway || /^https:\/\/api\.anthropic\.com\/?$/i.test(this.baseURL);
383
+ }
384
+ // How deep sub-agents may nest: the main agent is depth 0; an agent below this depth gets the Task tool.
385
+ get maxSubagentDepth() { return Math.max(0, int(this.env.CLAUDE_PRO_MAX_SUBAGENT_DEPTH, 2)); }
386
+
387
+ // Verification after edits: session override (/verify, --verify) wins over the env setting.
388
+ get verifyCommand() {
389
+ const v = this.verifyOverride ?? this.env.CLAUDE_PRO_VERIFY_COMMAND ?? '';
390
+ return v.trim() && v.trim() !== 'off' ? v.trim() : null;
391
+ }
392
+ get verifyMaxRounds() { return Math.max(1, int(this.env.CLAUDE_PRO_VERIFY_MAX_ROUNDS, 3)); }
393
+ get autoMemoryEnabled() { return !/^(1|true|yes)$/i.test(this.env.CLAUDE_PRO_DISABLE_AUTO_MEMORY || ''); }
394
+
395
+ display(key) {
396
+ const v = this.env[key];
397
+ if (v === undefined) return undefined;
398
+ if (SECRET_KEYS.has(key)) return v.length > 10 ? `${v.slice(0, 5)}…${v.slice(-4)}` : '****';
399
+ return v;
400
+ }
401
+ }
402
+
403
+ // Update the "env" block (or another top level key) of a settings file. A file that is there but is not
404
+ // valid JSON is an error, not an empty file: writing it back would throw away everything in it.
405
+ export function updateSettingsFile(file, mutate) {
406
+ let json = {};
407
+ let text = null;
408
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
409
+ if (text !== null && text.trim()) {
410
+ try { json = JSON.parse(text.replace(/^\uFEFF/, '')); } catch { throw new Error(`${file} is not valid JSON (left as it is; fix it by hand)`); }
411
+ }
412
+ mutate(json);
413
+ fs.mkdirSync(path.dirname(file), { recursive: true });
414
+ fs.writeFileSync(file, JSON.stringify(json, null, 2) + '\n');
415
+ }
416
+
417
+ // What /model and /effort chose, kept per provider in the "remembered" block of the user settings so that
418
+ // the choice is still the one in use at the next start (interactive or -p). It is applied as the session's
419
+ // override (applyRememberedChoices), so it wins over every configured value; only --model / --effort on
420
+ // the command line win over it, for that run. "/model default" and "/effort default" forget it.
421
+ const userSettingsFile = () => path.join(CONFIG_DIR, 'settings.json');
422
+
423
+ export function rememberedChoices(provider) {
424
+ const entry = readJson(userSettingsFile())?.remembered?.[provider];
425
+ return entry && typeof entry === 'object' ? entry : {};
426
+ }
427
+
428
+ // patch: {model?, effort?}; null or '' forgets that one. Returns whether the file changed.
429
+ export function rememberChoice(provider, patch) {
430
+ const before = rememberedChoices(provider);
431
+ const after = { ...before };
432
+ for (const [key, value] of Object.entries(patch)) {
433
+ if (value === undefined) continue;
434
+ if (value === null || value === '') delete after[key];
435
+ else after[key] = String(value);
436
+ }
437
+ if (JSON.stringify(after) === JSON.stringify(before)) return false;
438
+ updateSettingsFile(userSettingsFile(), (json) => {
439
+ const all = json.remembered && typeof json.remembered === 'object' ? json.remembered : {};
440
+ if (Object.keys(after).length) all[provider] = after;
441
+ else delete all[provider];
442
+ if (Object.keys(all).length) json.remembered = all;
443
+ else delete json.remembered;
444
+ });
445
+ return true;
446
+ }
447
+
448
+ export function applyRememberedChoices(config, flags = {}) {
449
+ const remembered = rememberedChoices(config.provider);
450
+ if (!flags.model && typeof remembered.model === 'string' && remembered.model.trim()) config.modelOverride = remembered.model.trim();
451
+ if (!flags.effort && typeof remembered.effort === 'string' && remembered.effort.trim()) config.effortOverride = remembered.effort.trim().toLowerCase();
452
+ return remembered;
453
+ }
package/src/context.js ADDED
@@ -0,0 +1,109 @@
1
+ // Gathers the environment context that goes into the system prompt: platform, git
2
+ // status and memory files (CLAUDE.md / AGENTS.md), like Claude Code.
3
+
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { run } from './utils/shell.js';
8
+ import { CONFIG_DIR } from './config.js';
9
+
10
+ export function gitInfo(cwd) {
11
+ const inside = run('git', ['rev-parse', '--is-inside-work-tree'], { cwd });
12
+ if (inside !== 'true') return null;
13
+ const branch = run('git', ['branch', '--show-current'], { cwd }) || '(detached)';
14
+ let main = null;
15
+ for (const b of ['main', 'master']) if (run('git', ['rev-parse', '--verify', '--quiet', b], { cwd }) !== null) { main = b; break; }
16
+ // quotepath=false: file names with non-ASCII characters (Chinese ones) are shown as they are, not as "\344\275\240".
17
+ let status = run('git', ['-c', 'core.quotepath=false', 'status', '--short'], { cwd }) || '';
18
+ const statusLines = status.split('\n');
19
+ if (statusLines.length > 40) status = statusLines.slice(0, 40).join('\n') + `\n... (${statusLines.length - 40} more lines, run "git status" for the rest)`;
20
+ const log = run('git', ['log', '--oneline', '-n', '5'], { cwd }) || '';
21
+ const user = run('git', ['config', 'user.name'], { cwd });
22
+ return { branch, main, status, log, user };
23
+ }
24
+
25
+ // Memory files, lowest priority first: user memory, then project files from the
26
+ // filesystem root down to cwd, then local (untracked) files.
27
+ export function memoryFiles(cwd) {
28
+ const files = [];
29
+ const add = (file, scope) => {
30
+ try {
31
+ const text = fs.readFileSync(file, 'utf8').trim();
32
+ if (text) files.push({ file, scope, text });
33
+ } catch { /* missing */ }
34
+ };
35
+ add(path.join(CONFIG_DIR, 'CLAUDE.md'), 'user');
36
+ const dirs = [];
37
+ let dir = path.resolve(cwd);
38
+ for (;;) {
39
+ dirs.unshift(dir);
40
+ const parent = path.dirname(dir);
41
+ if (parent === dir) break;
42
+ dir = parent;
43
+ }
44
+ for (const d of dirs) {
45
+ if (d === os.homedir()) continue; // home is covered by the user memory
46
+ add(path.join(d, 'CLAUDE.md'), 'project');
47
+ add(path.join(d, '.claude', 'CLAUDE.md'), 'project');
48
+ add(path.join(d, 'AGENTS.md'), 'project');
49
+ add(path.join(d, 'CLAUDE.local.md'), 'local');
50
+ }
51
+ return files;
52
+ }
53
+
54
+ const MEMORY_INDEX_MAX_LINES = 200;
55
+
56
+ // The auto-memory index (MEMORY.md) that is loaded into every session's system prompt.
57
+ export function loadAutoMemoryIndex(dir) {
58
+ let text = '';
59
+ try { text = fs.readFileSync(path.join(dir, 'MEMORY.md'), 'utf8').trim(); } catch { return ''; }
60
+ const lines = text.split(/\r?\n/);
61
+ if (lines.length <= MEMORY_INDEX_MAX_LINES) return text;
62
+ return `${lines.slice(0, MEMORY_INDEX_MAX_LINES).join('\n')}\n(… ${lines.length - MEMORY_INDEX_MAX_LINES} more lines not loaded; MEMORY.md should stay under ${MEMORY_INDEX_MAX_LINES} lines — consolidate it.)`;
63
+ }
64
+
65
+ // Memory files (excluding the index) in the auto-memory directory.
66
+ export function listAutoMemories(dir) {
67
+ try { return fs.readdirSync(dir).filter((f) => f.endsWith('.md') && f !== 'MEMORY.md'); } catch { return []; }
68
+ }
69
+
70
+ export function projectMemoryFile(cwd) {
71
+ return path.join(cwd, 'CLAUDE.md');
72
+ }
73
+
74
+ // The date where the user is (YYYY-MM-DD). toISOString() would give the UTC date, which for someone in
75
+ // UTC+8 is yesterday's between midnight and 8 o'clock.
76
+ export function localDate(now = new Date()) {
77
+ const pad = (n) => String(n).padStart(2, '0');
78
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
79
+ }
80
+
81
+ // The date and the git state that a conversation starts with. They are taken once and then reused, by the main
82
+ // agent's system prompt and by the sub-agents' alike: a prompt that changes with every `git status` (or at
83
+ // midnight) cannot be read from the prompt cache, and the git status is labelled as a snapshot anyway.
84
+ export function takeEnvSnapshot(cwd) {
85
+ return { date: localDate(), git: gitInfo(cwd) };
86
+ }
87
+
88
+ export function environmentBlock(cwd, { model, shellName, snapshot = null }) {
89
+ const { date, git } = snapshot || takeEnvSnapshot(cwd);
90
+ const lines = [
91
+ '<env>',
92
+ `Working directory: ${cwd}`,
93
+ `Is directory a git repo: ${git ? 'Yes' : 'No'}`,
94
+ `Platform: ${process.platform}`,
95
+ `Shell: ${shellName}`,
96
+ `OS Version: ${os.type()} ${os.release()}`,
97
+ `Today's date: ${date}`,
98
+ '</env>',
99
+ `You are powered by the model ${model.id}.`,
100
+ ];
101
+ if (git) {
102
+ lines.push('', 'gitStatus: This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.');
103
+ lines.push(`Current branch: ${git.branch}`);
104
+ if (git.main) lines.push(`Main branch (you will usually use this for PRs): ${git.main}`);
105
+ if (git.user) lines.push(`Git user: ${git.user}`);
106
+ lines.push('', `Status:\n${git.status || '(clean)'}`, '', `Recent commits:\n${git.log || '(none)'}`);
107
+ }
108
+ return lines.join('\n');
109
+ }
@@ -0,0 +1,57 @@
1
+ // Rebuild the "files the model has read" map from a restored transcript.
2
+ //
3
+ // Edit and Write refuse a file the model has not read in this process. A resumed conversation
4
+ // starts with an empty map, so the first edit of every file the model remembers reading (it is in
5
+ // the transcript) would be refused with "File has not been read yet", costing a wasted tool call.
6
+ //
7
+ // A file counts as known only if it has not changed on disk since the tool call that showed it
8
+ // (or wrote it) finished. Anything modified afterwards, for example by the user while the session
9
+ // was closed, has to be read again, so the model never overwrites changes it has not seen.
10
+ // Whether a read covered the whole file is restored too: Write refuses a partly read file.
11
+
12
+ import fs from 'node:fs';
13
+ import { resolvePath } from './utils/paths.js';
14
+
15
+ const TRACKED = new Set(['Read', 'Write', 'Edit', 'NotebookEdit']);
16
+ const SLACK_MS = 2000; // file timestamps can be coarser than the clock that stamped the message
17
+
18
+ const resultText = (content) => (typeof content === 'string' ? content : '');
19
+
20
+ // Did this Read leave part of the file unseen? (Mirrors what ReadTool records as `partial`.)
21
+ function readWasPartial(input, text) {
22
+ return Number(input?.offset) > 1
23
+ || /\(File has \d+ lines total; showing lines /.test(text)
24
+ || /\[output cut at \d+ characters/.test(text);
25
+ }
26
+
27
+ // messages: the restored conversation. times: when each message was stored (ms since epoch,
28
+ // parallel to `messages`; 0 or missing means unknown, which is never trusted).
29
+ export function rebuildFileState(messages, times = [], cwd = process.cwd()) {
30
+ const calls = new Map(); // tool_use id -> {name, input, file}
31
+ const seen = new Map(); // path -> {at: time of the latest successful call, partial}
32
+ messages.forEach((m, i) => {
33
+ if (!Array.isArray(m?.content)) return;
34
+ for (const b of m.content) {
35
+ if (m.role === 'assistant' && b.type === 'tool_use' && TRACKED.has(b.name)) {
36
+ const p = b.input?.file_path ?? b.input?.notebook_path;
37
+ if (typeof p === 'string' && p.trim()) calls.set(b.id, { name: b.name, input: b.input, file: resolvePath(p, cwd) });
38
+ } else if (m.role === 'user' && b.type === 'tool_result' && !b.is_error && calls.has(b.tool_use_id)) {
39
+ const { name, input, file } = calls.get(b.tool_use_id);
40
+ const text = resultText(b.content);
41
+ const prior = seen.get(file);
42
+ let partial;
43
+ if (name === 'Read') partial = readWasPartial(input, text);
44
+ else if (name === 'Write' || /File created successfully/.test(text)) partial = false;
45
+ else partial = prior?.partial ?? true; // an edit shows nothing beyond the text it replaced
46
+ seen.set(file, { at: Math.max(prior?.at ?? 0, times[i] || 0), partial });
47
+ }
48
+ }
49
+ });
50
+ const state = new Map();
51
+ for (const [file, { at, partial }] of seen) {
52
+ let mtimeMs;
53
+ try { mtimeMs = fs.statSync(file).mtimeMs; } catch { continue; }
54
+ if (at > 0 && mtimeMs <= at + SLACK_MS) state.set(file, { mtimeMs, partial });
55
+ }
56
+ return state;
57
+ }