@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 +1 -1
- package/src/api.js +48 -7
- package/src/codex/client.js +7 -0
- package/src/commands.js +46 -10
- package/src/gateway.js +33 -0
- package/src/peers.js +157 -156
- package/src/tools/effort.js +1 -1
- package/src/ui/repl.js +1460 -1450
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanzc/claude-pro",
|
|
3
|
-
"version": "0.4.
|
|
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,
|
|
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))
|
|
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
|
|
248
|
-
// the
|
|
249
|
-
|
|
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;
|
package/src/codex/client.js
CHANGED
|
@@ -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)
|
|
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
|
|
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:
|
|
247
|
-
question: `Current: ${
|
|
282
|
+
title: `Reasoning effort · ${id}`,
|
|
283
|
+
question: `Current: ${current}`,
|
|
248
284
|
options: [
|
|
249
|
-
...
|
|
250
|
-
{ label:
|
|
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' && !
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
this.
|
|
51
|
-
this.
|
|
52
|
-
this.
|
|
53
|
-
this.
|
|
54
|
-
this.
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.server.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
this.
|
|
69
|
-
this.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
this.cwd
|
|
87
|
-
this.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
try { this.
|
|
102
|
-
this.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (req.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
req.
|
|
112
|
-
req.on('
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
res.
|
|
135
|
-
res.on('
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
req.on('
|
|
144
|
-
req.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
}
|
package/src/tools/effort.js
CHANGED
|
@@ -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;
|