@alfe.ai/claude-code-host 0.1.15 → 0.1.16
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/dist/cli.js +1 -1
- package/dist/host-config.cjs +536 -122
- package/dist/host-config.js +469 -115
- package/dist/index.cjs +12 -2
- package/dist/index.d.cts +240 -54
- package/dist/index.d.ts +240 -54
- package/dist/index.js +2 -2
- package/package.json +3 -3
package/dist/host-config.cjs
CHANGED
|
@@ -13,22 +13,35 @@ let _alfe_ai_config = require("@alfe.ai/config");
|
|
|
13
13
|
/**
|
|
14
14
|
* Child-process environment construction for spawned `claude` CLI processes.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* Every turn runs on exactly ONE auth source (`ChildAuth`), resolved by the
|
|
17
|
+
* host (see DEVELOPING.md "Auth source resolution"):
|
|
18
|
+
*
|
|
19
|
+
* - `credential` — the agent's stored Claude credential, set as exactly one
|
|
20
|
+
* variable: `CLAUDE_CODE_OAUTH_TOKEN` (subscription token from
|
|
21
|
+
* `claude setup-token`) or `ANTHROPIC_API_KEY` (Anthropic API key).
|
|
22
|
+
* - `local_login` — self-hosted only: no auth variable, so `claude` uses the
|
|
23
|
+
* machine's own login (and whatever provider the operator configured).
|
|
24
|
+
* - `ai_proxy` — the Alfe AI proxy via the daemon's loopback proxy:
|
|
25
|
+
* `ANTHROPIC_BASE_URL` = the local proxy, `ANTHROPIC_AUTH_TOKEN` = a
|
|
26
|
+
* placeholder the local proxy replaces with the daemon's agent key, and
|
|
27
|
+
* every model alias pinned to an Alfe registry id (the proxy routes exact
|
|
28
|
+
* registry ids only; the CLI's built-in aliases resolve to dated ids that
|
|
29
|
+
* are a 409 there).
|
|
30
|
+
*
|
|
31
|
+
* Invariants:
|
|
17
32
|
*
|
|
18
33
|
* 1. **Scrub inherited Claude auth.** `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
|
|
19
34
|
* / `ANTHROPIC_AUTH_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` are removed from BOTH
|
|
20
35
|
* the parent env and any caller `extra`. A self-hosted Mac very often has
|
|
21
36
|
* these set for other tooling; if one reaches `claude` it silently switches
|
|
22
|
-
* accounts or billing plane
|
|
23
|
-
*
|
|
37
|
+
* accounts or billing plane. Credential and proxy modes also drop provider
|
|
38
|
+
* overrides (Bedrock/Vertex/Foundry); proxy mode additionally drops model
|
|
39
|
+
* and header overrides so nothing reaches the proxy that it cannot price.
|
|
24
40
|
*
|
|
25
|
-
* 2. **Inject the
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* else can override it. With no credential, no auth variable is set and
|
|
30
|
-
* `claude` uses the machine's own login (self-hosted fallback). The value
|
|
31
|
-
* lives only in the host's memory and this env — never on disk, never logged.
|
|
41
|
+
* 2. **Inject the source last.** Nothing in `base` or `extra` can override the
|
|
42
|
+
* injected credential or proxy settings. A credential lives only in the
|
|
43
|
+
* host's memory and this env, never on disk, never logged. Proxy settings
|
|
44
|
+
* never appear in a credential or local-login env.
|
|
32
45
|
*
|
|
33
46
|
* 3. **Set `ALFE_CLAUDE_BRIDGE=1`.** The Phase-1 CLI hooks
|
|
34
47
|
* (`alfe claude hook ...` in `packages/cli/src/commands/claude.ts`) check
|
|
@@ -46,10 +59,10 @@ const SCRUBBED_ANTHROPIC_ENV_VARS = [
|
|
|
46
59
|
];
|
|
47
60
|
/**
|
|
48
61
|
* Provider-selection and endpoint overrides that would route `claude` away from
|
|
49
|
-
* the Anthropic API. Dropped whenever
|
|
50
|
-
* inherited `CLAUDE_CODE_USE_BEDROCK=1` (say) can't send the turn to
|
|
51
|
-
* and ignore the
|
|
52
|
-
* the machine's own `claude` setup is the
|
|
62
|
+
* the Anthropic API. Dropped whenever a credential or the AI proxy is in use,
|
|
63
|
+
* so an inherited `CLAUDE_CODE_USE_BEDROCK=1` (say) can't send the turn to
|
|
64
|
+
* Bedrock and ignore the chosen source. On a local login they are left alone:
|
|
65
|
+
* the machine's own `claude` setup is the source, whatever provider it uses.
|
|
53
66
|
*/
|
|
54
67
|
const PROVIDER_OVERRIDE_ENV_VARS = [
|
|
55
68
|
"CLAUDE_CODE_USE_BEDROCK",
|
|
@@ -68,6 +81,60 @@ const PROVIDER_OVERRIDE_ENV_VARS = [
|
|
|
68
81
|
];
|
|
69
82
|
/** Cross-phase contract marker (see header + packages/cli/src/commands/claude.ts). */
|
|
70
83
|
const BRIDGE_ENV_VAR = "ALFE_CLAUDE_BRIDGE";
|
|
84
|
+
/**
|
|
85
|
+
* Placeholder `ANTHROPIC_AUTH_TOKEN` (sent as `Authorization: Bearer`) for
|
|
86
|
+
* proxy mode. `@alfe.ai/ai-proxy-local` keeps an incoming `alfe_…` key and
|
|
87
|
+
* replaces anything else with the daemon's agent key (as `x-api-key`, dropping
|
|
88
|
+
* `authorization`), so the agent key itself never enters the `claude` env (a
|
|
89
|
+
* managed agent has Bash). Never `alfe_…`.
|
|
90
|
+
*/
|
|
91
|
+
const PROXY_API_KEY_PLACEHOLDER = "alfe-local-proxy";
|
|
92
|
+
/**
|
|
93
|
+
* Default local AI proxy (`DEFAULT_AI_PROXY_PORT` in `@alfe.ai/ai-proxy-local`).
|
|
94
|
+
* The daemon passes the URL it actually bound as `ALFE_LOCAL_AI_PROXY_URL`;
|
|
95
|
+
* this is the fallback. Bare host, no `/v1`: `claude` appends `/v1/messages`.
|
|
96
|
+
*/
|
|
97
|
+
const DEFAULT_LOCAL_AI_PROXY_URL = "http://127.0.0.1:18193";
|
|
98
|
+
/**
|
|
99
|
+
* The model `claude -p --model` runs in proxy mode when the agent has no
|
|
100
|
+
* `defaultModel` (or the daemon's lookup timed out). `claude`'s own default is
|
|
101
|
+
* a dated id the proxy cannot route. Haiku 4.5 is the one Claude model seeded
|
|
102
|
+
* on every stage (the memory integration depends on it), so it is the only
|
|
103
|
+
* id guaranteed to route; the dashboard always sets a model, so this is rare.
|
|
104
|
+
*/
|
|
105
|
+
const PROXY_DEFAULT_MODEL = "claude-haiku-4-5";
|
|
106
|
+
/**
|
|
107
|
+
* Fixed model env in proxy mode, Alfe registry ids (`AnthropicModel` in
|
|
108
|
+
* `@alfe/types`; the child-env test asserts it). `ANTHROPIC_SMALL_FAST_MODEL`
|
|
109
|
+
* drives background calls (titles, summaries); `ANTHROPIC_DEFAULT_HAIKU_MODEL`
|
|
110
|
+
* resolves the `haiku` alias. Haiku 4.5 because it is seeded on every stage.
|
|
111
|
+
* claude 2.1.284/2.1.287 read these and {@link PROXY_AGENT_MODEL_ENV_VARS}.
|
|
112
|
+
*/
|
|
113
|
+
const PROXY_MODEL_ENV = {
|
|
114
|
+
ANTHROPIC_SMALL_FAST_MODEL: "claude-haiku-4-5",
|
|
115
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: "claude-haiku-4-5"
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* The `sonnet` / `opus` aliases (subagents) resolve to the agent's OWN model
|
|
119
|
+
* in proxy mode: the agents service validated it against the registry, while a
|
|
120
|
+
* fixed 5.x id may not be enabled on the workspace.
|
|
121
|
+
*/
|
|
122
|
+
const PROXY_AGENT_MODEL_ENV_VARS = ["ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL"];
|
|
123
|
+
/**
|
|
124
|
+
* Inherited overrides that would send a proxy turn a model or header the proxy
|
|
125
|
+
* can't price or doesn't expect. Dropped in proxy mode only.
|
|
126
|
+
*/
|
|
127
|
+
const PROXY_SCRUBBED_ENV_VARS = [
|
|
128
|
+
...[
|
|
129
|
+
...Object.keys(PROXY_MODEL_ENV),
|
|
130
|
+
...PROXY_AGENT_MODEL_ENV_VARS,
|
|
131
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"
|
|
132
|
+
],
|
|
133
|
+
"ANTHROPIC_MODEL",
|
|
134
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
135
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
136
|
+
"CLAUDE_CODE_SUBAGENT_MODEL"
|
|
137
|
+
];
|
|
71
138
|
/** The single env var each credential kind is exported as. */
|
|
72
139
|
const CREDENTIAL_ENV_VAR = {
|
|
73
140
|
oauth_token: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
@@ -78,21 +145,30 @@ const CREDENTIAL_ENV_VAR = {
|
|
|
78
145
|
* explicitly so it unit-tests without touching `process.env`.
|
|
79
146
|
*
|
|
80
147
|
* Order: merge `base` + `extra` → drop every scrubbed var (plus provider
|
|
81
|
-
* overrides
|
|
82
|
-
*
|
|
83
|
-
* smuggle an auth variable in or override the
|
|
148
|
+
* overrides for a credential or the proxy, plus model overrides for the
|
|
149
|
+
* proxy) → set the bridge marker → set the source's variables. Neither `base`
|
|
150
|
+
* nor `extra` can smuggle an auth variable in or override the source.
|
|
151
|
+
* `auth` omitted = local login.
|
|
84
152
|
*/
|
|
85
|
-
function buildChildEnv(base, extra,
|
|
153
|
+
function buildChildEnv(base, extra, auth = { mode: "local_login" }) {
|
|
86
154
|
const merged = {
|
|
87
155
|
...base,
|
|
88
156
|
...extra
|
|
89
157
|
};
|
|
90
158
|
const scrubbed = new Set(SCRUBBED_ANTHROPIC_ENV_VARS);
|
|
91
|
-
if (
|
|
159
|
+
if (auth.mode !== "local_login") for (const key of PROVIDER_OVERRIDE_ENV_VARS) scrubbed.add(key);
|
|
160
|
+
if (auth.mode === "ai_proxy") for (const key of PROXY_SCRUBBED_ENV_VARS) scrubbed.add(key);
|
|
92
161
|
const env = {};
|
|
93
162
|
for (const [key, value] of Object.entries(merged)) if (!scrubbed.has(key)) env[key] = value;
|
|
94
163
|
env[BRIDGE_ENV_VAR] = "1";
|
|
95
|
-
if (credential) env[CREDENTIAL_ENV_VAR[credential.kind]] = credential.value;
|
|
164
|
+
if (auth.mode === "credential") env[CREDENTIAL_ENV_VAR[auth.credential.kind]] = auth.credential.value;
|
|
165
|
+
else if (auth.mode === "ai_proxy") {
|
|
166
|
+
env.ANTHROPIC_BASE_URL = auth.baseUrl;
|
|
167
|
+
env.ANTHROPIC_AUTH_TOKEN = PROXY_API_KEY_PLACEHOLDER;
|
|
168
|
+
Object.assign(env, PROXY_MODEL_ENV);
|
|
169
|
+
for (const key of PROXY_AGENT_MODEL_ENV_VARS) env[key] = auth.model;
|
|
170
|
+
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
171
|
+
}
|
|
96
172
|
return env;
|
|
97
173
|
}
|
|
98
174
|
//#endregion
|
|
@@ -173,14 +249,52 @@ function isAuthFailureText(text) {
|
|
|
173
249
|
const lower = text.toLowerCase();
|
|
174
250
|
return AUTH_FAILURE_MARKERS.some((marker) => lower.includes(marker));
|
|
175
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Proxy texts for a model this workspace or agent cannot use: 409
|
|
254
|
+
* `model_route_denied` (unknown, disabled or mis-routed registry id) and 403
|
|
255
|
+
* `model_not_approved` (tenant model policy). The 403 must not read as a
|
|
256
|
+
* refused key.
|
|
257
|
+
*/
|
|
258
|
+
const MODEL_UNAVAILABLE_MARKERS = [
|
|
259
|
+
"model_route_denied",
|
|
260
|
+
"model_not_approved",
|
|
261
|
+
"model is not available",
|
|
262
|
+
"is restricted to:",
|
|
263
|
+
"not approved for this organization",
|
|
264
|
+
"requires an anthropic-routed model",
|
|
265
|
+
"no supported standard inference route",
|
|
266
|
+
"cannot use the standard data plane",
|
|
267
|
+
"not an enabled reviewed"
|
|
268
|
+
];
|
|
269
|
+
/**
|
|
270
|
+
* Classify an Alfe AI proxy refusal from the text `claude` reports. The proxy
|
|
271
|
+
* answers credit and usage-limit denials on `/v1/messages` with a 429
|
|
272
|
+
* `rate_limit_error` (never 402, see services/ai DEVELOPING.md "Credit denials
|
|
273
|
+
* on LLM routes are 429"); claude 2.1.287 surfaces it as
|
|
274
|
+
* `API Error: Request rejected (429) · <message>` (fixture
|
|
275
|
+
* `turn-proxy-credits.stream.jsonl`). Credit texts also say "usage limit
|
|
276
|
+
* reached", so they are checked first. An Anthropic subscription limit
|
|
277
|
+
* ("Claude AI usage limit reached") is not an Alfe denial.
|
|
278
|
+
*/
|
|
279
|
+
function classifyProxyDenial(text) {
|
|
280
|
+
const lower = text.toLowerCase();
|
|
281
|
+
if (lower.includes("no ai credits left") || lower.includes("partner account cannot run ai requests") || lower.includes("insufficient_credits") || lower.includes("credits_exhausted")) return "credits";
|
|
282
|
+
if (/\balfe (?:daily |weekly |monthly )?usage limit reached\b/.test(lower)) return "usage_limit";
|
|
283
|
+
if (MODEL_UNAVAILABLE_MARKERS.some((marker) => lower.includes(marker))) return "model_unavailable";
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
176
286
|
/** HTTP statuses on a `result` event that mean "credential refused". */
|
|
177
287
|
function isAuthFailureStatus(status) {
|
|
178
288
|
return status === 401 || status === 403;
|
|
179
289
|
}
|
|
180
290
|
const AUTH_PROBE_TIMEOUT_MS = 6e4;
|
|
181
291
|
const MAX_PROBE_OUTPUT_CHARS = 64e3;
|
|
182
|
-
/**
|
|
183
|
-
|
|
292
|
+
/**
|
|
293
|
+
* argv for the probe. No tools, no session file, no MCP, no settings files.
|
|
294
|
+
* `model` is `haiku` (the alias) on a credential or local login, and the
|
|
295
|
+
* registry Haiku id through the AI proxy.
|
|
296
|
+
*/
|
|
297
|
+
function buildProbeArgs(model = "haiku") {
|
|
184
298
|
return [
|
|
185
299
|
"-p",
|
|
186
300
|
"--input-format",
|
|
@@ -188,7 +302,7 @@ function buildProbeArgs() {
|
|
|
188
302
|
"--output-format",
|
|
189
303
|
"json",
|
|
190
304
|
"--model",
|
|
191
|
-
|
|
305
|
+
model,
|
|
192
306
|
"--tools",
|
|
193
307
|
"",
|
|
194
308
|
"--system-prompt",
|
|
@@ -215,7 +329,15 @@ function parseProbeOutput(stdout, stderr, exitCode) {
|
|
|
215
329
|
if (record.type !== "result") continue;
|
|
216
330
|
if (record.is_error === false) return { result: "ok" };
|
|
217
331
|
const text = typeof record.result === "string" ? record.result : "";
|
|
218
|
-
|
|
332
|
+
const denial = classifyProxyDenial(text);
|
|
333
|
+
if (denial) return {
|
|
334
|
+
result: "denied",
|
|
335
|
+
denial
|
|
336
|
+
};
|
|
337
|
+
if (isAuthFailureStatus(record.api_error_status) || isAuthFailureText(text)) return record.api_error_status === 403 ? {
|
|
338
|
+
result: "invalid",
|
|
339
|
+
forbidden: true
|
|
340
|
+
} : { result: "invalid" };
|
|
219
341
|
return {
|
|
220
342
|
result: "unknown",
|
|
221
343
|
reason: `probe failed (${typeof record.api_error_status === "number" ? `HTTP ${String(record.api_error_status)}` : "no auth error"})`
|
|
@@ -228,12 +350,14 @@ function parseProbeOutput(stdout, stderr, exitCode) {
|
|
|
228
350
|
};
|
|
229
351
|
}
|
|
230
352
|
/**
|
|
231
|
-
* One minimal real turn
|
|
232
|
-
*
|
|
353
|
+
* One minimal real turn on the given auth source: Anthropic with the agent's
|
|
354
|
+
* credential or the machine's own login, or the Alfe AI proxy (a Haiku ping
|
|
355
|
+
* billed from Alfe credits). Never throws.
|
|
233
356
|
*/
|
|
234
357
|
function runAuthProbe(opts) {
|
|
235
358
|
const spawnImpl = opts.spawnImpl ?? node_child_process.spawn;
|
|
236
|
-
const env = buildChildEnv(opts.baseEnv, { MAX_THINKING_TOKENS: "0" }, opts.
|
|
359
|
+
const env = buildChildEnv(opts.baseEnv, { MAX_THINKING_TOKENS: "0" }, opts.auth);
|
|
360
|
+
const model = opts.auth.mode === "ai_proxy" ? PROXY_MODEL_ENV.ANTHROPIC_DEFAULT_HAIKU_MODEL : void 0;
|
|
237
361
|
return new Promise((resolve) => {
|
|
238
362
|
if (opts.signal?.aborted) {
|
|
239
363
|
resolve({
|
|
@@ -266,7 +390,7 @@ function runAuthProbe(opts) {
|
|
|
266
390
|
}
|
|
267
391
|
let child;
|
|
268
392
|
try {
|
|
269
|
-
child = spawnImpl(opts.claudeBin ?? "claude", buildProbeArgs(), {
|
|
393
|
+
child = spawnImpl(opts.claudeBin ?? "claude", buildProbeArgs(model), {
|
|
270
394
|
cwd: opts.cwd,
|
|
271
395
|
env,
|
|
272
396
|
stdio: [
|
|
@@ -338,6 +462,7 @@ var CredentialStatusReporter = class {
|
|
|
338
462
|
async deliver(report) {
|
|
339
463
|
const key = JSON.stringify([
|
|
340
464
|
report.status,
|
|
465
|
+
report.source ?? null,
|
|
341
466
|
report.revision ?? null,
|
|
342
467
|
report.detail ?? null
|
|
343
468
|
]);
|
|
@@ -600,7 +725,8 @@ function mapStreamEvent(state, raw, nowMs) {
|
|
|
600
725
|
finalText: isError ? "" : finalText,
|
|
601
726
|
...durationMs !== void 0 ? { durationMs } : {},
|
|
602
727
|
...errorMessage ? { errorMessage } : {},
|
|
603
|
-
...authFailed ? { authFailed: true } : {}
|
|
728
|
+
...authFailed ? { authFailed: true } : {},
|
|
729
|
+
...authFailed && evt.api_error_status === 403 ? { authForbidden: true } : {}
|
|
604
730
|
}];
|
|
605
731
|
}
|
|
606
732
|
return [];
|
|
@@ -811,7 +937,7 @@ var SessionDriver = class {
|
|
|
811
937
|
abortCurrent = null;
|
|
812
938
|
bin;
|
|
813
939
|
baseEnv;
|
|
814
|
-
|
|
940
|
+
auth;
|
|
815
941
|
managed;
|
|
816
942
|
log;
|
|
817
943
|
/** Injected spawn — tests replace this with a fake child. */
|
|
@@ -819,7 +945,7 @@ var SessionDriver = class {
|
|
|
819
945
|
constructor(opts) {
|
|
820
946
|
this.bin = opts?.claudeBin ?? DEFAULT_CLAUDE_BIN;
|
|
821
947
|
this.baseEnv = opts?.baseEnv ?? process.env;
|
|
822
|
-
this.
|
|
948
|
+
this.auth = opts?.auth ?? { mode: "local_login" };
|
|
823
949
|
this.managed = opts?.managed ?? false;
|
|
824
950
|
this.log = opts?.logger ?? console;
|
|
825
951
|
this.spawnImpl = opts?.__spawn ?? node_child_process.spawn;
|
|
@@ -879,7 +1005,7 @@ var SessionDriver = class {
|
|
|
879
1005
|
let stderr = "";
|
|
880
1006
|
let rateLimited = false;
|
|
881
1007
|
const args = this.buildArgs(params);
|
|
882
|
-
const env = buildChildEnv(this.baseEnv, void 0, this.
|
|
1008
|
+
const env = buildChildEnv(this.baseEnv, void 0, this.auth);
|
|
883
1009
|
return new Promise((resolve) => {
|
|
884
1010
|
let child;
|
|
885
1011
|
try {
|
|
@@ -931,7 +1057,8 @@ var SessionDriver = class {
|
|
|
931
1057
|
finalText: finalIsError ? "" : endText,
|
|
932
1058
|
...finalErrMsg ? { errorMessage: finalErrMsg } : {},
|
|
933
1059
|
aborted,
|
|
934
|
-
...authFailed ? { authFailed: true } : {}
|
|
1060
|
+
...authFailed ? { authFailed: true } : {},
|
|
1061
|
+
...authFailed && end?.type === "turn-end" && end.authForbidden ? { authForbidden: true } : {}
|
|
935
1062
|
});
|
|
936
1063
|
};
|
|
937
1064
|
const requestTermination = (reason) => {
|
|
@@ -1059,6 +1186,27 @@ async function writeTextAtomically(path, contents) {
|
|
|
1059
1186
|
*
|
|
1060
1187
|
* where `sessionId = conversationId ?? sessionKey`.
|
|
1061
1188
|
*/
|
|
1189
|
+
/**
|
|
1190
|
+
* The channel every chat session is stored on. Every chat client lists with
|
|
1191
|
+
* `sessions.list { channel: 'alfe' }` and openclaw-chat stores `'alfe'`, so any
|
|
1192
|
+
* other value hides the conversation from the rail.
|
|
1193
|
+
*/
|
|
1194
|
+
const CHAT_SESSION_CHANNEL = "alfe";
|
|
1195
|
+
/**
|
|
1196
|
+
* Values hosts up to 0.1.16 stored as `channel`: the chat surface label
|
|
1197
|
+
* (`params.clientType ?? 'chat'`). Read as `CHAT_SESSION_CHANNEL`; the file is
|
|
1198
|
+
* rewritten on its next save.
|
|
1199
|
+
*/
|
|
1200
|
+
const LEGACY_SURFACE_CHANNELS = new Set([
|
|
1201
|
+
"chat",
|
|
1202
|
+
"dashboard",
|
|
1203
|
+
"mobile",
|
|
1204
|
+
"desktop",
|
|
1205
|
+
"widget",
|
|
1206
|
+
"unknown"
|
|
1207
|
+
]);
|
|
1208
|
+
/** Legacy values that never named a real surface, so carry no `clientType`. */
|
|
1209
|
+
const LEGACY_PLACEHOLDER_CHANNELS = new Set(["chat", "unknown"]);
|
|
1062
1210
|
function sessionsDir() {
|
|
1063
1211
|
return process.env.ALFE_SESSIONS_DIR ?? (0, node_path.join)((0, node_os.homedir)(), ".alfe", "sessions", "chat");
|
|
1064
1212
|
}
|
|
@@ -1077,6 +1225,7 @@ function sessionPath(sessionId) {
|
|
|
1077
1225
|
function asSessionData(value, expectedSessionId) {
|
|
1078
1226
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1079
1227
|
const record = value;
|
|
1228
|
+
normaliseLegacyChannel(record);
|
|
1080
1229
|
if (typeof record.sessionId !== "string" || expectedSessionId !== void 0 && record.sessionId !== expectedSessionId || typeof record.agentId !== "string" || typeof record.channel !== "string" || typeof record.createdAt !== "string" || typeof record.updatedAt !== "string" || !Array.isArray(record.messages)) return null;
|
|
1081
1230
|
if (!record.messages.every((message) => {
|
|
1082
1231
|
if (!message || typeof message !== "object" || Array.isArray(message)) return false;
|
|
@@ -1087,6 +1236,13 @@ function asSessionData(value, expectedSessionId) {
|
|
|
1087
1236
|
if (record.memory !== void 0 && !isMemoryCursor(record.memory)) delete record.memory;
|
|
1088
1237
|
return value;
|
|
1089
1238
|
}
|
|
1239
|
+
/** Map a pre-0.1.17 surface-label channel (or none) onto `CHAT_SESSION_CHANNEL`. */
|
|
1240
|
+
function normaliseLegacyChannel(record) {
|
|
1241
|
+
const channel = record.channel;
|
|
1242
|
+
if (channel !== void 0 && !(typeof channel === "string" && LEGACY_SURFACE_CHANNELS.has(channel))) return;
|
|
1243
|
+
if (typeof channel === "string" && !LEGACY_PLACEHOLDER_CHANNELS.has(channel) && record.clientType === void 0) record.clientType = channel;
|
|
1244
|
+
record.channel = CHAT_SESSION_CHANNEL;
|
|
1245
|
+
}
|
|
1090
1246
|
function isMemoryCursor(value) {
|
|
1091
1247
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1092
1248
|
const cursor = value;
|
|
@@ -1141,12 +1297,13 @@ async function saveSession(session) {
|
|
|
1141
1297
|
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1142
1298
|
await writeTextAtomically(sessionPath(session.sessionId), JSON.stringify(session, null, 2));
|
|
1143
1299
|
}
|
|
1144
|
-
async function createSession(sessionId, agentId, channel, tenantId, userId) {
|
|
1300
|
+
async function createSession(sessionId, agentId, channel, tenantId, userId, clientType) {
|
|
1145
1301
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1146
1302
|
const session = {
|
|
1147
1303
|
sessionId,
|
|
1148
1304
|
agentId,
|
|
1149
1305
|
channel,
|
|
1306
|
+
...clientType ? { clientType } : {},
|
|
1150
1307
|
tenantId,
|
|
1151
1308
|
userId,
|
|
1152
1309
|
createdAt: now,
|
|
@@ -1180,17 +1337,35 @@ async function withSessionLock(sessionId, fn) {
|
|
|
1180
1337
|
* memory-ingest index, or -1 when the session does not exist.
|
|
1181
1338
|
*/
|
|
1182
1339
|
async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
|
|
1340
|
+
return appendMessage(sessionId, {
|
|
1341
|
+
role,
|
|
1342
|
+
content,
|
|
1343
|
+
...senderId ? { senderId } : {},
|
|
1344
|
+
...senderName ? { senderName } : {},
|
|
1345
|
+
...components?.length ? { components } : {},
|
|
1346
|
+
...attachments?.length ? { attachments } : {}
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Store the failure message a turn settled with as the assistant's reply, so
|
|
1351
|
+
* the history shows what the user saw. Returns its index, or -1 when the
|
|
1352
|
+
* session does not exist.
|
|
1353
|
+
*/
|
|
1354
|
+
async function addErrorMessage(sessionId, content) {
|
|
1355
|
+
return appendMessage(sessionId, {
|
|
1356
|
+
role: "assistant",
|
|
1357
|
+
content,
|
|
1358
|
+
kind: "error"
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
async function appendMessage(sessionId, message) {
|
|
1183
1362
|
return withSessionLock(sessionId, async () => {
|
|
1184
1363
|
const session = await getSession(sessionId);
|
|
1185
1364
|
if (!session) return -1;
|
|
1365
|
+
const last = session.messages.at(-1)?.timestamp ?? 0;
|
|
1186
1366
|
session.messages.push({
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
timestamp: Date.now(),
|
|
1190
|
-
...senderId ? { senderId } : {},
|
|
1191
|
-
...senderName ? { senderName } : {},
|
|
1192
|
-
...components?.length ? { components } : {},
|
|
1193
|
-
...attachments?.length ? { attachments } : {}
|
|
1367
|
+
...message,
|
|
1368
|
+
timestamp: Math.max(Date.now(), last + 1)
|
|
1194
1369
|
});
|
|
1195
1370
|
await saveSession(session);
|
|
1196
1371
|
return session.messages.length - 1;
|
|
@@ -1278,8 +1453,8 @@ async function ensureMemoryCursor(sessionId) {
|
|
|
1278
1453
|
/**
|
|
1279
1454
|
* The messages not yet accepted by the memory service, oldest first, bounded
|
|
1280
1455
|
* by count and serialised bytes (always at least one message when any is
|
|
1281
|
-
* pending).
|
|
1282
|
-
* content) but still covered: `throughIndex` is the last index this batch
|
|
1456
|
+
* pending). Error replies (history only) and whitespace-only messages (the
|
|
1457
|
+
* service rejects empty content) are skipped but still covered: `throughIndex` is the last index this batch
|
|
1283
1458
|
* accounts for, sent or skipped. Leftovers go out with the next turn's batch.
|
|
1284
1459
|
*/
|
|
1285
1460
|
async function pendingMemoryBatch(sessionId, opts = {}) {
|
|
@@ -1297,7 +1472,7 @@ async function pendingMemoryBatch(sessionId, opts = {}) {
|
|
|
1297
1472
|
let bytes = 0;
|
|
1298
1473
|
for (let index = session.memory.lastIngestedIndex + 1; index < session.messages.length && messages.length < maxMessages; index += 1) {
|
|
1299
1474
|
const message = session.messages[index];
|
|
1300
|
-
if (message.content.trim() === "") {
|
|
1475
|
+
if (message.kind === "error" || message.content.trim() === "") {
|
|
1301
1476
|
throughIndex = index;
|
|
1302
1477
|
continue;
|
|
1303
1478
|
}
|
|
@@ -1585,14 +1760,15 @@ const RECALL_TIMEOUT_MS = 5e3;
|
|
|
1585
1760
|
//#endregion
|
|
1586
1761
|
//#region src/host.ts
|
|
1587
1762
|
/**
|
|
1588
|
-
* ClaudeCodeHost — the persistent chat bridge for a
|
|
1589
|
-
*
|
|
1763
|
+
* ClaudeCodeHost — the persistent chat bridge for a Claude Code agent
|
|
1764
|
+
* (self-hosted or managed).
|
|
1590
1765
|
*
|
|
1591
1766
|
* It holds ONE `ChatServiceClient` (reused verbatim from `@alfe.ai/chat`, with
|
|
1592
1767
|
* NO custom reconnect — the client already backs off with jitter and guards
|
|
1593
1768
|
* against orphan sockets), registers the inbound RPC handlers the chat service
|
|
1594
|
-
* calls, and drives the `SessionDriver` to run `claude -p` turns on
|
|
1595
|
-
*
|
|
1769
|
+
* calls, and drives the `SessionDriver` to run `claude -p` turns on one auth
|
|
1770
|
+
* source: the agent's stored credential, a self-hosted machine's own login, or
|
|
1771
|
+
* the Alfe AI proxy (see DEVELOPING.md "Auth source resolution").
|
|
1596
1772
|
*
|
|
1597
1773
|
* Every wire shape here is byte-for-byte the shape the `@alfe.ai/openclaw-chat`
|
|
1598
1774
|
* plugin emits, so the chat service / adapter / UI treat a Claude Code turn
|
|
@@ -1630,9 +1806,28 @@ const PERSONA_POINTER_SYSTEM_PROMPT = [
|
|
|
1630
1806
|
"Fully embody that persona in how you speak and act; respond AS that agent, not as a generic coding assistant."
|
|
1631
1807
|
].join("\n");
|
|
1632
1808
|
const CREDENTIAL_INVALID_MESSAGE = "This agent's Claude credential is invalid or expired. Update it in agent settings.";
|
|
1633
|
-
const
|
|
1634
|
-
const
|
|
1809
|
+
const OUT_OF_CREDITS_MESSAGE = "This agent is out of Alfe credits. Add credits in Alfe Billing, or add a Claude credential in agent settings, then resend your message.";
|
|
1810
|
+
const USAGE_LIMIT_MESSAGE = "This agent hit an Alfe usage limit. Raise the limit in Alfe or wait for it to reset, then resend your message.";
|
|
1811
|
+
const LOCAL_LOGIN_SWITCHED_MESSAGE = "Your machine's Claude login stopped working, so this agent switched to Alfe credits. Please resend your message.";
|
|
1812
|
+
const LOCAL_LOGIN_REFUSED_MESSAGE = "Claude refused this machine's claude login (403). Check the account or its organisation policy, or add a Claude credential in agent settings.";
|
|
1813
|
+
const MODEL_UNAVAILABLE_MESSAGE = "This agent's model isn't available on this workspace. Choose another Claude model in agent settings, then resend your message.";
|
|
1814
|
+
const PROXY_AUTH_FAILED_MESSAGE = "Alfe AI rejected this agent's key. Reconnect the agent (run `alfe setup` on its machine), or add a Claude credential in agent settings.";
|
|
1635
1815
|
const CREDENTIAL_UNAVAILABLE_MESSAGE = "This agent's Claude credential couldn't be loaded yet. Try again in a moment.";
|
|
1816
|
+
/** Fixed status details (never `claude` output). */
|
|
1817
|
+
const SOURCE_OK_DETAIL = {
|
|
1818
|
+
local_login: "Using this machine's claude login",
|
|
1819
|
+
ai_proxy: "Using Alfe AI credits"
|
|
1820
|
+
};
|
|
1821
|
+
const DENIAL_DETAIL = {
|
|
1822
|
+
credits: "Using Alfe AI credits. This workspace is out of credits.",
|
|
1823
|
+
usage_limit: "Using Alfe AI credits. An Alfe usage limit has been reached.",
|
|
1824
|
+
model_unavailable: "Using Alfe AI credits. The agent's model is not available on this workspace."
|
|
1825
|
+
};
|
|
1826
|
+
const DENIAL_MESSAGE = {
|
|
1827
|
+
credits: OUT_OF_CREDITS_MESSAGE,
|
|
1828
|
+
usage_limit: USAGE_LIMIT_MESSAGE,
|
|
1829
|
+
model_unavailable: MODEL_UNAVAILABLE_MESSAGE
|
|
1830
|
+
};
|
|
1636
1831
|
const MAX_AGENT_MESSAGE_BYTES = 256 * 1024;
|
|
1637
1832
|
const MAX_SESSION_KEY_CHARS = 768;
|
|
1638
1833
|
const MAX_IDENTIFIER_CHARS = 512;
|
|
@@ -1856,8 +2051,16 @@ var ClaudeCodeHost = class {
|
|
|
1856
2051
|
/** Turns waiting for the credential, before queue admission (chat.abort). */
|
|
1857
2052
|
waitingTurns = /* @__PURE__ */ new Map();
|
|
1858
2053
|
loaderRestartMs;
|
|
2054
|
+
/** How often a fallen-back self-hosted agent re-checks its local login. */
|
|
2055
|
+
localLoginRecheckMs;
|
|
2056
|
+
localLoginRecheck = null;
|
|
1859
2057
|
/** In memory only. Never logged, never persisted. */
|
|
1860
2058
|
credential = null;
|
|
2059
|
+
/**
|
|
2060
|
+
* The auth every new turn runs on, resolved once the credential fetch
|
|
2061
|
+
* answers (see `resolveAuthSource`). Null before that.
|
|
2062
|
+
*/
|
|
2063
|
+
authSource = null;
|
|
1861
2064
|
credentialAbort = new AbortController();
|
|
1862
2065
|
reporter;
|
|
1863
2066
|
probe;
|
|
@@ -1867,18 +2070,18 @@ var ClaudeCodeHost = class {
|
|
|
1867
2070
|
this.cfg = cfg;
|
|
1868
2071
|
this.log = cfg.logger ?? console;
|
|
1869
2072
|
this.claudeSessionsPath = cfg.claudeSessionsPath ?? (0, node_path.join)((0, node_os.homedir)(), ".alfe", "claude-code", "sessions.json");
|
|
1870
|
-
this.makeDriver = deps?.makeDriver ?? ((
|
|
2073
|
+
this.makeDriver = deps?.makeDriver ?? ((auth) => new SessionDriver({
|
|
1871
2074
|
logger: this.log,
|
|
1872
2075
|
baseEnv: process.env,
|
|
1873
|
-
|
|
2076
|
+
auth,
|
|
1874
2077
|
managed: cfg.managed === true
|
|
1875
2078
|
}));
|
|
1876
2079
|
this.api = deps?.api ?? new _alfe_ai_agent_api_client.AgentApiClient({
|
|
1877
2080
|
apiKey: cfg.apiKey,
|
|
1878
2081
|
apiUrl: cfg.apiUrl
|
|
1879
2082
|
});
|
|
1880
|
-
this.probe = deps?.probe ?? ((
|
|
1881
|
-
|
|
2083
|
+
this.probe = deps?.probe ?? ((auth, signal) => runAuthProbe({
|
|
2084
|
+
auth,
|
|
1882
2085
|
baseEnv: process.env,
|
|
1883
2086
|
cwd: (0, node_os.tmpdir)(),
|
|
1884
2087
|
signal
|
|
@@ -1886,6 +2089,7 @@ var ClaudeCodeHost = class {
|
|
|
1886
2089
|
this.credentialWaitMs = deps?.credentialWaitMs ?? 15e3;
|
|
1887
2090
|
this.loaderRestartMs = deps?.loaderRestartMs ?? 5e3;
|
|
1888
2091
|
this.credentialRetry = deps?.credentialRetry ?? {};
|
|
2092
|
+
this.localLoginRecheckMs = deps?.localLoginRecheckMs ?? 9e5;
|
|
1889
2093
|
this.reporter = new CredentialStatusReporter((report) => this.api.reportClaudeCredentialStatus(report), { log: this.log });
|
|
1890
2094
|
this.chat = deps?.chat ?? new _alfe_ai_chat.ChatServiceClient({
|
|
1891
2095
|
wsUrl: cfg.wsUrl,
|
|
@@ -1939,49 +2143,117 @@ var ClaudeCodeHost = class {
|
|
|
1939
2143
|
});
|
|
1940
2144
|
if (!loaded) return;
|
|
1941
2145
|
this.credential = loaded.credential;
|
|
2146
|
+
this.authSource = loaded.credential ? "credential" : this.cfg.managed ? "ai_proxy" : "local_login";
|
|
1942
2147
|
this.credentialLoaded = true;
|
|
1943
2148
|
this.resolveCredentialReady();
|
|
1944
2149
|
await this.checkCredential();
|
|
1945
2150
|
}
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2151
|
+
/**
|
|
2152
|
+
* A method, not a property read: `stopping` flips across awaits, which
|
|
2153
|
+
* control-flow narrowing cannot see.
|
|
2154
|
+
*/
|
|
2155
|
+
isStopping() {
|
|
2156
|
+
return this.stopping;
|
|
2157
|
+
}
|
|
2158
|
+
/** Same reason as `isStopping`: `authSource` can change across an await. */
|
|
2159
|
+
currentAuthSource() {
|
|
2160
|
+
return this.authSource;
|
|
2161
|
+
}
|
|
2162
|
+
/** The auth a new turn runs on. Local login until the fetch has answered. */
|
|
2163
|
+
childAuth() {
|
|
2164
|
+
if (this.authSource === "credential" && this.credential) return {
|
|
2165
|
+
mode: "credential",
|
|
2166
|
+
credential: {
|
|
2167
|
+
kind: this.credential.kind,
|
|
2168
|
+
value: this.credential.value
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
if (this.authSource === "ai_proxy") return {
|
|
2172
|
+
mode: "ai_proxy",
|
|
2173
|
+
baseUrl: this.cfg.localAiProxyUrl ?? "http://127.0.0.1:18193",
|
|
2174
|
+
model: this.cfg.model ?? "claude-haiku-4-5"
|
|
2175
|
+
};
|
|
2176
|
+
return { mode: "local_login" };
|
|
1951
2177
|
}
|
|
1952
|
-
/**
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2178
|
+
/**
|
|
2179
|
+
* A self-hosted agent with no credential whose local login was refused (401
|
|
2180
|
+
* or an explicit auth message, never a 403): run new turns through the Alfe
|
|
2181
|
+
* AI proxy, and re-check the login periodically so a re-login moves the agent
|
|
2182
|
+
* back off Alfe credits. Never for a stored credential (the user chose it; a
|
|
2183
|
+
* refusal there needs their fix) and never on managed.
|
|
2184
|
+
*/
|
|
2185
|
+
fallBackToProxy(why) {
|
|
2186
|
+
if (this.authSource !== "local_login") return;
|
|
2187
|
+
this.log.warn(`[claude-host] the claude login on this machine was refused (${why}); using the Alfe AI proxy`);
|
|
2188
|
+
this.authSource = "ai_proxy";
|
|
2189
|
+
this.scheduleLocalLoginRecheck();
|
|
2190
|
+
}
|
|
2191
|
+
scheduleLocalLoginRecheck() {
|
|
2192
|
+
if (this.localLoginRecheck || this.stopping) return;
|
|
2193
|
+
this.localLoginRecheck = setTimeout(() => {
|
|
2194
|
+
this.localLoginRecheck = null;
|
|
2195
|
+
this.recheckLocalLogin();
|
|
2196
|
+
}, this.localLoginRecheckMs);
|
|
2197
|
+
this.localLoginRecheck.unref();
|
|
2198
|
+
}
|
|
2199
|
+
/** While fallen back: one local-login probe; back to the login when it works. */
|
|
2200
|
+
async recheckLocalLogin() {
|
|
2201
|
+
if (this.isStopping() || this.authSource !== "ai_proxy" || this.credential || this.cfg.managed) return;
|
|
2202
|
+
let outcome;
|
|
2203
|
+
try {
|
|
2204
|
+
outcome = await this.probe({ mode: "local_login" }, this.credentialAbort.signal);
|
|
2205
|
+
} catch (err) {
|
|
2206
|
+
outcome = {
|
|
2207
|
+
result: "unknown",
|
|
2208
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
if (this.isStopping() || this.currentAuthSource() !== "ai_proxy") return;
|
|
2212
|
+
if (outcome.result === "ok") {
|
|
2213
|
+
this.log.info("[claude-host] the claude login on this machine works again; leaving the Alfe AI proxy");
|
|
2214
|
+
this.authSource = "local_login";
|
|
2215
|
+
await this.reportStatus("ok");
|
|
1959
2216
|
return;
|
|
1960
2217
|
}
|
|
1961
|
-
|
|
2218
|
+
this.scheduleLocalLoginRecheck();
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* Start-up check: one minimal real call on the resolved source, reported to
|
|
2222
|
+
* the cloud. A refused local login falls back to the proxy and checks that.
|
|
2223
|
+
*/
|
|
2224
|
+
async checkCredential() {
|
|
2225
|
+
let outcome = await this.probe(this.childAuth(), this.credentialAbort.signal);
|
|
1962
2226
|
if (this.stopping) return;
|
|
1963
|
-
if (outcome.result === "
|
|
1964
|
-
this.
|
|
1965
|
-
|
|
2227
|
+
if (outcome.result === "invalid" && !outcome.forbidden && this.authSource === "local_login") {
|
|
2228
|
+
this.fallBackToProxy("start-up check");
|
|
2229
|
+
outcome = await this.probe(this.childAuth(), this.credentialAbort.signal);
|
|
2230
|
+
if (this.isStopping()) return;
|
|
1966
2231
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2232
|
+
switch (outcome.result) {
|
|
2233
|
+
case "unknown":
|
|
2234
|
+
this.log.warn(`[claude-host] Claude auth check inconclusive (${outcome.reason}); the next turn will tell`);
|
|
2235
|
+
return;
|
|
2236
|
+
case "denied":
|
|
2237
|
+
if (outcome.denial === "model_unavailable") {
|
|
2238
|
+
this.log.warn("[claude-host] the probe model is unavailable through the Alfe AI proxy; the next turn will tell");
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
await this.reportStatus("ok", DENIAL_DETAIL[outcome.denial]);
|
|
2242
|
+
return;
|
|
2243
|
+
default: await this.reportStatus(outcome.result);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
/** Report the current auth source as working or refused. */
|
|
2247
|
+
async reportStatus(status, detail) {
|
|
2248
|
+
const source = this.authSource;
|
|
2249
|
+
if (!this.credentialLoaded || !source) return;
|
|
2250
|
+
const revision = source === "credential" ? this.credential?.revision : void 0;
|
|
2251
|
+
const fixedDetail = detail ?? (status === "ok" ? source === "credential" ? void 0 : SOURCE_OK_DETAIL[source] : source === "credential" ? "Anthropic rejected the stored credential" : source === "ai_proxy" ? "The Alfe AI proxy rejected this agent's key" : "The claude login on this machine is not signed in");
|
|
2252
|
+
await this.reporter.report({
|
|
2253
|
+
status,
|
|
2254
|
+
source,
|
|
2255
|
+
...fixedDetail ? { detail: fixedDetail } : {},
|
|
2256
|
+
...revision ? { revision } : {}
|
|
1985
2257
|
});
|
|
1986
2258
|
}
|
|
1987
2259
|
/**
|
|
@@ -2025,7 +2297,6 @@ var ClaudeCodeHost = class {
|
|
|
2025
2297
|
credentialGateMessage() {
|
|
2026
2298
|
if (!this.credentialReady) return null;
|
|
2027
2299
|
if (!this.credentialLoaded) return CREDENTIAL_UNAVAILABLE_MESSAGE;
|
|
2028
|
-
if (!this.credential && this.cfg.managed) return CREDENTIAL_MISSING_MESSAGE;
|
|
2029
2300
|
return null;
|
|
2030
2301
|
}
|
|
2031
2302
|
/** Stop admission, abort owned children, and give active turns time to settle. */
|
|
@@ -2033,6 +2304,10 @@ var ClaudeCodeHost = class {
|
|
|
2033
2304
|
if (this.stopPromise) return this.stopPromise;
|
|
2034
2305
|
this.stopping = true;
|
|
2035
2306
|
this.credentialAbort.abort();
|
|
2307
|
+
if (this.localLoginRecheck) {
|
|
2308
|
+
clearTimeout(this.localLoginRecheck);
|
|
2309
|
+
this.localLoginRecheck = null;
|
|
2310
|
+
}
|
|
2036
2311
|
for (const waiting of this.waitingTurns.values()) for (const turn of waiting) turn.cancellation.abort();
|
|
2037
2312
|
this.queues.stop();
|
|
2038
2313
|
for (const { driver, cancellation } of this.runningTurns.values()) {
|
|
@@ -2182,10 +2457,7 @@ var ClaudeCodeHost = class {
|
|
|
2182
2457
|
await addMessage(bareSessionId, "user", params.message, params.identityId ?? params.userId, params.displayName);
|
|
2183
2458
|
const credentialProblem = this.credentialGateMessage();
|
|
2184
2459
|
if (credentialProblem) {
|
|
2185
|
-
complete
|
|
2186
|
-
ok: false,
|
|
2187
|
-
payload: { message: credentialProblem }
|
|
2188
|
-
});
|
|
2460
|
+
await this.failTurn(bareSessionId, complete, credentialProblem);
|
|
2189
2461
|
return;
|
|
2190
2462
|
}
|
|
2191
2463
|
let memoryContext = "";
|
|
@@ -2200,26 +2472,36 @@ var ClaudeCodeHost = class {
|
|
|
2200
2472
|
const mcpConfigPath = this.cfg.mcpConfigPath && (0, node_fs.existsSync)(this.cfg.mcpConfigPath) ? this.cfg.mcpConfigPath : void 0;
|
|
2201
2473
|
const appendSystemPrompt = this.buildSystemPrompt(memoryContext, isVoice);
|
|
2202
2474
|
const claudeSessionId = this.claudeSessionIds.get(bareSessionId) ?? null;
|
|
2203
|
-
const driver = this.makeDriver(this.childCredential());
|
|
2204
2475
|
const cancellation = new AbortController();
|
|
2205
|
-
this.
|
|
2206
|
-
|
|
2207
|
-
driver,
|
|
2208
|
-
cancellation
|
|
2209
|
-
});
|
|
2476
|
+
let auth = this.childAuth();
|
|
2477
|
+
let switchedWithoutRerun = false;
|
|
2210
2478
|
let result;
|
|
2211
2479
|
try {
|
|
2212
|
-
result = await this.
|
|
2480
|
+
result = await this.runTurnOn(auth, sessionKey, runId, cancellation, bareSessionId, {
|
|
2213
2481
|
prompt: params.message,
|
|
2214
2482
|
claudeSessionId,
|
|
2215
2483
|
cwd: this.cfg.workspace,
|
|
2216
2484
|
...mcpConfigPath ? { mcpConfigPath } : {},
|
|
2217
2485
|
...appendSystemPrompt ? { appendSystemPrompt } : {},
|
|
2218
|
-
...this.cfg.model ? { model: this.cfg.model } : {},
|
|
2219
2486
|
onEmission: (emission) => {
|
|
2220
2487
|
this.forwardEmission(runId, sessionKey, emission);
|
|
2221
2488
|
}
|
|
2222
2489
|
}, isVoice);
|
|
2490
|
+
if (auth.mode === "local_login" && result.isError && result.authFailed && !result.authForbidden && !result.aborted && !cancellation.signal.aborted && !this.isStopping()) {
|
|
2491
|
+
this.fallBackToProxy("turn refused");
|
|
2492
|
+
if (producedVisibleOutput(result.emissions)) switchedWithoutRerun = true;
|
|
2493
|
+
else auth = this.childAuth();
|
|
2494
|
+
if (!switchedWithoutRerun && auth.mode === "ai_proxy") result = await this.runTurnOn(auth, sessionKey, runId, cancellation, bareSessionId, {
|
|
2495
|
+
prompt: params.message,
|
|
2496
|
+
claudeSessionId,
|
|
2497
|
+
cwd: this.cfg.workspace,
|
|
2498
|
+
...mcpConfigPath ? { mcpConfigPath } : {},
|
|
2499
|
+
...appendSystemPrompt ? { appendSystemPrompt } : {},
|
|
2500
|
+
onEmission: (emission) => {
|
|
2501
|
+
this.forwardEmission(runId, sessionKey, emission);
|
|
2502
|
+
}
|
|
2503
|
+
}, isVoice);
|
|
2504
|
+
}
|
|
2223
2505
|
} finally {
|
|
2224
2506
|
this.runningTurns.delete(sessionKey);
|
|
2225
2507
|
}
|
|
@@ -2237,21 +2519,30 @@ var ClaudeCodeHost = class {
|
|
|
2237
2519
|
return;
|
|
2238
2520
|
}
|
|
2239
2521
|
if (result.isError) {
|
|
2522
|
+
const denial = auth.mode === "ai_proxy" ? classifyProxyDenial(result.errorMessage ?? "") : null;
|
|
2523
|
+
if (denial) {
|
|
2524
|
+
await this.failTurn(bareSessionId, complete, DENIAL_MESSAGE[denial]);
|
|
2525
|
+
await this.reportStatus("ok", DENIAL_DETAIL[denial]);
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
if (switchedWithoutRerun) {
|
|
2529
|
+
await this.failTurn(bareSessionId, complete, LOCAL_LOGIN_SWITCHED_MESSAGE);
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
if (result.authFailed && auth.mode === "local_login") {
|
|
2533
|
+
await this.failTurn(bareSessionId, complete, result.authForbidden ? LOCAL_LOGIN_REFUSED_MESSAGE : CREDENTIAL_INVALID_MESSAGE);
|
|
2534
|
+
await this.reportStatus("invalid");
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2240
2537
|
if (result.authFailed) {
|
|
2241
|
-
complete
|
|
2242
|
-
|
|
2243
|
-
payload: { message: this.credential ? CREDENTIAL_INVALID_MESSAGE : LOCAL_LOGIN_FAILED_MESSAGE }
|
|
2244
|
-
});
|
|
2245
|
-
await this.reportAuth(false);
|
|
2538
|
+
await this.failTurn(bareSessionId, complete, auth.mode === "ai_proxy" ? PROXY_AUTH_FAILED_MESSAGE : CREDENTIAL_INVALID_MESSAGE);
|
|
2539
|
+
await this.reportStatus("invalid");
|
|
2246
2540
|
return;
|
|
2247
2541
|
}
|
|
2248
|
-
complete
|
|
2249
|
-
ok: false,
|
|
2250
|
-
payload: { message: result.errorMessage ?? "Claude Code turn failed" }
|
|
2251
|
-
});
|
|
2542
|
+
await this.failTurn(bareSessionId, complete, result.errorMessage ?? "Claude Code turn failed");
|
|
2252
2543
|
return;
|
|
2253
2544
|
}
|
|
2254
|
-
this.
|
|
2545
|
+
this.reportStatus("ok");
|
|
2255
2546
|
if (result.finalText) await addMessage(bareSessionId, "assistant", result.finalText);
|
|
2256
2547
|
const notification = result.finalText ? {
|
|
2257
2548
|
conversationId: bareSessionId,
|
|
@@ -2267,6 +2558,23 @@ var ClaudeCodeHost = class {
|
|
|
2267
2558
|
await this.ingestPendingMemory(bareSessionId, params);
|
|
2268
2559
|
}
|
|
2269
2560
|
/**
|
|
2561
|
+
* Settle a failed turn (C3) after storing its message as the assistant's
|
|
2562
|
+
* reply, so the history shows what the user saw. Stored first: a client that
|
|
2563
|
+
* reloads on the failure must find it. Best-effort: a store failure never
|
|
2564
|
+
* changes the outcome.
|
|
2565
|
+
*/
|
|
2566
|
+
async failTurn(bareSessionId, complete, message) {
|
|
2567
|
+
try {
|
|
2568
|
+
await addErrorMessage(bareSessionId, message);
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
this.log.warn(`[claude-host] failed to store a turn error in history: ${err instanceof Error ? err.message : String(err)}`);
|
|
2571
|
+
}
|
|
2572
|
+
complete({
|
|
2573
|
+
ok: false,
|
|
2574
|
+
payload: { message }
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2270
2578
|
* Send every message the memory service has not accepted yet. Indices are
|
|
2271
2579
|
* the messages' positions in the session file, so they advance across turns
|
|
2272
2580
|
* and survive host restarts; the per-conversation `ingestEpoch` tells the
|
|
@@ -2324,6 +2632,24 @@ var ClaudeCodeHost = class {
|
|
|
2324
2632
|
* Run a turn; on a `--resume` failure (stale/missing claude session id) start
|
|
2325
2633
|
* fresh and prepend the last-N stored messages so the model keeps context.
|
|
2326
2634
|
*/
|
|
2635
|
+
/**
|
|
2636
|
+
* Run one turn on `auth` with its own driver, registered as the session's
|
|
2637
|
+
* running turn so `chat.abort` reaches it. Proxy turns always carry a
|
|
2638
|
+
* registry model.
|
|
2639
|
+
*/
|
|
2640
|
+
runTurnOn(auth, sessionKey, runId, cancellation, bareSessionId, baseParams, isVoice) {
|
|
2641
|
+
const driver = this.makeDriver(auth);
|
|
2642
|
+
this.runningTurns.set(sessionKey, {
|
|
2643
|
+
runId,
|
|
2644
|
+
driver,
|
|
2645
|
+
cancellation
|
|
2646
|
+
});
|
|
2647
|
+
const model = auth.mode === "ai_proxy" ? auth.model : this.cfg.model;
|
|
2648
|
+
return this.runWithResumeFallback(driver, bareSessionId, cancellation.signal, {
|
|
2649
|
+
...baseParams,
|
|
2650
|
+
...model ? { model } : {}
|
|
2651
|
+
}, isVoice);
|
|
2652
|
+
}
|
|
2327
2653
|
async runWithResumeFallback(driver, bareSessionId, cancellation, baseParams, isVoice) {
|
|
2328
2654
|
const cancelled = () => cancellation.aborted || this.stopping;
|
|
2329
2655
|
const aborted = (result) => ({
|
|
@@ -2449,7 +2775,7 @@ var ClaudeCodeHost = class {
|
|
|
2449
2775
|
async buildHistoryPreamble(bareSessionId) {
|
|
2450
2776
|
const session = await getSession(bareSessionId);
|
|
2451
2777
|
if (!session || session.messages.length === 0) return "";
|
|
2452
|
-
const prior = session.messages.slice(0, -1).slice(-10);
|
|
2778
|
+
const prior = session.messages.slice(0, -1).filter((m) => m.kind !== "error").slice(-10);
|
|
2453
2779
|
if (prior.length === 0) return "";
|
|
2454
2780
|
return `Earlier in this conversation:\n${prior.map((m) => `${m.role === "assistant" ? "Assistant" : "User"}: ${m.content}`).join("\n")}`;
|
|
2455
2781
|
}
|
|
@@ -2459,7 +2785,7 @@ var ClaudeCodeHost = class {
|
|
|
2459
2785
|
if (params.tenantId && existing.tenantId && params.tenantId !== existing.tenantId || params.userId && existing.userId && params.userId !== existing.userId) throw new Error("Session ownership metadata mismatch");
|
|
2460
2786
|
return;
|
|
2461
2787
|
}
|
|
2462
|
-
await createSession(bareSessionId, params.agentId, params.
|
|
2788
|
+
await createSession(bareSessionId, params.agentId, CHAT_SESSION_CHANNEL, params.tenantId, params.userId, params.clientType);
|
|
2463
2789
|
}
|
|
2464
2790
|
async loadClaudeSessionIds() {
|
|
2465
2791
|
try {
|
|
@@ -2535,6 +2861,13 @@ function stripAgentPrefix(sessionKey) {
|
|
|
2535
2861
|
if (parts.length >= 3 && parts[0] === "agent") return parts.slice(2).join(":");
|
|
2536
2862
|
return sessionKey;
|
|
2537
2863
|
}
|
|
2864
|
+
/**
|
|
2865
|
+
* True when a turn already showed the user something (text or any activity
|
|
2866
|
+
* card). A refused turn that did is never re-run: its tools may have run.
|
|
2867
|
+
*/
|
|
2868
|
+
function producedVisibleOutput(emissions) {
|
|
2869
|
+
return emissions.some((emission) => emission.type === "chat-activity" || emission.type === "chat-delta" && emission.text.trim().length > 0);
|
|
2870
|
+
}
|
|
2538
2871
|
//#endregion
|
|
2539
2872
|
//#region src/host-config.ts
|
|
2540
2873
|
/**
|
|
@@ -2556,6 +2889,7 @@ function buildHostConfig(resolved, home, chat, env) {
|
|
|
2556
2889
|
if (!chat.apiKey || !chat.chatWsUrl) throw new Error("Unable to resolve chat service URL / API key from ~/.alfe/config.toml. Run `alfe login`.");
|
|
2557
2890
|
const model = env.ALFE_DEFAULT_MODEL?.trim();
|
|
2558
2891
|
const managedFlag = env.ALFE_MANAGED?.trim().toLowerCase();
|
|
2892
|
+
const localAiProxyUrl = parseLoopbackUrl(env.ALFE_LOCAL_AI_PROXY_URL);
|
|
2559
2893
|
return {
|
|
2560
2894
|
wsUrl: chat.chatWsUrl,
|
|
2561
2895
|
apiKey: chat.apiKey,
|
|
@@ -2564,10 +2898,30 @@ function buildHostConfig(resolved, home, chat, env) {
|
|
|
2564
2898
|
mcpConfigPath: (0, node_path.join)(home, "mcp-config.json"),
|
|
2565
2899
|
claudeSessionsPath: (0, node_path.join)((0, node_os.homedir)(), ".alfe", "claude-code", "sessions.json"),
|
|
2566
2900
|
...model ? { model } : {},
|
|
2567
|
-
managed: managedFlag === "1" || managedFlag === "true"
|
|
2901
|
+
managed: managedFlag === "1" || managedFlag === "true",
|
|
2902
|
+
...localAiProxyUrl ? { localAiProxyUrl } : {}
|
|
2568
2903
|
};
|
|
2569
2904
|
}
|
|
2570
2905
|
/**
|
|
2906
|
+
* The daemon's loopback AI proxy URL (`ALFE_LOCAL_AI_PROXY_URL`), as a bare
|
|
2907
|
+
* origin. Only plain-http loopback is accepted: the proxy-mode `claude` child
|
|
2908
|
+
* sends every model request there, so a stray value must never point it off
|
|
2909
|
+
* the machine. Anything else → undefined (the host default applies).
|
|
2910
|
+
*/
|
|
2911
|
+
function parseLoopbackUrl(raw) {
|
|
2912
|
+
const value = raw?.trim();
|
|
2913
|
+
if (!value) return void 0;
|
|
2914
|
+
let url;
|
|
2915
|
+
try {
|
|
2916
|
+
url = new URL(value);
|
|
2917
|
+
} catch {
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
if (url.protocol !== "http:") return void 0;
|
|
2921
|
+
if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") return void 0;
|
|
2922
|
+
return url.origin;
|
|
2923
|
+
}
|
|
2924
|
+
/**
|
|
2571
2925
|
* Build the host config. Throws (→ non-zero exit from the CLI) when the chat
|
|
2572
2926
|
* URL / api key can't resolve.
|
|
2573
2927
|
*/
|
|
@@ -2606,12 +2960,6 @@ Object.defineProperty(exports, "CREDENTIAL_INVALID_MESSAGE", {
|
|
|
2606
2960
|
return CREDENTIAL_INVALID_MESSAGE;
|
|
2607
2961
|
}
|
|
2608
2962
|
});
|
|
2609
|
-
Object.defineProperty(exports, "CREDENTIAL_MISSING_MESSAGE", {
|
|
2610
|
-
enumerable: true,
|
|
2611
|
-
get: function() {
|
|
2612
|
-
return CREDENTIAL_MISSING_MESSAGE;
|
|
2613
|
-
}
|
|
2614
|
-
});
|
|
2615
2963
|
Object.defineProperty(exports, "CREDENTIAL_UNAVAILABLE_MESSAGE", {
|
|
2616
2964
|
enumerable: true,
|
|
2617
2965
|
get: function() {
|
|
@@ -2636,16 +2984,28 @@ Object.defineProperty(exports, "DEFAULT_ALLOWED_TOOLS", {
|
|
|
2636
2984
|
return DEFAULT_ALLOWED_TOOLS;
|
|
2637
2985
|
}
|
|
2638
2986
|
});
|
|
2987
|
+
Object.defineProperty(exports, "DEFAULT_LOCAL_AI_PROXY_URL", {
|
|
2988
|
+
enumerable: true,
|
|
2989
|
+
get: function() {
|
|
2990
|
+
return DEFAULT_LOCAL_AI_PROXY_URL;
|
|
2991
|
+
}
|
|
2992
|
+
});
|
|
2639
2993
|
Object.defineProperty(exports, "DEFAULT_PERMISSION_MODE", {
|
|
2640
2994
|
enumerable: true,
|
|
2641
2995
|
get: function() {
|
|
2642
2996
|
return DEFAULT_PERMISSION_MODE;
|
|
2643
2997
|
}
|
|
2644
2998
|
});
|
|
2645
|
-
Object.defineProperty(exports, "
|
|
2999
|
+
Object.defineProperty(exports, "LOCAL_LOGIN_REFUSED_MESSAGE", {
|
|
3000
|
+
enumerable: true,
|
|
3001
|
+
get: function() {
|
|
3002
|
+
return LOCAL_LOGIN_REFUSED_MESSAGE;
|
|
3003
|
+
}
|
|
3004
|
+
});
|
|
3005
|
+
Object.defineProperty(exports, "LOCAL_LOGIN_SWITCHED_MESSAGE", {
|
|
2646
3006
|
enumerable: true,
|
|
2647
3007
|
get: function() {
|
|
2648
|
-
return
|
|
3008
|
+
return LOCAL_LOGIN_SWITCHED_MESSAGE;
|
|
2649
3009
|
}
|
|
2650
3010
|
});
|
|
2651
3011
|
Object.defineProperty(exports, "MANAGED_ALLOWED_TOOLS", {
|
|
@@ -2660,6 +3020,48 @@ Object.defineProperty(exports, "MANAGED_DISALLOWED_TOOLS", {
|
|
|
2660
3020
|
return MANAGED_DISALLOWED_TOOLS;
|
|
2661
3021
|
}
|
|
2662
3022
|
});
|
|
3023
|
+
Object.defineProperty(exports, "MODEL_UNAVAILABLE_MESSAGE", {
|
|
3024
|
+
enumerable: true,
|
|
3025
|
+
get: function() {
|
|
3026
|
+
return MODEL_UNAVAILABLE_MESSAGE;
|
|
3027
|
+
}
|
|
3028
|
+
});
|
|
3029
|
+
Object.defineProperty(exports, "OUT_OF_CREDITS_MESSAGE", {
|
|
3030
|
+
enumerable: true,
|
|
3031
|
+
get: function() {
|
|
3032
|
+
return OUT_OF_CREDITS_MESSAGE;
|
|
3033
|
+
}
|
|
3034
|
+
});
|
|
3035
|
+
Object.defineProperty(exports, "PROXY_AGENT_MODEL_ENV_VARS", {
|
|
3036
|
+
enumerable: true,
|
|
3037
|
+
get: function() {
|
|
3038
|
+
return PROXY_AGENT_MODEL_ENV_VARS;
|
|
3039
|
+
}
|
|
3040
|
+
});
|
|
3041
|
+
Object.defineProperty(exports, "PROXY_API_KEY_PLACEHOLDER", {
|
|
3042
|
+
enumerable: true,
|
|
3043
|
+
get: function() {
|
|
3044
|
+
return PROXY_API_KEY_PLACEHOLDER;
|
|
3045
|
+
}
|
|
3046
|
+
});
|
|
3047
|
+
Object.defineProperty(exports, "PROXY_AUTH_FAILED_MESSAGE", {
|
|
3048
|
+
enumerable: true,
|
|
3049
|
+
get: function() {
|
|
3050
|
+
return PROXY_AUTH_FAILED_MESSAGE;
|
|
3051
|
+
}
|
|
3052
|
+
});
|
|
3053
|
+
Object.defineProperty(exports, "PROXY_DEFAULT_MODEL", {
|
|
3054
|
+
enumerable: true,
|
|
3055
|
+
get: function() {
|
|
3056
|
+
return PROXY_DEFAULT_MODEL;
|
|
3057
|
+
}
|
|
3058
|
+
});
|
|
3059
|
+
Object.defineProperty(exports, "PROXY_MODEL_ENV", {
|
|
3060
|
+
enumerable: true,
|
|
3061
|
+
get: function() {
|
|
3062
|
+
return PROXY_MODEL_ENV;
|
|
3063
|
+
}
|
|
3064
|
+
});
|
|
2663
3065
|
Object.defineProperty(exports, "SCRUBBED_ANTHROPIC_ENV_VARS", {
|
|
2664
3066
|
enumerable: true,
|
|
2665
3067
|
get: function() {
|
|
@@ -2690,6 +3092,12 @@ Object.defineProperty(exports, "TURN_TIMEOUT_MS", {
|
|
|
2690
3092
|
return TURN_TIMEOUT_MS;
|
|
2691
3093
|
}
|
|
2692
3094
|
});
|
|
3095
|
+
Object.defineProperty(exports, "USAGE_LIMIT_MESSAGE", {
|
|
3096
|
+
enumerable: true,
|
|
3097
|
+
get: function() {
|
|
3098
|
+
return USAGE_LIMIT_MESSAGE;
|
|
3099
|
+
}
|
|
3100
|
+
});
|
|
2693
3101
|
Object.defineProperty(exports, "WARM_STREAMING_SUPPORTED", {
|
|
2694
3102
|
enumerable: true,
|
|
2695
3103
|
get: function() {
|
|
@@ -2720,6 +3128,12 @@ Object.defineProperty(exports, "buildProbeArgs", {
|
|
|
2720
3128
|
return buildProbeArgs;
|
|
2721
3129
|
}
|
|
2722
3130
|
});
|
|
3131
|
+
Object.defineProperty(exports, "classifyProxyDenial", {
|
|
3132
|
+
enumerable: true,
|
|
3133
|
+
get: function() {
|
|
3134
|
+
return classifyProxyDenial;
|
|
3135
|
+
}
|
|
3136
|
+
});
|
|
2723
3137
|
Object.defineProperty(exports, "collectTurnActivityRecords", {
|
|
2724
3138
|
enumerable: true,
|
|
2725
3139
|
get: function() {
|