@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/oauth.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Renewing the Claude Code subscription login.
|
|
2
|
+
//
|
|
3
|
+
// The access token in ~/.claude/.credentials.json lives 8 hours. The official client renews it when it
|
|
4
|
+
// is used, with the refresh token stored next to it, and so does this program now: a token that is
|
|
5
|
+
// expired or about to be is renewed before a request is sent, instead of failing and asking the user to
|
|
6
|
+
// go and run `claude`.
|
|
7
|
+
//
|
|
8
|
+
// Renewing rotates the refresh token: the server answers with a new one and the old one stops working.
|
|
9
|
+
// So the new tokens must reach the credentials file, or the official client (which reads the same file)
|
|
10
|
+
// is left holding a dead one, and nobody may renew at the same moment as somebody else. The protocol is
|
|
11
|
+
// the official client's, read from its code:
|
|
12
|
+
// - two directory locks (proper-lockfile style: mkdir to take it, rmdir to release, the holder touches
|
|
13
|
+
// it every 5 s, one not touched for 60 s is stale): <config dir>/.oauth_refresh.lock and, for older
|
|
14
|
+
// versions, <config dir>.lock, taken in that order;
|
|
15
|
+
// - after getting the locks, read the file again: whoever held them may just have renewed it;
|
|
16
|
+
// - the request: POST {grant_type: "refresh_token", refresh_token, client_id, scope} as JSON to
|
|
17
|
+
// the token endpoint; the answer has access_token, expires_in and, normally, a new refresh_token;
|
|
18
|
+
// - a refresh token that is rejected may have been used by another process a moment ago: look in the
|
|
19
|
+
// file before giving up.
|
|
20
|
+
// Only the token fields of the file are changed, and it is replaced atomically.
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import crypto from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
export const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
27
|
+
export const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
28
|
+
|
|
29
|
+
const REFRESH_MARGIN_MS = 5 * 60_000; // renew when this close to expiry, like the official client
|
|
30
|
+
const LOCK_STALE_MS = 60_000;
|
|
31
|
+
const LOCK_UPDATE_MS = 5_000;
|
|
32
|
+
const LOCK_WAIT_MS = 30_000;
|
|
33
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
34
|
+
|
|
35
|
+
// The token endpoint. Only tests replace it: it is deliberately not a setting, because the refresh token
|
|
36
|
+
// is sent there, and a setting can come from a .env file or a settings file of whatever project is open.
|
|
37
|
+
export const endpoint = { url: OAUTH_TOKEN_URL };
|
|
38
|
+
|
|
39
|
+
export const timing = { // replaceable so tests need not wait
|
|
40
|
+
now: () => Date.now(),
|
|
41
|
+
sleep: (ms, signal) => new Promise((resolve, reject) => {
|
|
42
|
+
if (signal?.aborted) return reject(abortError());
|
|
43
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
44
|
+
const onAbort = () => { clearTimeout(timer); reject(abortError()); };
|
|
45
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const abortError = () => Object.assign(new Error('aborted'), { name: 'AbortError' });
|
|
50
|
+
|
|
51
|
+
// What the credentials file holds, or null when it cannot be read.
|
|
52
|
+
export function readStoredLogin(file) {
|
|
53
|
+
try {
|
|
54
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
55
|
+
const json = JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
56
|
+
return { json, oauth: json?.claudeAiOauth || null, text };
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function needsRefresh(oauth, now = timing.now()) {
|
|
63
|
+
const expiresAt = Number(oauth?.expiresAt);
|
|
64
|
+
return Number.isFinite(expiresAt) && expiresAt > 0 && now + REFRESH_MARGIN_MS >= expiresAt;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isExpired = (oauth, now = timing.now()) => Number(oauth?.expiresAt) > 0 && Number(oauth.expiresAt) <= now;
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------- locks
|
|
70
|
+
|
|
71
|
+
async function takeLock(lockPath, signal) {
|
|
72
|
+
const began = timing.now();
|
|
73
|
+
for (;;) {
|
|
74
|
+
try {
|
|
75
|
+
fs.mkdirSync(lockPath);
|
|
76
|
+
break;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.code !== 'EEXIST') throw err;
|
|
79
|
+
let age;
|
|
80
|
+
try {
|
|
81
|
+
age = timing.now() - fs.statSync(lockPath).mtimeMs;
|
|
82
|
+
} catch (statErr) {
|
|
83
|
+
if (statErr.code === 'ENOENT') continue; // released just now: take it
|
|
84
|
+
throw statErr;
|
|
85
|
+
}
|
|
86
|
+
if (age > LOCK_STALE_MS) { // its holder is gone
|
|
87
|
+
try { fs.rmdirSync(lockPath); } catch { /* somebody else took it over first */ }
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (timing.now() - began > LOCK_WAIT_MS) throw Object.assign(new Error(`Another process has held ${lockPath} for ${Math.round(LOCK_WAIT_MS / 1000)} seconds.`), { code: 'ELOCKED' });
|
|
91
|
+
await timing.sleep(250 + Math.floor(Math.random() * 250), signal);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const touch = setInterval(() => { try { const t = new Date(); fs.utimesSync(lockPath, t, t); } catch { /* released */ } }, LOCK_UPDATE_MS);
|
|
95
|
+
touch.unref?.();
|
|
96
|
+
return () => {
|
|
97
|
+
clearInterval(touch);
|
|
98
|
+
try { fs.rmdirSync(lockPath); } catch { /* already gone */ }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Both of the official client's locks. Returns the function that lets go of them.
|
|
103
|
+
async function lockLoginFile(file, signal) {
|
|
104
|
+
const dir = path.dirname(file);
|
|
105
|
+
const release = [];
|
|
106
|
+
try {
|
|
107
|
+
release.push(await takeLock(path.join(dir, '.oauth_refresh.lock'), signal));
|
|
108
|
+
release.push(await takeLock(`${dir}.lock`, signal));
|
|
109
|
+
} catch (err) {
|
|
110
|
+
for (const fn of release.reverse()) fn();
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
return () => { for (const fn of release.reverse()) fn(); };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------- writing the file
|
|
117
|
+
|
|
118
|
+
function replaceFile(file, text) {
|
|
119
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
120
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
|
121
|
+
try {
|
|
122
|
+
for (let attempt = 1; ; attempt++) {
|
|
123
|
+
try {
|
|
124
|
+
fs.renameSync(tmp, file);
|
|
125
|
+
return;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// On Windows the file may be open in another process for a moment.
|
|
128
|
+
if (attempt >= 8 || !['EPERM', 'EBUSY', 'EACCES'].includes(err.code)) throw err;
|
|
129
|
+
const until = Date.now() + 40 * attempt;
|
|
130
|
+
while (Date.now() < until) { /* a short blocking wait: this is a rare, tiny operation */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Put the new tokens into the file, keeping everything else in it as it is (and its layout).
|
|
139
|
+
function storeTokens(file, tokens, now) {
|
|
140
|
+
const stored = readStoredLogin(file);
|
|
141
|
+
if (!stored) throw new Error(`Could not read ${file} to store the renewed login.`);
|
|
142
|
+
const oauth = { ...(stored.oauth || {}) };
|
|
143
|
+
oauth.accessToken = tokens.accessToken;
|
|
144
|
+
oauth.refreshToken = tokens.refreshToken;
|
|
145
|
+
oauth.expiresAt = now + tokens.expiresInSec * 1000;
|
|
146
|
+
if (tokens.refreshExpiresInSec) oauth.refreshTokenExpiresAt = now + tokens.refreshExpiresInSec * 1000;
|
|
147
|
+
if (tokens.scopes?.length) oauth.scopes = tokens.scopes;
|
|
148
|
+
const json = { ...stored.json, claudeAiOauth: oauth };
|
|
149
|
+
const indent = /^\uFEFF?\{\r?\n([ \t]+)"/.exec(stored.text)?.[1];
|
|
150
|
+
const text = JSON.stringify(json, null, indent) + (/\n$/.test(stored.text) ? '\n' : '');
|
|
151
|
+
replaceFile(file, text);
|
|
152
|
+
// what was written is what is read back: a renewed token that did not reach the file is worse than none
|
|
153
|
+
const check = readStoredLogin(file);
|
|
154
|
+
if (check?.oauth?.accessToken !== tokens.accessToken || check?.oauth?.refreshToken !== tokens.refreshToken) {
|
|
155
|
+
throw new Error(`The renewed login was not saved correctly in ${file}.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- the request
|
|
160
|
+
|
|
161
|
+
// The server's own words about a failure, without anything that could be a token.
|
|
162
|
+
function describeFailure(data) {
|
|
163
|
+
const text = [data?.error, data?.error_description || data?.error?.message].filter((x) => typeof x === 'string').join(': ');
|
|
164
|
+
return text.replace(/[A-Za-z0-9_-]{40,}/g, '…').slice(0, 200);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function requestTokens(config, { refreshToken, scopes, signal }) {
|
|
168
|
+
const url = endpoint.url;
|
|
169
|
+
const post = async (withScope) => {
|
|
170
|
+
const body = { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: OAUTH_CLIENT_ID };
|
|
171
|
+
if (withScope && scopes?.length) body.scope = scopes.join(' ');
|
|
172
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
173
|
+
const res = await fetch(url, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'claude-cli (external, cli)' },
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
178
|
+
});
|
|
179
|
+
let data = null;
|
|
180
|
+
try { data = await res.json(); } catch { /* not JSON */ }
|
|
181
|
+
return { status: res.status, data };
|
|
182
|
+
};
|
|
183
|
+
let res = await post(true);
|
|
184
|
+
// a scope the server does not know (any more): renewing without one keeps the scopes the token had
|
|
185
|
+
if (res.status === 400 && /invalid_scope/i.test(JSON.stringify(res.data?.error ?? '')) && scopes?.length) res = await post(false);
|
|
186
|
+
return res;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseTokens(data, oldRefreshToken) {
|
|
190
|
+
if (typeof data?.access_token !== 'string' || !data.access_token || !(Number(data.expires_in) > 0)) return null;
|
|
191
|
+
return {
|
|
192
|
+
accessToken: data.access_token,
|
|
193
|
+
refreshToken: typeof data.refresh_token === 'string' && data.refresh_token ? data.refresh_token : oldRefreshToken,
|
|
194
|
+
expiresInSec: Number(data.expires_in),
|
|
195
|
+
refreshExpiresInSec: Number(data.refresh_token_expires_in) > 0 ? Number(data.refresh_token_expires_in) : null,
|
|
196
|
+
scopes: typeof data.scope === 'string' && data.scope.trim() ? data.scope.trim().split(/\s+/) : null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------- renewing
|
|
201
|
+
|
|
202
|
+
const SIGN_IN_AGAIN = 'Sign in again: run `claude` and use /login.';
|
|
203
|
+
|
|
204
|
+
async function renew(config, { force }) {
|
|
205
|
+
const signal = undefined; // one renewal serves every caller in the process, so none of them cancels it
|
|
206
|
+
const file = config.claudeCodeCredentialsFile;
|
|
207
|
+
const first = readStoredLogin(file);
|
|
208
|
+
const oauth = first?.oauth;
|
|
209
|
+
if (!oauth?.refreshToken) return { refreshed: false, reason: 'no refresh token' };
|
|
210
|
+
if (!force && !needsRefresh(oauth)) return { refreshed: false, reason: 'still valid' };
|
|
211
|
+
if (Number(oauth.refreshTokenExpiresAt) > 0 && Number(oauth.refreshTokenExpiresAt) <= timing.now()) {
|
|
212
|
+
throw new Error(`The Claude Code login has expired and cannot be renewed: its refresh token ran out. ${SIGN_IN_AGAIN}`);
|
|
213
|
+
}
|
|
214
|
+
const startedWith = oauth.accessToken;
|
|
215
|
+
// somebody else renewed it while we were looking: take theirs
|
|
216
|
+
const renewedElsewhere = () => {
|
|
217
|
+
const now = readStoredLogin(file)?.oauth;
|
|
218
|
+
return now && now.accessToken !== startedWith && !needsRefresh(now) ? now : null;
|
|
219
|
+
};
|
|
220
|
+
const failed = (why) => {
|
|
221
|
+
const now = readStoredLogin(file)?.oauth;
|
|
222
|
+
// renewing early failed but the token still works: carry on with it (unless renewing was asked for)
|
|
223
|
+
if (!force && now && !isExpired(now)) return { refreshed: false, reason: why, stillValid: true };
|
|
224
|
+
throw new Error(force ? `The Claude Code login could not be renewed: ${why}` : `The Claude Code access token has expired and could not be renewed: ${why}`);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
let release;
|
|
228
|
+
try {
|
|
229
|
+
release = await lockLoginFile(file, signal);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (err.name === 'AbortError') throw err;
|
|
232
|
+
if (renewedElsewhere()) return { refreshed: false, reason: 'renewed by another process' };
|
|
233
|
+
return failed(err.message);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
if (renewedElsewhere()) return { refreshed: false, reason: 'renewed by another process' };
|
|
237
|
+
const current = readStoredLogin(file)?.oauth || oauth;
|
|
238
|
+
let res;
|
|
239
|
+
try {
|
|
240
|
+
res = await requestTokens(config, { refreshToken: current.refreshToken, scopes: current.scopes, signal });
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return failed(err.name === 'TimeoutError' ? 'the token server did not answer in time' : `the token server could not be reached (${err.cause?.code || err.message})`);
|
|
243
|
+
}
|
|
244
|
+
if (res.status === 200) {
|
|
245
|
+
const tokens = parseTokens(res.data, current.refreshToken);
|
|
246
|
+
if (!tokens) return failed('the token server sent an answer that is not a login');
|
|
247
|
+
storeTokens(file, tokens, timing.now());
|
|
248
|
+
return { refreshed: true, expiresAt: timing.now() + tokens.expiresInSec * 1000 };
|
|
249
|
+
}
|
|
250
|
+
// The refresh token was refused. Another process may have used it a moment ago and already stored the
|
|
251
|
+
// new one: look before giving up.
|
|
252
|
+
if (res.status === 400 || res.status === 401 || res.status === 403) {
|
|
253
|
+
if (renewedElsewhere()) return { refreshed: false, reason: 'renewed by another process' };
|
|
254
|
+
throw new Error(`The Claude Code login can no longer be renewed (${describeFailure(res.data) || `HTTP ${res.status}`}). ${SIGN_IN_AGAIN}`);
|
|
255
|
+
}
|
|
256
|
+
return failed(`the token server answered HTTP ${res.status}${describeFailure(res.data) ? ` (${describeFailure(res.data)})` : ''}`);
|
|
257
|
+
} finally {
|
|
258
|
+
release();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const inflight = new Map();
|
|
263
|
+
|
|
264
|
+
const refreshDisabled = (config) => /^(1|true|yes)$/i.test(config.env?.CLAUDE_PRO_DISABLE_TOKEN_REFRESH || '');
|
|
265
|
+
|
|
266
|
+
// Whether this program is able to renew the login by itself: switched on, and the file has a refresh
|
|
267
|
+
// token that has not run out. (Whether the server will accept it is only known by asking.)
|
|
268
|
+
export function canRenewLogin(config) {
|
|
269
|
+
if (refreshDisabled(config)) return false;
|
|
270
|
+
const oauth = readStoredLogin(config.claudeCodeCredentialsFile)?.oauth;
|
|
271
|
+
if (!oauth?.refreshToken) return false;
|
|
272
|
+
return !(Number(oauth.refreshTokenExpiresAt) > 0 && Number(oauth.refreshTokenExpiresAt) <= timing.now());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Renew the login in the credentials file if it is expired or about to expire (always with `force`).
|
|
276
|
+
// Resolves {refreshed, reason}; throws only when the login is unusable and could not be renewed: a
|
|
277
|
+
// failure to renew a token that still has a few minutes left is not an error. Callers in the same
|
|
278
|
+
// process share one renewal; `signal` only stops this caller from waiting for it.
|
|
279
|
+
export async function refreshClaudeCodeLogin(config, { signal, force = false } = {}) {
|
|
280
|
+
if (refreshDisabled(config)) return { refreshed: false, reason: 'disabled' };
|
|
281
|
+
if (signal?.aborted) throw abortError();
|
|
282
|
+
const key = config.claudeCodeCredentialsFile;
|
|
283
|
+
if (!inflight.has(key)) inflight.set(key, renew(config, { force }).finally(() => inflight.delete(key)));
|
|
284
|
+
const shared = inflight.get(key);
|
|
285
|
+
if (!signal) return shared;
|
|
286
|
+
return Promise.race([shared, new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(abortError()), { once: true }))]);
|
|
287
|
+
}
|
package/src/peers.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Peers: the other interactive claude-pro sessions on this machine. Each session registers itself in
|
|
2
|
+
// CONFIG_DIR/peers/<id>.json (name, directory, pid, and a port and token of a small HTTP server on 127.0.0.1)
|
|
3
|
+
// so that sessions can send each other tasks: the user with `@name task` in the input box, the model with the
|
|
4
|
+
// MessagePeer tool. A task is answered automatically with the final text of the turn that handled it.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { CONFIG_DIR } from './config.js';
|
|
11
|
+
|
|
12
|
+
export const PEERS_DIR = path.join(CONFIG_DIR, 'peers');
|
|
13
|
+
const MAX_BODY = 2_000_000;
|
|
14
|
+
|
|
15
|
+
export function peersEnabled(env = process.env) {
|
|
16
|
+
return !/^(0|false|no|off)$/i.test(env.CLAUDE_PRO_PEERS || '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function alive(pid) {
|
|
20
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const cleanName = (s) => String(s || '').replace(/[^\w.-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40) || 'agent';
|
|
24
|
+
|
|
25
|
+
// The sessions that are running now (stale entries of sessions that died are removed).
|
|
26
|
+
export function listPeers({ excludeId } = {}) {
|
|
27
|
+
let files = [];
|
|
28
|
+
try { files = fs.readdirSync(PEERS_DIR).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
29
|
+
const peers = [];
|
|
30
|
+
for (const f of files) {
|
|
31
|
+
const file = path.join(PEERS_DIR, f);
|
|
32
|
+
let p;
|
|
33
|
+
try { p = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
|
|
34
|
+
if (!p?.id || !p.port || !alive(p.pid)) { try { fs.unlinkSync(file); } catch { /* ignore */ } continue; }
|
|
35
|
+
if (p.id !== excludeId) peers.push(p);
|
|
36
|
+
}
|
|
37
|
+
return peers.sort((a, b) => a.startedAt - b.startedAt);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findPeer(peers, ref) {
|
|
41
|
+
const q = String(ref || '').replace(/^@/, '').toLowerCase();
|
|
42
|
+
if (!q) return null;
|
|
43
|
+
return peers.find((p) => p.name.toLowerCase() === q) || peers.find((p) => p.id.startsWith(q)) || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class PeerNode {
|
|
47
|
+
// onMessage(msg): msg = {id, kind: 'task'|'reply', text, inReplyTo, from: {id, name, cwd}}
|
|
48
|
+
// `id`: the id this session had before a restart, so that answers on their way still find it.
|
|
49
|
+
constructor({ cwd, name, id, onMessage }) {
|
|
50
|
+
this.cwd = cwd;
|
|
51
|
+
this.wantedName = name || null;
|
|
52
|
+
this.onMessage = onMessage;
|
|
53
|
+
this.id = /^[0-9a-f]{12}$/.test(id || '') ? id : crypto.randomBytes(6).toString('hex');
|
|
54
|
+
this.token = crypto.randomBytes(16).toString('hex');
|
|
55
|
+
this.name = null;
|
|
56
|
+
this.server = null;
|
|
57
|
+
this.port = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get file() { return path.join(PEERS_DIR, `${this.id}.json`); }
|
|
61
|
+
|
|
62
|
+
async start() {
|
|
63
|
+
this.server = http.createServer((req, res) => this.handle(req, res));
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
this.server.once('error', reject);
|
|
66
|
+
this.server.listen(0, '127.0.0.1', resolve);
|
|
67
|
+
});
|
|
68
|
+
this.server.unref();
|
|
69
|
+
this.port = this.server.address().port;
|
|
70
|
+
this.setName(this.wantedName || path.basename(this.cwd) || 'agent');
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Take `name`, or name-2, name-3... when another live session has it. Returns the name taken.
|
|
75
|
+
setName(name) {
|
|
76
|
+
const base = cleanName(name);
|
|
77
|
+
const taken = new Set(listPeers({ excludeId: this.id }).map((p) => p.name.toLowerCase()));
|
|
78
|
+
let n = base;
|
|
79
|
+
for (let i = 2; taken.has(n.toLowerCase()); i++) n = `${base}-${i}`;
|
|
80
|
+
this.name = n;
|
|
81
|
+
this.write();
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setCwd(cwd) {
|
|
86
|
+
if (cwd === this.cwd) return;
|
|
87
|
+
this.cwd = cwd;
|
|
88
|
+
this.write();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
write() {
|
|
92
|
+
if (!this.port) return;
|
|
93
|
+
fs.mkdirSync(PEERS_DIR, { recursive: true });
|
|
94
|
+
const data = { id: this.id, name: this.name, cwd: this.cwd, pid: process.pid, port: this.port, token: this.token, startedAt: this.startedAt ??= Date.now() };
|
|
95
|
+
fs.writeFileSync(this.file, JSON.stringify(data));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
peers() { return listPeers({ excludeId: this.id }); }
|
|
99
|
+
|
|
100
|
+
close() {
|
|
101
|
+
try { fs.unlinkSync(this.file); } catch { /* ignore */ }
|
|
102
|
+
try { this.server?.close(); } catch { /* ignore */ }
|
|
103
|
+
this.port = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
handle(req, res) {
|
|
107
|
+
const reply = (code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
|
|
108
|
+
if (req.method !== 'POST' || req.url !== '/message') return reply(404, { error: 'not found' });
|
|
109
|
+
if (req.headers.authorization !== `Bearer ${this.token}`) return reply(401, { error: 'bad token' });
|
|
110
|
+
let body = '';
|
|
111
|
+
req.setEncoding('utf8');
|
|
112
|
+
req.on('data', (d) => { body += d; if (body.length > MAX_BODY) req.destroy(); });
|
|
113
|
+
req.on('end', () => {
|
|
114
|
+
let msg;
|
|
115
|
+
try { msg = JSON.parse(body); } catch { return reply(400, { error: 'bad json' }); }
|
|
116
|
+
if (typeof msg?.text !== 'string' || !msg.from?.name) return reply(400, { error: 'text and from are required' });
|
|
117
|
+
const id = crypto.randomBytes(4).toString('hex');
|
|
118
|
+
reply(200, { ok: true, id, name: this.name });
|
|
119
|
+
try { this.onMessage?.({ id, kind: msg.kind === 'reply' ? 'reply' : 'task', text: msg.text, inReplyTo: msg.inReplyTo || null, from: msg.from }); } catch { /* the receiver's problem */ }
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Send to a peer (an entry of peers(), or its name). Resolves with {id} (the id the receiver gave the message).
|
|
124
|
+
async send(target, { text, kind = 'task', inReplyTo = null }) {
|
|
125
|
+
const peer = typeof target === 'string' ? findPeer(this.peers(), target) : target;
|
|
126
|
+
if (!peer) throw new Error(`No running session called "${target}". /peers lists them.`);
|
|
127
|
+
const body = JSON.stringify({ kind, text, inReplyTo, from: { id: this.id, name: this.name, cwd: this.cwd } });
|
|
128
|
+
return await new Promise((resolve, reject) => {
|
|
129
|
+
const req = http.request({
|
|
130
|
+
host: '127.0.0.1', port: peer.port, path: '/message', method: 'POST', timeout: 10_000,
|
|
131
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), authorization: `Bearer ${peer.token}` },
|
|
132
|
+
}, (res) => {
|
|
133
|
+
let out = '';
|
|
134
|
+
res.setEncoding('utf8');
|
|
135
|
+
res.on('data', (d) => { out += d; });
|
|
136
|
+
res.on('end', () => {
|
|
137
|
+
let data = {};
|
|
138
|
+
try { data = JSON.parse(out); } catch { /* ignore */ }
|
|
139
|
+
if (res.statusCode !== 200) reject(new Error(`${peer.name} refused the message: ${data.error || res.statusCode}`));
|
|
140
|
+
else resolve({ id: data.id, peer });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
req.on('timeout', () => req.destroy(new Error(`${peer.name} did not answer`)));
|
|
144
|
+
req.on('error', (e) => reject(new Error(`Could not reach ${peer.name}: ${e.message}`)));
|
|
145
|
+
req.end(body);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// What the model is given for a message from another session.
|
|
151
|
+
export function peerMessagePrompt(msg) {
|
|
152
|
+
const from = `${msg.from.name}${msg.from.cwd ? ` (working in ${msg.from.cwd})` : ''}`;
|
|
153
|
+
if (msg.kind === 'reply') {
|
|
154
|
+
return `<peer-message from="${msg.from.name}" kind="reply">\n${msg.text}\n</peer-message>\n<system-reminder>This is the answer of another ${'claude-pro'} session, ${from}, to a message you sent it with MessagePeer. Carry on with your task using it.</system-reminder>`;
|
|
155
|
+
}
|
|
156
|
+
return `<peer-message from="${msg.from.name}" kind="task">\n${msg.text}\n</peer-message>\n<system-reminder>This task was sent to you by another claude-pro session, ${from}, not typed by your user. Do it as you would a request of the user. The final text of your answer is sent back to ${msg.from.name} automatically, so end with a self-contained report of what you did or found.</system-reminder>`;
|
|
157
|
+
}
|
package/src/print.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Non-interactive mode: `claude-pro -p "prompt"`. Prints only the final answer to stdout
|
|
2
|
+
// (or JSON), tool activity goes to stderr with --verbose.
|
|
3
|
+
|
|
4
|
+
import { createRuntime } from './runtime.js';
|
|
5
|
+
import { SessionStore } from './session.js';
|
|
6
|
+
import { describePause, shouldAnnounce } from './ratelimit.js';
|
|
7
|
+
import { c } from './ui/ansi.js';
|
|
8
|
+
|
|
9
|
+
export async function runPrint({ prompt, flags, cwd, stdout = process.stdout, stderr = process.stderr }) {
|
|
10
|
+
const format = flags.outputFormat || 'text';
|
|
11
|
+
const err = (s) => stderr.write(s + '\n');
|
|
12
|
+
const emit = (obj) => stdout.write(JSON.stringify(obj) + '\n');
|
|
13
|
+
// A pause can last hours: always say so, or the run looks hung. `who` names a sub-agent. A reporter
|
|
14
|
+
// remembers the pause it announced, so the waits that only continue it (polling) are not repeated.
|
|
15
|
+
const rateLimitReporter = (who = '') => {
|
|
16
|
+
let paused = null;
|
|
17
|
+
return (ev) => {
|
|
18
|
+
if (ev.type === 'rate_limit_wait') {
|
|
19
|
+
if (shouldAnnounce(paused, ev)) err(c.yellow(who + describePause(ev)));
|
|
20
|
+
paused = { resumeAt: ev.resumeAt, waitingForLogin: Boolean(ev.waitingForLogin) };
|
|
21
|
+
}
|
|
22
|
+
if (ev.type === 'rate_limit_resume') {
|
|
23
|
+
paused = null;
|
|
24
|
+
err(c.yellow(`${who}The limit has cleared; continuing.`));
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
const mainRateLimit = rateLimitReporter();
|
|
29
|
+
const io = {
|
|
30
|
+
approvePlan: async () => 'reject', // non-interactive: a plan is only presented, never executed
|
|
31
|
+
askQuestion: async () => null,
|
|
32
|
+
notice: (text) => err(c.yellow(text)),
|
|
33
|
+
toolStart: (tu, tool, input, agent) => {
|
|
34
|
+
if (flags.verbose) err(c.gray(`⏺ ${tool.name}(${tool.summarize?.(input, agent.makeCtx()) ?? ''})`));
|
|
35
|
+
return {};
|
|
36
|
+
},
|
|
37
|
+
assistantMessage: (msg) => { if (format === 'stream-json') emit({ type: 'assistant', message: msg }); },
|
|
38
|
+
messageAdded: (msg) => { store.message(msg); if (format === 'stream-json' && msg.role === 'user') emit({ type: 'user', message: msg }); },
|
|
39
|
+
messagesReplaced: (msgs) => store.reset(msgs),
|
|
40
|
+
systemPrompt: (text) => store.setSystemPrompt(text),
|
|
41
|
+
compactStart: (auto) => { if (flags.verbose) err(c.gray(`✻ ${auto ? 'Auto-compacting' : 'Compacting'} conversation…`)); },
|
|
42
|
+
streamEvent: (ev) => {
|
|
43
|
+
if (ev.type === 'retry' && flags.verbose) err(c.yellow(`API error (${ev.error.message}), retrying in ${Math.round(ev.delayMs / 1000)}s…`));
|
|
44
|
+
mainRateLimit(ev);
|
|
45
|
+
},
|
|
46
|
+
compactProgress: (ev) => mainRateLimit(ev),
|
|
47
|
+
// Sub-agents have no screen of their own here; their pauses are still reported, whether it is a
|
|
48
|
+
// turn or the compaction of their own conversation that is paused.
|
|
49
|
+
subagentIo: ({ def }) => {
|
|
50
|
+
const report = rateLimitReporter(`[${def.name}] `);
|
|
51
|
+
return { streamEvent: report, compactProgress: report };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const runtime = await createRuntime({ cwd, flags, io });
|
|
55
|
+
let store = new SessionStore(cwd);
|
|
56
|
+
if (flags.resumeFile) {
|
|
57
|
+
const loaded = SessionStore.load(flags.resumeFile);
|
|
58
|
+
runtime.agent.messages = loaded.messages;
|
|
59
|
+
runtime.agent.restoreFileState(loaded.messages, loaded.times);
|
|
60
|
+
runtime.agent.lastActivityAt = loaded.lastAt;
|
|
61
|
+
store = new SessionStore(cwd, flags.resumeId);
|
|
62
|
+
store.systemPrompt = loaded.systemPrompt;
|
|
63
|
+
runtime.resumeSystemPrompt(loaded.systemPrompt); // continue behind the prompt (and the cache) the conversation had
|
|
64
|
+
}
|
|
65
|
+
const ctrl = new AbortController();
|
|
66
|
+
const onSigint = () => ctrl.abort();
|
|
67
|
+
process.on('SIGINT', onSigint);
|
|
68
|
+
const started = Date.now();
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
result = await runtime.agent.run(prompt, { signal: ctrl.signal });
|
|
72
|
+
// Background agents report back before we exit, so their work is part of the answer.
|
|
73
|
+
while (!result.interrupted && !ctrl.signal.aborted && (runtime.tasks.running().length || runtime.agent.notifications.length)) {
|
|
74
|
+
if (!runtime.agent.notifications.length) {
|
|
75
|
+
if (flags.verbose) err(c.gray(`… waiting for ${runtime.tasks.running().length} background agent(s)`));
|
|
76
|
+
await Promise.race([runtime.tasks.waitAny(), new Promise((r) => ctrl.signal.addEventListener('abort', r, { once: true }))]);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
result = await runtime.agent.run(runtime.agent.drainNotifications(), { signal: ctrl.signal });
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (format === 'json') emit({ type: 'result', is_error: true, error: e.message });
|
|
83
|
+
else err(c.red(`Error: ${e.message}`));
|
|
84
|
+
runtime.tasks.killAll(); runtime.mcp.close();
|
|
85
|
+
return 1;
|
|
86
|
+
} finally {
|
|
87
|
+
process.off('SIGINT', onSigint);
|
|
88
|
+
}
|
|
89
|
+
const a = runtime.agent;
|
|
90
|
+
const used = a.totalUsage(); // sub-agents and compactions are billed too
|
|
91
|
+
if (format === 'json' || format === 'stream-json') {
|
|
92
|
+
emit({
|
|
93
|
+
type: 'result',
|
|
94
|
+
is_error: false,
|
|
95
|
+
result: result.text,
|
|
96
|
+
session_id: store.id,
|
|
97
|
+
duration_ms: Date.now() - started,
|
|
98
|
+
num_turns: a.usage.requests,
|
|
99
|
+
usage: { input_tokens: used.input, output_tokens: used.output, cache_read_input_tokens: used.cacheRead, cache_creation_input_tokens: used.cacheWrite },
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
stdout.write(result.text + '\n');
|
|
103
|
+
}
|
|
104
|
+
runtime.tasks.killAll(); runtime.mcp.close();
|
|
105
|
+
return result.interrupted ? 130 : 0;
|
|
106
|
+
}
|