@alfe.ai/claude-code-host 0.1.15 → 0.1.17

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.
@@ -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
- * THREE non-negotiable invariants:
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 (or routes through a proxy). The break is
23
- * silent: the turn "works" but bills the wrong account.
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 agent's own credential, last.** When the agents service holds
26
- * a Claude credential for this agent, it is set AFTER the scrub as exactly
27
- * one variable: `CLAUDE_CODE_OAUTH_TOKEN` (subscription token from
28
- * `claude setup-token`) or `ANTHROPIC_API_KEY` (Anthropic API key). Nothing
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 an agent credential is injected, so an
50
- * inherited `CLAUDE_CODE_USE_BEDROCK=1` (say) can't send the turn to Bedrock
51
- * and ignore the stored credential. Without a credential they are left alone:
52
- * the machine's own `claude` setup is the fallback, whatever provider it uses.
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 when a credential is injected) → set the bridge
82
- * marker → set the agent credential (if any). Neither `base` nor `extra` can
83
- * smuggle an auth variable in or override the injected credential.
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, credential) {
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 (credential) for (const key of PROVIDER_OVERRIDE_ENV_VARS) scrubbed.add(key);
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
- /** argv for the probe. No tools, no session file, no MCP, no settings files. */
183
- function buildProbeArgs() {
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
- "haiku",
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
- if (isAuthFailureStatus(record.api_error_status) || isAuthFailureText(text)) return { result: "invalid" };
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 against Anthropic with the agent's credential (or,
232
- * when none is stored, the machine's own login). Never throws.
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.credential);
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
  ]);
@@ -353,6 +478,64 @@ var CredentialStatusReporter = class {
353
478
  }
354
479
  };
355
480
  //#endregion
481
+ //#region src/mcp-allow-rules.ts
482
+ /**
483
+ * Allow rules for the MCP servers a bridged turn loads.
484
+ *
485
+ * Turns run under `--permission-mode dontAsk`, which DENIES any tool not in
486
+ * `--allowedTools`. MCP tools are named `mcp__<server>__<tool>`, so without a
487
+ * rule per server every MCP call (alfe-platform memory/voice/send/secrets and
488
+ * every integration server) is refused (prod, 2026-10-17). Turns pass
489
+ * `--strict-mcp-config --mcp-config <file>`, so the servers in that file are
490
+ * exactly the Alfe-installed set; each gets a server-wide `mcp__<server>` rule.
491
+ * No blanket `mcp__*` wildcard: the allow-list stays scoped to what Alfe wrote.
492
+ *
493
+ * The file is read on every spawn, so a newly installed integration server is
494
+ * allowed on the next turn without restarting the host.
495
+ */
496
+ const CLAUDE_AI_PREFIX = "claude.ai ";
497
+ /**
498
+ * Mirrors how claude (2.1.x) builds the `<server>` segment of an MCP tool id:
499
+ * every character outside `[a-zA-Z0-9_-]` becomes `_` (hyphens are kept), and
500
+ * `claude.ai ` connector names also collapse and trim underscores. Permission
501
+ * rules match on this normalised name, so a rule must use it too.
502
+ */
503
+ function normalizeMcpServerName(name) {
504
+ let normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_");
505
+ if (name.startsWith(CLAUDE_AI_PREFIX)) normalized = normalized.replace(/_+/g, "_").replace(/^_|_$/g, "");
506
+ return normalized;
507
+ }
508
+ /**
509
+ * Server-wide allow rules (`mcp__<server>`) for every server in the
510
+ * `--mcp-config` file. A missing or malformed file yields no rules and one
511
+ * warning; the turn still runs, it just cannot call MCP tools.
512
+ */
513
+ function mcpServerAllowRules(configPath, log) {
514
+ if (!configPath) return [];
515
+ let parsed;
516
+ try {
517
+ parsed = JSON.parse((0, node_fs.readFileSync)(configPath, "utf8"));
518
+ } catch (err) {
519
+ log.warn(`[claude-host] could not read MCP config ${configPath}; MCP tools are not allowed this turn: ${err instanceof Error ? err.message : String(err)}`);
520
+ return [];
521
+ }
522
+ const servers = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed.mcpServers : void 0;
523
+ if (servers === null || typeof servers !== "object" || Array.isArray(servers)) {
524
+ log.warn(`[claude-host] MCP config ${configPath} has no mcpServers map; MCP tools are not allowed this turn`);
525
+ return [];
526
+ }
527
+ const rules = /* @__PURE__ */ new Set();
528
+ for (const name of Object.keys(servers)) {
529
+ const normalized = normalizeMcpServerName(name);
530
+ if (!normalized || normalized.includes("__")) {
531
+ log.warn(`[claude-host] MCP server "${name}" cannot be expressed as a server-wide allow rule; its tools stay denied`);
532
+ continue;
533
+ }
534
+ rules.add(`mcp__${normalized}`);
535
+ }
536
+ return [...rules];
537
+ }
538
+ //#endregion
356
539
  //#region src/stream-mapper.ts
357
540
  /**
358
541
  * Claude Code `--output-format stream-json` → Alfe chat-activity mapper.
@@ -600,7 +783,8 @@ function mapStreamEvent(state, raw, nowMs) {
600
783
  finalText: isError ? "" : finalText,
601
784
  ...durationMs !== void 0 ? { durationMs } : {},
602
785
  ...errorMessage ? { errorMessage } : {},
603
- ...authFailed ? { authFailed: true } : {}
786
+ ...authFailed ? { authFailed: true } : {},
787
+ ...authFailed && evt.api_error_status === 403 ? { authForbidden: true } : {}
604
788
  }];
605
789
  }
606
790
  return [];
@@ -811,7 +995,7 @@ var SessionDriver = class {
811
995
  abortCurrent = null;
812
996
  bin;
813
997
  baseEnv;
814
- credential;
998
+ auth;
815
999
  managed;
816
1000
  log;
817
1001
  /** Injected spawn — tests replace this with a fake child. */
