@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.
- package/.env.example +41 -0
- package/LICENSE +21 -0
- package/README.md +314 -3
- package/bin/claude-pro.js +32 -0
- package/package.json +40 -6
- package/src/agent.js +752 -0
- package/src/agents.js +186 -0
- package/src/api.js +529 -0
- package/src/cache.js +150 -0
- package/src/checkpoints.js +184 -0
- package/src/claude-code.js +60 -0
- package/src/codex/client.js +307 -0
- package/src/codex/translate-request.js +239 -0
- package/src/codex/translate-stream.js +328 -0
- package/src/commands.js +839 -0
- package/src/config.js +453 -0
- package/src/context.js +109 -0
- package/src/filestate.js +57 -0
- package/src/gateway.js +143 -0
- package/src/hooks.js +81 -0
- package/src/main.js +193 -0
- package/src/mcp.js +290 -0
- package/src/modes.js +154 -0
- package/src/oauth.js +287 -0
- package/src/peers.js +157 -0
- package/src/print.js +106 -0
- package/src/prompts.js +179 -0
- package/src/ratelimit.js +244 -0
- package/src/restart.js +115 -0
- package/src/runtime.js +162 -0
- package/src/selfcheck.js +71 -0
- package/src/session.js +139 -0
- package/src/supervisor.js +110 -0
- package/src/tasks.js +76 -0
- package/src/tools/bash.js +227 -0
- package/src/tools/effort.js +41 -0
- package/src/tools/files.js +352 -0
- package/src/tools/index.js +52 -0
- package/src/tools/interaction.js +157 -0
- package/src/tools/notebook.js +91 -0
- package/src/tools/peers.js +45 -0
- package/src/tools/restart.js +50 -0
- package/src/tools/search.js +458 -0
- package/src/tools/skill.js +32 -0
- package/src/tools/task.js +139 -0
- package/src/tools/web.js +283 -0
- package/src/ui/ansi.js +228 -0
- package/src/ui/dialogs.js +122 -0
- package/src/ui/editor.js +389 -0
- package/src/ui/markdown.js +249 -0
- package/src/ui/render.js +155 -0
- package/src/ui/repl.js +1460 -0
- package/src/ui/screen.js +259 -0
- package/src/updater.js +93 -0
- package/src/utils/clipboard.js +197 -0
- package/src/utils/diff.js +95 -0
- package/src/utils/paths.js +37 -0
- package/src/utils/shell.js +251 -0
package/src/runtime.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Wires config, session mode, tools, MCP servers and the main agent together.
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { loadConfig, applyRememberedChoices } from './config.js';
|
|
6
|
+
import { ModeManager } from './modes.js';
|
|
7
|
+
import { CheckpointStore } from './checkpoints.js';
|
|
8
|
+
import { loadAgents, loadSkills } from './agents.js';
|
|
9
|
+
import { buildTools, toolsForSubagent } from './tools/index.js';
|
|
10
|
+
import { findShell, pruneStaleShellFiles } from './utils/shell.js';
|
|
11
|
+
import { mainSystemPrompt, subagentSystemPrompt } from './prompts.js';
|
|
12
|
+
import { Agent } from './agent.js';
|
|
13
|
+
import { isAbort } from './api.js';
|
|
14
|
+
import { McpManager } from './mcp.js';
|
|
15
|
+
import { HookRunner } from './hooks.js';
|
|
16
|
+
import { BackgroundTasks, taskNotification } from './tasks.js';
|
|
17
|
+
import { autoMemoryDir, pruneToolResults, pruneCheckpoints, projectDir } from './session.js';
|
|
18
|
+
import { loadAutoMemoryIndex, takeEnvSnapshot, localDate } from './context.js';
|
|
19
|
+
import { canRestart } from './restart.js';
|
|
20
|
+
|
|
21
|
+
export async function createRuntime({ cwd, flags = {}, io }) {
|
|
22
|
+
const config = loadConfig({ cwd, flags });
|
|
23
|
+
applyRememberedChoices(config, flags); // what /model and /effort chose last time
|
|
24
|
+
if (flags.verify) config.verifyOverride = flags.verify;
|
|
25
|
+
pruneToolResults(cwd);
|
|
26
|
+
pruneCheckpoints(cwd);
|
|
27
|
+
pruneStaleShellFiles();
|
|
28
|
+
// Full access: nothing is ever asked. The only mode switch is the read-only plan mode (--plan).
|
|
29
|
+
const modes = new ModeManager(flags.plan ? 'plan' : 'full');
|
|
30
|
+
const checkpoints = new CheckpointStore(path.join(projectDir(cwd), 'checkpoints', `${Date.now()}-${crypto.randomBytes(3).toString('hex')}`));
|
|
31
|
+
|
|
32
|
+
const agents = loadAgents(cwd);
|
|
33
|
+
const mcp = new McpManager(config.settings.mcpServers, { cwd });
|
|
34
|
+
if (!flags.noMcp) await mcp.start((msg) => io.notice?.(msg, 'warn'));
|
|
35
|
+
const shell = findShell(config);
|
|
36
|
+
|
|
37
|
+
const hooks = new HookRunner(config);
|
|
38
|
+
const tasks = new BackgroundTasks();
|
|
39
|
+
const memoryDir = autoMemoryDir(cwd);
|
|
40
|
+
const runtime = { config, modes, checkpoints, agents, mcp, shell, io, cwd, hooks, tasks, memoryDir };
|
|
41
|
+
runtime.skills = loadSkills(cwd);
|
|
42
|
+
// The self-check builds the Restart tool too, so that a mistake in it is found before a restart relies on it.
|
|
43
|
+
const restartable = canRestart() || process.env.CLAUDE_PRO_SELF_CHECK === '1';
|
|
44
|
+
runtime.tools = buildTools(config, { agents, skills: runtime.skills, mcpTools: mcp.tools(), restart: restartable, peers: Boolean(io?.peers) || process.env.CLAUDE_PRO_SELF_CHECK === '1' });
|
|
45
|
+
|
|
46
|
+
// The system prompt is built once and kept for the conversation: a prompt that changes in the middle of it
|
|
47
|
+
// cannot be read from the prompt cache. So a change to what it is built from (memory, configuration) waits
|
|
48
|
+
// for the next place where the conversation starts over anyway, /clear or a compaction, which is how Claude
|
|
49
|
+
// Code treats edits to CLAUDE.md. The date and git state in it are a snapshot, shared with the sub-agents.
|
|
50
|
+
let promptCache = null;
|
|
51
|
+
let promptStale = false; // what the prompt is built from has changed since it was built
|
|
52
|
+
const snapshots = new Map(); // directory -> {date, git}
|
|
53
|
+
const snapshotFor = (dir) => {
|
|
54
|
+
const key = path.resolve(dir);
|
|
55
|
+
if (!snapshots.has(key)) snapshots.set(key, takeEnvSnapshot(key));
|
|
56
|
+
return snapshots.get(key);
|
|
57
|
+
};
|
|
58
|
+
runtime.systemPrompt = () => {
|
|
59
|
+
if (!promptCache) {
|
|
60
|
+
const autoMemory = config.autoMemoryEnabled ? { dir: memoryDir, index: loadAutoMemoryIndex(memoryDir) } : null;
|
|
61
|
+
promptCache = flags.systemPrompt || mainSystemPrompt({ cwd, model: config.mainModel, shellName: shell.name, extra: flags.appendSystemPrompt || '', autoMemory, selfRestart: restartable, snapshot: snapshotFor(cwd) });
|
|
62
|
+
promptStale = false;
|
|
63
|
+
}
|
|
64
|
+
return promptCache;
|
|
65
|
+
};
|
|
66
|
+
// Build the prompt again at the next request: for a new conversation (/clear), another model, a compaction.
|
|
67
|
+
runtime.invalidateSystemPrompt = () => { promptCache = null; promptStale = false; snapshots.clear(); notedDate = null; };
|
|
68
|
+
// Something it is built from changed in the middle of a conversation: note it, and leave the prompt as it is.
|
|
69
|
+
runtime.deferSystemPromptRefresh = () => { if (promptCache) promptStale = true; };
|
|
70
|
+
Object.defineProperty(runtime, 'systemPromptStale', { get: () => promptStale });
|
|
71
|
+
// A resumed conversation continues behind the system prompt it started with (as in Claude Code): its history
|
|
72
|
+
// was cached behind that one, and nothing is gained from building the prompt again. Not when the prompt was
|
|
73
|
+
// built for another model (it names the model, and each model's cache is its own anyway). The date in it is
|
|
74
|
+
// brought up to date by a note to the model, which comes after everything that is cached.
|
|
75
|
+
runtime.resumeSystemPrompt = (saved) => {
|
|
76
|
+
if (flags.systemPrompt || typeof saved !== 'string' || !saved.trim()) return false;
|
|
77
|
+
// --append-system-prompt is the last thing in the prompt: one built with other text is not what this run asked for
|
|
78
|
+
const extra = flags.appendSystemPrompt || '';
|
|
79
|
+
if (extra && !saved.endsWith(`\n\n${extra}`)) return false;
|
|
80
|
+
const modelIn = (text) => /^You are powered by the model (.+)\.$/m.exec(text)?.[1];
|
|
81
|
+
if (modelIn(saved) !== config.mainModel.id) return false;
|
|
82
|
+
promptCache = saved;
|
|
83
|
+
promptStale = false;
|
|
84
|
+
const then = /^Today's date: (\d{4}-\d{2}-\d{2})$/m.exec(saved)?.[1];
|
|
85
|
+
const today = localDate();
|
|
86
|
+
if (then && then !== today) {
|
|
87
|
+
runtime.agent.addReminder(`Today's date is ${today}. This conversation was resumed from an earlier session, so the date in the environment block of your instructions (${then}) is out of date.`);
|
|
88
|
+
notedDate = today;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
// A conversation that goes on past midnight keeps the date its system prompt was built with (rebuilding the
|
|
93
|
+
// prompt would throw its cache away). The model is told the new date instead, once a day, in a note that goes
|
|
94
|
+
// with the next user message, after everything that is cached. Called before each of the main agent's turns.
|
|
95
|
+
let notedDate = null;
|
|
96
|
+
runtime.noteDateChange = (today = localDate()) => {
|
|
97
|
+
const then = promptCache && /^Today's date: (\d{4}-\d{2}-\d{2})$/m.exec(promptCache)?.[1];
|
|
98
|
+
if (!then || then === today || notedDate === today) return false;
|
|
99
|
+
notedDate = today;
|
|
100
|
+
runtime.agent.addReminder(`Today's date is ${today}. This conversation started on ${then}, so the date in the environment block of your instructions is out of date.`);
|
|
101
|
+
return true;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
runtime.spawnSubagent = async ({ def, prompt, description, ctx }) => {
|
|
105
|
+
const model = def.model ? config.resolveModel(def.model) : config.subagentModel;
|
|
106
|
+
const subIo = io.subagentIo ? io.subagentIo({ def, description, parentCtx: ctx }) : {};
|
|
107
|
+
// The main agent is depth 0; an agent below the nesting limit may start sub-agents itself.
|
|
108
|
+
const depth = (ctx.depth ?? ctx.agent?.depth ?? 0) + 1;
|
|
109
|
+
const sub = new Agent({
|
|
110
|
+
config,
|
|
111
|
+
modes,
|
|
112
|
+
checkpoints,
|
|
113
|
+
depth,
|
|
114
|
+
spawnSubagent: runtime.spawnSubagent,
|
|
115
|
+
tools: toolsForSubagent(runtime.tools, def, { allowTask: depth < config.maxSubagentDepth }),
|
|
116
|
+
systemPrompt: subagentSystemPrompt({ cwd: ctx.cwd, model, shellName: shell.name, agentPrompt: def.prompt, snapshot: snapshotFor(ctx.cwd) }),
|
|
117
|
+
io: subIo,
|
|
118
|
+
model,
|
|
119
|
+
isSubagent: true,
|
|
120
|
+
cwd: ctx.cwd,
|
|
121
|
+
name: def.name,
|
|
122
|
+
hooks,
|
|
123
|
+
});
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
result = await sub.run(prompt, { signal: ctx.signal });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// An API failure (rate limit, outage) must not throw away what the agent already found.
|
|
129
|
+
const partial = isAbort(err) || ctx.signal?.aborted ? '' : sub.assistantText();
|
|
130
|
+
if (partial) err.message += `\n\nThe agent failed before finishing (${sub.toolUseCount} tool uses). What it had written so far:\n${partial}`;
|
|
131
|
+
throw err;
|
|
132
|
+
} finally {
|
|
133
|
+
runtime.agent.addSubUsage(sub.usage); // whatever became of it, it was billed
|
|
134
|
+
}
|
|
135
|
+
if (result.interrupted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
|
136
|
+
return { text: result.text, toolUses: sub.toolUseCount, tokens: sub.usage.output + sub.lastContextTokens };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
runtime.agent = new Agent({
|
|
140
|
+
config,
|
|
141
|
+
modes,
|
|
142
|
+
checkpoints,
|
|
143
|
+
tools: runtime.tools,
|
|
144
|
+
systemPrompt: runtime.systemPrompt,
|
|
145
|
+
io,
|
|
146
|
+
model: () => config.mainModel,
|
|
147
|
+
cwd,
|
|
148
|
+
spawnSubagent: runtime.spawnSubagent,
|
|
149
|
+
hooks,
|
|
150
|
+
tasks,
|
|
151
|
+
refreshSystemPrompt: () => runtime.invalidateSystemPrompt(), // a compaction starts the conversation over
|
|
152
|
+
beforeTurn: () => runtime.noteDateChange(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// A finished background agent's report is queued for the main agent; the UI decides
|
|
156
|
+
// whether to start a new turn for it (when idle) or let the running turn pick it up.
|
|
157
|
+
tasks.onDone((task) => {
|
|
158
|
+
runtime.agent.notifications.push({ taskId: task.id, text: taskNotification(task) });
|
|
159
|
+
io.backgroundTaskDone?.(task);
|
|
160
|
+
});
|
|
161
|
+
return runtime;
|
|
162
|
+
}
|
package/src/selfcheck.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `claude-pro --self-check`: start the program the way a session does, but in a fake terminal, and see that
|
|
2
|
+
// it comes up. A restart runs this first in a throwaway process, so that code which does not load or start
|
|
3
|
+
// is found out while the session that can still fix it is running.
|
|
4
|
+
|
|
5
|
+
import { PassThrough, Writable } from 'node:stream';
|
|
6
|
+
|
|
7
|
+
const STARTUP_LIMIT_MS = 30_000;
|
|
8
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
|
+
|
|
10
|
+
async function check() {
|
|
11
|
+
const problems = [];
|
|
12
|
+
const fail = (text) => problems.push(text);
|
|
13
|
+
|
|
14
|
+
let App;
|
|
15
|
+
try {
|
|
16
|
+
({ App } = await import('./ui/repl.js'));
|
|
17
|
+
for (const file of ['./main.js', './print.js', './restart.js', './supervisor.js']) await import(file);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return [`A module does not load: ${e?.stack || e}`];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let screen = '';
|
|
23
|
+
const stdout = new Writable({ write(chunk, _enc, cb) { screen += chunk; cb(); } });
|
|
24
|
+
Object.assign(stdout, { isTTY: true, columns: 100, rows: 30, getColorDepth: () => 8 });
|
|
25
|
+
const stdin = new PassThrough();
|
|
26
|
+
Object.assign(stdin, { isTTY: true, setRawMode() {} });
|
|
27
|
+
|
|
28
|
+
const app = new App({ flags: { noMcp: true }, cwd: process.cwd(), stdin, stdout, noProcessExit: true });
|
|
29
|
+
let exited;
|
|
30
|
+
try {
|
|
31
|
+
exited = app.start('');
|
|
32
|
+
exited.catch((e) => fail(`Starting failed: ${e?.stack || e}`));
|
|
33
|
+
const began = Date.now();
|
|
34
|
+
while (!(screen.includes('Welcome to') && screen.includes('for shortcuts'))) {
|
|
35
|
+
if (problems.length) return problems;
|
|
36
|
+
if (Date.now() - began > STARTUP_LIMIT_MS) return [`The interface did not come up within ${STARTUP_LIMIT_MS / 1000} seconds.\n${screen.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').slice(-800)}`];
|
|
37
|
+
await sleep(25);
|
|
38
|
+
}
|
|
39
|
+
if (/render error/.test(screen)) fail(`The interface failed to render: ${screen.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').match(/render error[^\n]*/)?.[0]}`);
|
|
40
|
+
|
|
41
|
+
const names = new Set();
|
|
42
|
+
for (const tool of app.runtime.agent.toolSchemas()) {
|
|
43
|
+
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(tool.name || '')) fail(`Tool name ${JSON.stringify(tool.name)} is not valid.`);
|
|
44
|
+
if (names.has(tool.name)) fail(`Tool ${tool.name} is defined twice.`);
|
|
45
|
+
names.add(tool.name);
|
|
46
|
+
if (typeof tool.description !== 'string' || !tool.description.trim()) fail(`Tool ${tool.name} has no description.`);
|
|
47
|
+
if (tool.input_schema?.type !== 'object') fail(`Tool ${tool.name} has no object input_schema.`);
|
|
48
|
+
}
|
|
49
|
+
const prompt = app.runtime.systemPrompt();
|
|
50
|
+
if (typeof prompt !== 'string' || prompt.length < 200) fail('The system prompt is empty.');
|
|
51
|
+
} catch (e) {
|
|
52
|
+
fail(`The check itself failed: ${e?.stack || e}`);
|
|
53
|
+
} finally {
|
|
54
|
+
try { app.exit(0); } catch { /* ignore */ }
|
|
55
|
+
}
|
|
56
|
+
return problems;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Prints the result and ends the process (an interface that was started leaves timers and watchers around).
|
|
60
|
+
export async function selfCheck() {
|
|
61
|
+
let problems;
|
|
62
|
+
try {
|
|
63
|
+
problems = await check();
|
|
64
|
+
} catch (e) {
|
|
65
|
+
problems = [`The check crashed: ${e?.stack || e}`];
|
|
66
|
+
}
|
|
67
|
+
const text = problems.length ? `Self-check failed:\n- ${problems.join('\n- ')}\n` : 'Self-check passed: every module loads and the interface starts.\n';
|
|
68
|
+
const code = problems.length ? 1 : 0;
|
|
69
|
+
process.stdout.write(text, () => process.exit(code));
|
|
70
|
+
return code;
|
|
71
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Transcript persistence: ~/.claude-pro/projects/<project>/<session-id>.jsonl
|
|
2
|
+
// Each line is {type:'message', message} or {type:'reset', messages} (after /clear or compaction)
|
|
3
|
+
// or {type:'meta', ...}.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { CONFIG_DIR } from './config.js';
|
|
9
|
+
|
|
10
|
+
export function projectDir(cwd) {
|
|
11
|
+
const name = path.resolve(cwd).replace(/[^a-zA-Z0-9]/g, '-');
|
|
12
|
+
return path.join(CONFIG_DIR, 'projects', name);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Where oversized tool output is saved (see persistLargeOutput in agent.js).
|
|
16
|
+
export function toolResultsDir(cwd) {
|
|
17
|
+
return path.join(projectDir(cwd), 'tool-results');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Delete saved tool output older than maxAgeMs (best effort, run at startup).
|
|
21
|
+
export function pruneToolResults(cwd, maxAgeMs = 7 * 24 * 3600_000) {
|
|
22
|
+
const dir = toolResultsDir(cwd);
|
|
23
|
+
let entries = [];
|
|
24
|
+
try { entries = fs.readdirSync(dir); } catch { return; }
|
|
25
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
26
|
+
for (const name of entries) {
|
|
27
|
+
const file = path.join(dir, name);
|
|
28
|
+
try { if (fs.statSync(file).mtimeMs < cutoff) fs.rmSync(file, { force: true }); } catch { /* ignore */ }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Where /rewind keeps file snapshots: one sub-directory per run of the program.
|
|
33
|
+
export function checkpointsDir(cwd) {
|
|
34
|
+
return path.join(projectDir(cwd), 'checkpoints');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Delete snapshot directories of old runs (best effort, run at startup).
|
|
38
|
+
export function pruneCheckpoints(cwd, maxAgeMs = 14 * 24 * 3600_000) {
|
|
39
|
+
const dir = checkpointsDir(cwd);
|
|
40
|
+
let entries = [];
|
|
41
|
+
try { entries = fs.readdirSync(dir); } catch { return; }
|
|
42
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
43
|
+
for (const name of entries) {
|
|
44
|
+
const sub = path.join(dir, name);
|
|
45
|
+
try { if (fs.statSync(sub).mtimeMs < cutoff) fs.rmSync(sub, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Persistent, model-maintained memory for this project (MEMORY.md + topic files).
|
|
50
|
+
export function autoMemoryDir(cwd) {
|
|
51
|
+
return path.join(projectDir(cwd), 'memory');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SessionStore {
|
|
55
|
+
constructor(cwd, id = crypto.randomUUID()) {
|
|
56
|
+
this.cwd = cwd;
|
|
57
|
+
this.id = id;
|
|
58
|
+
this.dir = projectDir(cwd);
|
|
59
|
+
this.file = path.join(this.dir, `${id}.jsonl`);
|
|
60
|
+
this.started = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
append(entry) {
|
|
64
|
+
try {
|
|
65
|
+
if (!this.started) {
|
|
66
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
67
|
+
this.started = true;
|
|
68
|
+
if (!fs.existsSync(this.file)) fs.appendFileSync(this.file, JSON.stringify({ type: 'meta', cwd: this.cwd, created: new Date().toISOString() }) + '\n');
|
|
69
|
+
}
|
|
70
|
+
fs.appendFileSync(this.file, JSON.stringify(entry) + '\n');
|
|
71
|
+
} catch { /* persistence is best effort */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
message(message) { this.append({ type: 'message', message, at: Date.now() }); }
|
|
75
|
+
// What the conversation is about now (the tab title); the newest one wins when the session is loaded.
|
|
76
|
+
setTopic(topic) { this.append({ type: 'meta', topic, at: Date.now() }); }
|
|
77
|
+
reset(messages) { this.append({ type: 'reset', messages, at: Date.now() }); }
|
|
78
|
+
// The system prompt this conversation runs behind, kept so that a resumed one continues behind the same prompt
|
|
79
|
+
// and so with the prompt cache it already has (see runtime.resumeSystemPrompt). Written when it first appears
|
|
80
|
+
// and whenever it changes (after a compaction); the newest one wins when the session is loaded.
|
|
81
|
+
setSystemPrompt(text) {
|
|
82
|
+
if (!text || text === this.systemPrompt) return;
|
|
83
|
+
this.systemPrompt = text;
|
|
84
|
+
this.append({ type: 'meta', systemPrompt: text, at: Date.now() });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static load(file) {
|
|
88
|
+
const messages = [];
|
|
89
|
+
const times = []; // when each message was stored (ms since epoch), parallel to `messages`; 0 = unknown
|
|
90
|
+
let meta = {};
|
|
91
|
+
let title = null;
|
|
92
|
+
let lastAt = 0; // when the conversation last changed: the latest message or reset (0: unknown)
|
|
93
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
94
|
+
if (!line.trim()) continue;
|
|
95
|
+
let e;
|
|
96
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
97
|
+
if (e.type === 'message' || e.type === 'reset') lastAt = Math.max(lastAt, e.at || 0);
|
|
98
|
+
if (e.type === 'meta') meta = { ...meta, ...e };
|
|
99
|
+
else if (e.type === 'message') {
|
|
100
|
+
messages.push(e.message);
|
|
101
|
+
times.push(e.at || 0);
|
|
102
|
+
if (!title && e.message.role === 'user') title = firstUserText(e.message);
|
|
103
|
+
} else if (e.type === 'reset') {
|
|
104
|
+
// The messages of a reset (after /clear, compaction or a rewind) were not stored one by one,
|
|
105
|
+
// so their own times are unknown.
|
|
106
|
+
messages.splice(0, messages.length, ...e.messages);
|
|
107
|
+
times.splice(0, times.length, ...e.messages.map(() => 0));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
messages, times, lastAt, meta, title,
|
|
112
|
+
topic: typeof meta.topic === 'string' && meta.topic.trim() ? meta.topic : null,
|
|
113
|
+
systemPrompt: typeof meta.systemPrompt === 'string' && meta.systemPrompt.trim() ? meta.systemPrompt : null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static list(cwd) {
|
|
118
|
+
const dir = projectDir(cwd);
|
|
119
|
+
let files = [];
|
|
120
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { return []; }
|
|
121
|
+
return files.map((f) => {
|
|
122
|
+
const file = path.join(dir, f);
|
|
123
|
+
const st = fs.statSync(file);
|
|
124
|
+
let title = null, count = 0;
|
|
125
|
+
try {
|
|
126
|
+
const loaded = SessionStore.load(file);
|
|
127
|
+
title = loaded.topic || loaded.title; // what it was about last, else its first message
|
|
128
|
+
count = loaded.messages.length;
|
|
129
|
+
} catch { /* ignore */ }
|
|
130
|
+
return { id: f.slice(0, -6), file, mtime: st.mtimeMs, title: title || '(no prompt)', count };
|
|
131
|
+
}).filter((s) => s.count > 0).sort((a, b) => b.mtime - a.mtime);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function firstUserText(message) {
|
|
136
|
+
const blocks = typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content;
|
|
137
|
+
const t = blocks.find((b) => b.type === 'text' && !b.text.startsWith('<system-reminder>') && !b.text.startsWith('['));
|
|
138
|
+
return t ? t.text.replace(/\s+/g, ' ').trim().slice(0, 100) : null;
|
|
139
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// A thin supervisor for interactive sessions, so that the program can restart itself and pick up changes
|
|
2
|
+
// to its own source code without the user quitting and starting it again.
|
|
3
|
+
//
|
|
4
|
+
// bin/claude-pro.js runs an interactive session as a child process of the supervisor, on the same terminal.
|
|
5
|
+
// A session that wants fresh code writes what has to survive into the file named by CLAUDE_PRO_RESTART_FILE
|
|
6
|
+
// (see restart.js) and exits with RESTART_EXIT_CODE; the supervisor starts the program again, which reads
|
|
7
|
+
// that file and carries on with the same conversation. Any other exit ends the session as it would have
|
|
8
|
+
// without a supervisor.
|
|
9
|
+
//
|
|
10
|
+
// This file uses nothing but Node's built-in modules and stays loaded for the whole session, so a broken
|
|
11
|
+
// source file can never take it down. (A change to this file or to bin/claude-pro.js is only picked up by
|
|
12
|
+
// the next real start, because the running supervisor is the old code.)
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import tty from 'node:tty';
|
|
20
|
+
|
|
21
|
+
export const RESTART_EXIT_CODE = 75; // EX_TEMPFAIL: the usual "start me again"
|
|
22
|
+
export const ENV_SUPERVISED = 'CLAUDE_PRO_SUPERVISED'; // set in the child: it runs under a supervisor
|
|
23
|
+
export const ENV_RESTART_FILE = 'CLAUDE_PRO_RESTART_FILE'; // where a restart request is left for the next child
|
|
24
|
+
export const ENV_RESTARTED = 'CLAUDE_PRO_RESTARTED'; // set in a child that was started by a restart
|
|
25
|
+
|
|
26
|
+
const NOT_INTERACTIVE = new Set(['-p', '--print', '-h', '--help', '-v', '--version', '--self-check']);
|
|
27
|
+
const QUICK_CRASH_MS = 15_000;
|
|
28
|
+
|
|
29
|
+
// How the session is started. VS Code (1.140 and later, terminal.integrated.tabs.allowAgentCliTitle) shows
|
|
30
|
+
// the title a program sets (see Screen.setTitle) as the tab label only for the AI command lines it
|
|
31
|
+
// recognises; any other program's tab just says "node". On Windows it recognises them by the command line
|
|
32
|
+
// of node.exe, where a `\claude-code\` path segment means Claude Code. Starting the session through such a
|
|
33
|
+
// path gets this program the same treatment. The directory does not have to exist: Node resolves `..` in
|
|
34
|
+
// the path of the main script by itself, before looking at the file.
|
|
35
|
+
export function sessionEntry(entry, platform = process.platform) {
|
|
36
|
+
if (platform !== 'win32') return entry;
|
|
37
|
+
const sep = path.win32.sep;
|
|
38
|
+
return `${path.win32.dirname(entry)}${sep}claude-code${sep}..${sep}${path.win32.basename(entry)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Whether this invocation is an interactive session that should run under the supervisor.
|
|
42
|
+
export function shouldSupervise(argv, env = process.env, isTty = (fd) => tty.isatty(fd)) {
|
|
43
|
+
if (env[ENV_SUPERVISED] || /^(1|true|yes)$/i.test(env.CLAUDE_PRO_NO_SUPERVISOR || '')) return false;
|
|
44
|
+
if (!isTty(0) || !isTty(1)) return false;
|
|
45
|
+
if (argv[0] === 'config') return false;
|
|
46
|
+
return !argv.some((a) => NOT_INTERACTIVE.has(a));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function putTerminalRight() {
|
|
50
|
+
// The child may have died with the terminal in the mode it had set for itself.
|
|
51
|
+
try { process.stdout.write('\x1b[?2004l\x1b[?25h'); } catch { /* nothing to do */ }
|
|
52
|
+
try { if (tty.isatty(0)) process.stdin.setRawMode(false); } catch { /* nothing to do */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Run `entry` (the program's own bin script) with `argv` until it ends without asking for a restart.
|
|
56
|
+
// Resolves with the exit code to end the process with.
|
|
57
|
+
export function supervise(argv, { entry, env = process.env, execPath = process.execPath, err = process.stderr } = {}) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const stateFile = path.join(os.tmpdir(), `claude-pro-restart-${process.pid}-${crypto.randomBytes(4).toString('hex')}.json`);
|
|
60
|
+
let child = null;
|
|
61
|
+
let stopping = false;
|
|
62
|
+
let restarted = false;
|
|
63
|
+
let finished = false;
|
|
64
|
+
|
|
65
|
+
const stop = (signal) => { stopping = true; try { child?.kill(signal); } catch { /* already gone */ } };
|
|
66
|
+
const handlers = {
|
|
67
|
+
SIGINT: () => { /* the terminal sends it to the child too, which decides what it means */ },
|
|
68
|
+
SIGTERM: () => stop('SIGTERM'),
|
|
69
|
+
SIGHUP: () => stop('SIGHUP'),
|
|
70
|
+
};
|
|
71
|
+
for (const [signal, fn] of Object.entries(handlers)) { try { process.on(signal, fn); } catch { /* not on this platform */ } }
|
|
72
|
+
|
|
73
|
+
const finish = (code, { abnormal = false } = {}) => {
|
|
74
|
+
if (finished) return;
|
|
75
|
+
finished = true;
|
|
76
|
+
for (const [signal, fn] of Object.entries(handlers)) { try { process.off(signal, fn); } catch { /* ignore */ } }
|
|
77
|
+
try { fs.rmSync(stateFile, { force: true }); } catch { /* ignore */ }
|
|
78
|
+
if (abnormal) putTerminalRight();
|
|
79
|
+
resolve(code);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const start = () => {
|
|
83
|
+
const childEnv = { ...env, [ENV_SUPERVISED]: '1', [ENV_RESTART_FILE]: stateFile };
|
|
84
|
+
if (restarted) childEnv[ENV_RESTARTED] = '1'; else delete childEnv[ENV_RESTARTED];
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
child = spawn(execPath, [sessionEntry(entry), ...argv], { stdio: 'inherit', env: childEnv });
|
|
87
|
+
child.on('error', (e) => {
|
|
88
|
+
child = null;
|
|
89
|
+
err.write(`claude-pro: could not start (${e.message})\n`);
|
|
90
|
+
finish(1, { abnormal: true });
|
|
91
|
+
});
|
|
92
|
+
child.on('exit', (code, signal) => {
|
|
93
|
+
child = null;
|
|
94
|
+
if (finished) return;
|
|
95
|
+
if (code === RESTART_EXIT_CODE && !stopping) {
|
|
96
|
+
restarted = true;
|
|
97
|
+
start();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const exitCode = code ?? (signal ? 128 + (os.constants.signals[signal] || 0) : 1);
|
|
101
|
+
const quick = restarted && exitCode !== 0 && Date.now() - startedAt < QUICK_CRASH_MS;
|
|
102
|
+
if (quick) {
|
|
103
|
+
err.write(`\nclaude-pro stopped (exit code ${exitCode}) right after restarting. The code that was just loaded may be broken: fix it and start claude-pro again. The conversation is saved; \`claude-pro --continue\` resumes it.\n`);
|
|
104
|
+
}
|
|
105
|
+
finish(exitCode, { abnormal: exitCode !== 0 });
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
start();
|
|
109
|
+
});
|
|
110
|
+
}
|
package/src/tasks.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Background tasks: sub-agents started with `run_in_background`. Each runs with its own
|
|
2
|
+
// abort signal (so finishing or interrupting the current turn does not stop it); when it
|
|
3
|
+
// ends, listeners are told so the main agent can be notified.
|
|
4
|
+
|
|
5
|
+
const MAX_RESULT_CHARS = 30_000;
|
|
6
|
+
|
|
7
|
+
export class BackgroundTasks {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.tasks = new Map();
|
|
10
|
+
this.counter = 0;
|
|
11
|
+
this.listeners = new Set();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
onDone(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
|
|
15
|
+
|
|
16
|
+
// run(signal) -> Promise<{text, toolUses, tokens}>
|
|
17
|
+
start({ description, subagentType, run }) {
|
|
18
|
+
const id = `agent_${++this.counter}`;
|
|
19
|
+
const ctrl = new AbortController();
|
|
20
|
+
const task = { id, description, subagentType, status: 'running', started: Date.now(), ended: null, result: null, error: null, ctrl };
|
|
21
|
+
task.promise = Promise.resolve()
|
|
22
|
+
.then(() => run(ctrl.signal))
|
|
23
|
+
.then((result) => { task.status = 'completed'; task.result = result; })
|
|
24
|
+
.catch((err) => {
|
|
25
|
+
task.status = ctrl.signal.aborted ? 'killed' : 'failed';
|
|
26
|
+
task.error = ctrl.signal.aborted ? 'Stopped' : (err?.message || String(err));
|
|
27
|
+
})
|
|
28
|
+
.then(() => {
|
|
29
|
+
task.ended = Date.now();
|
|
30
|
+
for (const fn of this.listeners) { try { fn(task); } catch { /* listener errors must not break others */ } }
|
|
31
|
+
return task;
|
|
32
|
+
});
|
|
33
|
+
this.tasks.set(id, task);
|
|
34
|
+
return task;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get(id) { return this.tasks.get(id); }
|
|
38
|
+
list() { return [...this.tasks.values()]; }
|
|
39
|
+
running() { return this.list().filter((t) => t.status === 'running'); }
|
|
40
|
+
|
|
41
|
+
kill(id) {
|
|
42
|
+
const t = this.tasks.get(id);
|
|
43
|
+
if (!t || t.status !== 'running') return false;
|
|
44
|
+
t.ctrl.abort();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
killAll() { for (const t of this.running()) t.ctrl.abort(); }
|
|
49
|
+
|
|
50
|
+
// Resolves with the next task to finish (or null if none is running).
|
|
51
|
+
waitAny() {
|
|
52
|
+
const running = this.running();
|
|
53
|
+
return running.length ? Promise.race(running.map((t) => t.promise)) : Promise.resolve(null);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function taskResultText(task) {
|
|
58
|
+
if (task.status === 'completed') {
|
|
59
|
+
const text = task.result?.text || '(the agent returned no text)';
|
|
60
|
+
return text.length > MAX_RESULT_CHARS ? `${text.slice(0, MAX_RESULT_CHARS)}\n[result truncated]` : text;
|
|
61
|
+
}
|
|
62
|
+
return task.error || task.status;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The message the main agent sees when a background task ends.
|
|
66
|
+
export function taskNotification(task) {
|
|
67
|
+
const secs = Math.round(((task.ended || Date.now()) - task.started) / 1000);
|
|
68
|
+
return `<task-notification>
|
|
69
|
+
<task-id>${task.id}</task-id>
|
|
70
|
+
<status>${task.status}</status>
|
|
71
|
+
<summary>Background agent "${task.description}" ${task.status} after ${secs}s</summary>
|
|
72
|
+
<result>
|
|
73
|
+
${taskResultText(task)}
|
|
74
|
+
</result>
|
|
75
|
+
</task-notification>`;
|
|
76
|
+
}
|