@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 +1 -1
- package/src/api.js +59 -8
- package/src/codex/client.js +7 -0
- package/src/commands.js +44 -10
- package/src/gateway.js +48 -0
- package/src/peers.js +157 -156
- package/src/tools/effort.js +1 -1
- package/src/tools/peers.js +3 -1
- package/src/ui/repl.js +1459 -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.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,
|
|
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
|
-
|
|
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))
|
|
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
|
|
248
|
-
// the
|
|
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;
|
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, 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)
|
|
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
|
|
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:
|
|
247
|
-
question: `Current: ${
|
|
280
|
+
title: `Reasoning effort · ${id}`,
|
|
281
|
+
question: `Current: ${current}`,
|
|
248
282
|
options: [
|
|
249
|
-
...
|
|
250
|
-
{ label:
|
|
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' && !
|
|
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
|
-
|
|
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
|
+
}
|