@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.
@@ -13,22 +13,35 @@ import { resolveAgentWorkspace, resolveConfig, resolveRuntimeHome } from "@alfe.
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 ?? 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
  ]);
@@ -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
- credential;
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.credential = opts?.credential ?? null;
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 ?? 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.credential);
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 ?? join(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
- role,
1188
- content,
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). Whitespace-only messages are skipped (the service rejects empty
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 self-hosted Claude Code
1589
- * agent.
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 the user's
1595
- * subscription.
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 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.";
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 ?? join(homedir(), ".alfe", "claude-code", "sessions.json");
1870
- this.makeDriver = deps?.makeDriver ?? ((credential) => new SessionDriver({
2073
+ this.makeDriver = deps?.makeDriver ?? ((auth) => new SessionDriver({
1871
2074
  logger: this.log,
1872
2075
  baseEnv: process.env,
1873
- credential,
2076
+ auth,
1874
2077
  managed: cfg.managed === true
1875
2078
  }));
1876
2079
  this.api = deps?.api ?? new AgentApiClient({
1877
2080
  apiKey: cfg.apiKey,
1878
2081
  apiUrl: cfg.apiUrl
1879
2082
  });
1880
- this.probe = deps?.probe ?? ((credential, signal) => runAuthProbe({
1881
- credential,
2083
+ this.probe = deps?.probe ?? ((auth, signal) => runAuthProbe({
2084
+ auth,
1882
2085
  baseEnv: process.env,
1883
2086
  cwd: 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 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
- childCredential() {
1947
- return this.credential ? {
1948
- kind: this.credential.kind,
1949
- value: this.credential.value
1950
- } : null;
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
- /** 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
- });
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
- const outcome = await this.probe(this.childCredential(), this.credentialAbort.signal);
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 === "unknown") {
1964
- this.log.warn(`[claude-host] Claude auth check inconclusive (${outcome.reason}); the next turn will tell`);
1965
- return;
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
- 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"
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 && 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.runningTurns.set(sessionKey, {
2206
- runId,
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.runWithResumeFallback(driver, bareSessionId, cancellation.signal, {
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
- ok: false,
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.reportAuth(true);
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.clientType ?? "chat", params.tenantId, params.userId);
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: join(home, "mcp-config.json"),
2565
2899
  claudeSessionsPath: join(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
  */
@@ -2576,4 +2930,4 @@ function resolveHostConfig() {
2576
2930
  return buildHostConfig(resolved, resolveRuntimeHome("claude-code"), resolveAlfeChat({ apiKey: resolved.apiKey }), process.env);
2577
2931
  }
2578
2932
  //#endregion
2579
- export { buildProbeArgs as A, collectTurnActivityRecords as C, AUTH_PROBE_TIMEOUT_MS as D, mapStreamEvents as E, BRIDGE_ENV_VAR as F, CREDENTIAL_ENV_VAR as I, SCRUBBED_ANTHROPIC_ENV_VARS as L, loadCredentialWithRetry as M, parseProbeOutput as N, CredentialStatusReporter as O, runAuthProbe as P, buildChildEnv as R, WARM_STREAMING_SUPPORTED as S, mapStreamEvent as T, MANAGED_ALLOWED_TOOLS as _, ClaudeCodeHost as a, TERMINATION_GRACE_MS as b, addMessage as c, getSession as d, listSessions as f, DEFAULT_PERMISSION_MODE as g, DEFAULT_ALLOWED_TOOLS as h, CREDENTIAL_UNAVAILABLE_MESSAGE as i, isAuthFailureText as j, STATUS_REPORT_INTERVAL_MS as k, appendActivity as l, CLAUDE_PERMISSION_MODES as m, CREDENTIAL_INVALID_MESSAGE as n, LOCAL_LOGIN_FAILED_MESSAGE as o, saveSession as p, CREDENTIAL_MISSING_MESSAGE as r, stripAgentPrefix as s, resolveHostConfig as t, createSession as u, MANAGED_DISALLOWED_TOOLS as v, createMapperState as w, TURN_TIMEOUT_MS as x, SessionDriver as y };
2933
+ export { mapStreamEvents as A, BRIDGE_ENV_VAR as B, SessionDriver as C, collectTurnActivityRecords as D, WARM_STREAMING_SUPPORTED as E, classifyProxyDenial as F, PROXY_DEFAULT_MODEL as G, DEFAULT_LOCAL_AI_PROXY_URL as H, isAuthFailureText as I, buildChildEnv as J, PROXY_MODEL_ENV as K, loadCredentialWithRetry as L, CredentialStatusReporter as M, STATUS_REPORT_INTERVAL_MS as N, createMapperState as O, buildProbeArgs as P, parseProbeOutput as R, MANAGED_DISALLOWED_TOOLS as S, TURN_TIMEOUT_MS as T, PROXY_AGENT_MODEL_ENV_VARS as U, CREDENTIAL_ENV_VAR as V, PROXY_API_KEY_PLACEHOLDER as W, saveSession as _, LOCAL_LOGIN_REFUSED_MESSAGE as a, DEFAULT_PERMISSION_MODE as b, OUT_OF_CREDITS_MESSAGE as c, stripAgentPrefix as d, addMessage as f, listSessions as g, getSession as h, ClaudeCodeHost as i, AUTH_PROBE_TIMEOUT_MS as j, mapStreamEvent as k, PROXY_AUTH_FAILED_MESSAGE as l, createSession as m, CREDENTIAL_INVALID_MESSAGE as n, LOCAL_LOGIN_SWITCHED_MESSAGE as o, appendActivity as p, SCRUBBED_ANTHROPIC_ENV_VARS as q, CREDENTIAL_UNAVAILABLE_MESSAGE as r, MODEL_UNAVAILABLE_MESSAGE as s, resolveHostConfig as t, USAGE_LIMIT_MESSAGE as u, CLAUDE_PERMISSION_MODES as v, TERMINATION_GRACE_MS as w, MANAGED_ALLOWED_TOOLS as x, DEFAULT_ALLOWED_TOOLS as y, runAuthProbe as z };