@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/cache.js ADDED
@@ -0,0 +1,150 @@
1
+ // Prompt caching, the way Claude Code does it (https://code.claude.com/docs/en/prompt-caching):
2
+ // - cache_control markers go on the tools, the system prompt and the newest message (see api.js);
3
+ // - there are two TTL buckets: "main" is the conversation itself, "other" is everything else
4
+ // (sub-agents, compaction, side queries). Each is 5 minutes by default, or 1 hour when asked for;
5
+ // - the whole thing can be switched off;
6
+ // - what the API reports (cache_read_input_tokens, cache_creation_input_tokens) is tracked here, so
7
+ // that /cost can say how much of the input came from the cache and why a request missed it.
8
+
9
+ import crypto from 'node:crypto';
10
+
11
+ export const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 };
12
+
13
+ // A TTL setting is "5m" or "1h"; Claude Code ignores any other value, and so does this.
14
+ export function normalizeTtl(value) {
15
+ const v = String(value ?? '').trim().toLowerCase();
16
+ return v === '5m' || v === '1h' ? v : null;
17
+ }
18
+
19
+ // The 5-minute TTL is the API's default, so it is sent without a ttl field: the request is then the same as
20
+ // it always was, and endpoints that do not know the field are not bothered by it.
21
+ export function cacheControl(ttl) {
22
+ return ttl === '1h' ? { type: 'ephemeral', ttl: '1h' } : { type: 'ephemeral' };
23
+ }
24
+
25
+ const sha = (text) => crypto.createHash('sha1').update(text).digest('hex').slice(0, 16);
26
+
27
+ // What decides whether two requests can share a cache: the model, the system prompt, the tool definitions and
28
+ // the parameters that are part of the key. (The messages are compared by the caller's own notes: see noteRewrite.)
29
+ export function fingerprint(params) {
30
+ const system = (params.system || []).map((b) => b.text).join('\n');
31
+ const tools = JSON.stringify((params.tools || []).map(({ cache_control, ...rest }) => rest));
32
+ return {
33
+ model: params.model,
34
+ system: sha(system),
35
+ tools: sha(tools),
36
+ effort: params.output_config?.effort || null,
37
+ thinking: JSON.stringify(params.thinking || null),
38
+ };
39
+ }
40
+
41
+ // The TTL a request ended up with, and whether it carries any marker at all.
42
+ export function cachePlanOf(params) {
43
+ const marks = [
44
+ ...(params.system || []).map((b) => b.cache_control),
45
+ ...(params.tools || []).map((t) => t.cache_control),
46
+ ...(params.messages || []).flatMap((m) => (Array.isArray(m.content) ? m.content.map((b) => b.cache_control) : [])),
47
+ ].filter(Boolean);
48
+ return { caching: marks.length > 0, ttl: marks.some((m) => m.ttl === '1h') ? '1h' : '5m' };
49
+ }
50
+
51
+ // Below this size the API does not cache anything (the smallest minimum of any model).
52
+ const MIN_CACHED = 1024;
53
+ // A request that read less than this share of what the previous one left in the cache missed.
54
+ const MISS_BELOW = 0.5;
55
+
56
+ const human = (ms) => {
57
+ const s = Math.max(0, Math.round(ms / 1000));
58
+ if (s < 60) return `${s}s`;
59
+ const m = Math.floor(s / 60);
60
+ return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
61
+ };
62
+
63
+ export class CacheStats {
64
+ constructor() {
65
+ this.requests = 0; // requests recorded (compaction included)
66
+ this.input = 0; // input tokens that were not cached
67
+ this.read = 0; // input tokens read from the cache
68
+ this.write = 0; // input tokens written to the cache
69
+ this.write1h = 0; // ... of which with the 1-hour TTL
70
+ this.misses = 0;
71
+ this.lastMiss = null; // {n, cause, expected, read}
72
+ this.newConversation();
73
+ }
74
+
75
+ // The next request starts a conversation: there is nothing that it should find in the cache.
76
+ newConversation() {
77
+ this.expected = null; // tokens the previous request left cached; the next one should read about that many
78
+ this.prev = null; // fingerprint of the previous request
79
+ this.expiresAt = 0; // when what the previous request left in the cache runs out
80
+ this.ttl = '5m';
81
+ this.model = null; // the model that answered the last request
82
+ this.effort = null;
83
+ }
84
+
85
+ // The history was cut or replaced (compaction, /rewind, /resume): the next request will not find the old
86
+ // one in the cache, and that is expected, not a miss.
87
+ noteRewrite() {
88
+ this.expected = null;
89
+ }
90
+
91
+ // One finished request. `at` is when it started: the TTL counts from there, not from the end of the reply.
92
+ record({ usage, fingerprint: fp = null, ttl = '5m', caching = true, at = Date.now() }) {
93
+ const u = usage || {};
94
+ const input = u.input_tokens || 0;
95
+ const read = u.cache_read_input_tokens || 0;
96
+ const write = u.cache_creation_input_tokens || 0;
97
+ this.requests++;
98
+ this.input += input;
99
+ this.read += read;
100
+ this.write += write;
101
+ this.write1h += u.cache_creation?.ephemeral_1h_input_tokens || 0;
102
+ if (caching && this.expected != null && this.expected >= MIN_CACHED && read < this.expected * MISS_BELOW) {
103
+ this.misses++;
104
+ this.lastMiss = { n: this.requests, cause: this.causeOf(fp, at), expected: this.expected, read };
105
+ }
106
+ // Whatever this request read or wrote is a prefix of the next one.
107
+ this.expected = caching ? read + write : null;
108
+ this.expiresAt = at + TTL_MS[ttl];
109
+ this.ttl = ttl;
110
+ if (fp) {
111
+ this.prev = fp;
112
+ this.model = fp.model;
113
+ this.effort = fp.effort;
114
+ }
115
+ }
116
+
117
+ causeOf(fp, at) {
118
+ const p = this.prev;
119
+ if (p && fp) {
120
+ if (p.model !== fp.model) return `the model changed (${p.model} → ${fp.model})`;
121
+ if (p.system !== fp.system) return 'the system prompt changed';
122
+ if (p.tools !== fp.tools) return 'the tool definitions changed';
123
+ if (p.effort !== fp.effort) return `the effort changed (${p.effort || 'auto'} → ${fp.effort || 'auto'})`;
124
+ if (p.thinking !== fp.thinking) return 'the thinking settings changed';
125
+ }
126
+ if (at >= this.expiresAt) return `the cache had expired (${human(at - (this.expiresAt - TTL_MS[this.ttl]))} since the previous request, TTL ${this.ttl})`;
127
+ return 'earlier messages changed, or the endpoint does not cache';
128
+ }
129
+
130
+ // Is there something worth keeping in the cache right now? Not when nothing was cached, or it has expired.
131
+ isWarm(now = Date.now()) {
132
+ return this.expected != null && this.expected >= MIN_CACHED && now < this.expiresAt;
133
+ }
134
+
135
+ get hitRatio() {
136
+ const total = this.input + this.read + this.write;
137
+ return total > 0 ? this.read / total : null;
138
+ }
139
+
140
+ // The lines /cost shows for this conversation's cache.
141
+ describe(now = Date.now()) {
142
+ if (!this.requests) return [];
143
+ if (!this.read && !this.write) return ['Prompt cache: nothing reported (the endpoint does not report cache use, or does not cache)'];
144
+ const pct = this.hitRatio == null ? '–' : `${Math.round(this.hitRatio * 100)}%`;
145
+ const state = this.isWarm(now) ? `warm for ${human(this.expiresAt - now)} more` : 'cold';
146
+ const lines = [`Prompt cache: ${pct} of the input came from the cache · ${this.misses} miss${this.misses === 1 ? '' : 'es'} · ${state} (TTL ${this.ttl})`];
147
+ if (this.lastMiss) lines.push(` last miss: request ${this.lastMiss.n}, probably because ${this.lastMiss.cause}`);
148
+ return lines;
149
+ }
150
+ }
@@ -0,0 +1,184 @@
1
+ // Checkpoints: before the agent edits a file (Write / Edit / NotebookEdit), the file's
2
+ // previous content is saved, grouped by the user prompt that led to the edit. /rewind can
3
+ // then put files back, rewind the conversation, or both. Changes made by shell commands
4
+ // (Bash) are not tracked.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { estimateTokens } from './agent.js';
10
+
11
+ const MAX_SNAPSHOT_BYTES = 200 * 1024 * 1024;
12
+ const keyOf = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
13
+
14
+ export class CheckpointStore {
15
+ // dir: where snapshot files are kept (created lazily).
16
+ constructor(dir) {
17
+ this.dir = dir;
18
+ // {id, label, time, messageIndex, conversationValid, orphaned, files: [{path, existed, snap, skipped}]}
19
+ this.entries = [];
20
+ this.counter = 0;
21
+ this.current = null;
22
+ }
23
+
24
+ // Start a checkpoint for a new user prompt. messageIndex is where its message will sit in
25
+ // the conversation, so rewinding the conversation can cut it off there.
26
+ beginTurn({ label, messageIndex }) {
27
+ const entry = {
28
+ id: ++this.counter,
29
+ label: String(label || '').replace(/\s+/g, ' ').trim().slice(0, 200),
30
+ time: Date.now(),
31
+ messageIndex,
32
+ conversationValid: true,
33
+ orphaned: false,
34
+ files: [],
35
+ };
36
+ this.entries.push(entry);
37
+ this.current = entry;
38
+ return entry;
39
+ }
40
+
41
+ // Record `file` as it is now, once per checkpoint. Call before the file is changed.
42
+ track(file) {
43
+ const entry = this.current;
44
+ if (!entry) return;
45
+ const abs = path.resolve(file);
46
+ if (entry.files.some((f) => keyOf(f.path) === keyOf(abs))) return;
47
+ let stat = null;
48
+ try { stat = fs.statSync(abs); } catch { /* does not exist yet */ }
49
+ if (!stat) { entry.files.push({ path: abs, existed: false }); this.writeIndex(); return; }
50
+ if (!stat.isFile()) return;
51
+ if (stat.size > MAX_SNAPSHOT_BYTES) { entry.files.push({ path: abs, existed: true, skipped: true }); return; }
52
+ const snap = `${entry.id}-${crypto.createHash('sha1').update(abs).digest('hex').slice(0, 12)}`;
53
+ try {
54
+ fs.mkdirSync(this.dir, { recursive: true });
55
+ fs.copyFileSync(abs, path.join(this.dir, snap));
56
+ entry.files.push({ path: abs, existed: true, snap });
57
+ this.writeIndex();
58
+ } catch {
59
+ entry.files.push({ path: abs, existed: true, skipped: true });
60
+ }
61
+ }
62
+
63
+ // The state to hand to the next process of a restarted session (see adopt).
64
+ snapshot() { return { dir: this.dir, entries: this.entries, counter: this.counter }; }
65
+
66
+ // Take over what the process before a restart had: its snapshots stay where they are on disk.
67
+ adopt({ dir, entries, counter } = {}) {
68
+ if (!dir || !Array.isArray(entries)) return false;
69
+ this.dir = dir;
70
+ this.entries = entries;
71
+ this.counter = Math.max(Number(counter) || 0, ...entries.map((e) => e.id || 0));
72
+ this.current = entries[entries.length - 1] || null;
73
+ return true;
74
+ }
75
+
76
+ // Checkpoints that are part of the current conversation, newest first.
77
+ list() { return this.entries.filter((e) => !e.orphaned).reverse(); }
78
+ get(id) { return this.entries.find((e) => e.id === id); }
79
+
80
+ // Files that rewinding to `id` would put back: every file touched by that checkpoint or a later one.
81
+ filesSince(id) {
82
+ const seen = new Map();
83
+ for (const e of this.entries) {
84
+ if (e.id < id) continue;
85
+ for (const f of e.files) if (!seen.has(keyOf(f.path))) seen.set(keyOf(f.path), f.path);
86
+ }
87
+ return [...seen.values()];
88
+ }
89
+
90
+ // Put every file touched since `id` back to how it was before that checkpoint.
91
+ // The oldest snapshot of each file decides its final state. Returns {restored, removed, skipped}.
92
+ restoreCode(id) {
93
+ const idx = this.entries.findIndex((e) => e.id === id);
94
+ if (idx < 0) throw new Error(`No checkpoint ${id}`);
95
+ const oldest = new Map();
96
+ for (let i = idx; i < this.entries.length; i++) {
97
+ for (const f of this.entries[i].files) if (!oldest.has(keyOf(f.path))) oldest.set(keyOf(f.path), f);
98
+ }
99
+ const result = { restored: [], removed: [], skipped: [] };
100
+ for (const f of oldest.values()) {
101
+ try {
102
+ if (f.skipped) { result.skipped.push(f.path); continue; }
103
+ if (f.existed) {
104
+ fs.mkdirSync(path.dirname(f.path), { recursive: true });
105
+ fs.copyFileSync(path.join(this.dir, f.snap), f.path);
106
+ result.restored.push(f.path);
107
+ } else {
108
+ fs.rmSync(f.path, { force: true });
109
+ result.removed.push(f.path);
110
+ }
111
+ } catch (e) {
112
+ result.skipped.push(`${f.path} (${e.message})`);
113
+ }
114
+ }
115
+ // Those changes no longer exist on disk, so later checkpoints have nothing left to undo.
116
+ for (let i = idx; i < this.entries.length; i++) this.entries[i].files = [];
117
+ this.writeIndex();
118
+ return result;
119
+ }
120
+
121
+ // Forget checkpoint `id` and everything after it (their turns were removed from the conversation).
122
+ discardFrom(id) {
123
+ this.entries = this.entries.filter((e) => e.id < id);
124
+ this.current = this.entries[this.entries.length - 1] || null;
125
+ this.writeIndex();
126
+ }
127
+
128
+ // The conversation was rewound to before `id` but the files were left alone: keep the
129
+ // snapshots (the edits are still on disk) but stop listing those turns.
130
+ orphanFrom(id) {
131
+ for (const e of this.entries) if (e.id >= id) e.orphaned = true;
132
+ this.writeIndex();
133
+ }
134
+
135
+ // The conversation was compacted or replaced: it can no longer be rewound to older points,
136
+ // but file snapshots remain valid.
137
+ markConversationStale() {
138
+ for (const e of this.entries) e.conversationValid = false;
139
+ }
140
+
141
+ reset() {
142
+ this.entries = [];
143
+ this.current = null;
144
+ this.writeIndex();
145
+ }
146
+
147
+ writeIndex() {
148
+ try {
149
+ fs.mkdirSync(this.dir, { recursive: true });
150
+ fs.writeFileSync(path.join(this.dir, 'index.json'), JSON.stringify(this.entries, null, 1));
151
+ } catch { /* best effort: the index only helps manual recovery */ }
152
+ }
153
+ }
154
+
155
+ // Apply a rewind to a running agent. mode: 'both' | 'code' | 'conversation'.
156
+ // Returns {restore, prompt, conversation}: what was restored, the prompt text (so the UI can put it
157
+ // back in the input box) and whether the conversation was cut.
158
+ export function rewind(agent, store, id, mode = 'both') {
159
+ const entry = store.get(id);
160
+ if (!entry) throw new Error(`No checkpoint ${id}`);
161
+ if (mode !== 'code' && !entry.conversationValid) {
162
+ throw new Error('The conversation was compacted after this point, so it can no longer be rewound to it. You can still restore the code.');
163
+ }
164
+ const out = { restore: null, prompt: entry.label, conversation: false };
165
+ if (mode !== 'conversation') {
166
+ out.restore = store.restoreCode(id);
167
+ // Files changed behind the agent's back: make it read them again before editing.
168
+ agent.fileState.clear();
169
+ }
170
+ if (mode !== 'code') {
171
+ agent.replaceMessages(agent.messages.slice(0, entry.messageIndex));
172
+ agent.todos = [];
173
+ agent.lastContextTokens = estimateTokens(agent.messages);
174
+ agent.tokensSinceUsage = 0;
175
+ if (mode === 'both') store.discardFrom(id); else store.orphanFrom(id);
176
+ out.conversation = true;
177
+ } else {
178
+ agent.pushMessage({
179
+ role: 'user',
180
+ content: [{ type: 'text', text: `<system-reminder>The user rewound the code: ${out.restore.restored.length} file(s) were restored to their state before the prompt "${entry.label}" and ${out.restore.removed.length} file(s) created since then were deleted. Files may differ from what you last read; read them again before editing.</system-reminder>` }],
181
+ });
182
+ }
183
+ return out;
184
+ }
@@ -0,0 +1,60 @@
1
+ // Read an existing Claude Code subscription login. There is no login flow here and no subprocess: signing
2
+ // in is the official application's. Renewing the access token with the refresh token stored beside it is
3
+ // done by oauth.js, which is the only code that writes the credentials file; this reads it.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+
8
+ export const CLAUDE_CODE_API_URL = 'https://api.anthropic.com';
9
+ export const CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
10
+ export const CLAUDE_CODE_BETAS = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14';
11
+ const sessionIds = new WeakMap();
12
+ let installedVersion;
13
+
14
+ function readJson(file, label) {
15
+ let text;
16
+ try { text = fs.readFileSync(file, 'utf8'); }
17
+ catch { throw new Error(`Could not read existing Claude Code ${label}: ${file}. Select the existing login with CLAUDE_PRO_CLAUDE_CONFIG_FILE / CLAUDE_PRO_CLAUDE_CREDENTIALS_FILE.`); }
18
+ // JSON.parse errors can contain an excerpt of the token. Never echo them.
19
+ try { return JSON.parse(text.replace(/^\uFEFF/, '')); }
20
+ catch { throw new Error(`Invalid JSON in Claude Code ${label}: ${file}.`); }
21
+ }
22
+
23
+ function clientVersion(config, metadata) {
24
+ if (config.env.CLAUDE_PRO_CLAUDE_CODE_VERSION) return config.env.CLAUDE_PRO_CLAUDE_CODE_VERSION;
25
+ if (installedVersion) return installedVersion;
26
+ for (const dir of (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)) {
27
+ try {
28
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir.replace(/^"|"$/g, ''), 'node_modules', '@anthropic-ai', 'claude-code', 'package.json'), 'utf8'));
29
+ if (/^\d+\.\d+\.\d+$/.test(pkg.version)) return (installedVersion = pkg.version);
30
+ } catch { /* native installs do not have an npm package */ }
31
+ }
32
+ return /^\d+\.\d+\.\d+$/.test(metadata.lastOnboardingVersion || '') ? metadata.lastOnboardingVersion : '2.1.289';
33
+ }
34
+
35
+ export function readClaudeCodeLogin(config) {
36
+ const metadata = readJson(config.claudeCodeConfigFile, 'account configuration');
37
+ const credentials = readJson(config.claudeCodeCredentialsFile, 'credentials');
38
+ const oauth = credentials?.claudeAiOauth;
39
+ if (!oauth || typeof oauth.accessToken !== 'string' || !oauth.accessToken.trim()) {
40
+ throw new Error(`No existing Claude Code subscription access token in ${config.claudeCodeCredentialsFile}. The .claude.json file contains account metadata; the token is in .claude/.credentials.json.`);
41
+ }
42
+ if (oauth.expiresAt && Number(oauth.expiresAt) <= Date.now()) {
43
+ throw new Error('The existing Claude Code access token has expired and cannot be renewed from here (the credentials file has no refresh token, or CLAUDE_PRO_DISABLE_TOKEN_REFRESH is set). Run `claude` and use /login to sign in again; this project will read the updated file automatically.');
44
+ }
45
+ if (!sessionIds.has(config)) sessionIds.set(config, crypto.randomUUID());
46
+ return {
47
+ accessToken: oauth.accessToken,
48
+ expiresAt: Number(oauth.expiresAt) || null, // ms since epoch; the token lives 8 hours
49
+ userId: JSON.stringify({ device_id: metadata.userID || '', account_uuid: metadata.oauthAccount?.accountUuid || '', session_id: sessionIds.get(config) }),
50
+ version: clientVersion(config, metadata),
51
+ };
52
+ }
53
+
54
+ export function claudeCodeHeaders(login) {
55
+ return {
56
+ 'anthropic-beta': CLAUDE_CODE_BETAS,
57
+ 'user-agent': `claude-cli/${login.version} (external, cli)`,
58
+ 'x-app': 'cli',
59
+ };
60
+ }