@alfe.ai/claude-code-host 0.1.14 → 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.
@@ -1,74 +1,527 @@
1
1
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
2
2
  import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
3
+ import { existsSync } from "node:fs";
4
+ import { homedir, tmpdir } from "node:os";
3
5
  import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
4
6
  import { basename, dirname, join } from "node:path";
5
- import { homedir } from "node:os";
6
7
  import { createHash, randomUUID } from "node:crypto";
8
+ import { StringDecoder } from "node:string_decoder";
7
9
  import { spawn } from "node:child_process";
8
10
  import { createInterface } from "node:readline";
9
- import { existsSync } from "node:fs";
10
- import { resolveConfig } from "@alfe.ai/config";
11
+ import { resolveAgentWorkspace, resolveConfig, resolveRuntimeHome } from "@alfe.ai/config";
11
12
  //#region src/child-env.ts
12
13
  /**
13
14
  * Child-process environment construction for spawned `claude` CLI processes.
14
15
  *
15
- * TWO non-negotiable invariants (Phase 2 plan, audit B2 + the hard
16
- * constraints):
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).
17
30
  *
18
- * 1. **Scrub `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` /
19
- * `ANTHROPIC_AUTH_TOKEN`.** A self-hosted Mac very often has these set (for
20
- * other tooling). If ANY of them reaches `claude`, the CLI silently switches
21
- * from the user's Claude subscription (OAuth) to API-key/proxy auth — which
22
- * is exactly what this whole runtime exists to avoid (no Agent SDK, no
23
- * proxy, subscription only). The break is SILENT: the turn "works" but bills
24
- * the wrong plane. So we strip them unconditionally from the child env.
31
+ * Invariants:
25
32
  *
26
- * 2. **Set `ALFE_CLAUDE_BRIDGE=1`.** The Phase-1 CLI hooks
33
+ * 1. **Scrub inherited Claude auth.** `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
34
+ * / `ANTHROPIC_AUTH_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` are removed from BOTH
35
+ * the parent env and any caller `extra`. A self-hosted Mac very often has
36
+ * these set for other tooling; if one reaches `claude` it silently switches
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.
40
+ *
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.
45
+ *
46
+ * 3. **Set `ALFE_CLAUDE_BRIDGE=1`.** The Phase-1 CLI hooks
27
47
  * (`alfe claude hook ...` in `packages/cli/src/commands/claude.ts`) check
28
48
  * `process.env.ALFE_CLAUDE_BRIDGE` FIRST and no-op when set, so the host —
29
49
  * not the interactive-session hooks — owns memory recall/capture for bridged
30
50
  * turns. Omitting this double-captures every message. The env var NAME is a
31
51
  * cross-phase contract; do not rename without updating the CLI.
32
52
  */
33
- /** Anthropic env vars that force API-key / proxy auth and MUST be scrubbed. */
53
+ /** Claude auth env vars that MUST NOT be inherited by a `claude` child. */
34
54
  const SCRUBBED_ANTHROPIC_ENV_VARS = [
35
55
  "ANTHROPIC_API_KEY",
36
56
  "ANTHROPIC_BASE_URL",
37
- "ANTHROPIC_AUTH_TOKEN"
57
+ "ANTHROPIC_AUTH_TOKEN",
58
+ "CLAUDE_CODE_OAUTH_TOKEN"
59
+ ];
60
+ /**
61
+ * Provider-selection and endpoint overrides that would route `claude` away from
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.
66
+ */
67
+ const PROVIDER_OVERRIDE_ENV_VARS = [
68
+ "CLAUDE_CODE_USE_BEDROCK",
69
+ "CLAUDE_CODE_USE_VERTEX",
70
+ "CLAUDE_CODE_USE_FOUNDRY",
71
+ "CLAUDE_CODE_SKIP_BEDROCK_AUTH",
72
+ "CLAUDE_CODE_SKIP_VERTEX_AUTH",
73
+ "CLAUDE_CODE_SKIP_FOUNDRY_AUTH",
74
+ "ANTHROPIC_BEDROCK_BASE_URL",
75
+ "ANTHROPIC_VERTEX_BASE_URL",
76
+ "ANTHROPIC_VERTEX_PROJECT_ID",
77
+ "ANTHROPIC_FOUNDRY_BASE_URL",
78
+ "ANTHROPIC_FOUNDRY_RESOURCE",
79
+ "ANTHROPIC_FOUNDRY_API_KEY",
80
+ "AWS_BEARER_TOKEN_BEDROCK"
38
81
  ];
39
82
  /** Cross-phase contract marker (see header + packages/cli/src/commands/claude.ts). */
40
83
  const BRIDGE_ENV_VAR = "ALFE_CLAUDE_BRIDGE";
41
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
+ ];
138
+ /** The single env var each credential kind is exported as. */
139
+ const CREDENTIAL_ENV_VAR = {
140
+ oauth_token: "CLAUDE_CODE_OAUTH_TOKEN",
141
+ api_key: "ANTHROPIC_API_KEY"
142
+ };
143
+ /**
42
144
  * Build the env handed to a spawned `claude` child. Pure — takes the base env
43
145
  * explicitly so it unit-tests without touching `process.env`.
44
146
  *
45
- * - Every scrubbed Anthropic var is DELETED (not blanked) so `claude`'s
46
- * env-detection treats it as absent.
47
- * - `ALFE_CLAUDE_BRIDGE=1` is set.
48
- * - Any caller-supplied `extra` entries are layered last (e.g. a per-turn
49
- * marker) but can NEVER re-introduce a scrubbed var — the scrub runs after
50
- * the merge as the final gate.
147
+ * Order: merge `base` + `extra` → drop every scrubbed var (plus provider
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.
51
152
  */
52
- function buildChildEnv(base, extra) {
153
+ function buildChildEnv(base, extra, auth = { mode: "local_login" }) {
53
154
  const merged = {
54
155
  ...base,
55
- ...extra,
56
- [BRIDGE_ENV_VAR]: "1"
156
+ ...extra
57
157
  };
58
158
  const scrubbed = new Set(SCRUBBED_ANTHROPIC_ENV_VARS);
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);
59
161
  const env = {};
60
162
  for (const [key, value] of Object.entries(merged)) if (!scrubbed.has(key)) env[key] = value;
163
+ env[BRIDGE_ENV_VAR] = "1";
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
+ }
61
172
  return env;
62
173
  }
63
174
  //#endregion