@@ -819,7 +1003,7 @@ var SessionDriver = class {
819
1003
  constructor(opts) {
820
1004
  this.bin = opts?.claudeBin ?? DEFAULT_CLAUDE_BIN;
821
1005
  this.baseEnv = opts?.baseEnv ?? process.env;
822
- this.credential = opts?.credential ?? null;
1006
+ this.auth = opts?.auth ?? { mode: "local_login" };
823
1007
  this.managed = opts?.managed ?? false;
824
1008
  this.log = opts?.logger ?? console;
825
1009
  this.spawnImpl = opts?.__spawn ?? node_child_process.spawn;
@@ -844,7 +1028,7 @@ var SessionDriver = class {
844
1028
  return this.runPerTurn(params);
845
1029
  }
846
1030
  buildArgs(params) {
847
- const allowed = (params.allowedTools ?? (this.managed ? MANAGED_ALLOWED_TOOLS : DEFAULT_ALLOWED_TOOLS)).join(",");
1031
+ const allowed = [...params.allowedTools ?? (this.managed ? MANAGED_ALLOWED_TOOLS : DEFAULT_ALLOWED_TOOLS), ...mcpServerAllowRules(params.mcpConfigPath, this.log)].join(",");
848
1032
  const permissionMode = params.permissionMode ?? "dontAsk";
849
1033
  if (!CLAUDE_PERMISSION_MODES.includes(permissionMode)) throw new Error(`Unsupported Claude permission mode: ${permissionMode}`);
850
1034
  const args = [
@@ -879,7 +1063,7 @@ var SessionDriver = class {
879
1063
  let stderr = "";
880
1064
  let rateLimited = false;
881
1065
  const args = this.buildArgs(params);
882
- const env = buildChildEnv(this.baseEnv, void 0, this.credential);
1066
+ const env = buildChildEnv(this.baseEnv, void 0, this.auth);
883
1067
  return new Promise((resolve) => {
884
1068
  let child;
885
1069
  try {
@@ -931,7 +1115,8 @@ var SessionDriver = class {
931
1115
  finalText: finalIsError ? "" : endText,
932
1116
  ...finalErrMsg ? { errorMessage: finalErrMsg } : {},
933
1117
  aborted,
934
- ...authFailed ? { authFailed: true } : {}
1118
+ ...authFailed ? { authFailed: true } : {},
1119
+ ...authFailed && end?.type === "turn-end" && end.authForbidden ? { authForbidden: true } : {}
935
1120
  });
936
1121
  };
937
1122
  const requestTermination = (reason) => {
@@ -1059,6 +1244,27 @@ async function writeTextAtomically(path, contents) {
1059
1244
  *
1060
1245
  * where `sessionId = conversationId ?? sessionKey`.
1061
1246
  */
1247
+ /**
1248
+ * The channel every chat session is stored on. Every chat client lists with
1249
+ * `sessions.list { channel: 'alfe' }` and openclaw-chat stores `'alfe'`, so any
1250
+ * other value hides the conversation from the rail.
1251
+ */
1252
+ const CHAT_SESSION_CHANNEL = "alfe";
1253
+ /**
1254
+ * Values hosts up to 0.1.16 stored as `channel`: the chat surface label
1255
+ * (`params.clientType ?? 'chat'`). Read as `CHAT_SESSION_CHANNEL`; the file is
1256
+ * rewritten on its next save.
1257
+ */
1258
+ const LEGACY_SURFACE_CHANNELS = new Set([
1259
+ "chat",
1260
+ "dashboard",
1261
+ "mobile",
1262
+ "desktop",
1263
+ "widget",
1264
+ "unknown"
1265
+ ]);
1266
+ /** Legacy values that never named a real surface, so carry no `clientType`. */
1267
+ const LEGACY_PLACEHOLDER_CHANNELS = new Set(["chat", "unknown"]);
1062
1268
  function sessionsDir() {
1063
1269
  return process.env.ALFE_SESSIONS_DIR ?? (0, node_path.join)((0, node_os.homedir)(), ".alfe", "sessions", "chat");
1064
1270
  }
@@ -1077,6 +1283,7 @@ function sessionPath(sessionId) {
1077
1283
  function asSessionData(value, expectedSessionId) {
1078
1284
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1079
1285
  const record = value;
1286
+ normaliseLegacyChannel(record);
1080
1287
  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
1288
  if (!record.messages.every((message) => {
1082
1289
  if (!message || typeof message !== "object" || Array.isArray(message)) return false;
@@ -1087,6 +1294,13 @@ function asSessionData(value, expectedSessionId) {
1087
1294
  if (record.memory !== void 0 && !isMemoryCursor(record.memory)) delete record.memory;
1088
1295
  return value;
1089
1296
  }
1297
+ /** Map a pre-0.1.17 surface-label channel (or none) onto `CHAT_SESSION_CHANNEL`. */
1298
+ function normaliseLegacyChannel(record) {
1299
+ const channel = record.channel;
1300
+ if (channel !== void 0 && !(typeof channel === "string" && LEGACY_SURFACE_CHANNELS.has(channel))) return;
1301
+ if (typeof channel === "string" && !LEGACY_PLACEHOLDER_CHANNELS.has(channel) && record.clientType === void 0) record.clientType = channel;
1302
+ record.channel = CHAT_SESSION_CHANNEL;
1303
+ }
1090
1304
  function isMemoryCursor(value) {
1091
1305
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1092
1306
  const cursor = value;
@@ -1141,12 +1355,13 @@ async function saveSession(session) {
1141
1355
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1142
1356
  await writeTextAtomically(sessionPath(session.sessionId), JSON.stringify(session, null, 2));
1143
1357
  }
1144
- async function createSession(sessionId, agentId, channel, tenantId, userId) {
1358
+ async function createSession(sessionId, agentId, channel, tenantId, userId, clientType) {
1145
1359
  const now = (/* @__PURE__ */ new Date()).toISOString();
1146
1360
  const session = {
1147
1361
  sessionId,
1148
1362
  agentId,
1149
1363
  channel,
1364
+ ...clientType ? { clientType } : {},
1150
1365
  tenantId,
1151
1366
  userId,
1152
1367
  createdAt: now,
@@ -1180,17 +1395,35 @@ async function withSessionLock(sessionId, fn) {
1180
1395
  * memory-ingest index, or -1 when the session does not exist.
1181
1396
  */
1182
1397
  async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
1398
+ return appendMessage(sessionId, {
1399
+ role,
1400
+ content,
1401
+ ...senderId ? { senderId } : {},
1402
+ ...senderName ? { senderName } : {},
1403
+ ...components?.length ? { components } : {},
1404
+ ...attachments?.length ? { attachments } : {}
1405
+ });
1406
+ }
1407
+ /**
1408
+ * Store the failure message a turn settled with as the assistant's reply, so
1409
+ * the history shows what the user saw. Returns its index, or -1 when the
1410
+ * session does not exist.
1411
+ */
1412
+ async function addErrorMessage(sessionId, content) {
1413
+ return appendMessage(sessionId, {
1414
+ role: "assistant",
1415
+ content,
1416
+ kind: "error"
1417
+ });
1418
+ }
1419
+ async function appendMessage(sessionId, message) {
1183
1420
  return withSessionLock(sessionId, async () => {
1184
1421
  const session = await getSession(sessionId);
1185
1422
  if (!session) return -1;
1423
+ const last = session.messages.at(-1)?.timestamp ?? 0;
1186
1424
  session.messages.push({
1187
- role,
1188
- content,
1189
- timestamp: Date.now(),
1190
- ...senderId ? { senderId } : {},
1191
- ...senderName ? { senderName } : {},
1192
- ...components?.length ? { components } : {},
1193
- ...attachments?.length ? { attachments } : {}
1425
+ ...message,
1426
+ timestamp: Math.max(Date.now(), last + 1)
1194
1427
  });
1195
1428
  await saveSession(session);
1196
1429
  return session.messages.length - 1;
@@ -1278,8 +1511,8 @@ async function ensureMemoryCursor(sessionId) {
1278
1511
  /**
1279
1512
  * The messages not yet accepted by the memory service, oldest first, bounded
1280
1513
  * by count and serialised bytes (always at least one message when any is
1281
- * pending). Whitespace-only messages are skipped (the service rejects empty
1282
- * content) but still covered: `throughIndex` is the last index this batch
1514
+ * pending). Error replies (history only) and whitespace-only messages (the
1515
+ * service rejects empty content) are skipped but still covered: `throughIndex` is the last index this batch
1283
1516
  * accounts for, sent or skipped. Leftovers go out with the next turn's batch.
1284
1517
  */
1285
1518
  async function pendingMemoryBatch(sessionId, opts = {}) {
@@ -1297,7 +1530,7 @@ async function pendingMemoryBatch(sessionId, opts = {}) {
1297
1530
  let bytes = 0;
1298
1531
  for (let index = session.memory.lastIngestedIndex + 1; index < session.messages.length && messages.length < maxMessages; index += 1) {
1299
1532
  const message = session.messages[index];
1300
- if (message.content.trim() === "") {
1533
+ if (message.kind === "error" || message.content.trim() === "") {
1301
1534
  throughIndex = index;
1302
1535
  continue;
1303
1536
  }
@@ -1585,14 +1818,15 @@ const RECALL_TIMEOUT_MS = 5e3;
1585
1818
  //#endregion
1586
1819
  //#region src/host.ts
1587
1820
  /**
1588
- * ClaudeCodeHost — the persistent chat bridge for a self-hosted Claude Code
1589
- * agent.
1821
+ * ClaudeCodeHost — the persistent chat bridge for a Claude Code agent
1822
+ * (self-hosted or managed).
1590
1823
  *
1591
1824
  * It holds ONE `ChatServiceClient` (reused verbatim from `@alfe.ai/chat`, with
1592
1825
  * NO custom reconnect — the client already backs off with jitter and guards
1593
1826
  * against orphan sockets), registers the inbound RPC handlers the chat service
1594
- * calls, and drives the `SessionDriver` to run `claude -p` turns on the user's
1595
- * subscription.
1827
+ * calls, and drives the `SessionDriver` to run `claude -p` turns on one auth
1828
+ * source: the agent's stored credential, a self-hosted machine's own login, or
1829
+ * the Alfe AI proxy (see DEVELOPING.md "Auth source resolution").
1596
1830
  *
1597
1831
  * Every wire shape here is byte-for-byte the shape the `@alfe.ai/openclaw-chat`
1598
1832
  * plugin emits, so the chat service / adapter / UI treat a Claude Code turn
@@ -1630,9 +1864,28 @@ const PERSONA_POINTER_SYSTEM_PROMPT = [
1630
1864
  "Fully embody that persona in how you speak and act; respond AS that agent, not as a generic coding assistant."
1631
1865
  ].join("\n");
1632
1866
  const CREDENTIAL_INVALID_MESSAGE = "This agent's Claude credential is invalid or expired. Update it in agent settings.";
1633
- const LOCAL_LOGIN_FAILED_MESSAGE = "Claude Code is not signed in on this machine. Run `claude` and log in, or add a Claude credential in agent settings.";
1634
- const CREDENTIAL_MISSING_MESSAGE = "This agent has no Claude credential. Add a Claude subscription token or an Anthropic API key in agent settings.";
1867
+ 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.";
1868
+ 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.";
1869
+ const LOCAL_LOGIN_SWITCHED_MESSAGE = "Your machine's Claude login stopped working, so this agent switched to Alfe credits. Please resend your message.";
1870
+ 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.";
1871
+ 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.";
1872
+ 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
1873
  const CREDENTIAL_UNAVAILABLE_MESSAGE = "This agent's Claude credential couldn't be loaded yet. Try again in a moment.";
1874
+ /** Fixed status details (never `claude` output). */
1875
+ const SOURCE_OK_DETAIL = {
1876
+ local_login: "Using this machine's claude login",
1877
+ ai_proxy: "Using Alfe AI credits"
1878
+ };
1879
+ const DENIAL_DETAIL = {
1880
+ credits: "Using Alfe AI credits. This workspace is out of credits.",
1881
+ usage_limit: "Using Alfe AI credits. An Alfe usage limit has been reached.",
1882
+ model_unavailable: "Using Alfe AI credits. The agent's model is not available on this workspace."
1883
+ };
1884
+ const DENIAL_MESSAGE = {
1885
+ credits: OUT_OF_CREDITS_MESSAGE,
1886
+ usage_limit: USAGE_LIMIT_MESSAGE,
1887
+ model_unavailable: MODEL_UNAVAILABLE_MESSAGE
1888
+ };
1636
1889
  const MAX_AGENT_MESSAGE_BYTES = 256 * 1024;
1637
1890
  const MAX_SESSION_KEY_CHARS = 768;
1638
1891
  const MAX_IDENTIFIER_CHARS = 512;
@@ -1856,8 +2109,16 @@ var ClaudeCodeHost = class {
1856
2109
  /** Turns waiting for the credential, before queue admission (chat.abort). */
1857
2110
  waitingTurns = /* @__PURE__ */ new Map();
1858
2111
  loaderRestartMs;
2112
+ /** How often a fallen-back self-hosted agent re-checks its local login. */
2113
+ localLoginRecheckMs;
2114
+ localLoginRecheck = null;
1859
2115
  /** In memory only. Never logged, never persisted. */
1860
2116
  credential = null;
2117
+ /**
2118
+ * The auth every new turn runs on, resolved once the credential fetch
2119
+ * answers (see `resolveAuthSource`). Null before that.
2120
+ */
2121
+ authSource = null;
1861
2122
  credentialAbort = new AbortController();
1862
2123
  reporter;
1863
2124
  probe;
@@ -1867,18 +2128,18 @@ var ClaudeCodeHost = class {
1867
2128
  this.cfg = cfg;
1868
2129
  this.log = cfg.logger ?? console;
1869
2130
  this.claudeSessionsPath = cfg.claudeSessionsPath ?? (0, node_path.join)((0, node_os.homedir)(), ".alfe", "claude-code", "sessions.json");
1870
- this.makeDriver = deps?.makeDriver ?? ((credential) => new SessionDriver({
2131
+ this.makeDriver = deps?.makeDriver ?? ((auth) => new SessionDriver({
1871
2132
  logger: this.log,
1872
2133
  baseEnv: process.env,
1873
- credential,
2134
+ auth,
1874
2135
  managed: cfg.managed === true
1875
2136
  }));
1876
2137
  this.api = deps?.api ?? new _alfe_ai_agent_api_client.AgentApiClient({
1877
2138
  apiKey: cfg.apiKey,
1878
2139
  apiUrl: cfg.apiUrl
1879
2140
  });
1880
- this.probe = deps?.probe ?? ((credential, signal) => runAuthProbe({
1881
- credential,
2141
+ this.probe = deps?.probe ?? ((auth, signal) => runAuthProbe({
2142
+ auth,
1882
2143
  baseEnv: process.env,
1883
2144
  cwd: (0, node_os.tmpdir)(),
1884
2145
  signal
@@ -1886,6 +2147,7 @@ var ClaudeCodeHost = class {
1886
2147
  this.credentialWaitMs = deps?.credentialWaitMs ?? 15e3;
1887
2148
  this.loaderRestartMs = deps?.loaderRestartMs ?? 5e3;
1888
2149
  this.credentialRetry = deps?.credentialRetry ?? {};
2150
+ this.localLoginRecheckMs = deps?.localLoginRecheckMs ?? 9e5;
1889
2151
  this.reporter = new CredentialStatusReporter((report) => this.api.reportClaudeCredentialStatus(report), { log: this.log });
1890
2152
  this.chat = deps?.chat ?? new _alfe_ai_chat.ChatServiceClient({
1891
2153
  wsUrl: cfg.wsUrl,
@@ -1939,49 +2201,117 @@ var ClaudeCodeHost = class {
1939
2201
  });
1940
2202
  if (!loaded) return;
1941
2203
  this.credential = loaded.credential;
2204
+ this.authSource = loaded.credential ? "credential" : this.cfg.managed ? "ai_proxy" : "local_login";
1942
2205
  this.credentialLoaded = true;
1943
2206
  this.resolveCredentialReady();
1944
2207
  await this.checkCredential();
1945
2208
  }
1946
- childCredential() {
1947
- return this.credential ? {
1948
- kind: this.credential.kind,
1949
- value: this.credential.value
1950
- } : null;
2209
+ /**
2210
+ * A method, not a property read: `stopping` flips across awaits, which
2211
+ * control-flow narrowing cannot see.
2212
+ */
2213
+ isStopping() {
2214
+ return this.stopping;
2215
+ }
2216
+ /** Same reason as `isStopping`: `authSource` can change across an await. */
2217
+ currentAuthSource() {
2218
+ return this.authSource;
2219
+ }
2220
+ /** The auth a new turn runs on. Local login until the fetch has answered. */
2221
+ childAuth() {
2222
+ if (this.authSource === "credential" && this.credential) return {
2223
+ mode: "credential",
2224
+ credential: {
2225
+ kind: this.credential.kind,
2226
+ value: this.credential.value
2227
+ }
2228
+ };
2229
+ if (this.authSource === "ai_proxy") return {
2230
+ mode: "ai_proxy",
2231
+ baseUrl: this.cfg.localAiProxyUrl ?? "http://127.0.0.1:18193",
2232
+ model: this.cfg.model ?? "claude-haiku-4-5"
2233
+ };
2234
+ return { mode: "local_login" };
1951
2235
  }
1952
- /** Start-up health check: one minimal real call, reported to the cloud. */
1953
- async checkCredential() {
1954
- if (!this.credential && this.cfg.managed) {
1955
- await this.reporter.report({
1956
- status: "missing",
1957
- detail: "No Claude credential is stored for this agent"
1958
- });
2236
+ /**
2237
+ * A self-hosted agent with no credential whose local login was refused (401
2238
+ * or an explicit auth message, never a 403): run new turns through the Alfe
2239
+ * AI proxy, and re-check the login periodically so a re-login moves the agent
2240
+ * back off Alfe credits. Never for a stored credential (the user chose it; a
2241
+ * refusal there needs their fix) and never on managed.
2242
+ */
2243
+ fallBackToProxy(why) {
2244
+ if (this.authSource !== "local_login") return;
2245
+ this.log.warn(`[claude-host] the claude login on this machine was refused (${why}); using the Alfe AI proxy`);
2246
+ this.authSource = "ai_proxy";
2247
+ this.scheduleLocalLoginRecheck();
2248
+ }
2249
+ scheduleLocalLoginRecheck() {
2250
+ if (this.localLoginRecheck || this.stopping) return;
2251
+ this.localLoginRecheck = setTimeout(() => {
2252
+ this.localLoginRecheck = null;
2253
+ this.recheckLocalLogin();
2254
+ }, this.localLoginRecheckMs);
2255
+ this.localLoginRecheck.unref();
2256
+ }
2257
+ /** While fallen back: one local-login probe; back to the login when it works. */
2258
+ async recheckLocalLogin() {
2259
+ if (this.isStopping() || this.authSource !== "ai_proxy" || this.credential || this.cfg.managed) return;
2260
+ let outcome;
2261
+ try {
2262
+ outcome = await this.probe({ mode: "local_login" }, this.credentialAbort.signal);
2263
+ } catch (err) {
2264
+ outcome = {
2265
+ result: "unknown",
2266
+ reason: err instanceof Error ? err.message : String(err)
2267
+ };
2268
+ }
2269
+ if (this.isStopping() || this.currentAuthSource() !== "ai_proxy") return;
2270
+ if (outcome.result === "ok") {
2271
+ this.log.info("[claude-host] the claude login on this machine works again; leaving the Alfe AI proxy");
2272
+ this.authSource = "local_login";
2273
+ await this.reportStatus("ok");
1959
2274
  return;
1960
2275
  }
1961
- const outcome = await this.probe(this.childCredential(), this.credentialAbort.signal);
2276
+ this.scheduleLocalLoginRecheck();
2277
+ }
2278
+ /**
2279
+ * Start-up check: one minimal real call on the resolved source, reported to
2280
+ * the cloud. A refused local login falls back to the proxy and checks that.
2281
+ */
2282
+ async checkCredential() {
2283
+ let outcome = await this.probe(this.childAuth(), this.credentialAbort.signal);
1962
2284
  if (this.stopping) return;
1963
- if (outcome.result === "unknown") {
1964
- this.log.warn(`[claude-host] Claude auth check inconclusive (${outcome.reason}); the next turn will tell`);
1965
- return;
2285
+ if (outcome.result === "invalid" && !outcome.forbidden && this.authSource === "local_login") {
2286
+ this.fallBackToProxy("start-up check");
2287
+ outcome = await this.probe(this.childAuth(), this.credentialAbort.signal);
2288
+ if (this.isStopping()) return;
1966
2289
  }
1967
- await this.reportAuth(outcome.result === "ok");
1968
- }
1969
- /** Report the credential (or the local login) as working or refused. */
1970
- async reportAuth(ok) {
1971
- if (!this.credentialLoaded) return;
1972
- const revision = this.credential?.revision;
1973
- if (ok) await this.reporter.report({
1974
- status: "ok",
1975
- ...revision ? { revision } : { detail: "Using this machine's claude login" }
1976
- });
1977
- else if (revision) await this.reporter.report({
1978
- status: "invalid",
1979
- detail: "Anthropic rejected the stored credential",
1980
- revision
1981
- });
1982
- else await this.reporter.report({
1983
- status: "missing",
1984
- detail: "No Claude credential, and the claude login on this machine is not signed in"
2290
+ switch (outcome.result) {
2291
+ case "unknown":
2292
+ this.log.warn(`[claude-host] Claude auth check inconclusive (${outcome.reason}); the next turn will tell`);
2293
+ return;
2294
+ case "denied":
2295
+ if (outcome.denial === "model_unavailable") {
2296
+ this.log.warn("[claude-host] the probe model is unavailable through the Alfe AI proxy; the next turn will tell");
2297
+ return;
2298
+ }
2299
+ await this.reportStatus("ok", DENIAL_DETAIL[outcome.denial]);
2300
+ return;
2301
+ default: await this.reportStatus(outcome.result);
2302
+ }
2303
+ }
2304
+ /** Report the current auth source as working or refused. */
2305
+ async reportStatus(status, detail) {
2306
+ const source = this.authSource;
2307
+ if (!this.credentialLoaded || !source) return;
2308
+ const revision = source === "credential" ? this.credential?.revision : void 0;
2309
+ 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");
2310
+ await this.reporter.report({
2311
+ status,
2312
+ source,
2313
+ ...fixedDetail ? { detail: fixedDetail } : {},
2314
+ ...revision ? { revision } : {}
1985
2315
  });
1986
2316
  }
1987
2317
  /**
@@ -2025,7 +2355,6 @@ var ClaudeCodeHost = class {
2025
2355
  credentialGateMessage() {
2026
2356
  if (!this.credentialReady) return null;
2027
2357
  if (!this.credentialLoaded) return CREDENTIAL_UNAVAILABLE_MESSAGE;
2028
- if (!this.credential && this.cfg.managed) return CREDENTIAL_MISSING_MESSAGE;
2029
2358
  return null;
2030
2359
  }
2031
2360
  /** Stop admission, abort owned children, and give active turns time to settle. */
@@ -2033,6 +2362,10 @@ var ClaudeCodeHost = class {
2033
2362
  if (this.stopPromise) return this.stopPromise;
2034
2363
  this.stopping = true;
2035
2364
  this.credentialAbort.abort();
2365
+ if (this.localLoginRecheck) {
2366
+ clearTimeout(this.localLoginRecheck);
2367
+ this.localLoginRecheck = null;
2368
+ }
2036
2369
  for (const waiting of this.waitingTurns.values()) for (const turn of waiting) turn.cancellation.abort();
2037
2370
  this.queues.stop();
2038
2371
  for (const { driver, cancellation } of this.runningTurns.values()) {
@@ -2182,10 +2515,7 @@ var ClaudeCodeHost = class {
2182
2515
  await addMessage(bareSessionId, "user", params.message, params.identityId ?? params.userId, params.displayName);
2183
2516
  const credentialProblem = this.credentialGateMessage();
2184
2517
  if (credentialProblem) {
2185
- complete({
2186
- ok: false,
2187
- payload: { message: credentialProblem }
2188
- });
2518
+ await this.failTurn(bareSessionId, complete, credentialProblem);
2189
2519
  return;
2190
2520
  }
2191
2521
  let memoryContext = "";
@@ -2200,26 +2530,36 @@ var ClaudeCodeHost = class {
2200
2530
  const mcpConfigPath = this.cfg.mcpConfigPath && (0, node_fs.existsSync)(this.cfg.mcpConfigPath) ? this.cfg.mcpConfigPath : void 0;
2201
2531
  const appendSystemPrompt = this.buildSystemPrompt(memoryContext, isVoice);
2202
2532
  const claudeSessionId = this.claudeSessionIds.get(bareSessionId) ?? null;
2203
- const driver = this.makeDriver(this.childCredential());
2204
2533
  const cancellation = new AbortController();
2205
- this.runningTurns.set(sessionKey, {
2206
- runId,
2207
- driver,
2208
- cancellation
2209
- });
2534
+ let auth = this.childAuth();
2535
+ let switchedWithoutRerun = false;
2210
2536
  let result;
2211
2537
  try {
2212
- result = await this.runWithResumeFallback(driver, bareSessionId, cancellation.signal, {
2538
+ result = await this.runTurnOn(auth, sessionKey, runId, cancellation, bareSessionId, {
2213
2539
  prompt: params.message,
2214
2540
  claudeSessionId,
2215
2541
  cwd: this.cfg.workspace,
2216
2542
  ...mcpConfigPath ? { mcpConfigPath } : {},
2217
2543
  ...appendSystemPrompt ? { appendSystemPrompt } : {},
2218
- ...this.cfg.model ? { model: this.cfg.model } : {},
2219
2544
  onEmission: (emission) => {
2220
2545
  this.forwardEmission(runId, sessionKey, emission);
2221
2546
  }
2222
2547
  }, isVoice);
2548
+ if (auth.mode === "local_login" && result.isError && result.authFailed && !result.authForbidden && !result.aborted && !cancellation.signal.aborted && !this.isStopping()) {
2549
+ this.fallBackToProxy("turn refused");
2550
+ if (producedVisibleOutput(result.emissions)) switchedWithoutRerun = true;
2551
+ else auth = this.childAuth();
2552
+ if (!switchedWithoutRerun && auth.mode === "ai_proxy") result = await this.runTurnOn(auth, sessionKey, runId, cancellation, bareSessionId, {
2553
+ prompt: params.message,
2554
+ claudeSessionId,
2555
+ cwd: this.cfg.workspace,
2556
+ ...mcpConfigPath ? { mcpConfigPath } : {},
2557
+ ...appendSystemPrompt ? { appendSystemPrompt } : {},
2558
+ onEmission: (emission) => {
2559
+ this.forwardEmission(runId, sessionKey, emission);
2560
+ }
2561
+ }, isVoice);
2562
+ }
2223
2563
  } finally {
2224
2564
  this.runningTurns.delete(sessionKey);
2225
2565
  }
@@ -2237,21 +2577,30 @@ var ClaudeCodeHost = class {
2237
2577
  return;
2238
2578
  }
2239
2579
  if (result.isError) {
2580
+ const denial = auth.mode === "ai_proxy" ? classifyProxyDenial(result.errorMessage ?? "") : null;
2581
+ if (denial) {
2582
+ await this.failTurn(bareSessionId, complete, DENIAL_MESSAGE[denial]);
2583
+ await this.reportStatus("ok", DENIAL_DETAIL[denial]);
2584
+ return;
2585
+ }
2586
+ if (switchedWithoutRerun) {
2587
+ await this.failTurn(bareSessionId, complete, LOCAL_LOGIN_SWITCHED_MESSAGE);
2588
+ return;
2589
+ }
2590
+ if (result.authFailed && auth.mode === "local_login") {
2591
+ await this.failTurn(bareSessionId, complete, result.authForbidden ? LOCAL_LOGIN_REFUSED_MESSAGE : CREDENTIAL_INVALID_MESSAGE);
2592
+ await this.reportStatus("invalid");
2593
+ return;
2594
+ }
2240
2595
  if (result.authFailed) {
2241
- complete({
2242
- ok: false,
2243
- payload: { message: this.credential ? CREDENTIAL_INVALID_MESSAGE : LOCAL_LOGIN_FAILED_MESSAGE }
2244
- });
2245
- await this.reportAuth(false);
2596
+ await this.failTurn(bareSessionId, complete, auth.mode === "ai_proxy" ? PROXY_AUTH_FAILED_MESSAGE : CREDENTIAL_INVALID_MESSAGE);
2597
+ await this.reportStatus("invalid");
2246
2598
  return;
2247
2599
  }
2248
- complete({
2249
- ok: false,
2250
- payload: { message: result.errorMessage ?? "Claude Code turn failed" }
2251
- });
2600
+ await this.failTurn(bareSessionId, complete, result.errorMessage ?? "Claude Code turn failed");
2252
2601
  return;
2253
2602
  }
2254
- this.reportAuth(true);
2603
+ this.reportStatus("ok");
2255
2604
  if (result.finalText) await addMessage(bareSessionId, "assistant", result.finalText);
2256
2605
  const notification = result.finalText ? {
2257
2606
  conversationId: bareSessionId,
@@ -2267,6 +2616,23 @@ var ClaudeCodeHost = class {
2267
2616
  await this.ingestPendingMemory(bareSessionId, params);
2268
2617
  }
2269
2618
  /**
2619
+ * Settle a failed turn (C3) after storing its message as the assistant's
2620
+ * reply, so the history shows what the user saw. Stored first: a client that
2621
+ * reloads on the failure must find it. Best-effort: a store failure never
2622
+ * changes the outcome.
2623
+ */
2624
+ async failTurn(bareSessionId, complete, message) {
2625
+ try {
2626
+ await addErrorMessage(bareSessionId, message);
2627
+ } catch (err) {
2628
+ this.log.warn(`[claude-host] failed to store a turn error in history: ${err instanceof Error ? err.message : String(err)}`);
2629
+ }
2630
+ complete({
2631
+ ok: false,
2632
+ payload: { message }
2633
+ });
2634
+ }
2635
+ /**
2270
2636
  * Send every message the memory service has not accepted yet. Indices are
2271
2637
  * the messages' positions in the session file, so they advance across turns
2272
2638
  * and survive host restarts; the per-conversation `ingestEpoch` tells the
@@ -2324,6 +2690,24 @@ var ClaudeCodeHost = class {
2324
2690
  * Run a turn; on a `--resume` failure (stale/missing claude session id) start
2325
2691
  * fresh and prepend the last-N stored messages so the model keeps context.
2326
2692
  */
2693
+ /**
2694
+ * Run one turn on `auth` with its own driver, registered as the session's
2695
+ * running turn so `chat.abort` reaches it. Proxy turns always carry a
2696
+ * registry model.
2697
+ */
2698
+ runTurnOn(auth, sessionKey, runId, cancellation, bareSessionId, baseParams, isVoice) {
2699
+ const driver = this.makeDriver(auth);
2700
+ this.runningTurns.set(sessionKey, {
2701
+ runId,
2702
+ driver,
2703
+ cancellation
2704
+ });
2705
+ const model = auth.mode === "ai_proxy" ? auth.model : this.cfg.model;
2706
+ return this.runWithResumeFallback(driver, bareSessionId, cancellation.signal, {
2707
+ ...baseParams,
2708
+ ...model ? { model } : {}
2709
+ }, isVoice);
2710
+ }
2327
2711
  async runWithResumeFallback(driver, bareSessionId, cancellation, baseParams, isVoice) {
2328
2712
  const cancelled = () => cancellation.aborted || this.stopping;
2329
2713
  const aborted = (result) => ({
@@ -2449,7 +2833,7 @@ var ClaudeCodeHost = class {
2449
2833
  async buildHistoryPreamble(bareSessionId) {
2450
2834
  const session = await getSession(bareSessionId);
2451
2835
  if (!session || session.messages.length === 0) return "";
2452
- const prior = session.messages.slice(0, -1).slice(-10);
2836
+ const prior = session.messages.slice(0, -1).filter((m) => m.kind !== "error").slice(-10);
2453
2837
  if (prior.length === 0) return "";
2454
2838
  return `Earlier in this conversation:\n${prior.map((m) => `${m.role === "assistant" ? "Assistant" : "User"}: ${m.content}`).join("\n")}`;
2455
2839
  }
@@ -2459,7 +2843,7 @@ var ClaudeCodeHost = class {
2459
2843
  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
2844
  return;
2461
2845
  }
2462
- await createSession(bareSessionId, params.agentId, params.clientType ?? "chat", params.tenantId, params.userId);
2846
+ await createSession(bareSessionId, params.agentId, CHAT_SESSION_CHANNEL, params.tenantId, params.userId, params.clientType);
2463
2847
  }
2464
2848
  async loadClaudeSessionIds() {
2465
2849
  try {
@@ -2535,6 +2919,13 @@ function stripAgentPrefix(sessionKey) {
2535
2919
  if (parts.length >= 3 && parts[0] === "agent") return parts.slice(2).join(":");
2536
2920
  return sessionKey;
2537
2921
  }
2922
+ /**
2923
+ * True when a turn already showed the user something (text or any activity
2924
+ * card). A refused turn that did is never re-run: its tools may have run.
2925
+ */
2926
+ function producedVisibleOutput(emissions) {
2927
+ return emissions.some((emission) => emission.type === "chat-activity" || emission.type === "chat-delta" && emission.text.trim().length > 0);
2928
+ }
2538
2929
  //#endregion
2539
2930
  //#region src/host-config.ts
2540
2931
  /**
@@ -2556,6 +2947,7 @@ function buildHostConfig(resolved, home, chat, env) {
2556
2947
  if (!chat.apiKey || !chat.chatWsUrl) throw new Error("Unable to resolve chat service URL / API key from ~/.alfe/config.toml. Run `alfe login`.");
2557
2948
  const model = env.ALFE_DEFAULT_MODEL?.trim();
2558
2949
  const managedFlag = env.ALFE_MANAGED?.trim().toLowerCase();
2950
+ const localAiProxyUrl = parseLoopbackUrl(env.ALFE_LOCAL_AI_PROXY_URL);
2559
2951
  return {
2560
2952
  wsUrl: chat.chatWsUrl,
2561
2953
  apiKey: chat.apiKey,
@@ -2564,10 +2956,30 @@ function buildHostConfig(resolved, home, chat, env) {
2564
2956
  mcpConfigPath: (0, node_path.join)(home, "mcp-config.json"),
2565
2957
  claudeSessionsPath: (0, node_path.join)((0, node_os.homedir)(), ".alfe", "claude-code", "sessions.json"),
2566
2958
  ...model ? { model } : {},
2567
- managed: managedFlag === "1" || managedFlag === "true"
2959
+ managed: managedFlag === "1" || managedFlag === "true",
2960
+ ...localAiProxyUrl ? { localAiProxyUrl } : {}
2568
2961
  };
2569
2962
  }
2570
2963
  /**
2964
+ * The daemon's loopback AI proxy URL (`ALFE_LOCAL_AI_PROXY_URL`), as a bare
2965
+ * origin. Only plain-http loopback is accepted: the proxy-mode `claude` child
2966
+ * sends every model request there, so a stray value must never point it off
2967
+ * the machine. Anything else → undefined (the host default applies).
2968
+ */
2969
+ function parseLoopbackUrl(raw) {
2970
+ const value = raw?.trim();
2971
+ if (!value) return void 0;
2972
+ let url;
2973
+ try {
2974
+ url = new URL(value);
2975
+ } catch {
2976
+ return;
2977
+ }
2978
+ if (url.protocol !== "http:") return void 0;
2979
+ if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") return void 0;
2980
+ return url.origin;
2981
+ }
2982
+ /**
2571
2983
  * Build the host config. Throws (→ non-zero exit from the CLI) when the chat
2572
2984
  * URL / api key can't resolve.
2573
2985
  */
@@ -2606,12 +3018,6 @@ Object.defineProperty(exports, "CREDENTIAL_INVALID_MESSAGE", {
2606
3018
  return CREDENTIAL_INVALID_MESSAGE;
2607
3019
  }
2608
3020
  });
2609
- Object.defineProperty(exports, "CREDENTIAL_MISSING_MESSAGE", {
2610
- enumerable: true,
2611
- get: function() {
2612
- return CREDENTIAL_MISSING_MESSAGE;
2613
- }
2614
- });
2615
3021
  Object.defineProperty(exports, "CREDENTIAL_UNAVAILABLE_MESSAGE", {
2616
3022
  enumerable: true,
2617
3023
  get: function() {
@@ -2636,16 +3042,28 @@ Object.defineProperty(exports, "DEFAULT_ALLOWED_TOOLS", {
2636
3042
  return DEFAULT_ALLOWED_TOOLS;
2637
3043
  }
2638
3044
  });
3045
+ Object.defineProperty(exports, "DEFAULT_LOCAL_AI_PROXY_URL", {
3046
+ enumerable: true,
3047
+ get: function() {
3048
+ return DEFAULT_LOCAL_AI_PROXY_URL;
3049
+ }
3050
+ });
2639
3051
  Object.defineProperty(exports, "DEFAULT_PERMISSION_MODE", {
2640
3052
  enumerable: true,
2641
3053
  get: function() {
2642
3054
  return DEFAULT_PERMISSION_MODE;
2643
3055
  }
2644
3056
  });
2645
- Object.defineProperty(exports, "LOCAL_LOGIN_FAILED_MESSAGE", {
3057
+ Object.defineProperty(exports, "LOCAL_LOGIN_REFUSED_MESSAGE", {
2646
3058
  enumerable: true,
2647
3059
  get: function() {
2648
- return LOCAL_LOGIN_FAILED_MESSAGE;
3060
+ return LOCAL_LOGIN_REFUSED_MESSAGE;
3061
+ }
3062
+ });
3063
+ Object.defineProperty(exports, "LOCAL_LOGIN_SWITCHED_MESSAGE", {
3064
+ enumerable: true,
3065
+ get: function() {
3066
+ return LOCAL_LOGIN_SWITCHED_MESSAGE;
2649
3067
  }
2650
3068
  });
2651
3069
  Object.defineProperty(exports, "MANAGED_ALLOWED_TOOLS", {
@@ -2660,6 +3078,48 @@ Object.defineProperty(exports, "MANAGED_DISALLOWED_TOOLS", {
2660
3078
  return MANAGED_DISALLOWED_TOOLS;
2661
3079
  }
2662
3080
  });
3081
+ Object.defineProperty(exports, "MODEL_UNAVAILABLE_MESSAGE", {
3082
+ enumerable: true,
3083
+ get: function() {
3084
+ return MODEL_UNAVAILABLE_MESSAGE;
3085
+ }
3086
+ });
3087
+ Object.defineProperty(exports, "OUT_OF_CREDITS_MESSAGE", {
3088
+ enumerable: true,
3089
+ get: function() {
3090
+ return OUT_OF_CREDITS_MESSAGE;
3091
+ }
3092
+ });
3093
+ Object.defineProperty(exports, "PROXY_AGENT_MODEL_ENV_VARS", {
3094
+ enumerable: true,
3095
+ get: function() {
3096
+ return PROXY_AGENT_MODEL_ENV_VARS;
3097
+ }
3098
+ });
3099
+ Object.defineProperty(exports, "PROXY_API_KEY_PLACEHOLDER", {
3100
+ enumerable: true,
3101
+ get: function() {
3102
+ return PROXY_API_KEY_PLACEHOLDER;
3103
+ }
3104
+ });
3105
+ Object.defineProperty(exports, "PROXY_AUTH_FAILED_MESSAGE", {
3106
+ enumerable: true,
3107
+ get: function() {
3108
+ return PROXY_AUTH_FAILED_MESSAGE;
3109
+ }
3110
+ });
3111
+ Object.defineProperty(exports, "PROXY_DEFAULT_MODEL", {
3112
+ enumerable: true,
3113
+ get: function() {
3114
+ return PROXY_DEFAULT_MODEL;
3115
+ }
3116
+ });
3117
+ Object.defineProperty(exports, "PROXY_MODEL_ENV", {
3118
+ enumerable: true,
3119
+ get: function() {
3120
+ return PROXY_MODEL_ENV;
3121
+ }
3122
+ });
2663
3123
  Object.defineProperty(exports, "SCRUBBED_ANTHROPIC_ENV_VARS", {
2664
3124
  enumerable: true,
2665
3125
  get: function() {
@@ -2690,6 +3150,12 @@ Object.defineProperty(exports, "TURN_TIMEOUT_MS", {
2690
3150
  return TURN_TIMEOUT_MS;
2691
3151
  }
2692
3152
  });
3153
+ Object.defineProperty(exports, "USAGE_LIMIT_MESSAGE", {
3154
+ enumerable: true,
3155
+ get: function() {
3156
+ return USAGE_LIMIT_MESSAGE;
3157
+ }
3158
+ });
2693
3159
  Object.defineProperty(exports, "WARM_STREAMING_SUPPORTED", {
2694
3160
  enumerable: true,
2695
3161
  get: function() {
@@ -2720,6 +3186,12 @@ Object.defineProperty(exports, "buildProbeArgs", {
2720
3186
  return buildProbeArgs;
2721
3187
  }
2722
3188
  });
3189
+ Object.defineProperty(exports, "classifyProxyDenial", {
3190
+ enumerable: true,
3191
+ get: function() {
3192
+ return classifyProxyDenial;
3193
+ }
3194
+ });
2723
3195
  Object.defineProperty(exports, "collectTurnActivityRecords", {
2724
3196
  enumerable: true,
2725
3197
  get: function() {