@yanzc/claude-pro 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanzc/claude-pro",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "claude-pro: a self-implemented coding agent for Anthropic-compatible APIs or local Claude Code subscription login. Full access with no permission prompts; file edits are checkpointed and can be rewound.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/api.js CHANGED
@@ -6,8 +6,9 @@ import { readClaudeCodeLogin, claudeCodeHeaders, CLAUDE_CODE_API_URL, CLAUDE_COD
6
6
  import { refreshClaudeCodeLogin, canRenewLogin } from './oauth.js';
7
7
  import { humanDuration, parseRateLimit, parseRetryAfter, planRetry, sleepUntil, timing } from './ratelimit.js';
8
8
  import { cacheControl, cachePlanOf, fingerprint } from './cache.js';
9
- import { readGatewayLogin } from './gateway.js';
10
- import { createCodexFetch, codexModels } from './codex/client.js';
9
+ import { readGatewayLogin, fetchGatewayModels, cachedGatewayModel, gatewayEffort, gatewayDefaultEffort } from './gateway.js';
10
+ import { createCodexFetch, codexModels, codexEffort } from './codex/client.js';
11
+ import { SIGNATURE_PREFIX } from './codex/translate-request.js';
11
12
 
12
13
  let cached = null;
13
14
 
@@ -194,17 +195,33 @@ const noEffort = new Set();
194
195
  export const effortSupported = (modelId) => !noEffort.has(modelId);
195
196
 
196
197
  // The effort a model uses when none is sent (docs.claude.com, "Effort"): medium on Opus 5.5 and Haiku 5.5, high on
197
- // the other models that take one; for a GPT model, its default in the local Codex install's models cache. null for
198
+ // the other models that take one; for a GPT model, the gateway's metadata or the local Codex models cache. null for
198
199
  // a model that takes none or is not known.
199
- export function defaultEffort(modelId) {
200
+ // The environment that says where the Codex install is: CODEX_HOME may be set in the shell only.
201
+ const codexEnv = (config) => ({ ...process.env, ...(config?.env || {}) });
202
+ const gatewayModelInfo = (config, id) => (config?.usesGateway ? cachedGatewayModel(readGatewayLogin(config.gatewayUrl), id) : null);
203
+
204
+ export function defaultEffort(modelId, config = null) {
200
205
  const id = String(modelId || '');
201
206
  if (!effortSupported(id)) return null;
202
- if (/^gpt-/i.test(id)) return codexModels().models.find((m) => m.slug === id)?.defaultEffort || null;
207
+ if (/^gpt-/i.test(id)) {
208
+ return gatewayDefaultEffort(gatewayModelInfo(config, id)) || codexModels(codexEnv(config)).models.find((m) => m.slug === id)?.defaultEffort || null;
209
+ }
203
210
  if (/^claude-(?:opus|haiku)-5-5(?:-|$)/i.test(id)) return 'medium';
204
211
  if (/^claude-(?:fable|mythos|opus-(?:5|4-[5-8])|sonnet-(?:5|4-6))(?:-|$)/i.test(id)) return 'high';
205
212
  return null;
206
213
  }
207
214
 
215
+ // The level a request for this model carries when `effort` is chosen: the nearest one the model takes. A GPT model
216
+ // lists its own (the gateway's /v1/models, else the local Codex models cache). Claude has no "ultra", the level
217
+ // above max that some GPT models take, which stays chosen when the user switches from such a model to Claude.
218
+ export function effortToSend(config, modelId, effort) {
219
+ if (!effort) return effort;
220
+ if (!/^gpt-/i.test(modelId)) return effort === 'ultra' ? 'max' : effort;
221
+ const info = gatewayModelInfo(config, modelId);
222
+ return info ? gatewayEffort(info, effort) : codexEffort(modelId, effort, codexEnv(config));
223
+ }
224
+
208
225
  // Pull the allowed maximum out of an error such as
209
226
  // "max_tokens: 128000 > 64000, which is the maximum allowed number of output tokens for claude-..."
210
227
  // "Invalid max_tokens value, the valid range of max_tokens is [1, 8192]"
@@ -218,6 +235,26 @@ export function parseMaxTokensLimit(message) {
218
235
  return n >= 256 ? n : null;
219
236
  }
220
237
 
238
+ // Thinking blocks made from Codex reasoning (a GPT model's turns, signed "cgw1:...") replay that reasoning on the
239
+ // Codex backend only: Anthropic refuses their signatures ("Invalid `signature` in `thinking` block"), so a
240
+ // conversation that moves from a GPT model to Claude, or to another provider, sends them without those blocks.
241
+ // The session keeps them, for a later switch back. `inTurn`: blocks were taken out of the turn in progress (the
242
+ // tool loop since the last prompt), whose assistant messages Anthropic wants to start with thinking when thinking
243
+ // is on; such a turn goes on without thinking until the next prompt.
244
+ const isCodexThinking = (b) => b?.type === 'thinking' && typeof b.signature === 'string' && b.signature.startsWith(SIGNATURE_PREFIX);
245
+ const isPrompt = (m) => m.role === 'user' && !(Array.isArray(m.content) && m.content.some((b) => b.type === 'tool_result'));
246
+ function withoutCodexThinking(messages) {
247
+ const prompt = messages.findLastIndex(isPrompt);
248
+ let inTurn = false;
249
+ const out = messages.flatMap((m, i) => {
250
+ if (m.role !== 'assistant' || !Array.isArray(m.content) || !m.content.some(isCodexThinking)) return [m];
251
+ if (i > prompt) inTurn = true;
252
+ const content = m.content.filter((b) => !isCodexThinking(b));
253
+ return content.length ? [{ ...m, content }] : [];
254
+ });
255
+ return { messages: out, inTurn };
256
+ }
257
+
221
258
  // `cacheBucket` says which prompt cache TTL applies: "main" for the conversation itself (the default), "other"
222
259
  // for sub-agents, compaction and side queries (see Config.cacheTtl). `cacheMessages: false` leaves the messages
223
260
  // unmarked: for a request whose messages are never sent twice, writing them to the cache only costs more.
@@ -229,6 +266,10 @@ export function buildParams(config, { model, system, messages, tools, maxTokens,
229
266
  const ttl = refused?.has('ttl') ? '5m' : config.cacheTtl(cacheBucket);
230
267
  const mark = (place) => (caching && !refused?.has(place) ? { ...cacheControl(ttl) } : null);
231
268
  const systemMark = mark('system');
269
+ // The codex provider, and the gateway for a GPT model, take the request to the Codex backend.
270
+ const toCodex = config.usesCodex || /^gpt-/i.test(model.id);
271
+ let turnLostThinking = false;
272
+ if (!toCodex) ({ messages, inTurn: turnLostThinking } = withoutCodexThinking(messages));
232
273
  const params = {
233
274
  model: model.id,
234
275
  max_tokens: outputCaps.has(model.id) ? Math.min(wanted, outputCaps.get(model.id)) : wanted,
@@ -244,13 +285,14 @@ export function buildParams(config, { model, system, messages, tools, maxTokens,
244
285
  const toolMark = mark('tools');
245
286
  params.tools = tools.map((t, i) => (i === tools.length - 1 && toolMark ? { ...t, cache_control: toolMark } : t));
246
287
  }
247
- // A GPT model with no effort set gets its default from the local Codex models cache, sent explicitly, so that
248
- // the gateway (whose own default is its Codex config) uses what the footer shows.
249
- const eff = (effort === undefined ? config.effort : effort) || (/^gpt-/i.test(model.id) ? defaultEffort(model.id) : null);
288
+ // A GPT model with no effort set gets its default from the gateway's model metadata (or the local Codex cache
289
+ // when using the Codex provider), sent explicitly so that the footer and backend use the same level.
290
+ const eff = effortToSend(config, model.id, (effort === undefined ? config.effort : effort) || (/^gpt-/i.test(model.id) ? defaultEffort(model.id, config) : null));
250
291
  if (eff && !noEffort.has(model.id)) params.output_config = { effort: eff };
251
292
  const budget = config.thinkingBudget;
252
293
  if (budget === 0) params.thinking = { type: 'disabled' };
253
294
  else if (budget > 0) params.thinking = { type: 'enabled', budget_tokens: Math.min(budget, params.max_tokens - 1) };
295
+ if (turnLostThinking) params.thinking = { type: 'disabled' };
254
296
  return params;
255
297
  }
256
298
 
@@ -306,6 +348,15 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
306
348
  // A token that has expired (or is about to) is renewed first, as the official client does.
307
349
  if (config.usesClaudeCode) await renewLogin(config, signal);
308
350
  let login = config.usesClaudeCode ? readClaudeCodeLogin(config) : null;
351
+ // Load gateway model metadata before the synchronous parameter builder; the cache is keyed by gateway and key,
352
+ // so changing accounts or gateway URLs cannot leak a model's effort levels into another context.
353
+ if (config.usesGateway && /^gpt-/i.test(request.model?.id)) {
354
+ const gatewayLogin = readGatewayLogin(config.gatewayUrl);
355
+ if (gatewayLogin) {
356
+ // A gateway that does not answer costs at most a few seconds once (see fetchGatewayModels), not the request.
357
+ try { await fetchGatewayModels(gatewayLogin, { signal }); } catch { if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] }); }
358
+ }
359
+ }
309
360
  let client = getClient(config, login);
310
361
  const params = buildParams(config, request, login);
311
362
  let authReloaded = false;
@@ -174,6 +174,13 @@ function clampEffort(effort, supported) {
174
174
  return supported.reduce((best, s) => (Math.abs(EFFORTS.indexOf(s) - want) < Math.abs(EFFORTS.indexOf(best) - want) ? s : best), supported[0]);
175
175
  }
176
176
 
177
+ // The effort levels a GPT model takes, from the local Codex models cache ([] when it is not listed there):
178
+ // gpt-6.1-sol takes low … ultra, but not none or minimal.
179
+ export const codexEfforts = (modelId, env = process.env) => codexModels(env).models.find((m) => m.slug === modelId)?.efforts || [];
180
+
181
+ // The level the Codex backend gets for a chosen one: the nearest the model takes.
182
+ export const codexEffort = (modelId, effort, env = process.env) => clampEffort(effort, codexEfforts(modelId, env));
183
+
177
184
  // An Anthropic-shaped error response, which the SDK turns into an APIError with this status.
178
185
  function errorResponse(status, type, message, headers = {}) {
179
186
  return new Response(JSON.stringify({ type: 'error', error: { type, message } }), { status, headers: { 'content-type': 'application/json', ...headers } });
package/src/commands.js CHANGED
@@ -6,8 +6,8 @@ import { c, padEnd, truncate } from './ui/ansi.js';
6
6
  import { SelectDialog } from './ui/dialogs.js';
7
7
  import { renderTodos, resultLines, formatTokens, formatDuration } from './ui/render.js';
8
8
  import { loadConfig, settingsFiles, updateSettingsFile, rememberChoice, applyRememberedChoices, KNOWN_KEYS, VERSION, APP_NAME, CONFIG_DIR } from './config.js';
9
- import { loginWithBrowser, readGatewayLogin, clearGatewayLogin, fetchGatewayAccount, formatUsd } from './gateway.js';
10
- import { codexLoginSummary, codexModels } from './codex/client.js';
9
+ import { loginWithBrowser, readGatewayLogin, clearGatewayLogin, fetchGatewayAccount, fetchGatewayModels, cachedGatewayModel, gatewayEfforts, formatUsd } from './gateway.js';
10
+ import { codexLoginSummary, codexModels, codexEfforts } from './codex/client.js';
11
11
  import { readClaudeCodeLogin } from './claude-code.js';
12
12
  import { SessionStore } from './session.js';
13
13
  import { memoryFiles, listAutoMemories } from './context.js';
@@ -15,13 +15,20 @@ import { INIT_PROMPT } from './prompts.js';
15
15
  import { loadCustomCommands, renderSkill, expandCommandBody, expandShellEmbeds } from './agents.js';
16
16
  import { listBackgroundShells } from './tools/bash.js';
17
17
  import { estimateTokens } from './agent.js';
18
- import { quickQuery, cacheRefusals } from './api.js';
18
+ import { quickQuery, cacheRefusals, defaultEffort, effortToSend } from './api.js';
19
19
  import { hasRipgrep } from './utils/shell.js';
20
20
  import { displayPath } from './utils/paths.js';
21
21
  import { rewind } from './checkpoints.js';
22
22
  import { canRestart, runSelfCheck } from './restart.js';
23
23
 
24
24
  const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
25
+ const isGpt = (modelId) => /^gpt-/i.test(modelId);
26
+ // The levels /effort offers for a model: gateway metadata first, then the local Codex cache, else EFFORTS.
27
+ const effortLevels = (modelId, modelInfo = null) => {
28
+ const remote = gatewayEfforts(modelInfo);
29
+ if (remote.length) return remote;
30
+ return isGpt(modelId) && codexEfforts(modelId).length ? codexEfforts(modelId) : EFFORTS;
31
+ };
25
32
 
26
33
  export function buildCommands(app) {
27
34
  const commands = new Map();
@@ -217,7 +224,18 @@ export function buildCommands(app) {
217
224
  } else {
218
225
  opts.push(...['opus', 'sonnet', 'haiku'].map((a) => ({ label: `${a} ${c.dim(`→ ${cfg.resolveModel(cfg.aliasTarget(a)).label}`)}`, value: a })));
219
226
  }
220
- if (cfg.usesGateway) opts.push({ label: `gpt-6.1-sol ${c.dim('(OpenAI, through the gateway\'s Codex login)')}`, value: 'gpt-6.1-sol' });
227
+ if (cfg.usesGateway) {
228
+ // The other models the gateway serves (Fable, the GPT models, …); without an answer, gpt-6.1-sol.
229
+ const login = readGatewayLogin(cfg.gatewayUrl);
230
+ let served = null;
231
+ try { if (login) served = await fetchGatewayModels(login, { signal: AbortSignal.timeout(4000) }); } catch { /* offline: the fallback below */ }
232
+ const aliased = new Set(['opus', 'sonnet', 'haiku'].map((a) => cfg.resolveModel(cfg.aliasTarget(a)).id));
233
+ for (const m of served?.length ? served : [{ id: 'gpt-6.1-sol' }]) {
234
+ if (aliased.has(m.id)) continue;
235
+ const note = /^gpt-/i.test(m.id) ? 'OpenAI, through the gateway\'s Codex login' : m.display_name;
236
+ opts.push({ label: `${m.id}${note ? ` ${c.dim(`(${note})`)}` : ''}`, value: m.id });
237
+ }
238
+ }
221
239
  if (cfg.provider === 'api' && cfg.env.CLAUDE_CODE_SUBAGENT_MODEL) opts.push({ label: `${cfg.env.CLAUDE_CODE_SUBAGENT_MODEL} ${c.dim('(sub-agent model)')}`, value: cfg.env.CLAUDE_CODE_SUBAGENT_MODEL });
222
240
  choice = await app.showDialog(new SelectDialog({ title: 'Select model', question: `Current: ${c.bold(cfg.mainModel.label)}. Any name works too: /model <name>`, options: opts }));
223
241
  if (!choice) return;
@@ -235,25 +253,41 @@ export function buildCommands(app) {
235
253
 
236
254
  add({
237
255
  name: 'effort',
238
- argHint: `[${EFFORTS.join('|')}|auto|default]`,
256
+ get argHint() {
257
+ const cfg = app.runtime.config;
258
+ const login = cfg.usesGateway ? readGatewayLogin(cfg.gatewayUrl) : null;
259
+ return `[${effortLevels(cfg.mainModel.id, cachedGatewayModel(login, cfg.mainModel.id)).join('|')}|auto|default]`;
260
+ },
239
261
  description: 'Set reasoning effort (output_config.effort; kept for later sessions)',
240
262
  async run(args) {
241
263
  const cfg = app.runtime.config;
264
+ const id = cfg.mainModel.id;
265
+ let modelInfo = null;
266
+ if (cfg.usesGateway && isGpt(id)) {
267
+ const login = readGatewayLogin(cfg.gatewayUrl);
268
+ try { if (login) modelInfo = (await fetchGatewayModels(login, { signal: AbortSignal.timeout(4000) })).find((m) => m.id === id) || null; } catch { /* use local cache or generic levels */ }
269
+ }
270
+ const levels = effortLevels(id, modelInfo);
242
271
  let choice = args.trim().toLowerCase();
243
272
  if (!choice) {
244
273
  const configured = (cfg.env.CLAUDE_CODE_EFFORT_LEVEL || '').trim().toLowerCase() || 'auto';
274
+ const fallback = defaultEffort(id, cfg);
275
+ // A level this model does not take goes out as the nearest one it does.
276
+ const sent = effortToSend(cfg, id, cfg.effort);
277
+ const current = !cfg.effort ? c.bold(`auto (${fallback ? `model default: ${fallback}` : 'not sent'})`)
278
+ : `${c.bold(cfg.effort)}${sent !== cfg.effort ? c.yellow(` (${id} does not take it: sent as ${sent})`) : ''}`;
245
279
  choice = await app.showDialog(new SelectDialog({
246
- title: 'Reasoning effort',
247
- question: `Current: ${c.bold(cfg.effort || 'auto (not sent)')}`,
280
+ title: `Reasoning effort · ${id}`,
281
+ question: `Current: ${current}`,
248
282
  options: [
249
- ...EFFORTS.map((e) => ({ label: e, value: e })),
250
- { label: 'auto (use API default)', value: 'auto' },
283
+ ...levels.map((e) => ({ label: e, value: e })),
284
+ { label: `auto ${c.dim(fallback ? `(model default: ${fallback})` : '(use API default)')}`, value: 'auto' },
251
285
  { label: `default ${c.dim(`(configured: ${configured})`)}`, value: 'default' },
252
286
  ],
253
287
  }));
254
288
  if (!choice) return;
255
289
  }
256
- if (choice !== 'auto' && choice !== 'default' && !EFFORTS.includes(choice)) { out(c.red(`Unknown effort "${choice}". Use one of: ${EFFORTS.join(', ')}, auto, default`)); return; }
290
+ if (choice !== 'auto' && choice !== 'default' && !levels.includes(choice)) { out(c.red(`${id} does not take effort "${choice}". Use one of: ${levels.join(', ')}, auto, default`)); return; }
257
291
  const forget = choice === 'default';
258
292
  const wanted = (forget ? (cfg.env.CLAUDE_CODE_EFFORT_LEVEL || '') : choice).trim().toLowerCase();
259
293
  const next = wanted && wanted !== 'auto' && wanted !== 'default' ? wanted : null;
package/src/gateway.js CHANGED
@@ -67,6 +67,54 @@ export async function fetchGatewayAccount({ url, apiKey }, { fetchImpl = globalT
67
67
  return body.account;
68
68
  }
69
69
 
70
+ // The models the gateway serves (GET /v1/models), fetched once per gateway and key. The reasoning fields are
71
+ // optional so older gateways still work; when present they are kept with the model for /effort and requests.
72
+ // Every GPT request waits for it the first time, so it gives up after a few seconds, and after a failure it is not
73
+ // asked again for a minute: a gateway whose /v1/models fails must not slow down every request.
74
+ const modelLists = new Map();
75
+ const modelListFailures = new Map(); // key -> {at, error}
76
+ const MODELS_TIMEOUT_MS = 5000;
77
+ const MODELS_RETRY_MS = 60_000;
78
+ export async function fetchGatewayModels({ url, apiKey }, { fetchImpl = globalThis.fetch, signal, timeoutMs = MODELS_TIMEOUT_MS } = {}) {
79
+ const key = `${normalizeGatewayUrl(url)}\n${apiKey}`;
80
+ if (modelLists.has(key)) return modelLists.get(key);
81
+ const failed = modelListFailures.get(key);
82
+ if (failed && Date.now() - failed.at < MODELS_RETRY_MS) throw failed.error;
83
+ const timeout = AbortSignal.timeout(timeoutMs);
84
+ let body;
85
+ try {
86
+ body = await gatewayJson(`${normalizeGatewayUrl(url)}/v1/models`, { headers: { authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, timeout]) : timeout }, fetchImpl);
87
+ } catch (e) {
88
+ if (!signal?.aborted) modelListFailures.set(key, { at: Date.now(), error: e });
89
+ throw e;
90
+ }
91
+ modelListFailures.delete(key);
92
+ const list = (body.data || []).filter((m) => typeof m?.id === 'string').map((m) => ({
93
+ ...m,
94
+ supported_reasoning_levels: (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : Array.isArray(m.reasoning_levels) ? m.reasoning_levels : [])
95
+ .map((r) => typeof r === 'string' ? r : r?.effort || r?.name).filter(Boolean),
96
+ default_reasoning_level: typeof m.default_reasoning_level === 'string' ? m.default_reasoning_level
97
+ : m.default_reasoning_level?.effort || m.default_effort || null,
98
+ }));
99
+ modelLists.set(key, list);
100
+ return list;
101
+ }
102
+
103
+ export function cachedGatewayModel(login, modelId) {
104
+ if (!login?.url || !login?.apiKey) return null;
105
+ return modelLists.get(`${normalizeGatewayUrl(login.url)}\n${login.apiKey}`)?.find((m) => m.id === modelId) || null;
106
+ }
107
+
108
+ const EFFORT_ORDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
109
+ export function gatewayEfforts(model) { return model?.supported_reasoning_levels || []; }
110
+ export function gatewayDefaultEffort(model) { return model?.default_reasoning_level || null; }
111
+ export function gatewayEffort(model, effort) {
112
+ const supported = gatewayEfforts(model);
113
+ if (!effort || !supported.length || supported.includes(effort)) return effort;
114
+ const want = EFFORT_ORDER.indexOf(effort);
115
+ return supported.reduce((best, item) => (Math.abs(EFFORT_ORDER.indexOf(item) - want) < Math.abs(EFFORT_ORDER.indexOf(best) - want) ? item : best), supported[0]);
116
+ }
117
+
70
118
  const PAGE = (title, text) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;line-height:1.6"><h2>${title}</h2><p>${text}</p></body>`;
71
119
 
72
120
  // Log in through the browser. `onUrl(url)` is told the login page's address (to show it, for when the browser
package/src/peers.js CHANGED
@@ -1,156 +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
- constructor({ cwd, name, onMessage }) {
49
- this.cwd = cwd;
50
- this.wantedName = name || null;
51
- this.onMessage = onMessage;
52
- this.id = crypto.randomBytes(6).toString('hex');
53
- this.token = crypto.randomBytes(16).toString('hex');
54
- this.name = null;
55
- this.server = null;
56
- this.port = null;
57
- }
58
-
59
- get file() { return path.join(PEERS_DIR, `${this.id}.json`); }
60
-
61
- async start() {
62
- this.server = http.createServer((req, res) => this.handle(req, res));
63
- await new Promise((resolve, reject) => {
64
- this.server.once('error', reject);
65
- this.server.listen(0, '127.0.0.1', resolve);
66
- });
67
- this.server.unref();
68
- this.port = this.server.address().port;
69
- this.setName(this.wantedName || path.basename(this.cwd) || 'agent');
70
- return this;
71
- }
72
-
73
- // Take `name`, or name-2, name-3... when another live session has it. Returns the name taken.
74
- setName(name) {
75
- const base = cleanName(name);
76
- const taken = new Set(listPeers({ excludeId: this.id }).map((p) => p.name.toLowerCase()));
77
- let n = base;
78
- for (let i = 2; taken.has(n.toLowerCase()); i++) n = `${base}-${i}`;
79
- this.name = n;
80
- this.write();
81
- return n;
82
- }
83
-
84
- setCwd(cwd) {
85
- if (cwd === this.cwd) return;
86
- this.cwd = cwd;
87
- this.write();
88
- }
89
-
90
- write() {
91
- if (!this.port) return;
92
- fs.mkdirSync(PEERS_DIR, { recursive: true });
93
- 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() };
94
- fs.writeFileSync(this.file, JSON.stringify(data));
95
- }
96
-
97
- peers() { return listPeers({ excludeId: this.id }); }
98
-
99
- close() {
100
- try { fs.unlinkSync(this.file); } catch { /* ignore */ }
101
- try { this.server?.close(); } catch { /* ignore */ }
102
- this.port = null;
103
- }
104
-
105
- handle(req, res) {
106
- const reply = (code, body) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
107
- if (req.method !== 'POST' || req.url !== '/message') return reply(404, { error: 'not found' });
108
- if (req.headers.authorization !== `Bearer ${this.token}`) return reply(401, { error: 'bad token' });
109
- let body = '';
110
- req.setEncoding('utf8');
111
- req.on('data', (d) => { body += d; if (body.length > MAX_BODY) req.destroy(); });
112
- req.on('end', () => {
113
- let msg;
114
- try { msg = JSON.parse(body); } catch { return reply(400, { error: 'bad json' }); }
115
- if (typeof msg?.text !== 'string' || !msg.from?.name) return reply(400, { error: 'text and from are required' });
116
- const id = crypto.randomBytes(4).toString('hex');
117
- reply(200, { ok: true, id, name: this.name });
118
- 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 */ }
119
- });
120
- }
121
-
122
- // Send to a peer (an entry of peers(), or its name). Resolves with {id} (the id the receiver gave the message).
123
- async send(target, { text, kind = 'task', inReplyTo = null }) {
124
- const peer = typeof target === 'string' ? findPeer(this.peers(), target) : target;
125
- if (!peer) throw new Error(`No running session called "${target}". /peers lists them.`);
126
- const body = JSON.stringify({ kind, text, inReplyTo, from: { id: this.id, name: this.name, cwd: this.cwd } });
127
- return await new Promise((resolve, reject) => {
128
- const req = http.request({
129
- host: '127.0.0.1', port: peer.port, path: '/message', method: 'POST', timeout: 10_000,
130
- headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), authorization: `Bearer ${peer.token}` },
131
- }, (res) => {
132
- let out = '';
133
- res.setEncoding('utf8');
134
- res.on('data', (d) => { out += d; });
135
- res.on('end', () => {
136
- let data = {};
137
- try { data = JSON.parse(out); } catch { /* ignore */ }
138
- if (res.statusCode !== 200) reject(new Error(`${peer.name} refused the message: ${data.error || res.statusCode}`));
139
- else resolve({ id: data.id, peer });
140
- });
141
- });
142
- req.on('timeout', () => req.destroy(new Error(`${peer.name} did not answer`)));
143
- req.on('error', (e) => reject(new Error(`Could not reach ${peer.name}: ${e.message}`)));
144
- req.end(body);
145
- });
146
- }
147
- }
148
-
149
- // What the model is given for a message from another session.
150
- export function peerMessagePrompt(msg) {
151
- const from = `${msg.from.name}${msg.from.cwd ? ` (working in ${msg.from.cwd})` : ''}`;
152
- if (msg.kind === 'reply') {
153
- 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>`;
154
- }
155
- 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>`;
156
- }
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
+ }