175
+ //#region src/credential.ts
176
+ /**
177
+ * The agent's Claude credential, on the host side: fetching it, checking it
178
+ * against Anthropic, classifying auth failures, and reporting health.
179
+ *
180
+ * Contract (see DEVELOPING.md "Claude credential"):
181
+ * - Fetched with the agent API key (`GET /agent/claude-credential`) on every
182
+ * host start. The daemon restarts the host when the credential changes, so
183
+ * a start IS the refetch. A failed fetch retries with backoff forever; the
184
+ * host never exits over it (that would crash-loop the daemon).
185
+ * - The value lives only in memory and in each `claude` child env
186
+ * (`buildChildEnv`). Never logged, never written to disk.
187
+ * - Health is checked once per start with the cheapest call that actually
188
+ * reaches Anthropic. `claude auth status` does NOT: with an env token it
189
+ * reports `loggedIn: true` for any string (verified on claude 2.1.284), so
190
+ * the probe is a one-turn `claude -p` on haiku with no tools, a one-line
191
+ * system prompt and thinking off (measured 381 input + 4 output tokens,
192
+ * $0.0004 at list price, under a second).
193
+ */
194
+ function sleepFor(ms, signal) {
195
+ return new Promise((resolve) => {
196
+ const timer = setTimeout(done, ms);
197
+ function done() {
198
+ clearTimeout(timer);
199
+ signal.removeEventListener("abort", done);
200
+ resolve();
201
+ }
202
+ signal.addEventListener("abort", done, { once: true });
203
+ });
204
+ }
205
+ /**
206
+ * Fetch until it answers. `{ credential: null }` = nothing stored (a real
207
+ * answer); `null` = aborted before any answer.
208
+ */
209
+ async function loadCredentialWithRetry(fetchCredential, opts) {
210
+ const base = opts.baseDelayMs ?? 1e3;
211
+ const max = opts.maxDelayMs ?? 6e4;
212
+ const random = opts.random ?? Math.random;
213
+ const sleep = opts.sleep ?? sleepFor;
214
+ const aborted = () => opts.signal.aborted;
215
+ for (let attempt = 0;; attempt += 1) {
216
+ if (aborted()) return null;
217
+ try {
218
+ const credential = await fetchCredential();
219
+ opts.log.info(credential ? `[claude-host] Claude credential loaded (${credential.kind})` : "[claude-host] no Claude credential stored for this agent");
220
+ return { credential };
221
+ } catch (err) {
222
+ if (aborted()) return null;
223
+ const cap = Math.min(max, base * 2 ** attempt);
224
+ const delay = Math.round(cap * (.5 + random() / 2));
225
+ opts.log.warn(`[claude-host] could not load the Claude credential (${err instanceof Error ? err.message : String(err)}); retrying in ${String(delay)}ms`);
226
+ await sleep(delay, opts.signal);
227
+ }
228
+ }
229
+ }
230
+ /**
231
+ * Messages `claude` prints when Anthropic (or the CLI) refuses the credential.
232
+ * Captured from claude 2.1.284 (`src/__fixtures__/turn-auth-*.stream.jsonl`)
233
+ * plus the documented expiry message. Rate limits, overload and timeouts are
234
+ * deliberately NOT here — they say nothing about the credential.
235
+ */
236
+ const AUTH_FAILURE_MARKERS = [
237
+ "authentication_failed",
238
+ "failed to authenticate",
239
+ "invalid api key",
240
+ "invalid x-api-key",
241
+ "oauth access token is invalid",
242
+ "oauth token has expired",
243
+ "oauth token has been revoked",
244
+ "invalid bearer token",
245
+ "not logged in",
246
+ "please run /login"
247
+ ];
248
+ function isAuthFailureText(text) {
249
+ const lower = text.toLowerCase();
250
+ return AUTH_FAILURE_MARKERS.some((marker) => lower.includes(marker));
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
+ }
286
+ /** HTTP statuses on a `result` event that mean "credential refused". */
287
+ function isAuthFailureStatus(status) {
288
+ return status === 401 || status === 403;
289
+ }
290
+ const AUTH_PROBE_TIMEOUT_MS = 6e4;
291
+ const MAX_PROBE_OUTPUT_CHARS = 64e3;
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") {
298
+ return [
299
+ "-p",
300
+ "--input-format",
301
+ "text",
302
+ "--output-format",
303
+ "json",
304
+ "--model",
305
+ model,
306
+ "--tools",
307
+ "",
308
+ "--system-prompt",
309
+ "Reply with the single word OK.",
310
+ "--max-turns",
311
+ "1",
312
+ "--no-session-persistence",
313
+ "--strict-mcp-config",
314
+ "--setting-sources",
315
+ ""
316
+ ];
317
+ }
318
+ function parseProbeOutput(stdout, stderr, exitCode) {
319
+ const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
320
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
321
+ let event;
322
+ try {
323
+ event = JSON.parse(lines[index]);
324
+ } catch {
325
+ continue;
326
+ }
327
+ if (!event || typeof event !== "object") continue;
328
+ const record = event;
329
+ if (record.type !== "result") continue;
330
+ if (record.is_error === false) return { result: "ok" };
331
+ const text = typeof record.result === "string" ? record.result : "";
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" };
341
+ return {
342
+ result: "unknown",
343
+ reason: `probe failed (${typeof record.api_error_status === "number" ? `HTTP ${String(record.api_error_status)}` : "no auth error"})`
344
+ };
345
+ }
346
+ if (isAuthFailureText(stderr)) return { result: "invalid" };
347
+ return {
348
+ result: "unknown",
349
+ reason: `probe produced no result (exit ${String(exitCode)})`
350
+ };
351
+ }
352
+ /**
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.
356
+ */
357
+ function runAuthProbe(opts) {
358
+ const spawnImpl = opts.spawnImpl ?? spawn;
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;
361
+ return new Promise((resolve) => {
362
+ if (opts.signal?.aborted) {
363
+ resolve({
364
+ result: "unknown",
365
+ reason: "host stopping"
366
+ });
367
+ return;
368
+ }
369
+ let settled = false;
370
+ let stdout = "";
371
+ let stderr = "";
372
+ const finish = (outcome) => {
373
+ if (settled) return;
374
+ settled = true;
375
+ clearTimeout(timer);
376
+ opts.signal?.removeEventListener("abort", onAbort);
377
+ resolve(outcome);
378
+ };
379
+ const kill = () => {
380
+ try {
381
+ child.kill("SIGKILL");
382
+ } catch {}
383
+ };
384
+ function onAbort() {
385
+ kill();
386
+ finish({
387
+ result: "unknown",
388
+ reason: "host stopping"
389
+ });
390
+ }
391
+ let child;
392
+ try {
393
+ child = spawnImpl(opts.claudeBin ?? "claude", buildProbeArgs(model), {
394
+ cwd: opts.cwd,
395
+ env,
396
+ stdio: [
397
+ "pipe",
398
+ "pipe",
399
+ "pipe"
400
+ ]
401
+ });
402
+ } catch (err) {
403
+ resolve({
404
+ result: "unknown",
405
+ reason: `could not start claude: ${err instanceof Error ? err.message : String(err)}`
406
+ });
407
+ return;
408
+ }
409
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
410
+ const timer = setTimeout(() => {
411
+ kill();
412
+ finish({
413
+ result: "unknown",
414
+ reason: "probe timed out"
415
+ });
416
+ }, opts.timeoutMs ?? 6e4);
417
+ timer.unref();
418
+ const stdoutDecoder = new StringDecoder("utf8");
419
+ const stderrDecoder = new StringDecoder("utf8");
420
+ child.stdout?.on("data", (chunk) => {
421
+ stdout = `${stdout}${stdoutDecoder.write(chunk)}`.slice(-MAX_PROBE_OUTPUT_CHARS);
422
+ });
423
+ child.stderr?.on("data", (chunk) => {
424
+ stderr = `${stderr}${stderrDecoder.write(chunk)}`.slice(-MAX_PROBE_OUTPUT_CHARS);
425
+ });
426
+ child.on("error", (err) => {
427
+ finish({
428
+ result: "unknown",
429
+ reason: `claude process error: ${err.message}`
430
+ });
431
+ });
432
+ child.on("close", (code) => {
433
+ finish(parseProbeOutput(stdout, stderr, code));
434
+ });
435
+ try {
436
+ child.stdin?.end("ping");
437
+ } catch {}
438
+ });
439
+ }
440
+ /** An unchanged status is re-sent at most this often (a liveness signal). */
441
+ const STATUS_REPORT_INTERVAL_MS = 15 * 6e4;
442
+ /**
443
+ * Sends health reports on change, or when the same report is older than
444
+ * {@link STATUS_REPORT_INTERVAL_MS}. Sends are serialized; a failed send is
445
+ * not remembered, so the next signal retries. Never throws.
446
+ */
447
+ var CredentialStatusReporter = class {
448
+ lastKey = null;
449
+ lastSentAt = 0;
450
+ chain = Promise.resolve();
451
+ now;
452
+ log;
453
+ constructor(send, opts) {
454
+ this.send = send;
455
+ this.log = opts.log;
456
+ this.now = opts.now ?? Date.now;
457
+ }
458
+ report(report) {
459
+ this.chain = this.chain.then(() => this.deliver(report));
460
+ return this.chain;
461
+ }
462
+ async deliver(report) {
463
+ const key = JSON.stringify([
464
+ report.status,
465
+ report.source ?? null,
466
+ report.revision ?? null,
467
+ report.detail ?? null
468
+ ]);
469
+ if (key === this.lastKey && this.now() - this.lastSentAt < 9e5) return;
470
+ try {
471
+ const { accepted } = await this.send(report);
472
+ this.lastKey = key;
473
+ this.lastSentAt = this.now();
474
+ this.log.info(`[claude-host] Claude credential status ${report.status} ${accepted ? "reported" : "ignored (credential changed since)"}`);
475
+ } catch (err) {
476
+ this.log.warn(`[claude-host] could not report Claude credential status ${report.status}: ${err instanceof Error ? err.message : String(err)}`);
477
+ }
478
+ }
479
+ };
480
+ //#endregion
64
481
  //#region src/stream-mapper.ts
482
+ /**
483
+ * Claude Code `--output-format stream-json` → Alfe chat-activity mapper.
484
+ *
485
+ * This is the load-bearing core of the bridge. It consumes the newline-delimited
486
+ * JSON events emitted by `claude -p ... --output-format stream-json
487
+ * --include-partial-messages` and translates them into the SAME wire shapes the
488
+ * `@alfe.ai/openclaw-chat` plugin emits, so the chat service / adapter / UI
489
+ * treat a Claude Code turn identically to an OpenClaw turn.
490
+ *
491
+ * Ground truth: a real captured turn (thinking + Read tool + text + result) at
492
+ * `src/__fixtures__/turn-read-tool.stream.jsonl`. The mapper test runs against
493
+ * that fixture (TEST-FIRST).
494
+ *
495
+ * Event model (verified against the fixture + Claude Code stream-json docs):
496
+ * - `{type:'system', subtype:'init', session_id}` — carries the claude
497
+ * session id we persist for `--resume`.
498
+ * - `{type:'stream_event', event:{type:'content_block_delta', delta:{
499
+ * type:'thinking_delta', thinking}}}` — a thinking chunk.
500
+ * - `{type:'stream_event', event:{type:'content_block_delta', delta:{
501
+ * type:'text_delta', text}}}` — a visible-text chunk (accumulate → the
502
+ * CUMULATIVE snapshot the adapter length-slices).
503
+ * - `{type:'assistant', message:{content:[{type:'tool_use', id, name,
504
+ * input}]}}` — a tool CALL. The non-streaming `assistant` message has the
505
+ * COMPLETE id/name/input (the streaming `content_block_start` has empty
506
+ * input, filled by `input_json_delta`), so we correlate off THIS.
507
+ * - `{type:'user', message:{content:[{type:'tool_result', tool_use_id,
508
+ * content, is_error?}]}}` — a tool RESULT. `tool_use_id === tool_use.id`.
509
+ * - `{type:'result', is_error, result, duration_ms}` — the turn end.
510
+ *
511
+ * The mapper is a PURE reducer over one event: it takes the running state and an
512
+ * event, mutates the state, and returns the list of `MapperEmission`s to send.
513
+ * It performs NO IO — the host does the sending / persisting. `progressText` /
514
+ * the 250ms mid-tool throttle from openclaw-chat is inert here: Claude Code has
515
+ * no mid-tool progress analog, so we deliberately don't implement it.
516
+ */
65
517
  function createMapperState() {
66
518
  return {
67
519
  claudeSessionId: null,
68
520
  seq: 0,
69
521
  visibleText: "",
70
522
  pendingTools: /* @__PURE__ */ new Map(),
71
- ended: false
523
+ ended: false,
524
+ authFailed: false
72
525
  };
73
526
  }
74
527
  const MAX_ARGS_CHARS = 2e3;
@@ -183,6 +636,7 @@ function mapStreamEvent(state, raw, nowMs) {
183
636
  return [];
184
637
  }
