@yanzc/claude-pro 0.4.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanzc/claude-pro",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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';
9
+ import { readGatewayLogin, fetchGatewayModels, cachedGatewayModel, gatewayEffort, gatewayDefaultEffort } from './gateway.js';
10
10
  import { createCodexFetch, codexModels } from './codex/client.js';
11
+ import { SIGNATURE_PREFIX } from './codex/translate-request.js';
11
12
 
12
13
  let cached = null;
13
14
 
@@ -194,12 +195,18 @@ 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
+ export function defaultEffort(modelId, config = null) {
200
201
  const id = String(modelId || '');
201
202
  if (!effortSupported(id)) return null;
202
- if (/^gpt-/i.test(id)) return codexModels().models.find((m) => m.slug === id)?.defaultEffort || null;
203
+ if (/^gpt-/i.test(id)) {
204
+ if (config?.usesGateway) {
205
+ const login = readGatewayLogin(config.gatewayUrl);
206
+ return gatewayDefaultEffort(cachedGatewayModel(login, id)) || codexModels(config.env).models.find((m) => m.slug === id)?.defaultEffort || null;
207
+ }
208
+ return codexModels(config?.env).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;
@@ -218,6 +225,26 @@ export function parseMaxTokensLimit(message) {
218
225
  return n >= 256 ? n : null;
219
226
  }
220
227
 
228
+ // Thinking blocks made from Codex reasoning (a GPT model's turns, signed "cgw1:...") replay that reasoning on the
229
+ // Codex backend only: Anthropic refuses their signatures ("Invalid `signature` in `thinking` block"), so a
230
+ // conversation that moves from a GPT model to Claude, or to another provider, sends them without those blocks.
231
+ // The session keeps them, for a later switch back. `inTurn`: blocks were taken out of the turn in progress (the
232
+ // tool loop since the last prompt), whose assistant messages Anthropic wants to start with thinking when thinking
233
+ // is on; such a turn goes on without thinking until the next prompt.
234
+ const isCodexThinking = (b) => b?.type === 'thinking' && typeof b.signature === 'string' && b.signature.startsWith(SIGNATURE_PREFIX);
235
+ const isPrompt = (m) => m.role === 'user' && !(Array.isArray(m.content) && m.content.some((b) => b.type === 'tool_result'));
236
+ function withoutCodexThinking(messages) {
237
+ const prompt = messages.findLastIndex(isPrompt);
238
+ let inTurn = false;
239
+ const out = messages.flatMap((m, i) => {
240
+ if (m.role !== 'assistant' || !Array.isArray(m.content) || !m.content.some(isCodexThinking)) return [m];
241
+ if (i > prompt) inTurn = true;
242
+ const content = m.content.filter((b) => !isCodexThinking(b));
243
+ return content.length ? [{ ...m, content }] : [];
244
+ });
245
+ return { messages: out, inTurn };
246
+ }
247
+
221
248
  // `cacheBucket` says which prompt cache TTL applies: "main" for the conversation itself (the default), "other"
222
249
  // for sub-agents, compaction and side queries (see Config.cacheTtl). `cacheMessages: false` leaves the messages
223
250
  // unmarked: for a request whose messages are never sent twice, writing them to the cache only costs more.
@@ -229,6 +256,10 @@ export function buildParams(config, { model, system, messages, tools, maxTokens,
229
256
  const ttl = refused?.has('ttl') ? '5m' : config.cacheTtl(cacheBucket);
230
257
  const mark = (place) => (caching && !refused?.has(place) ? { ...cacheControl(ttl) } : null);
231
258
  const systemMark = mark('system');
259
+ // The codex provider, and the gateway for a GPT model, take the request to the Codex backend.
260
+ const toCodex = config.usesCodex || /^gpt-/i.test(model.id);
261
+ let turnLostThinking = false;
262
+ if (!toCodex) ({ messages, inTurn: turnLostThinking } = withoutCodexThinking(messages));
232
263
  const params = {
233
264
  model: model.id,
234
265
  max_tokens: outputCaps.has(model.id) ? Math.min(wanted, outputCaps.get(model.id)) : wanted,
@@ -244,13 +275,15 @@ export function buildParams(config, { model, system, messages, tools, maxTokens,
244
275
  const toolMark = mark('tools');
245
276
  params.tools = tools.map((t, i) => (i === tools.length - 1 && toolMark ? { ...t, cache_control: toolMark } : t));
246
277
  }
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);
278
+ // A GPT model with no effort set gets its default from the gateway's model metadata (or the local Codex cache
279
+ // when using the Codex provider), sent explicitly so that the footer and backend use the same level.
280
+ let eff = (effort === undefined ? config.effort : effort) || (/^gpt-/i.test(model.id) ? defaultEffort(model.id, config) : null);
281
+ if (config.usesGateway && /^gpt-/i.test(model.id)) eff = gatewayEffort(cachedGatewayModel(readGatewayLogin(config.gatewayUrl), model.id), eff);
250
282
  if (eff && !noEffort.has(model.id)) params.output_config = { effort: eff };
251
283
  const budget = config.thinkingBudget;
252
284
  if (budget === 0) params.thinking = { type: 'disabled' };
253
285
  else if (budget > 0) params.thinking = { type: 'enabled', budget_tokens: Math.min(budget, params.max_tokens - 1) };
286
+ if (turnLostThinking) params.thinking = { type: 'disabled' };
254
287
  return params;
255
288
  }
256
289
 
@@ -306,6 +339,14 @@ export async function streamMessage(config, request, { signal, onEvent = () => {
306
339
  // A token that has expired (or is about to) is renewed first, as the official client does.
307
340
  if (config.usesClaudeCode) await renewLogin(config, signal);
308
341
  let login = config.usesClaudeCode ? readClaudeCodeLogin(config) : null;
342
+ // Load gateway model metadata before the synchronous parameter builder; the cache is keyed by gateway and key,
343
+ // so changing accounts or gateway URLs cannot leak a model's effort levels into another context.
344
+ if (config.usesGateway && /^gpt-/i.test(request.model?.id)) {
345
+ const gatewayLogin = readGatewayLogin(config.gatewayUrl);
346
+ if (gatewayLogin) {
347
+ try { await fetchGatewayModels(gatewayLogin, { signal }); } catch (e) { if (e.name === 'AbortError' || signal?.aborted) throw e; }
348
+ }
349
+ }
309
350
  let client = getClient(config, login);
310
351
  const params = buildParams(config, request, login);
311
352
  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, gatewayEffort, formatUsd } from './gateway.js';
10
+ import { codexLoginSummary, codexModels, codexEfforts, codexEffort } 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 } 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,43 @@ 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 GPT model does not take goes out as the nearest one it does.
276
+ const sent = cfg.effort && isGpt(id)
277
+ ? modelInfo ? gatewayEffort(modelInfo, cfg.effort) : codexEffort(id, cfg.effort)
278
+ : cfg.effort;
279
+ const current = !cfg.effort ? c.bold(`auto (${fallback ? `model default: ${fallback}` : 'not sent'})`)
280
+ : `${c.bold(cfg.effort)}${sent !== cfg.effort ? c.yellow(` (${id} does not take it: sent as ${sent})`) : ''}`;
245
281
  choice = await app.showDialog(new SelectDialog({
246
- title: 'Reasoning effort',
247
- question: `Current: ${c.bold(cfg.effort || 'auto (not sent)')}`,
282
+ title: `Reasoning effort · ${id}`,
283
+ question: `Current: ${current}`,
248
284
  options: [
249
- ...EFFORTS.map((e) => ({ label: e, value: e })),
250
- { label: 'auto (use API default)', value: 'auto' },
285
+ ...levels.map((e) => ({ label: e, value: e })),
286
+ { label: `auto ${c.dim(fallback ? `(model default: ${fallback})` : '(use API default)')}`, value: 'auto' },
251
287
  { label: `default ${c.dim(`(configured: ${configured})`)}`, value: 'default' },
252
288
  ],
253
289
  }));
254
290
  if (!choice) return;
255
291
  }
256
- if (choice !== 'auto' && choice !== 'default' && !EFFORTS.includes(choice)) { out(c.red(`Unknown effort "${choice}". Use one of: ${EFFORTS.join(', ')}, auto, default`)); return; }
292
+ 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
293
  const forget = choice === 'default';
258
294
  const wanted = (forget ? (cfg.env.CLAUDE_CODE_EFFORT_LEVEL || '') : choice).trim().toLowerCase();
259
295
  const next = wanted && wanted !== 'auto' && wanted !== 'default' ? wanted : null;
package/src/gateway.js CHANGED
@@ -67,6 +67,39 @@ 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
+ const modelLists = new Map();
73
+ export async function fetchGatewayModels({ url, apiKey }, { fetchImpl = globalThis.fetch, signal } = {}) {
74
+ const key = `${normalizeGatewayUrl(url)}\n${apiKey}`;
75
+ if (modelLists.has(key)) return modelLists.get(key);
76
+ const body = await gatewayJson(`${normalizeGatewayUrl(url)}/v1/models`, { headers: { authorization: `Bearer ${apiKey}` }, signal }, fetchImpl);
77
+ const list = (body.data || []).filter((m) => typeof m?.id === 'string').map((m) => ({
78
+ ...m,
79
+ supported_reasoning_levels: (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : Array.isArray(m.reasoning_levels) ? m.reasoning_levels : [])
80
+ .map((r) => typeof r === 'string' ? r : r?.effort || r?.name).filter(Boolean),
81
+ default_reasoning_level: typeof m.default_reasoning_level === 'string' ? m.default_reasoning_level
82
+ : m.default_reasoning_level?.effort || m.default_effort || null,
83
+ }));
84
+ modelLists.set(key, list);
85
+ return list;
86
+ }
87
+
88
+ export function cachedGatewayModel(login, modelId) {
89
+ if (!login?.url || !login?.apiKey) return null;
90
+ return modelLists.get(`${normalizeGatewayUrl(login.url)}\n${login.apiKey}`)?.find((m) => m.id === modelId) || null;
91
+ }
92
+
93
+ const EFFORT_ORDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
94
+ export function gatewayEfforts(model) { return model?.supported_reasoning_levels || []; }
95
+ export function gatewayDefaultEffort(model) { return model?.default_reasoning_level || null; }
96
+ export function gatewayEffort(model, effort) {
97
+ const supported = gatewayEfforts(model);
98
+ if (!effort || !supported.length || supported.includes(effort)) return effort;
99
+ const want = EFFORT_ORDER.indexOf(effort);
100
+ return supported.reduce((best, item) => (Math.abs(EFFORT_ORDER.indexOf(item) - want) < Math.abs(EFFORT_ORDER.indexOf(best) - want) ? item : best), supported[0]);
101
+ }
102
+
70
103
  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
104
 
72
105
  // 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
+ }
@@ -25,7 +25,7 @@ Do not raise it for routine work, and do not switch back and forth: the prompt c
25
25
  async call(input) {
26
26
  const level = String(input.level || '').trim().toLowerCase();
27
27
  if (!EFFORT_LEVELS.includes(level)) return { isError: true, content: `Unknown effort "${input.level}". Use one of: ${EFFORT_LEVELS.join(', ')}.` };
28
- const shown = () => config.effort || defaultEffort(config.mainModel.id) || 'the model default';
28
+ const shown = () => config.effort || defaultEffort(config.mainModel.id, config) || 'the model default';
29
29
  const before = shown();
30
30
  if (level === before) return { content: `The effort is already ${level}.` };
31
31
  config.effortBoost = null;