feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
AI Proxy (was AI Router): - server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/ - dashboard_runs.router renamed in place to proxy: an older build on the same DB fails on that column, so dev and prod sharing one DB update together - a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages); Test probes Claude /v1/messages and Codex /v1/responses - Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker Updates: - every tab reloads onto the new UI build when its WebSocket reconnects to a server of another version (lib/newBuildReload.ts, once per version) - Update now stops waiting on a plugin chain that failed or pulled nothing (the old server never goes down), and reloads as soon as a new version answers Also: - a pane whose command exits at once says so (EPANEEXITED) instead of "Command failed: tmux set-option … mouse on" - the pane drag preview draws where the pane actually lands
This commit is contained in:
@@ -1,30 +1,35 @@
|
||||
/**
|
||||
* @file ai-routers.js
|
||||
* @description Named AI Router profiles — an endpoint (9Router, OpenRouter, a
|
||||
* @file ai-proxies.js
|
||||
* @description Named AI Proxy profiles — an endpoint (9Router, OpenRouter, a
|
||||
* self-hosted gateway) plus the model names to use against it, saved once and
|
||||
* attached to a lane when that lane is created.
|
||||
* chosen by a new session (`proxy-apply.js`).
|
||||
*
|
||||
* A profile is exactly what the two CLIs need to talk to a router and nothing
|
||||
* A profile is exactly what the two CLIs need to talk to a proxy and nothing
|
||||
* more: `baseUrl`, `apiKey`, a default `model`, and one slot per tier mirroring
|
||||
* the env keys Claude Code reads. Per-tier slots because a router may expose a
|
||||
* the env keys Claude Code reads. Per-tier slots because a proxy may expose a
|
||||
* different id per tier — and the `model` field fills every one of them,
|
||||
* because the common case is a single symbolic name for everything.
|
||||
*
|
||||
* Profiles are stored one JSON file per profile under
|
||||
* `<CLAUDE_HOME>/agenthub-routers/` (dir 0700, files 0600), the same shape and
|
||||
* the same reasoning as `cc-profiles.js`: they hold a live API key, so **no
|
||||
* route ever returns key material** — `listRouters` reports `hasKey` and
|
||||
* nothing else about it.
|
||||
* `baseUrl` is the proxy's ROOT, without `/v1`: Claude Code appends
|
||||
* `/v1/messages` to it, and the Codex layer appends `/v1` itself. A `/v1`
|
||||
* typed (or stored by an older build) is dropped by `normalizeBaseUrl`.
|
||||
*
|
||||
* `native` is a built-in profile rather than a branch in the caller: it carries
|
||||
* empty connection fields and the vendor's own model names, so "switch back to
|
||||
* native" is the same single write path as "switch to a router". It cannot be
|
||||
* Profiles are stored one JSON file per profile under
|
||||
* `<CLAUDE_HOME>/agenthub-proxies/` (dir 0700, files 0600; the pre-rename
|
||||
* `agenthub-routers/` is moved there on first read, see `proxiesDir`), the same
|
||||
* shape and the same reasoning as `cc-profiles.js`: they hold a live API key,
|
||||
* so **no route ever returns key material** — `listProxies` reports `hasKey`
|
||||
* and nothing else about it.
|
||||
*
|
||||
* `native` is a built-in profile rather than a branch in the caller: it has no
|
||||
* endpoint and no model of its own (the session's model picker decides), so
|
||||
* "use the vendor's own API" is a choice like any profile. It cannot be
|
||||
* edited, removed, or shadowed by a saved profile.
|
||||
*
|
||||
* `testRouter` is the one thing here that talks to the network: the smallest
|
||||
* real request a claude lane would make, so "saved" and "actually works" stop
|
||||
* being the same claim. It is deliberately NOT a generic proxy — see the note
|
||||
* on its stored-key fallback.
|
||||
* `testProxy` is the one thing here that talks to the network: the smallest
|
||||
* real request each CLI would make, so "saved" and "actually works" stop
|
||||
* being the same claim. It is deliberately NOT a generic forwarder — see the
|
||||
* note on its stored-key fallback.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -50,10 +55,8 @@ const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
const BUILTIN_NAME = "native";
|
||||
|
||||
/**
|
||||
* The vendor's own defaults, used when a lane is set back to native. Empty
|
||||
* connection fields rather than absent ones: the native reference settings.json
|
||||
* sets both to "", and an absent key would let a user-level router base URL
|
||||
* leak into a lane that asked for native.
|
||||
* The vendor's own API. Never written anywhere (`resolveProxy` answers null for
|
||||
* it), so it carries no model ids — the session's model picker picks.
|
||||
*/
|
||||
const NATIVE = Object.freeze({
|
||||
name: BUILTIN_NAME,
|
||||
@@ -61,14 +64,8 @@ const NATIVE = Object.freeze({
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "claude-sonnet-5",
|
||||
models: Object.freeze({
|
||||
opus: "claude-opus-5",
|
||||
sonnet: "claude-sonnet-5",
|
||||
haiku: "claude-haiku-4-5-20251001",
|
||||
smallFast: "claude-haiku-4-5-20251001",
|
||||
subagent: "claude-haiku-4-5-20251001",
|
||||
}),
|
||||
model: "",
|
||||
models: Object.freeze({}),
|
||||
});
|
||||
|
||||
function err(code, message) {
|
||||
@@ -77,15 +74,34 @@ function err(code, message) {
|
||||
return e;
|
||||
}
|
||||
|
||||
function routersDir() {
|
||||
return path.join(getClaudeHome(), "agenthub-routers");
|
||||
/**
|
||||
* The profile store. The first read after the rename moves the old
|
||||
* `agenthub-routers/` here and leaves a symlink at the old name, so an older
|
||||
* build on the same CLAUDE_HOME still finds its profiles.
|
||||
*/
|
||||
function proxiesDir() {
|
||||
const dir = path.join(getClaudeHome(), "agenthub-proxies");
|
||||
if (fs.existsSync(dir)) return dir;
|
||||
const legacy = path.join(getClaudeHome(), "agenthub-routers");
|
||||
try {
|
||||
if (fs.lstatSync(legacy).isSymbolicLink()) return dir;
|
||||
fs.renameSync(legacy, dir);
|
||||
} catch {
|
||||
return dir; // no legacy store (or it could not be moved — read it next time)
|
||||
}
|
||||
try {
|
||||
fs.symlinkSync("agenthub-proxies", legacy, "dir");
|
||||
} catch {
|
||||
/* best effort — the profiles themselves are already in place */
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function routerFile(name) {
|
||||
function proxyFile(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||
}
|
||||
return path.join(routersDir(), `${name}.json`);
|
||||
return path.join(proxiesDir(), `${name}.json`);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
@@ -119,7 +135,7 @@ function slugify(text) {
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
return NAME_RE.test(base) ? base : "router";
|
||||
return NAME_RE.test(base) ? base : "proxy";
|
||||
}
|
||||
|
||||
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
||||
@@ -127,7 +143,7 @@ function freeName(base) {
|
||||
let name = base;
|
||||
for (
|
||||
let n = 2;
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(routersDir(), `${name}.json`));
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(proxiesDir(), `${name}.json`));
|
||||
) {
|
||||
name = `${base}-${n}`;
|
||||
n += 1;
|
||||
@@ -147,9 +163,10 @@ function normalizeLabel(label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A router endpoint is an http(s) URL and nothing else. A `file:` or
|
||||
* `javascript:` base URL is never a router — it is a mistake, or an attempt to
|
||||
* point a CLI somewhere it should not go.
|
||||
* A proxy endpoint is an http(s) URL and nothing else. A `file:` or
|
||||
* `javascript:` base URL is never a proxy — it is a mistake, or an attempt to
|
||||
* point a CLI somewhere it should not go. A trailing `/v1` is dropped: the
|
||||
* root is stored, and each CLI's path is added where it is used.
|
||||
*/
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
const raw = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
||||
@@ -163,10 +180,10 @@ function normalizeBaseUrl(baseUrl) {
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw err("EBADURL", "baseUrl must be http:// or https://");
|
||||
}
|
||||
return raw.replace(/\/+$/, "");
|
||||
return raw.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
}
|
||||
|
||||
/** A model name is free text — a router names its own models — but not blank. */
|
||||
/** A model name is free text — a proxy names its own models — but not blank. */
|
||||
function normalizeModel(model) {
|
||||
const clean = typeof model === "string" ? model.trim() : "";
|
||||
if (!clean || clean.length > 200 || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||
@@ -190,10 +207,10 @@ function normalizeModels(model, models) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A known Claude model id that Claude Code should treat the router's own model
|
||||
* A known Claude model id that Claude Code should treat the proxy's own model
|
||||
* names as (its `modelPicker` `behavesAs`, issue #56). Optional; `""` clears it.
|
||||
* The user picks it, never AgentHub: it also sets the context window Claude
|
||||
* Code compacts at, and a router may route a name to a non-Claude model.
|
||||
* Code compacts at, and a proxy may route a name to a non-Claude model.
|
||||
*/
|
||||
function normalizeBehavesAs(value) {
|
||||
const clean = typeof value === "string" ? value.trim() : null;
|
||||
@@ -210,10 +227,17 @@ function assertNotBuiltin(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one stored profile (key included), or null when it does not exist. */
|
||||
function readRouter(name) {
|
||||
const stored = readJson(routerFile(name));
|
||||
return stored && typeof stored === "object" ? stored : null;
|
||||
/**
|
||||
* Read one stored profile (key included), or null when it does not exist. A
|
||||
* base URL an older build stored with `/v1` comes back as the root.
|
||||
*/
|
||||
function readProxy(name) {
|
||||
const stored = readJson(proxyFile(name));
|
||||
if (!stored || typeof stored !== "object") return null;
|
||||
if (typeof stored.baseUrl === "string") {
|
||||
stored.baseUrl = stored.baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** The browser-safe view: everything except the key, plus whether one is set. */
|
||||
@@ -226,11 +250,11 @@ function publicView(profile) {
|
||||
* Every saved profile, with the built-in native one first.
|
||||
* @returns `{ items }` — key material is never included.
|
||||
*/
|
||||
function listRouters() {
|
||||
function listProxies() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(routersDir())
|
||||
.readdirSync(proxiesDir())
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
@@ -242,7 +266,7 @@ function listRouters() {
|
||||
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readRouter(name);
|
||||
stored = readProxy(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
}
|
||||
@@ -254,12 +278,12 @@ function listRouters() {
|
||||
|
||||
/**
|
||||
* One profile INCLUDING its key — for the server's own writers only. No route
|
||||
* may return this shape; `listRouters` is what the browser sees.
|
||||
* may return this shape; `listProxies` is what the browser sees.
|
||||
*/
|
||||
function getRouter(name) {
|
||||
function getProxy(name) {
|
||||
if (name === BUILTIN_NAME) return { ...NATIVE, models: { ...NATIVE.models } };
|
||||
const stored = readRouter(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
const stored = readProxy(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||
return { ...stored, name, builtin: false };
|
||||
}
|
||||
|
||||
@@ -268,7 +292,7 @@ function getRouter(name) {
|
||||
* models?:Record<string,string>, behavesAs?:string}} input
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createRouter(input = {}) {
|
||||
function createProxy(input = {}) {
|
||||
const label = normalizeLabel(input.label);
|
||||
const slug = slugify(label);
|
||||
assertNotBuiltin(slug);
|
||||
@@ -285,19 +309,20 @@ function createRouter(input = {}) {
|
||||
};
|
||||
|
||||
const name = freeName(slug);
|
||||
writeJson(routerFile(name), profile);
|
||||
writeJson(proxyFile(name), profile);
|
||||
return publicView({ ...profile, name, builtin: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge changes into a saved profile. The `name` never changes — a lane stores
|
||||
* it, and renaming would orphan that lane. An omitted `apiKey` keeps the stored
|
||||
* one: the edit form cannot show a key, so it cannot send one back either.
|
||||
* Merge changes into a saved profile. The `name` never changes — a session's
|
||||
* run row stores it for a resume, and renaming would orphan that. An omitted
|
||||
* `apiKey` keeps the stored one: the edit form cannot show a key, so it cannot
|
||||
* send one back either.
|
||||
*/
|
||||
function updateRouter(name, changes = {}) {
|
||||
function updateProxy(name, changes = {}) {
|
||||
assertNotBuiltin(name);
|
||||
const stored = readRouter(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
const stored = readProxy(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||
|
||||
const model = changes.model === undefined ? stored.model : normalizeModel(changes.model);
|
||||
const next = {
|
||||
@@ -320,14 +345,14 @@ function updateRouter(name, changes = {}) {
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeJson(routerFile(name), next);
|
||||
writeJson(proxyFile(name), next);
|
||||
return publicView({ ...next, name, builtin: false });
|
||||
}
|
||||
|
||||
function removeRouter(name) {
|
||||
function removeProxy(name) {
|
||||
assertNotBuiltin(name);
|
||||
const file = routerFile(name);
|
||||
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no router profile named '${name}'`);
|
||||
const file = proxyFile(name);
|
||||
if (!fs.existsSync(file)) throw err("ENOTFOUND", `no proxy profile named '${name}'`);
|
||||
fs.rmSync(file);
|
||||
return { removed: name };
|
||||
}
|
||||
@@ -335,59 +360,21 @@ function removeRouter(name) {
|
||||
/** How long a connection test waits before calling the endpoint dead. */
|
||||
const TEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Enough of a failing router's own words to be actionable, not enough to flood. */
|
||||
/** Enough of a failing proxy's own words to be actionable, not enough to flood. */
|
||||
const TEST_BODY_MAX = 300;
|
||||
|
||||
/**
|
||||
* Probe a router endpoint with the smallest real request a claude lane makes:
|
||||
* one Anthropic-style message capped at a single output token.
|
||||
*
|
||||
* Deliberately the CLAUDE wire shape and not Codex's `responses` one. A profile
|
||||
* is not tool-specific, so a test has to pick; `/v1/messages` is what a claude
|
||||
* lane's eight env keys point at, and it is the path a router has to serve for
|
||||
* this feature to work at all.
|
||||
*
|
||||
* **The stored key is only ever sent to the endpoint it was stored against.**
|
||||
* A blank `apiKey` falls back to the saved profile named by `name` — but only
|
||||
* when the submitted `baseUrl` still matches the saved one. Without that check
|
||||
* this route would be a way to make the server POST a saved API key to any URL
|
||||
* a browser names, which is exactly the leak `listRouters` exists to prevent.
|
||||
*
|
||||
* A router that answers is a success even when it answers with an error: the
|
||||
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad INPUT
|
||||
* throws.
|
||||
*
|
||||
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
|
||||
* @returns {Promise<{ok:boolean, status:number|null, message:string}>}
|
||||
*/
|
||||
async function testRouter(input = {}) {
|
||||
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||
const model = normalizeModel(input.model);
|
||||
|
||||
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
|
||||
if (!apiKey && input.name) {
|
||||
const stored = readRouter(input.name);
|
||||
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
|
||||
}
|
||||
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
|
||||
|
||||
/** POST `body` to `url`; the outcome is a result, never a throw. */
|
||||
async function probe(url, apiKey, headers, body, model) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}/v1/messages`, {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
// Both, because routers disagree on which one they read, and it is the
|
||||
// same key going to the same endpoint either way.
|
||||
"x-api-key": apiKey,
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -403,22 +390,74 @@ async function testRouter(input = {}) {
|
||||
message: e.name === "TimeoutError" ? "timed out" : (e.cause && e.cause.message) || e.message,
|
||||
};
|
||||
}
|
||||
|
||||
const body = await res.text().catch(() => "");
|
||||
const text = await res.text().catch(() => "");
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
message: res.ok ? model : body.slice(0, TEST_BODY_MAX) || res.statusText,
|
||||
message: res.ok ? model : text.slice(0, TEST_BODY_MAX) || res.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a proxy endpoint with the smallest real request each CLI makes, in
|
||||
* parallel: Claude Code's Anthropic message (`/v1/messages`, one output token)
|
||||
* and Codex's OpenAI Responses call (`/v1/responses`, the 16-token minimum). A
|
||||
* profile is not tool-specific, so a proxy that serves only one wire shape
|
||||
* shows up as one green line and one red.
|
||||
*
|
||||
* **The stored key is only ever sent to the endpoint it was stored against.**
|
||||
* A blank `apiKey` falls back to the saved profile named by `name` — but only
|
||||
* when the submitted `baseUrl` still matches the saved one. Without that check
|
||||
* this route would be a way to make the server POST a saved API key to any URL
|
||||
* a browser names, which is exactly the leak `listProxies` exists to prevent.
|
||||
*
|
||||
* A proxy that answers is a success even when it answers with an error: each
|
||||
* outcome is reported (`ok`, `status`, `message`), never thrown. Only bad
|
||||
* INPUT throws.
|
||||
*
|
||||
* @param {{name?:string, baseUrl:string, apiKey?:string, model:string}} input
|
||||
* @returns {Promise<{claude: TestResult, codex: TestResult}>} where TestResult
|
||||
* is `{ok:boolean, status:number|null, message:string}`
|
||||
*/
|
||||
async function testProxy(input = {}) {
|
||||
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||
const model = normalizeModel(input.model);
|
||||
|
||||
let apiKey = typeof input.apiKey === "string" ? input.apiKey.trim() : "";
|
||||
if (!apiKey && input.name) {
|
||||
const stored = readProxy(input.name);
|
||||
if (stored && normalizeBaseUrl(stored.baseUrl || "") === baseUrl) apiKey = stored.apiKey || "";
|
||||
}
|
||||
if (!apiKey) throw err("EBADKEY", "enter the API key to test this endpoint");
|
||||
|
||||
const [claude, codex] = await Promise.all([
|
||||
probe(
|
||||
`${baseUrl}/v1/messages`,
|
||||
apiKey,
|
||||
// `x-api-key` beside the Bearer header, because proxies disagree on which
|
||||
// one they read, and it is the same key going to the same endpoint.
|
||||
{ "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
||||
{ model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] },
|
||||
model
|
||||
),
|
||||
probe(
|
||||
`${baseUrl}/v1/responses`,
|
||||
apiKey,
|
||||
{},
|
||||
{ model, input: "ping", max_output_tokens: 16 },
|
||||
model
|
||||
),
|
||||
]);
|
||||
return { claude, codex };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRouters,
|
||||
getRouter,
|
||||
createRouter,
|
||||
updateRouter,
|
||||
removeRouter,
|
||||
testRouter,
|
||||
listProxies,
|
||||
getProxy,
|
||||
createProxy,
|
||||
updateProxy,
|
||||
removeProxy,
|
||||
testProxy,
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
NATIVE,
|
||||
@@ -21,7 +21,7 @@ const CMD_RE = /^[A-Za-z0-9._+-]+$/;
|
||||
const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const LAUNCHERS = new Set(["claude", "codex"]);
|
||||
const PROMPT_MODES = new Set(["argv", "flag", "after-start"]);
|
||||
const ROUTER_CAPABLE = new Set(["claude", "codex"]);
|
||||
const PROXY_CAPABLE = new Set(["claude", "codex"]);
|
||||
const PKG_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||
// Files shipped in a release (built-in, or synced from the newest release) are
|
||||
// trusted to name a launcher and an install/update; a custom file is not.
|
||||
@@ -417,7 +417,7 @@ function view() {
|
||||
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
||||
resume: !!t.resume,
|
||||
bypass: !!(t.bypass || t.bypassEnv),
|
||||
routerCapable: ROUTER_CAPABLE.has(t.id),
|
||||
proxyCapable: PROXY_CAPABLE.has(t.id),
|
||||
// claude/codex take a model through their own argv builders.
|
||||
modelFlag: Boolean(t.model || t.launcher),
|
||||
defaultModel: state.models[t.id] || null,
|
||||
@@ -458,7 +458,7 @@ module.exports = {
|
||||
which,
|
||||
spawnTarget,
|
||||
siteOf,
|
||||
ROUTER_CAPABLE,
|
||||
PROXY_CAPABLE,
|
||||
CONFIG_SECRET_RE,
|
||||
__setCatalogDirs,
|
||||
};
|
||||
|
||||
@@ -572,7 +572,7 @@ function patchSettings(args) {
|
||||
}
|
||||
|
||||
// A patch that changes nothing writes nothing — and therefore backs up
|
||||
// nothing. Re-applying the same AI Router profile to a lane is the common
|
||||
// nothing. Re-applying the same AI Proxy profile to a lane is the common
|
||||
// case (every re-attach sends the same eight env keys), and an unconditional
|
||||
// backup turned that into a `.bak` per apply piling up inside the user's
|
||||
// project. `changed` still lists the keys the caller asked for: they ARE the
|
||||
|
||||
@@ -32,11 +32,11 @@ const insertStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO dashboard_runs (
|
||||
id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
||||
lane_id, tool, worktree, router
|
||||
lane_id, tool, worktree, proxy
|
||||
) VALUES (
|
||||
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
||||
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
||||
@lane_id, @tool, @worktree, @router
|
||||
@lane_id, @tool, @worktree, @proxy
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -51,7 +51,7 @@ const updateStmt = db.prepare(`
|
||||
|
||||
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code,
|
||||
started_at, ended_at, lane_id, tool, worktree, router`;
|
||||
started_at, ended_at, lane_id, tool, worktree, proxy`;
|
||||
|
||||
const listStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
@@ -123,7 +123,7 @@ function recordRun(handle) {
|
||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||
tool: typeof handle.tool === "string" && handle.tool ? handle.tool : "claude",
|
||||
worktree: handle.worktree ? 1 : 0,
|
||||
router: handle.router || null,
|
||||
proxy: handle.proxy || null,
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
@@ -181,19 +181,19 @@ function getRun(id) {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionRouterStmt = db.prepare(`
|
||||
SELECT router FROM dashboard_runs
|
||||
const sessionProxyStmt = db.prepare(`
|
||||
SELECT proxy FROM dashboard_runs
|
||||
WHERE session_id = @sessionId OR resume_session_id = @sessionId
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
`);
|
||||
|
||||
/**
|
||||
* The AI Router the newest run holding `sessionId` started on (null = native,
|
||||
* or no run known) — what a resume that names no router goes back to.
|
||||
* The AI Proxy the newest run holding `sessionId` started on (null = native,
|
||||
* or no run known) — what a resume that names no proxy goes back to.
|
||||
*/
|
||||
function routerForSession(sessionId) {
|
||||
function proxyForSession(sessionId) {
|
||||
try {
|
||||
return sessionRouterStmt.get({ sessionId })?.router || null;
|
||||
return sessionProxyStmt.get({ sessionId })?.proxy || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -308,7 +308,7 @@ module.exports = {
|
||||
getRun,
|
||||
forgetShellRun,
|
||||
reconcileOrphans,
|
||||
routerForSession,
|
||||
proxyForSession,
|
||||
laneForSession,
|
||||
claimToolSessions,
|
||||
};
|
||||
|
||||
@@ -233,7 +233,10 @@ function importExportBundle(db, bundle) {
|
||||
// Session-independent, config-like tables: restore by natural PK, never
|
||||
// overwriting a row the target machine already has.
|
||||
for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) {
|
||||
if (r && r.id && insert.dashboard_runs(r).changes > 0) counters.dashboard_runs++;
|
||||
// `router` is the pre-rename name of `proxy`, in a bundle an older build exported.
|
||||
const row =
|
||||
r && r.router !== undefined && r.proxy === undefined ? { ...r, proxy: r.router } : r;
|
||||
if (row && row.id && insert.dashboard_runs(row).changes > 0) counters.dashboard_runs++;
|
||||
}
|
||||
for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) {
|
||||
if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
|
||||
* file 0600, written through a temp file), one record per protocol + host,
|
||||
* shared by everyone using this AgentHub. Like the AI Router store, **no route
|
||||
* shared by everyone using this AgentHub. Like the AI Proxy store, **no route
|
||||
* ever returns a password** — `list` reports `hasToken`.
|
||||
*
|
||||
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
|
||||
|
||||
@@ -77,8 +77,8 @@ const VALID_RANGES = new Set(["work", "branch", "commit"]);
|
||||
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
|
||||
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
|
||||
/** The file whose CONTENT this module never returns, at any rev and at ANY
|
||||
* depth: `settings.local.json` is where router-apply.js writes a lane's AI
|
||||
* Router API key, and key material never leaves the server. Any depth,
|
||||
* depth: `settings.local.json` is where AgentHub once wrote a lane's AI
|
||||
* Proxy API key, and key material never leaves the server. Any depth,
|
||||
* because a lane at the repo root sees a subdirectory lane's key as
|
||||
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
|
||||
* filesystem (macOS) opens it under any casing. */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* The guards are the READ path's own (git-diff.js), so the editor can never
|
||||
* write where the viewer could not read: a lexical `lanePath`, a realpath
|
||||
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
|
||||
* — the lane router's key file — at any depth or through any symlink. On top:
|
||||
* — where a lane's proxy key once lived — at any depth or through any symlink. On top:
|
||||
* only an EXISTING, textual file within the viewer's size cap, and only over
|
||||
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
|
||||
* never silently overwrites what the agent wrote in the meantime (ESTALE).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file router-apply.js
|
||||
* @description Starts ONE session on an AI Router profile (see `ai-routers.js`).
|
||||
* A router is chosen per new session, not per workspace, so nothing here writes
|
||||
* @file proxy-apply.js
|
||||
* @description Starts ONE session on an AI Proxy profile (see `ai-proxies.js`).
|
||||
* A proxy is chosen per new session, not per workspace, so nothing here writes
|
||||
* into the user's project.
|
||||
*
|
||||
* The two CLIs are configured in different places, and the difference is
|
||||
@@ -9,24 +9,27 @@
|
||||
*
|
||||
* - **Claude Code** gets `--settings=<file>`: a settings file holding the
|
||||
* profile's eight `env` keys (plus `modelPicker` rows when the profile
|
||||
* sets `behavesAs`), `<data dir>/router-settings/<name>.json`
|
||||
* sets `behavesAs`), `<data dir>/proxy-settings/<name>.json`
|
||||
* (0600). The command-line layer outranks the project-local and user
|
||||
* settings (verified on 2.1.286), so two sessions of one workspace can run
|
||||
* on two routers, and the key stays out of argv — only the path is there.
|
||||
* on two proxies, and the key stays out of argv — only the path is there.
|
||||
*
|
||||
* - **Codex** has no project-level config at all. What it does have is
|
||||
* `-p <name>`, which layers `$CODEX_HOME/<name>.config.toml` on top of the
|
||||
* base. So a profile becomes a layer file, the base is never touched, and
|
||||
* the key reaches the pane through tmux's own `-e` under the variable the
|
||||
* layer's `env_key` names, never as typed text.
|
||||
* layer's `env_key` names, never as typed text. Its `base_url` is the
|
||||
* profile's root plus `/v1` (Codex appends `/responses`); Claude Code's
|
||||
* `ANTHROPIC_BASE_URL` is the bare root (it appends `/v1/messages`).
|
||||
*
|
||||
* Both files are (re)written at every spawn, so an edited profile reaches the
|
||||
* next session and a moved CODEX_HOME needs no repair. `refreshProfileFiles`
|
||||
* rewrites them on an edit (a live Claude session reads its file again);
|
||||
* `removeProfileFiles` deletes them with the profile.
|
||||
*
|
||||
* Workspaces once carried a router of their own in `lanes.router_profile` and
|
||||
* `<cwd>/.claude/settings.local.json`; `clearLaneRouters` takes that out once.
|
||||
* Workspaces once carried a proxy (then called a router) of their own in
|
||||
* `lanes.router_profile` and `<cwd>/.claude/settings.local.json`;
|
||||
* `clearLaneProxies` takes that out once.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -34,19 +37,19 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const routers = require("./ai-routers");
|
||||
const proxies = require("./ai-proxies");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { ROUTER_CAPABLE } = require("./ai-tools");
|
||||
const { PROXY_CAPABLE } = require("./ai-tools");
|
||||
|
||||
/**
|
||||
* The env var Codex is told to read the router key from. One fixed name rather
|
||||
* The env var Codex is told to read the proxy key from. One fixed name rather
|
||||
* than one per profile: a Codex pane runs exactly one profile, so a second name
|
||||
* would only create the chance of the wrong one being set.
|
||||
*/
|
||||
const CODEX_KEY_ENV = "AGENTHUB_ROUTER_KEY";
|
||||
const CODEX_KEY_ENV = "AGENTHUB_PROXY_KEY";
|
||||
|
||||
/** The eight env keys a router sets for Claude Code. */
|
||||
/** The eight env keys a proxy sets for Claude Code. */
|
||||
function envForProfile(profile) {
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
@@ -61,7 +64,7 @@ function envForProfile(profile) {
|
||||
}
|
||||
|
||||
function claudeSettingsPath(name) {
|
||||
return path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||
return path.join(getDataDir(), "proxy-settings", `${name}.json`);
|
||||
}
|
||||
|
||||
function codexLayerPath(name) {
|
||||
@@ -75,7 +78,7 @@ function writeSecret(file, body) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `modelPicker` rows telling Claude Code which known model each of the router's
|
||||
* `modelPicker` rows telling Claude Code which known model each of the proxy's
|
||||
* own names behaves as — without one, a name like 9Router's `agent` draws an
|
||||
* "isn't described by this version's model catalog" warning and a 200k
|
||||
* compact window (issue #56). Only when the profile sets `behavesAs`: this
|
||||
@@ -100,7 +103,7 @@ function writeClaudeSettings(profile) {
|
||||
return file;
|
||||
}
|
||||
|
||||
/** TOML string literal — router names and URLs are validated, quotes are not free. */
|
||||
/** TOML string literal — proxy names and URLs are validated, quotes are not free. */
|
||||
function tomlString(value) {
|
||||
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
@@ -112,14 +115,14 @@ function tomlString(value) {
|
||||
function writeCodexLayer(profile) {
|
||||
const file = codexLayerPath(profile.name);
|
||||
const body =
|
||||
`# Written by AgentHub — AI Router profile ${profile.label}.\n` +
|
||||
`# Written by AgentHub — AI Proxy profile ${profile.label}.\n` +
|
||||
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
||||
`model = ${tomlString(profile.model)}\n` +
|
||||
`model_provider = ${tomlString(profile.name)}\n\n` +
|
||||
`[model_providers.${profile.name}]\n` +
|
||||
`name = ${tomlString(profile.label)}\n` +
|
||||
`base_url = ${tomlString(profile.baseUrl)}\n` +
|
||||
// `env_key` only when there is a key to name. A local router (the 9Router
|
||||
`base_url = ${tomlString(`${profile.baseUrl}/v1`)}\n` +
|
||||
// `env_key` only when there is a key to name. A local proxy (the 9Router
|
||||
// reference config is exactly this) needs none, and naming a variable that
|
||||
// is never set makes Codex refuse to start.
|
||||
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||
@@ -133,26 +136,26 @@ function writeCodexLayer(profile) {
|
||||
/**
|
||||
* The profile a new session asked for, checked against its tool. `null`,
|
||||
* `""` and `"native"` mean the vendor's own API and resolve to null.
|
||||
* @throws EBADROUTER for a profile the store does not have, EUNSUPPORTED for a
|
||||
* router on a tool that has no endpoint to point at one.
|
||||
* @throws EBADPROXY for a profile the store does not have, EUNSUPPORTED for a
|
||||
* proxy on a tool that has no endpoint to point at one.
|
||||
*/
|
||||
function resolveRouter(tool, name) {
|
||||
if (name == null || name === "" || name === routers.BUILTIN_NAME) return null;
|
||||
function resolveProxy(tool, name) {
|
||||
if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null;
|
||||
if (typeof name !== "string") {
|
||||
throw Object.assign(new Error("router must be a profile name"), { code: "EBADROUTER" });
|
||||
throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" });
|
||||
}
|
||||
let profile;
|
||||
try {
|
||||
profile = routers.getRouter(name);
|
||||
profile = proxies.getProxy(name);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOTFOUND") throw e;
|
||||
throw Object.assign(new Error(`no AI Router profile named "${name}"`), {
|
||||
code: "EBADROUTER",
|
||||
throw Object.assign(new Error(`no AI Proxy profile named "${name}"`), {
|
||||
code: "EBADPROXY",
|
||||
});
|
||||
}
|
||||
if (profile.builtin) return null;
|
||||
if (!ROUTER_CAPABLE.has(tool)) {
|
||||
throw Object.assign(new Error(`a ${tool} session cannot use an AI Router`), {
|
||||
if (!PROXY_CAPABLE.has(tool)) {
|
||||
throw Object.assign(new Error(`a ${tool} session cannot use an AI Proxy`), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
@@ -160,15 +163,15 @@ function resolveRouter(tool, name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The router a resume that names none goes back to: the one its session last
|
||||
* The proxy a resume that names none goes back to: the one its session last
|
||||
* ran on, while that profile still exists — a deleted one resumes on native.
|
||||
*/
|
||||
function resumeRouter(sessionId) {
|
||||
function resumeProxy(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const name = require("./dashboard-runs").routerForSession(sessionId);
|
||||
const name = require("./dashboard-runs").proxyForSession(sessionId);
|
||||
if (!name) return null;
|
||||
try {
|
||||
routers.getRouter(name);
|
||||
proxies.getProxy(name);
|
||||
return name;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -176,11 +179,11 @@ function resumeRouter(sessionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Argv and env one spawn needs for `profile` (from `resolveRouter`): Claude's
|
||||
* Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's
|
||||
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
|
||||
* @returns {{argv: string[], env: Record<string,string>}}
|
||||
*/
|
||||
function routerForSpawn(tool, profile) {
|
||||
function proxyForSpawn(tool, profile) {
|
||||
if (!profile) return { argv: [], env: {} };
|
||||
if (tool === "codex") {
|
||||
writeCodexLayer(profile);
|
||||
@@ -199,18 +202,19 @@ function routerForSpawn(tool, profile) {
|
||||
* do not exist yet are written at the next spawn.
|
||||
*/
|
||||
function refreshProfileFiles(name) {
|
||||
const profile = routers.getRouter(name);
|
||||
const profile = proxies.getProxy(name);
|
||||
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
|
||||
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a delete: the files hold the deleted router's key and endpoint. Left
|
||||
* After a delete: the files hold the deleted proxy's key and endpoint. Left
|
||||
* behind, a later profile that slugifies to the same name would also inherit
|
||||
* this one's `base_url` in Codex.
|
||||
* this one's `base_url` in Codex. The pre-rename `router-settings/` copy goes too.
|
||||
*/
|
||||
function removeProfileFiles(name) {
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name)]) {
|
||||
const legacy = path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy]) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
@@ -220,13 +224,13 @@ function removeProfileFiles(name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the old per-workspace router out, once: the eight env keys AgentHub
|
||||
* Take the old per-workspace proxy out, once: the eight env keys AgentHub
|
||||
* wrote into a claude workspace's `.claude/settings.local.json` (they would
|
||||
* otherwise route every session there, native ones included) and the
|
||||
* `lanes.router_profile` column. A lane whose file cannot be cleaned (not
|
||||
* JSON, say) keeps its column and is tried again at the next start.
|
||||
*/
|
||||
function clearLaneRouters() {
|
||||
function clearLaneProxies() {
|
||||
const { db } = require("../db");
|
||||
const { patchSettings } = require("./cc-mutate");
|
||||
const keys = Object.keys(envForProfile({ models: {} })).map((k) => `env.${k}`);
|
||||
@@ -240,17 +244,17 @@ function clearLaneRouters() {
|
||||
}
|
||||
db.prepare("UPDATE lanes SET router_profile = NULL WHERE id = ?").run(lane.id);
|
||||
} catch (e) {
|
||||
console.warn(`[router] could not clear workspace ${lane.id}'s old router: ${e.message}`);
|
||||
console.warn(`[proxy] could not clear workspace ${lane.id}'s old proxy: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveRouter,
|
||||
resumeRouter,
|
||||
routerForSpawn,
|
||||
resolveProxy,
|
||||
resumeProxy,
|
||||
proxyForSpawn,
|
||||
refreshProfileFiles,
|
||||
removeProfileFiles,
|
||||
clearLaneRouters,
|
||||
clearLaneProxies,
|
||||
CODEX_KEY_ENV,
|
||||
};
|
||||
+17
-17
@@ -31,7 +31,7 @@
|
||||
*/
|
||||
|
||||
const tmux = require("./tmux");
|
||||
const { resolveRouter, routerForSpawn } = require("./router-apply");
|
||||
const { resolveProxy, proxyForSpawn } = require("./proxy-apply");
|
||||
const { desktopEnv } = require("./desktop-env");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const aiTools = require("./ai-tools");
|
||||
@@ -131,7 +131,7 @@ function buildClaudeArgv({
|
||||
initialPrompt,
|
||||
addDirs = [],
|
||||
systemPromptFile = null,
|
||||
routerArgv = [],
|
||||
proxyArgv = [],
|
||||
}) {
|
||||
const argv = ["claude"];
|
||||
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
||||
@@ -139,8 +139,8 @@ function buildClaudeArgv({
|
||||
// always ends it — after the prompt it would eat the prompt as a directory.
|
||||
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
||||
argv.push("--permission-mode", permissionMode || "auto");
|
||||
// The session's AI Router: `--settings=<file>` (router-apply.js).
|
||||
argv.push(...routerArgv);
|
||||
// The session's AI Proxy: `--settings=<file>` (proxy-apply.js).
|
||||
argv.push(...proxyArgv);
|
||||
// Claude Code makes the worktree itself (`.claude/worktrees/<name>`, branch
|
||||
// `worktree-<name>`, `.worktreeinclude`, the user's WorktreeCreate hook).
|
||||
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
|
||||
@@ -182,7 +182,7 @@ function buildCodexArgv({
|
||||
resumeLast,
|
||||
bypass,
|
||||
initialPrompt,
|
||||
routerArgv = [],
|
||||
proxyArgv = [],
|
||||
addDirs = [],
|
||||
systemPrompt = null,
|
||||
systemPromptFile = null,
|
||||
@@ -191,7 +191,7 @@ function buildCodexArgv({
|
||||
// Before the subcommand and before the prompt: `-p` layers
|
||||
// `$CODEX_HOME/<name>.config.toml` over the base config, and an option that
|
||||
// lands after the positional prompt is not an option any more.
|
||||
argv.push(...routerArgv);
|
||||
argv.push(...proxyArgv);
|
||||
if (resumeSessionId) argv.push("resume", resumeSessionId);
|
||||
else if (resumeLast) argv.push("resume", "--last");
|
||||
if (model) argv.push("-m", model);
|
||||
@@ -266,7 +266,7 @@ function buildArgv(args, entry) {
|
||||
* @param {boolean} [args.bypass] the user ticked "bypass permissions" for THIS
|
||||
* run; appends the catalog's bypass args/env. Claude has no catalog bypass.
|
||||
* @param {string} [args.initialPrompt]
|
||||
* @param {string} [args.router] AI Router profile THIS session runs on — a
|
||||
* @param {string} [args.proxy] AI Proxy profile THIS session runs on — a
|
||||
* name only, its key and endpoint come from the store; null/"native" = the
|
||||
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
|
||||
*/
|
||||
@@ -288,7 +288,7 @@ function spawnRun(args) {
|
||||
resumeLast,
|
||||
bypass,
|
||||
initialPrompt,
|
||||
router,
|
||||
proxy,
|
||||
} = args || {};
|
||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
@@ -345,7 +345,7 @@ function spawnRun(args) {
|
||||
if (worktree === true && entry.launcher !== "claude") {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||
}
|
||||
const routerProfile = resolveRouter(tool, router); // ENOTFOUND, EUNSUPPORTED
|
||||
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
|
||||
if (forkSession === true) {
|
||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||
if (entry.launcher !== "claude") {
|
||||
@@ -382,7 +382,7 @@ function spawnRun(args) {
|
||||
laneId,
|
||||
tool
|
||||
);
|
||||
const routerSpawn = routerForSpawn(tool, routerProfile);
|
||||
const proxySpawn = proxyForSpawn(tool, proxyProfile);
|
||||
// A new Claude session gets its id from us, so the run knows the session it
|
||||
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
|
||||
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
||||
@@ -406,7 +406,7 @@ function spawnRun(args) {
|
||||
resumeLast,
|
||||
bypass: bypass === true,
|
||||
initialPrompt,
|
||||
routerArgv: routerSpawn.argv,
|
||||
proxyArgv: proxySpawn.argv,
|
||||
},
|
||||
entry
|
||||
);
|
||||
@@ -432,7 +432,7 @@ function spawnRun(args) {
|
||||
effort: effort || null,
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
worktree: worktree === true || inWorktree === true,
|
||||
router: routerProfile ? routerProfile.name : null,
|
||||
proxy: proxyProfile ? proxyProfile.name : null,
|
||||
prompt: initialPrompt || "",
|
||||
status: "running",
|
||||
startedAt,
|
||||
@@ -446,9 +446,9 @@ function spawnRun(args) {
|
||||
// from inside it then resolves the lane exactly, instead of matching on cwd —
|
||||
// which is ambiguous now that one directory can back both a `claude` lane and
|
||||
// a `codex` lane (see bin/agenthub.js's resolveLaneArg).
|
||||
// A codex session on an AI Router also needs the key its layer's `env_key`
|
||||
// A codex session on an AI Proxy also needs the key its layer's `env_key`
|
||||
// names. A claude one needs nothing here — its `--settings` file carries the
|
||||
// router — and a session on the vendor's own API needs nothing either.
|
||||
// proxy — and a session on the vendor's own API needs nothing either.
|
||||
// The graphical session's own DISPLAY/WAYLAND_DISPLAY goes in too: the
|
||||
// server normally runs as a systemd user service with neither, so a pane it
|
||||
// starts has no clipboard at all — pasting an image into `claude` there
|
||||
@@ -466,14 +466,14 @@ function spawnRun(args) {
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
// A codex pane must read the same home the dashboard reads — its rollout
|
||||
// journals and the router layer `-p <name>` loads live there — and the
|
||||
// journals and the proxy layer `-p <name>` loads live there — and the
|
||||
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||||
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
// `"tui"` setting decides; under fullscreen the pane scrolls in Claude.
|
||||
...routerSpawn.env,
|
||||
...proxySpawn.env,
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
|
||||
@@ -571,7 +571,7 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
worktree: false,
|
||||
router: null,
|
||||
proxy: null,
|
||||
prompt: "",
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
|
||||
+12
-1
@@ -77,7 +77,18 @@ function envFlags(env) {
|
||||
*/
|
||||
function newSession({ name, cwd, argv, env }) {
|
||||
execImpl(["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), "--", ...argv]);
|
||||
enableMouse(name);
|
||||
try {
|
||||
enableMouse(name);
|
||||
} catch (err) {
|
||||
// The pane's command exited at once (a CLI that refuses the terminal tmux
|
||||
// gives it, say) and tmux closed the session with it: say that, not the
|
||||
// "Command failed: tmux set-option …" that hid the real problem.
|
||||
if (hasSession(name)) throw err;
|
||||
throw Object.assign(
|
||||
new Error(`the session's command exited as soon as it started: ${argv[0]}`),
|
||||
{ code: "EPANEEXITED" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Without this the pane has no scrollbar/drag scroll at all — wheel events
|
||||
|
||||
Reference in New Issue
Block a user