185
638
  if (type === "assistant") {
639
+ if (evt.error === "authentication_failed") state.authFailed = true;
186
640
  const content = asRecord$1(evt.message)?.content;
187
641
  if (!Array.isArray(content)) return [];
188
642
  const emissions = [];
@@ -262,6 +716,7 @@ function mapStreamEvent(state, raw, nowMs) {
262
716
  const durationRaw = evt.duration_ms;
263
717
  const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? Math.max(0, durationRaw) : void 0;
264
718
  const errorMessage = isError ? asString(evt.result) ?? asString(evt.subtype) ?? "Claude Code turn failed" : void 0;
719
+ const authFailed = isError && (state.authFailed || isAuthFailureStatus(evt.api_error_status) || isAuthFailureText(asString(evt.result) ?? ""));
265
720
  return [{
266
721
  type: "turn-end",
267
722
  seq: state.seq++,
@@ -269,7 +724,9 @@ function mapStreamEvent(state, raw, nowMs) {
269
724
  isError,
270
725
  finalText: isError ? "" : finalText,
271
726
  ...durationMs !== void 0 ? { durationMs } : {},
272
- ...errorMessage ? { errorMessage } : {}
727
+ ...errorMessage ? { errorMessage } : {},
728
+ ...authFailed ? { authFailed: true } : {},
729
+ ...authFailed && evt.api_error_status === 403 ? { authForbidden: true } : {}
273
730
  }];
274
731
  }
275
732
  return [];
@@ -408,6 +865,30 @@ const DEFAULT_ALLOWED_TOOLS = [
408
865
  "Task",
409
866
  "TodoWrite"
410
867
  ];
868
+ /**
869
+ * Managed VMs (`ALFE_MANAGED=1|true`) add `Bash` to the allow-list. The VM is
870
+ * the agent's own machine (no user data beside the agent's workspace), and
871
+ * shell access is what makes a managed coding agent useful. Still
872
+ * `--permission-mode dontAsk` (never bypass), and the managed VM runs as root:
873
+ * claude refuses only `--dangerously-skip-permissions` under root, not
874
+ * `dontAsk`. `Edit`/`Write` are already in the default set; `MultiEdit` is not
875
+ * a tool in the pinned claude (2.1.x), so it is not listed.
876
+ */
877
+ const MANAGED_ALLOWED_TOOLS = [...DEFAULT_ALLOWED_TOOLS, "Bash"];
878
+ /**
879
+ * Deny rules layered on the managed allow-list (`--disallowedTools`, which
880
+ * wins over `--allowedTools`). They keep the agent from re-running Google
881
+ * Workspace CLI auth and from reading its token directory with the Read tool.
882
+ * They are NOT a security boundary: with Bash allowed, `cat`, `cp`, `env` and
883
+ * friends can still read anything the root user can. Verified on claude
884
+ * 2.1.284: a `Bash(<prefix>:*)` rule denies the matching command under
885
+ * `dontAsk` while other Bash commands run.
886
+ */
887
+ const MANAGED_DISALLOWED_TOOLS = [
888
+ "Bash(gws auth)",
889
+ "Bash(gws auth:*)",
890
+ "Read(~/.config/gws-*/**)"
891
+ ];
411
892
  const CLAUDE_PERMISSION_MODES = [
412
893
  "acceptEdits",
413
894
  "auto",
@@ -456,12 +937,16 @@ var SessionDriver = class {
456
937
  abortCurrent = null;
457
938
  bin;
458
939
  baseEnv;
940
+ auth;
941
+ managed;
459
942
  log;
460
943
  /** Injected spawn — tests replace this with a fake child. */
461
944
  spawnImpl;
462
945
  constructor(opts) {
463
946
  this.bin = opts?.claudeBin ?? DEFAULT_CLAUDE_BIN;
464
947
  this.baseEnv = opts?.baseEnv ?? process.env;
948
+ this.auth = opts?.auth ?? { mode: "local_login" };
949
+ this.managed = opts?.managed ?? false;
465
950
  this.log = opts?.logger ?? console;
466
951
  this.spawnImpl = opts?.__spawn ?? spawn;
467
952
  }
@@ -485,7 +970,7 @@ var SessionDriver = class {
485
970
  return this.runPerTurn(params);
486
971
  }
487
972
  buildArgs(params) {
488
- const allowed = (params.allowedTools ?? DEFAULT_ALLOWED_TOOLS).join(",");
973
+ const allowed = (params.allowedTools ?? (this.managed ? MANAGED_ALLOWED_TOOLS : DEFAULT_ALLOWED_TOOLS)).join(",");
489
974
  const permissionMode = params.permissionMode ?? "dontAsk";
490
975
  if (!CLAUDE_PERMISSION_MODES.includes(permissionMode)) throw new Error(`Unsupported Claude permission mode: ${permissionMode}`);
491
976
  const args = [
@@ -499,8 +984,10 @@ var SessionDriver = class {
499
984
  "--permission-mode",
500
985
  permissionMode,
501
986
  "--allowedTools",
502
- allowed
987
+ allowed,
988
+ "--strict-mcp-config"
503
989
  ];
990
+ if (this.managed) args.push("--disallowedTools", MANAGED_DISALLOWED_TOOLS.join(","));
504
991
  if (params.model) args.push("--model", params.model);
505
992
  if (params.claudeSessionId) args.push("--resume", params.claudeSessionId);
506
993
  if (params.mcpConfigPath) args.push("--mcp-config", params.mcpConfigPath);
@@ -518,7 +1005,7 @@ var SessionDriver = class {
518
1005
  let stderr = "";
519
1006
  let rateLimited = false;
520
1007
  const args = this.buildArgs(params);
521
- const env = buildChildEnv(this.baseEnv);
1008
+ const env = buildChildEnv(this.baseEnv, void 0, this.auth);
522
1009
  return new Promise((resolve) => {
523
1010
  let child;
524
1011
  try {
@@ -556,18 +1043,22 @@ var SessionDriver = class {
556
1043
  this.abortCurrent = null;
557
1044
  const end = emissions.find((e) => e.type === "turn-end");
558
1045
  const endIsError = end?.type === "turn-end" ? end.isError : false;
1046
+ const endAuthFailed = end?.type === "turn-end" && end.authFailed === true;
559
1047
  const endText = end?.type === "turn-end" ? end.finalText : state.visibleText;
560
1048
  const endErrMsg = end?.type === "turn-end" ? end.errorMessage : void 0;
561
1049
  const finalIsError = isError || endIsError;
562
1050
  const finalErrMsg = errorMessage ?? endErrMsg ?? (finalIsError ? rateLimited || looksRateLimited(stderr) ? "Claude subscription rate limit hit — try again shortly" : stderr.trim().slice(0, 500) || "Claude Code turn failed" : void 0);
563
1051
  const retainedEmissions = latestTextEmission ? [...emissions, latestTextEmission].sort((a, b) => a.seq - b.seq) : emissions;
1052
+ const authFailed = finalIsError && !aborted && terminationReason === null && (end ? endAuthFailed : isAuthFailureText(stderr));
564
1053
  resolve({
565
1054
  claudeSessionId: state.claudeSessionId,
566
1055
  emissions: retainedEmissions,
567
1056
  isError: finalIsError,
568
1057
  finalText: finalIsError ? "" : endText,
569
1058
  ...finalErrMsg ? { errorMessage: finalErrMsg } : {},
570
- aborted
1059
+ aborted,
1060
+ ...authFailed ? { authFailed: true } : {},
1061
+ ...authFailed && end?.type === "turn-end" && end.authForbidden ? { authForbidden: true } : {}
571
1062
  });
572
1063
  };
573
1064
  const requestTermination = (reason) => {
@@ -619,9 +1110,10 @@ var SessionDriver = class {
619
1110
  }
620
1111
  }
621
1112
  });
1113
+ const stderrDecoder = new StringDecoder("utf8");
622
1114
  child.stderr.on("data", (chunk) => {
623
1115
  if (settled) return;
624
- const text = chunk.toString("utf-8");
1116
+ const text = stderrDecoder.write(chunk);
625
1117
  rateLimited ||= looksRateLimited(text);
626
1118
  stderr = `${stderr}${text}`.slice(-MAX_STDERR_CHARS);
627
1119
  });
@@ -694,6 +1186,27 @@ async function writeTextAtomically(path, contents) {
694
1186
  *
695
1187
  * where `sessionId = conversationId ?? sessionKey`.
696
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"]);
697
1210
  function sessionsDir() {
698
1211
  return process.env.ALFE_SESSIONS_DIR ?? join(homedir(), ".alfe", "sessions", "chat");
699
1212
  }
@@ -712,6 +1225,7 @@ function sessionPath(sessionId) {
712
1225
  function asSessionData(value, expectedSessionId) {
713
1226
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
714
1227
  const record = value;
1228
+ normaliseLegacyChannel(record);
715
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;
716
1230
  if (!record.messages.every((message) => {
717
1231
  if (!message || typeof message !== "object" || Array.isArray(message)) return false;
@@ -719,8 +1233,21 @@ function asSessionData(value, expectedSessionId) {
719
1233
  return (item.role === "user" || item.role === "assistant") && typeof item.content === "string" && typeof item.timestamp === "number" && Number.isFinite(item.timestamp);
720
1234
  })) return null;
721
1235
  if (record.activity !== void 0 && !Array.isArray(record.activity)) return null;
1236
+ if (record.memory !== void 0 && !isMemoryCursor(record.memory)) delete record.memory;
722
1237
  return value;
723
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
+ }
1246
+ function isMemoryCursor(value) {
1247
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1248
+ const cursor = value;
1249
+ return Number.isSafeInteger(cursor.ingestEpoch) && cursor.ingestEpoch >= 0 && Number.isSafeInteger(cursor.lastIngestedIndex) && cursor.lastIngestedIndex >= -1;
1250
+ }
724
1251
  async function cleanupOldSessions() {
725
1252
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
726
1253
  const dir = sessionsDir();
@@ -770,42 +1297,216 @@ async function saveSession(session) {
770
1297
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
771
1298
  await writeTextAtomically(sessionPath(session.sessionId), JSON.stringify(session, null, 2));
772
1299
  }
773
- async function createSession(sessionId, agentId, channel, tenantId, userId) {
1300
+ async function createSession(sessionId, agentId, channel, tenantId, userId, clientType) {
774
1301
  const now = (/* @__PURE__ */ new Date()).toISOString();
775
1302
  const session = {
776
1303
  sessionId,
777
1304
  agentId,
778
1305
  channel,
1306
+ ...clientType ? { clientType } : {},
779
1307
  tenantId,
780
1308
  userId,
781
1309
  createdAt: now,
782
1310
  updatedAt: now,
783
- messages: []
1311
+ messages: [],
1312
+ memory: {
1313
+ ingestEpoch: await nextIngestEpoch(),
1314
+ lastIngestedIndex: -1
1315
+ }
784
1316
  };
785
1317
  await saveSession(session);
786
1318
  cleanupOldSessions();
787
1319
  return session;
788
1320
  }
789
1321
  const writeLocks = /* @__PURE__ */ new Map();
1322
+ /** Run `fn` under the per-session write lock and return its result. */
1323
+ async function withSessionLock(sessionId, fn) {
1324
+ const prev = writeLocks.get(sessionId) ?? Promise.resolve();
1325
+ let result;
1326
+ const next = prev.catch(() => void 0).then(async () => {
1327
+ result = await fn();
1328
+ }).finally(() => {
1329
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
1330
+ });
1331
+ writeLocks.set(sessionId, next);
1332
+ await next;
1333
+ return result;
1334
+ }
1335
+ /**
1336
+ * Append a message. Returns its index (position in `messages`), the stable
1337
+ * memory-ingest index, or -1 when the session does not exist.
1338
+ */
790
1339
  async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
791
- const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
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) {
1362
+ return withSessionLock(sessionId, async () => {
792
1363
  const session = await getSession(sessionId);
793
- if (!session) return;
1364
+ if (!session) return -1;
1365
+ const last = session.messages.at(-1)?.timestamp ?? 0;
794
1366
  session.messages.push({
795
- role,
796
- content,
797
- timestamp: Date.now(),
798
- ...senderId ? { senderId } : {},
799
- ...senderName ? { senderName } : {},
800
- ...components?.length ? { components } : {},
801
- ...attachments?.length ? { attachments } : {}
1367
+ ...message,
1368
+ timestamp: Math.max(Date.now(), last + 1)
802
1369
  });
803
1370
  await saveSession(session);
804
- }).finally(() => {
805
- if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
1371
+ return session.messages.length - 1;
1372
+ });
1373
+ }
1374
+ const MAX_MEMORY_MESSAGE_BYTES = 6e4;
1375
+ /** Allowance per message for role/index/JSON structure. */
1376
+ const MEMORY_MESSAGE_OVERHEAD_BYTES = 64;
1377
+ function serialisedBytes(text) {
1378
+ return Buffer.byteLength(JSON.stringify(text), "utf8");
1379
+ }
1380
+ /**
1381
+ * Longest prefix of `text` whose JSON-serialised UTF-8 size is at most
1382
+ * `maxBytes`, never splitting a surrogate pair.
1383
+ */
1384
+ function capSerialisedBytes(text, maxBytes) {
1385
+ if (serialisedBytes(text) <= maxBytes) return text;
1386
+ let lo = 0;
1387
+ let hi = text.length;
1388
+ while (lo < hi) {
1389
+ const mid = Math.ceil((lo + hi) / 2);
1390
+ if (serialisedBytes(text.slice(0, mid)) <= maxBytes) lo = mid;
1391
+ else hi = mid - 1;
1392
+ }
1393
+ let end = lo;
1394
+ const last = text.charCodeAt(end - 1);
1395
+ if (end > 0 && last >= 55296 && last <= 56319) end -= 1;
1396
+ return text.slice(0, end);
1397
+ }
1398
+ let epochChain = Promise.resolve();
1399
+ function epochStatePath() {
1400
+ return join(sessionsDir(), ".ingest-epoch");
1401
+ }
1402
+ async function readLastEpoch() {
1403
+ try {
1404
+ const parsed = JSON.parse(await readFile(epochStatePath(), "utf-8"));
1405
+ const epoch = parsed && typeof parsed === "object" ? parsed.epoch : void 0;
1406
+ return Number.isSafeInteger(epoch) && epoch >= 0 ? epoch : 0;
1407
+ } catch {
1408
+ return 0;
1409
+ }
1410
+ }
1411
+ /**
1412
+ * Next conversation-lineage epoch: `max(Date.now(), lastIssued + 1)`, persisted
1413
+ * (same guard as openclaw-memory-cloud's `ingest-epoch.ts`). The memory service
1414
+ * treats an OLDER epoch than the one it stored as stale and drops the batch, so
1415
+ * a clock that stepped backwards must never produce a smaller epoch.
1416
+ */
1417
+ async function nextIngestEpoch() {
1418
+ const run = async () => {
1419
+ await ensureDir();
1420
+ const epoch = Math.max(Date.now(), await readLastEpoch() + 1);
1421
+ await writeTextAtomically(epochStatePath(), `${JSON.stringify({ epoch })}\n`);
1422
+ return epoch;
1423
+ };
1424
+ const result = epochChain.then(run, run);
1425
+ epochChain = result.catch(() => void 0);
1426
+ return result;
1427
+ }
1428
+ /**
1429
+ * Cursor for a session written before the cursor existed. The old host
1430
+ * ingested every turn as indices 0 and 1, so the service stored exactly the
1431
+ * messages at positions 0/1 (when the session had them) and dropped the rest.
1432
+ * Start at 2 so the dropped backlog is backfilled through the batch bound.
1433
+ */
1434
+ async function initialCursor(session) {
1435
+ return {
1436
+ ingestEpoch: await nextIngestEpoch(),
1437
+ lastIngestedIndex: session.messages.length >= 2 ? 1 : -1
1438
+ };
1439
+ }
1440
+ /**
1441
+ * Give a pre-cursor session its cursor. Call BEFORE appending the turn's user
1442
+ * message, so a turn that then fails still leaves that message pending.
1443
+ * No-op for a session that already has one (or does not exist).
1444
+ */
1445
+ async function ensureMemoryCursor(sessionId) {
1446
+ await withSessionLock(sessionId, async () => {
1447
+ const session = await getSession(sessionId);
1448
+ if (!session || session.memory) return;
1449
+ session.memory = await initialCursor(session);
1450
+ await saveSession(session);
1451
+ });
1452
+ }
1453
+ /**
1454
+ * The messages not yet accepted by the memory service, oldest first, bounded
1455
+ * by count and serialised bytes (always at least one message when any is
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
1458
+ * accounts for, sent or skipped. Leftovers go out with the next turn's batch.
1459
+ */
1460
+ async function pendingMemoryBatch(sessionId, opts = {}) {
1461
+ const maxMessages = opts.maxMessages ?? 20;
1462
+ const maxBytes = opts.maxBytes ?? 2e5;
1463
+ return withSessionLock(sessionId, async () => {
1464
+ const session = await getSession(sessionId);
1465
+ if (!session) return null;
1466
+ if (!session.memory) {
1467
+ session.memory = await initialCursor(session);
1468
+ await saveSession(session);
1469
+ }
1470
+ const messages = [];
1471
+ let throughIndex = session.memory.lastIngestedIndex;
1472
+ let bytes = 0;
1473
+ for (let index = session.memory.lastIngestedIndex + 1; index < session.messages.length && messages.length < maxMessages; index += 1) {
1474
+ const message = session.messages[index];
1475
+ if (message.kind === "error" || message.content.trim() === "") {
1476
+ throughIndex = index;
1477
+ continue;
1478
+ }
1479
+ const content = capSerialisedBytes(message.content, MAX_MEMORY_MESSAGE_BYTES);
1480
+ const size = serialisedBytes(content) + MEMORY_MESSAGE_OVERHEAD_BYTES;
1481
+ if (messages.length > 0 && bytes + size > maxBytes) break;
1482
+ bytes += size;
1483
+ messages.push({
1484
+ role: message.role,
1485
+ content,
1486
+ index
1487
+ });
1488
+ throughIndex = index;
1489
+ }
1490
+ return {
1491
+ ingestEpoch: session.memory.ingestEpoch,
1492
+ messages,
1493
+ throughIndex
1494
+ };
1495
+ });
1496
+ }
1497
+ /**
1498
+ * Record that the memory service has dealt with messages up to `lastIndex`.
1499
+ * Ignored unless it moves the cursor forward within the same epoch, so a late
1500
+ * or duplicate acknowledgement can never move it back.
1501
+ */
1502
+ async function markMemoryIngested(sessionId, ingestEpoch, lastIndex) {
1503
+ await withSessionLock(sessionId, async () => {
1504
+ const session = await getSession(sessionId);
1505
+ const cursor = session?.memory;
1506
+ if (!session || cursor?.ingestEpoch !== ingestEpoch || lastIndex <= cursor.lastIngestedIndex || lastIndex >= session.messages.length) return;
1507
+ cursor.lastIngestedIndex = lastIndex;
1508
+ await saveSession(session);
806
1509
  });
807
- writeLocks.set(sessionId, next);
808
- await next;
809
1510
  }
810
1511
  const MAX_ACTIVITY_PER_SESSION = 500;
811
1512
  const MAX_ACTIVITY_THINKING_CHARS = 16e3;
@@ -815,7 +1516,7 @@ const MAX_ACTIVITY_THINKING_CHARS = 16e3;
815
1516
  */
816
1517
  async function appendActivity(sessionId, entries) {
817
1518
  if (entries.length === 0) return;
818
- const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
1519
+ await withSessionLock(sessionId, async () => {
819
1520
  const session = await getSession(sessionId);
820
1521
  if (!session) return;
821
1522
  const capped = entries.map((e) => e.kind === "thinking" && e.text.length > MAX_ACTIVITY_THINKING_CHARS ? {
@@ -825,11 +1526,7 @@ async function appendActivity(sessionId, entries) {
825
1526
  const combined = [...session.activity ?? [], ...capped];
826
1527
  session.activity = combined.length > MAX_ACTIVITY_PER_SESSION ? combined.slice(combined.length - MAX_ACTIVITY_PER_SESSION) : combined;
827
1528
  await saveSession(session);
828
- }).finally(() => {
829
- if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
830
1529
  });
831
- writeLocks.set(sessionId, next);
832
- await next;
833
1530
  }
834
1531
  async function listSessions(filters, limit = 50) {
835
1532
  await ensureDir();
@@ -871,16 +1568,207 @@ async function listSessions(filters, limit = 50) {
871
1568
  return summaries.slice(0, boundedLimit);
872
1569
  }
873
1570
  //#endregion
1571
+ //#region src/persona.ts
1572
+ /**
1573
+ * Host-owned `CLAUDE.md` — the persona Claude Code auto-loads from its cwd.
1574
+ *
1575
+ * The agent's persona lives in the workspace persona files (IDENTITY.md,
1576
+ * SOUL.md, AGENTS.md, USER.md, BOOTSTRAP.md). `alfe setup` seeds them from the
1577
+ * agent's template (seed-if-missing, agent-owned — the same semantics OpenClaw
1578
+ * uses), and the agent may edit them itself. OpenClaw reads those files live
1579
+ * every turn; Claude Code only reads `CLAUDE.md`. So the host regenerates
1580
+ * `CLAUDE.md` from them on start and before every turn, which keeps it as
1581
+ * current as OpenClaw's view of the same files.
1582
+ *
1583
+ * File layout:
1584
+ *
1585
+ * <!-- alfe:managed:start -->
1586
+ * <!-- Managed by Alfe. ... -->
1587
+ *
1588
+ * <persona sections>
1589
+ * <!-- alfe:managed:end. Add your own notes below this line; Alfe keeps them. -->
1590
+ * <user notes, kept verbatim>
1591
+ *
1592
+ * Everything above the end marker is overwritten. Everything after it is
1593
+ * preserved, so self-hosted users can add local notes.
1594
+ */
1595
+ /** Persona files, in the order they appear in `CLAUDE.md`. */
1596
+ const PERSONA_FILES = [
1597
+ "IDENTITY.md",
1598
+ "SOUL.md",
1599
+ "AGENTS.md",
1600
+ "USER.md",
1601
+ "BOOTSTRAP.md"
1602
+ ];
1603
+ /**
1604
+ * Start-line prefix. The full line records the sha256 of the managed region
1605
+ * the host last wrote (`<!-- alfe:managed:start sha256=<hex> -->`), so a hand
1606
+ * edit above the end marker is detected and backed up before it is replaced.
1607
+ */
1608
+ const MANAGED_START = "<!-- alfe:managed:start";
1609
+ const MANAGED_END = "<!-- alfe:managed:end. Add your own notes below this line; Alfe keeps them. -->";
1610
+ const MANAGED_NOTICE = "<!-- Managed by Alfe. Regenerated from IDENTITY.md, SOUL.md, AGENTS.md, USER.md and BOOTSTRAP.md in this folder; edit those files instead. Everything above alfe:managed:end is overwritten. -->";
1611
+ /** Matches the end marker even if a future version changes its trailing text. */
1612
+ const END_PREFIX = "<!-- alfe:managed:end";
1613
+ /** Separator between persona sections (also what the old CLI seed used). */
1614
+ const SECTION_SEPARATOR = "\n\n---\n\n";
1615
+ /**
1616
+ * Claude Code loads `CLAUDE.md` into every turn's context, so the persona is
1617
+ * capped per file and in total. Truncation is stated in the file itself.
1618
+ */
1619
+ const MAX_PERSONA_FILE_CHARS = 2e4;
1620
+ const MAX_PERSONA_TOTAL_CHARS = 6e4;
1621
+ function truncate(text, max, note) {
1622
+ if (text.length <= max) return text;
1623
+ let end = max;
1624
+ const last = text.charCodeAt(end - 1);
1625
+ if (last >= 55296 && last <= 56319) end -= 1;
1626
+ return `${text.slice(0, end)}\n\n[Truncated by Alfe: ${note}; the rest is not loaded.]`;
1627
+ }
1628
+ /**
1629
+ * Join the present persona files (stable order, trimmed, blanks skipped),
1630
+ * capped per file and in total. Returns undefined when there is no persona
1631
+ * content.
1632
+ */
1633
+ function composePersonaBody(files) {
1634
+ const sections = [];
1635
+ for (const name of PERSONA_FILES) {
1636
+ const content = files[name]?.trim();
1637
+ if (content) sections.push(truncate(content, MAX_PERSONA_FILE_CHARS, `${name} is longer than ${String(MAX_PERSONA_FILE_CHARS)} characters`));
1638
+ }
1639
+ if (sections.length === 0) return void 0;
1640
+ return truncate(sections.join(SECTION_SEPARATOR), MAX_PERSONA_TOTAL_CHARS, `the persona is longer than ${String(MAX_PERSONA_TOTAL_CHARS)} characters`);
1641
+ }
1642
+ function sha256(text) {
1643
+ return createHash("sha256").update(text, "utf8").digest("hex");
1644
+ }
1645
+ /**
1646
+ * Render the next `CLAUDE.md` from the persona body and the current file.
1647
+ * Returns undefined when nothing should be written: there is no persona and
1648
+ * the existing file (if any) is not Alfe-managed.
1649
+ */
1650
+ function renderClaudeMd(body, existing) {
1651
+ const endAt = existing === void 0 ? -1 : existing.indexOf(END_PREFIX);
1652
+ let notes = "";
1653
+ if (existing !== void 0 && endAt >= 0) {
1654
+ const lineEnd = existing.indexOf("\n", endAt);
1655
+ notes = lineEnd < 0 ? "" : existing.slice(lineEnd + 1);
1656
+ } else if (existing !== void 0 && existing.trim() !== "" && existing.trim() !== body) notes = existing.endsWith("\n") ? existing : `${existing}\n`;
1657
+ if (body === void 0 && endAt < 0) return void 0;
1658
+ const region = `${MANAGED_NOTICE}\n\n${body ? `${body}\n` : ""}`;
1659
+ return `${MANAGED_START} sha256=${sha256(region)} -->\n${region}${MANAGED_END}\n${notes}`;
1660
+ }
1661
+ /**
1662
+ * True when the managed region of a host-written file no longer matches the
1663
+ * hash the host recorded, i.e. someone edited above the end marker. Files
1664
+ * without a recorded hash are treated as unedited.
1665
+ */
1666
+ function managedRegionEdited(existing) {
1667
+ const firstLineEnd = existing.indexOf("\n");
1668
+ const endAt = existing.indexOf(END_PREFIX);
1669
+ if (firstLineEnd < 0 || endAt < firstLineEnd) return false;
1670
+ const recorded = /^<!-- alfe:managed:start sha256=([0-9a-f]{64}) -->$/u.exec(existing.slice(0, firstLineEnd))?.[1];
1671
+ if (!recorded) return false;
1672
+ return sha256(existing.slice(firstLineEnd + 1, endAt)) !== recorded;
1673
+ }
1674
+ /**
1675
+ * True for an unmarked `CLAUDE.md` in the format the CLI used to seed:
1676
+ * persona sections joined by `---`, whose first section is still the content
1677
+ * of a current persona file. Such a file is a stale persona snapshot (often
1678
+ * including BOOTSTRAP.md), not user notes.
1679
+ */
1680
+ function isLegacyComposedClaudeMd(existing, files) {
1681
+ if (existing.includes(END_PREFIX)) return false;
1682
+ const first = existing.split(SECTION_SEPARATOR)[0]?.trim();
1683
+ if (!first) return false;
1684
+ return PERSONA_FILES.some((name) => files[name]?.trim() === first);
1685
+ }
1686
+ async function readOptional(path) {
1687
+ try {
1688
+ return await readFile(path, "utf-8");
1689
+ } catch (err) {
1690
+ if (err.code === "ENOENT") return void 0;
1691
+ throw err;
1692
+ }
1693
+ }
1694
+ function timestamp() {
1695
+ return `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/gu, "-")}-${randomUUID().slice(0, 8)}`;
1696
+ }
1697
+ /**
1698
+ * Regenerate `<workspace>/CLAUDE.md` from the workspace persona files. Creates
1699
+ * the workspace directory if missing (it is `claude`'s cwd). Skips the write
1700
+ * when the content is unchanged. Returns true when it wrote.
1701
+ *
1702
+ * Before overwriting it preserves anything that is not a plain host-written
1703
+ * file: an old CLI-composed persona snapshot is moved to `CLAUDE.md.pre-alfe`,
1704
+ * and a managed region edited by hand is copied to
1705
+ * `CLAUDE.md.edited-<timestamp>.bak`. Both are logged.
1706
+ */
1707
+ async function syncClaudeMd(workspace, log) {
1708
+ await mkdir(workspace, { recursive: true });
1709
+ const files = {};
1710
+ for (const name of PERSONA_FILES) {
1711
+ const text = await readOptional(join(workspace, name));
1712
+ if (text !== void 0) files[name] = text;
1713
+ }
1714
+ const body = composePersonaBody(files);
1715
+ const target = join(workspace, "CLAUDE.md");
1716
+ let existing = await readOptional(target);
1717
+ if (existing !== void 0 && existing.trim() !== body && isLegacyComposedClaudeMd(existing, files)) {
1718
+ const aside = existsSync(`${target}.pre-alfe`) ? `${target}.pre-alfe-${timestamp()}` : `${target}.pre-alfe`;
1719
+ await rename(target, aside);
1720
+ log?.warn(`[claude-host] moved an old setup-generated CLAUDE.md to ${aside}; the persona is now regenerated from the persona files`);
1721
+ existing = void 0;
1722
+ }
1723
+ const next = renderClaudeMd(body, existing);
1724
+ if (next === void 0 || next === existing) return false;
1725
+ if (existing !== void 0 && managedRegionEdited(existing)) {
1726
+ const backup = `${target}.edited-${timestamp()}.bak`;
1727
+ await writeTextAtomically(backup, existing);
1728
+ log?.warn(`[claude-host] CLAUDE.md was edited above the alfe:managed:end marker; saved a copy to ${backup} before regenerating. Edit the persona files or add notes below the marker instead.`);
1729
+ }
1730
+ await writeTextAtomically(target, next);
1731
+ return true;
1732
+ }
1733
+ //#endregion
1734
+ //#region src/memory-recall.ts
1735
+ /**
1736
+ * Memory recall helpers. Mirrors `packages/openclaw-memory-cloud/src/auto-recall.ts`
1737
+ * so a claude-code agent recalls the same way an OpenClaw agent does: tier 1
1738
+ * (facts) always, plus tier 2 (topic context) when the message names a topic.
1739
+ * Copied rather than imported: the host must not depend on an OpenClaw plugin
1740
+ * package. Keep the two in step.
1741
+ */
1742
+ const STOP_WORDS = /^(The|And|But|For|With|This|That|What|How|Why|When|Where)$/;
1743
+ /**
1744
+ * Topic hint = the first capitalised word (a likely proper noun) after the
1745
+ * first word, longer than two characters and not a stop word, lower-cased.
1746
+ */
1747
+ function extractTopicHint(message) {
1748
+ const words = message.split(/\s+/);
1749
+ for (let i = 1; i < words.length; i++) {
1750
+ const word = words[i];
1751
+ if (word && word.length > 2 && /^[A-Z]/.test(word) && !STOP_WORDS.test(word)) return word.toLowerCase();
1752
+ }
1753
+ }
1754
+ /** Tier 2 (facts + topic context) needs a topic; otherwise tier 1 (facts). */
1755
+ function recallTier(topicHint) {
1756
+ return topicHint ? 2 : 1;
1757
+ }
1758
+ /** Recall is best-effort and must not hold up a turn. Matches auto-recall. */
1759
+ const RECALL_TIMEOUT_MS = 5e3;
1760
+ //#endregion
874
1761
  //#region src/host.ts
875
1762
  /**
876
- * ClaudeCodeHost — the persistent chat bridge for a self-hosted Claude Code
877
- * agent.
1763
+ * ClaudeCodeHost — the persistent chat bridge for a Claude Code agent
1764
+ * (self-hosted or managed).
878
1765
  *
879
1766
  * It holds ONE `ChatServiceClient` (reused verbatim from `@alfe.ai/chat`, with
880
1767
  * NO custom reconnect — the client already backs off with jitter and guards
881
1768
  * against orphan sockets), registers the inbound RPC handlers the chat service
882
- * calls, and drives the `SessionDriver` to run `claude -p` turns on the user's
883
- * 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").
884
1772
  *
885
1773
  * Every wire shape here is byte-for-byte the shape the `@alfe.ai/openclaw-chat`
886
1774
  * plugin emits, so the chat service / adapter / UI treat a Claude Code turn
@@ -903,8 +1791,6 @@ const VOICE_REPLY_SYSTEM_PROMPT = [
903
1791
  "- If the full answer is long or detailed, give the short spoken version and offer to send the details to the chat.",
904
1792
  "This is guidance on speaking STYLE only. Do NOT skip tool calls you would otherwise make, and never omit information the user actually needs."
905
1793
  ].join("\n");
906
- /** Cross-agent tool steering (every turn). Mirrors CROSS_AGENT_TOOLS_SYSTEM_PROMPT. */
907
- const CROSS_AGENT_TOOLS_SYSTEM_PROMPT = ["Cross-agent messaging: to contact another Alfe agent (IDs like agt_…), use list_agents to discover and message_agent to send.", "The runtime's built-in session tools do NOT know Alfe agent IDs and will fail — never use them to reach another agent."].join("\n");
908
1794
  /**
909
1795
  * Short, AUTHORITATIVE persona pointer for `--append-system-prompt`. The full
910
1796
  * persona (SOUL/IDENTITY/AGENTS/…) is delivered as the workspace `CLAUDE.md`,
@@ -919,6 +1805,29 @@ const PERSONA_POINTER_SYSTEM_PROMPT = [
919
1805
  "Your identity, persona, voice, and operating instructions live in CLAUDE.md and the other files in your workspace.",
920
1806
  "Fully embody that persona in how you speak and act; respond AS that agent, not as a generic coding assistant."
921
1807
  ].join("\n");
1808
+ const CREDENTIAL_INVALID_MESSAGE = "This agent's Claude credential is invalid or expired. Update it 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.";
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
+ };
922
1831
  const MAX_AGENT_MESSAGE_BYTES = 256 * 1024;
923
1832
  const MAX_SESSION_KEY_CHARS = 768;
924
1833
  const MAX_IDENTIFIER_CHARS = 512;
@@ -1130,18 +2039,58 @@ var ClaudeCodeHost = class {
1130
2039
  claudeSessionSave = Promise.resolve();
1131
2040
  stopping = false;
1132
2041
  stopPromise = null;
2042
+ /**
2043
+ * Resolves once the first fetch has answered. Null until `start()` (the
2044
+ * direct handleRequest test seam runs on the local login, no wait).
2045
+ */
2046
+ credentialReady = null;
2047
+ resolveCredentialReady = () => void 0;
2048
+ /** Turns wait for the first fetch only until this time (start + wait). */
2049
+ credentialDeadline = 0;
2050
+ credentialLoaded = false;
2051
+ /** Turns waiting for the credential, before queue admission (chat.abort). */
2052
+ waitingTurns = /* @__PURE__ */ new Map();
2053
+ loaderRestartMs;
2054
+ /** How often a fallen-back self-hosted agent re-checks its local login. */
2055
+ localLoginRecheckMs;
2056
+ localLoginRecheck = null;
2057
+ /** In memory only. Never logged, never persisted. */
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;
2064
+ credentialAbort = new AbortController();
2065
+ reporter;
2066
+ probe;
2067
+ credentialWaitMs;
2068
+ credentialRetry;
1133
2069
  constructor(cfg, deps) {
1134
2070
  this.cfg = cfg;
1135
2071
  this.log = cfg.logger ?? console;
1136
2072
  this.claudeSessionsPath = cfg.claudeSessionsPath ?? join(homedir(), ".alfe", "claude-code", "sessions.json");
1137
- this.makeDriver = deps?.makeDriver ?? (() => new SessionDriver({
2073
+ this.makeDriver = deps?.makeDriver ?? ((auth) => new SessionDriver({
1138
2074
  logger: this.log,
1139
- baseEnv: process.env
2075
+ baseEnv: process.env,
2076
+ auth,
2077
+ managed: cfg.managed === true
1140
2078
  }));
1141
2079
  this.api = deps?.api ?? new AgentApiClient({
1142
2080
  apiKey: cfg.apiKey,
1143
2081
  apiUrl: cfg.apiUrl
1144
2082
  });
2083
+ this.probe = deps?.probe ?? ((auth, signal) => runAuthProbe({
2084
+ auth,
2085
+ baseEnv: process.env,
2086
+ cwd: tmpdir(),
2087
+ signal
2088
+ }));
2089
+ this.credentialWaitMs = deps?.credentialWaitMs ?? 15e3;
2090
+ this.loaderRestartMs = deps?.loaderRestartMs ?? 5e3;
2091
+ this.credentialRetry = deps?.credentialRetry ?? {};
2092
+ this.localLoginRecheckMs = deps?.localLoginRecheckMs ?? 9e5;
2093
+ this.reporter = new CredentialStatusReporter((report) => this.api.reportClaudeCredentialStatus(report), { log: this.log });
1145
2094
  this.chat = deps?.chat ?? new ChatServiceClient({
1146
2095
  wsUrl: cfg.wsUrl,
1147
2096
  apiKey: cfg.apiKey,
@@ -1158,12 +2107,208 @@ var ClaudeCodeHost = class {
1158
2107
  async start() {
1159
2108
  if (this.stopping) throw new Error("Claude Code host is stopping");
1160
2109
  await this.loadClaudeSessionIds();
2110
+ await this.syncPersona();
2111
+ this.credentialReady = new Promise((resolve) => {
2112
+ this.resolveCredentialReady = resolve;
2113
+ });
2114
+ this.credentialDeadline = Date.now() + this.credentialWaitMs;
2115
+ this.runCredentialLoader();
1161
2116
  this.chat.start();
1162
2117
  }
2118
+ /**
2119
+ * Run the fetch + check, restarting it after an unexpected throw so a bug
2120
+ * can never leave the host without a credential until the next restart.
2121
+ */
2122
+ runCredentialLoader() {
2123
+ this.loadCredential().catch((err) => {
2124
+ if (this.stopping) return;
2125
+ this.log.error(`[claude-host] credential load failed unexpectedly (${err instanceof Error ? err.message : String(err)}); restarting it`);
2126
+ setTimeout(() => {
2127
+ if (!this.stopping) this.runCredentialLoader();
2128
+ }, this.loaderRestartMs).unref();
2129
+ });
2130
+ }
2131
+ async loadCredential() {
2132
+ const loaded = await loadCredentialWithRetry(async () => {
2133
+ const credential = await this.api.getClaudeCredential();
2134
+ return credential ? {
2135
+ kind: credential.kind,
2136
+ value: credential.value,
2137
+ revision: credential.revision
2138
+ } : null;
2139
+ }, {
2140
+ signal: this.credentialAbort.signal,
2141
+ log: this.log,
2142
+ ...this.credentialRetry
2143
+ });
2144
+ if (!loaded) return;
2145
+ this.credential = loaded.credential;
2146
+ this.authSource = loaded.credential ? "credential" : this.cfg.managed ? "ai_proxy" : "local_login";
2147
+ this.credentialLoaded = true;
2148
+ this.resolveCredentialReady();
2149
+ await this.checkCredential();
2150
+ }
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" };
2177
+ }
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");
2216
+ return;
2217
+ }
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);
2226
+ if (this.stopping) 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;
2231
+ }
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 } : {}
2257
+ });
2258
+ }
2259
+ /**
2260
+ * Wait for the first credential answer BEFORE queue admission, so a waiting
2261
+ * turn holds no slot. Bounded by the start-up deadline: once it has passed,
2262
+ * later turns stop waiting until the load completes. Cancelled by
2263
+ * `chat.abort` or host stop through `cancellation`.
2264
+ */
2265
+ async awaitCredential(cancellation) {
2266
+ if (!this.credentialReady || this.credentialLoaded) return "ready";
2267
+ const remaining = this.credentialDeadline - Date.now();
2268
+ if (remaining <= 0) return "timeout";
2269
+ let timer;
2270
+ let onAbort;
2271
+ try {
2272
+ return await Promise.race([
2273
+ this.credentialReady.then(() => "ready"),
2274
+ new Promise((resolve) => {
2275
+ timer = setTimeout(() => {
2276
+ resolve("timeout");
2277
+ }, remaining);
2278
+ }),
2279
+ new Promise((resolve) => {
2280
+ onAbort = () => {
2281
+ resolve("aborted");
2282
+ };
2283
+ if (cancellation.aborted) onAbort();
2284
+ else cancellation.addEventListener("abort", onAbort, { once: true });
2285
+ })
2286
+ ]);
2287
+ } finally {
2288
+ if (timer) clearTimeout(timer);
2289
+ if (onAbort) cancellation.removeEventListener("abort", onAbort);
2290
+ }
2291
+ }
2292
+ /**
2293
+ * Instant credential gate inside the turn (no waiting: `handleAgent` already
2294
+ * waited). Returns a user-facing failure message, or null when the turn may
2295
+ * run. A host driven without `start()` runs on the local login.
2296
+ */
2297
+ credentialGateMessage() {
2298
+ if (!this.credentialReady) return null;
2299
+ if (!this.credentialLoaded) return CREDENTIAL_UNAVAILABLE_MESSAGE;
2300
+ return null;
2301
+ }
1163
2302
  /** Stop admission, abort owned children, and give active turns time to settle. */
1164
2303
  stop() {
1165
2304
  if (this.stopPromise) return this.stopPromise;
1166
2305
  this.stopping = true;
2306
+ this.credentialAbort.abort();
2307
+ if (this.localLoginRecheck) {
2308
+ clearTimeout(this.localLoginRecheck);
2309
+ this.localLoginRecheck = null;
2310
+ }
2311
+ for (const waiting of this.waitingTurns.values()) for (const turn of waiting) turn.cancellation.abort();
1167
2312
  this.queues.stop();
1168
2313
  for (const { driver, cancellation } of this.runningTurns.values()) {
1169
2314
  cancellation.abort();
@@ -1252,6 +2397,29 @@ var ClaudeCodeHost = class {
1252
2397
  resolveCompletion(outcome);
1253
2398
  if (idem) this.pruneIdempotency();
1254
2399
  };
2400
+ if (this.credentialReady && !this.credentialLoaded) {
2401
+ const waiting = {
2402
+ runId: params.idempotencyKey ?? req.id,
2403
+ cancellation: new AbortController()
2404
+ };
2405
+ const set = this.waitingTurns.get(sessionKey) ?? /* @__PURE__ */ new Set();
2406
+ set.add(waiting);
2407
+ this.waitingTurns.set(sessionKey, set);
2408
+ let outcome;
2409
+ try {
2410
+ outcome = await this.awaitCredential(waiting.cancellation.signal);
2411
+ } finally {
2412
+ set.delete(waiting);
2413
+ if (set.size === 0) this.waitingTurns.delete(sessionKey);
2414
+ }
2415
+ if (outcome === "aborted") {
2416
+ complete({
2417
+ ok: true,
2418
+ payload: { sessionKey }
2419
+ });
2420
+ return;
2421
+ }
2422
+ }
1255
2423
  const coalesceKey = params.coalesce === "replace" ? sessionKey : void 0;
1256
2424
  try {
1257
2425
  await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params, complete), coalesceKey ? {
@@ -1285,36 +2453,55 @@ var ClaudeCodeHost = class {
1285
2453
  const runId = params.idempotencyKey ?? req.id;
1286
2454
  const isVoice = params.origin === "voice";
1287
2455
  await this.ensureSession(bareSessionId, params);
2456
+ await ensureMemoryCursor(bareSessionId);
1288
2457
  await addMessage(bareSessionId, "user", params.message, params.identityId ?? params.userId, params.displayName);
2458
+ const credentialProblem = this.credentialGateMessage();
2459
+ if (credentialProblem) {
2460
+ await this.failTurn(bareSessionId, complete, credentialProblem);
2461
+ return;
2462
+ }
1289
2463
  let memoryContext = "";
1290
2464
  try {
1291
- memoryContext = (await this.api.memoryLoadContext()).formatted.slice(0, MAX_MEMORY_CONTEXT_CHARS);
2465
+ const topicHint = extractTopicHint(params.message);
2466
+ memoryContext = (await withTimeout(this.api.memoryLoadContext(recallTier(topicHint), topicHint), RECALL_TIMEOUT_MS)).formatted.slice(0, MAX_MEMORY_CONTEXT_CHARS);
1292
2467
  } catch (err) {
1293
2468
  this.log.warn(`[claude-host] memoryLoadContext failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1294
2469
  }
1295
2470
  if (this.stopping) throw new Error("Claude Code host is stopping");
2471
+ await this.syncPersona();
2472
+ const mcpConfigPath = this.cfg.mcpConfigPath && existsSync(this.cfg.mcpConfigPath) ? this.cfg.mcpConfigPath : void 0;
1296
2473
  const appendSystemPrompt = this.buildSystemPrompt(memoryContext, isVoice);
1297
2474
  const claudeSessionId = this.claudeSessionIds.get(bareSessionId) ?? null;
1298
- const driver = this.makeDriver();
1299
2475
  const cancellation = new AbortController();
1300
- this.runningTurns.set(sessionKey, {
1301
- runId,
1302
- driver,
1303
- cancellation
1304
- });
2476
+ let auth = this.childAuth();
2477
+ let switchedWithoutRerun = false;
1305
2478
  let result;
1306
2479
  try {
1307
- result = await this.runWithResumeFallback(driver, bareSessionId, cancellation.signal, {
2480
+ result = await this.runTurnOn(auth, sessionKey, runId, cancellation, bareSessionId, {
1308
2481
  prompt: params.message,
1309
2482
  claudeSessionId,
1310
2483
  cwd: this.cfg.workspace,
1311
- ...this.cfg.mcpConfigPath ? { mcpConfigPath: this.cfg.mcpConfigPath } : {},
2484
+ ...mcpConfigPath ? { mcpConfigPath } : {},
1312
2485
  ...appendSystemPrompt ? { appendSystemPrompt } : {},
1313
- ...this.cfg.model ? { model: this.cfg.model } : {},
1314
2486
  onEmission: (emission) => {
1315
2487
  this.forwardEmission(runId, sessionKey, emission);
1316
2488
  }
1317
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
+ }
1318
2505
  } finally {
1319
2506
  this.runningTurns.delete(sessionKey);
1320
2507
  }
@@ -1332,12 +2519,30 @@ var ClaudeCodeHost = class {
1332
2519
  return;
1333
2520
  }
1334
2521
  if (result.isError) {
1335
- complete({
1336
- ok: false,
1337
- payload: { message: result.errorMessage ?? "Claude Code turn failed" }
1338
- });
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
+ }
2537
+ if (result.authFailed) {
2538
+ await this.failTurn(bareSessionId, complete, auth.mode === "ai_proxy" ? PROXY_AUTH_FAILED_MESSAGE : CREDENTIAL_INVALID_MESSAGE);
2539
+ await this.reportStatus("invalid");
2540
+ return;
2541
+ }
2542
+ await this.failTurn(bareSessionId, complete, result.errorMessage ?? "Claude Code turn failed");
1339
2543
  return;
1340
2544
  }
2545
+ this.reportStatus("ok");
1341
2546
  if (result.finalText) await addMessage(bareSessionId, "assistant", result.finalText);
1342
2547
  const notification = result.finalText ? {
1343
2548
  conversationId: bareSessionId,
@@ -1350,28 +2555,101 @@ var ClaudeCodeHost = class {
1350
2555
  payload: { sessionKey },
1351
2556
  ...notification ? { notification } : {}
1352
2557
  });
2558
+ await this.ingestPendingMemory(bareSessionId, params);
2559
+ }
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) {
1353
2567
  try {
1354
- const messages = [{
1355
- role: "user",
1356
- content: params.message,
1357
- index: 0
1358
- }, ...result.finalText ? [{
1359
- role: "assistant",
1360
- content: result.finalText,
1361
- index: 1
1362
- }] : []];
1363
- await this.api.memoryIngest(bareSessionId, messages, {
1364
- ...params.identityId ?? params.userId ? { userId: params.identityId ?? params.userId } : {},
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
+ /**
2578
+ * Send every message the memory service has not accepted yet. Indices are
2579
+ * the messages' positions in the session file, so they advance across turns
2580
+ * and survive host restarts; the per-conversation `ingestEpoch` tells the
2581
+ * service when that index space restarted. Ingest under the BARE session
2582
+ * id: the same key the session-store filename and the memory backfill use,
2583
+ * so there is one memory lineage.
2584
+ *
2585
+ * Cursor rules:
2586
+ * - success: advance through the batch. If the server trimmed the batch
2587
+ * (`messageCount` < sent) it kept the NEWEST messages and its watermark
2588
+ * moved past the dropped head, so a resend would be filtered anyway;
2589
+ * advance and warn. The byte bounds keep this path unreachable normally.
2590
+ * - non-retryable 4xx (400/413/422): skip only the oldest message, so one
2591
+ * poison message cannot wedge the conversation.
2592
+ * - anything else (429, 5xx, network): leave the cursor; the next turn
2593
+ * resends.
2594
+ */
2595
+ async ingestPendingMemory(bareSessionId, params) {
2596
+ let batch = null;
2597
+ try {
2598
+ batch = await pendingMemoryBatch(bareSessionId);
2599
+ if (!batch) return;
2600
+ if (batch.messages.length === 0) {
2601
+ await markMemoryIngested(bareSessionId, batch.ingestEpoch, batch.throughIndex);
2602
+ return;
2603
+ }
2604
+ const userId = params.identityId ?? params.userId;
2605
+ const result = await this.api.memoryIngest(bareSessionId, batch.messages, {
2606
+ ...userId ? { userId } : {},
1365
2607
  ...params.displayName ? { userName: params.displayName } : {}
1366
- });
2608
+ }, batch.ingestEpoch);
2609
+ if (result.messageCount < batch.messages.length) this.log.warn(`[claude-host] memory service trimmed ${String(batch.messages.length - result.messageCount)} oldest message(s) from an ingest batch; they are not recoverable`);
2610
+ await markMemoryIngested(bareSessionId, batch.ingestEpoch, batch.throughIndex);
2611
+ } catch (err) {
2612
+ const status = err?.status;
2613
+ const message = err instanceof Error ? err.message : String(err);
2614
+ if (batch && batch.messages.length > 0 && isPoisonIngestStatus(status)) {
2615
+ const oldest = batch.messages[0];
2616
+ this.log.warn(`[claude-host] memoryIngest rejected (${String(status)}); skipping message ${String(oldest.index)} so the conversation keeps capturing: ${message}`);
2617
+ await markMemoryIngested(bareSessionId, batch.ingestEpoch, oldest.index).catch(() => void 0);
2618
+ return;
2619
+ }
2620
+ this.log.warn(`[claude-host] memoryIngest failed (non-fatal, retried next turn): ${message}`);
2621
+ }
2622
+ }
2623
+ /** Regenerate CLAUDE.md from the workspace persona files. Never fatal. */
2624
+ async syncPersona() {
2625
+ try {
2626
+ if (await syncClaudeMd(this.cfg.workspace, this.log)) this.log.info("[claude-host] CLAUDE.md regenerated from persona files");
1367
2627
  } catch (err) {
1368
- this.log.warn(`[claude-host] memoryIngest failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
2628
+ this.log.warn(`[claude-host] CLAUDE.md sync failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1369
2629
  }
1370
2630
  }
1371
2631
  /**
1372
2632
  * Run a turn; on a `--resume` failure (stale/missing claude session id) start
1373
2633
  * fresh and prepend the last-N stored messages so the model keeps context.
1374
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
+ }
1375
2653
  async runWithResumeFallback(driver, bareSessionId, cancellation, baseParams, isVoice) {
1376
2654
  const cancelled = () => cancellation.aborted || this.stopping;
1377
2655
  const aborted = (result) => ({
@@ -1440,6 +2718,11 @@ var ClaudeCodeHost = class {
1440
2718
  active.driver.abort();
1441
2719
  aborted = true;
1442
2720
  }
2721
+ const waiting = sessionKey ? this.waitingTurns.get(sessionKey) : void 0;
2722
+ for (const turn of waiting ?? []) if (runId === void 0 || typeof runId === "string" && runId.length > 0 && runId.length <= 256 && turn.runId === runId) {
2723
+ turn.cancellation.abort();
2724
+ aborted = true;
2725
+ }
1443
2726
  this.chat.sendResponse(req.id, true, { aborted });
1444
2727
  }
1445
2728
  async handleSessionsList(req) {
@@ -1487,13 +2770,12 @@ var ClaudeCodeHost = class {
1487
2770
  const parts = [PERSONA_POINTER_SYSTEM_PROMPT];
1488
2771
  if (memoryContext) parts.push(`# Relevant memory\n\n${memoryContext}`);
1489
2772
  if (isVoice) parts.push(VOICE_REPLY_SYSTEM_PROMPT);
1490
- parts.push(CROSS_AGENT_TOOLS_SYSTEM_PROMPT);
1491
2773
  return parts.join("\n\n");
1492
2774
  }
1493
2775
  async buildHistoryPreamble(bareSessionId) {
1494
2776
  const session = await getSession(bareSessionId);
1495
2777
  if (!session || session.messages.length === 0) return "";
1496
- const prior = session.messages.slice(0, -1).slice(-10);
2778
+ const prior = session.messages.slice(0, -1).filter((m) => m.kind !== "error").slice(-10);
1497
2779
  if (prior.length === 0) return "";
1498
2780
  return `Earlier in this conversation:\n${prior.map((m) => `${m.role === "assistant" ? "Assistant" : "User"}: ${m.content}`).join("\n")}`;
1499
2781
  }
@@ -1503,7 +2785,7 @@ var ClaudeCodeHost = class {
1503
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");
1504
2786
  return;
1505
2787
  }
1506
- 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);
1507
2789
  }
1508
2790
  async loadClaudeSessionIds() {
1509
2791
  try {
@@ -1551,12 +2833,41 @@ var ClaudeCodeHost = class {
1551
2833
  }
1552
2834
  }
1553
2835
  };
2836
+ /**
2837
+ * Statuses where resending the same batch can never succeed. 429 and 5xx are
2838
+ * transient; 401/403 are credential problems that must not drop memories.
2839
+ */
2840
+ function isPoisonIngestStatus(status) {
2841
+ return status === 400 || status === 413 || status === 422;
2842
+ }
2843
+ /** Reject when `operation` outlives `timeoutMs` (the operation is not cancelled). */
2844
+ async function withTimeout(operation, timeoutMs) {
2845
+ let timer;
2846
+ const timeout = new Promise((_resolve, reject) => {
2847
+ timer = setTimeout(() => {
2848
+ reject(/* @__PURE__ */ new Error("Memory recall timed out"));
2849
+ }, timeoutMs);
2850
+ timer.unref();
2851
+ });
2852
+ try {
2853
+ return await Promise.race([operation, timeout]);
2854
+ } finally {
2855
+ if (timer) clearTimeout(timer);
2856
+ }
2857
+ }
1554
2858
  /** Strip the `agent:{agentId}:` prefix from a full server session key. */
1555
2859
  function stripAgentPrefix(sessionKey) {
1556
2860
  const parts = sessionKey.split(":");
1557
2861
  if (parts.length >= 3 && parts[0] === "agent") return parts.slice(2).join(":");
1558
2862
  return sessionKey;
1559
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
+ }
1560
2871
  //#endregion
1561
2872
  //#region src/host-config.ts
1562
2873
  /**
@@ -1566,22 +2877,57 @@ function stripAgentPrefix(sessionKey) {
1566
2877
  * `main()` (which would start the process on import).
1567
2878
  */
1568
2879
  /**
1569
- * Build the host config. Throws (→ non-zero exit from the CLI) when the chat
1570
- * URL / api key can't resolve.
2880
+ * Pure builder (test seam). `home` is the claude-code runtime HOME
2881
+ * (`[runtimes.claude-code].workspace`, e.g. `~/.claude-code`), NOT the agent's
2882
+ * working directory and NOT `resolveConfig().workspacePath` (that follows the
2883
+ * ACTIVE runtime and is `~/.openclaw` on an env-only config). `claude` must run in the agent workspace
2884
+ * (`<home>/workspace`, where `CLAUDE.md` and the persona files live) so it
2885
+ * auto-loads the persona; the generated `--mcp-config` stays in the home where
2886
+ * `ClaudeCodeMcpSync` writes it.
1571
2887
  */
1572
- function resolveHostConfig() {
1573
- const resolved = resolveConfig();
1574
- const chat = resolveAlfeChat({ apiKey: resolved.apiKey });
2888
+ function buildHostConfig(resolved, home, chat, env) {
1575
2889
  if (!chat.apiKey || !chat.chatWsUrl) throw new Error("Unable to resolve chat service URL / API key from ~/.alfe/config.toml. Run `alfe login`.");
2890
+ const model = env.ALFE_DEFAULT_MODEL?.trim();
2891
+ const managedFlag = env.ALFE_MANAGED?.trim().toLowerCase();
2892
+ const localAiProxyUrl = parseLoopbackUrl(env.ALFE_LOCAL_AI_PROXY_URL);
1576
2893
  return {
1577
2894
  wsUrl: chat.chatWsUrl,
1578
2895
  apiKey: chat.apiKey,
1579
2896
  apiUrl: resolved.apiUrl,
1580
- workspace: resolved.workspacePath,
1581
- mcpConfigPath: join(homedir(), ".claude-code", "mcp-config.json"),
2897
+ workspace: resolveAgentWorkspace("claude-code", home),
2898
+ mcpConfigPath: join(home, "mcp-config.json"),
1582
2899
  claudeSessionsPath: join(homedir(), ".alfe", "claude-code", "sessions.json"),
1583
- ...process.env.ALFE_DEFAULT_MODEL?.trim() ? { model: process.env.ALFE_DEFAULT_MODEL.trim() } : {}
2900
+ ...model ? { model } : {},
2901
+ managed: managedFlag === "1" || managedFlag === "true",
2902
+ ...localAiProxyUrl ? { localAiProxyUrl } : {}
1584
2903
  };
1585
2904
  }
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
+ /**
2925
+ * Build the host config. Throws (→ non-zero exit from the CLI) when the chat
2926
+ * URL / api key can't resolve.
2927
+ */
2928
+ function resolveHostConfig() {
2929
+ const resolved = resolveConfig();
2930
+ return buildHostConfig(resolved, resolveRuntimeHome("claude-code"), resolveAlfeChat({ apiKey: resolved.apiKey }), process.env);
2931
+ }
1586
2932
  //#endregion
1587
- export { buildChildEnv as C, SCRUBBED_ANTHROPIC_ENV_VARS as S, collectTurnActivityRecords as _, appendActivity as a, mapStreamEvents as b, listSessions as c, DEFAULT_ALLOWED_TOOLS as d, DEFAULT_PERMISSION_MODE as f, WARM_STREAMING_SUPPORTED as g, TURN_TIMEOUT_MS as h, addMessage as i, saveSession as l, TERMINATION_GRACE_MS as m, ClaudeCodeHost as n, createSession as o, SessionDriver as p, stripAgentPrefix as r, getSession as s, resolveHostConfig as t, CLAUDE_PERMISSION_MODES as u, createMapperState as v, BRIDGE_ENV_VAR as x, mapStreamEvent 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 };