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

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,402 @@
1
1
  let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
2
2
  let _alfe_ai_chat = require("@alfe.ai/chat");
3
+ let node_fs = require("node:fs");
4
+ let node_os = require("node:os");
3
5
  let node_fs_promises = require("node:fs/promises");
4
6
  let node_path = require("node:path");
5
- let node_os = require("node:os");
6
7
  let node_crypto = require("node:crypto");
8
+ let node_string_decoder = require("node:string_decoder");
7
9
  let node_child_process = require("node:child_process");
8
10
  let node_readline = require("node:readline");
9
- let node_fs = require("node:fs");
10
11
  let _alfe_ai_config = require("@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
+ * THREE non-negotiable invariants:
17
+ *
18
+ * 1. **Scrub inherited Claude auth.** `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
19
+ * / `ANTHROPIC_AUTH_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` are removed from BOTH
20
+ * the parent env and any caller `extra`. A self-hosted Mac very often has
21
+ * these set for other tooling; if one reaches `claude` it silently switches
22
+ * accounts or billing plane (or routes through a proxy). The break is
23
+ * silent: the turn "works" but bills the wrong account.
17
24
  *
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.
25
+ * 2. **Inject the agent's own credential, last.** When the agents service holds
26
+ * a Claude credential for this agent, it is set AFTER the scrub as exactly
27
+ * one variable: `CLAUDE_CODE_OAUTH_TOKEN` (subscription token from
28
+ * `claude setup-token`) or `ANTHROPIC_API_KEY` (Anthropic API key). Nothing
29
+ * else can override it. With no credential, no auth variable is set and
30
+ * `claude` uses the machine's own login (self-hosted fallback). The value
31
+ * lives only in the host's memory and this env — never on disk, never logged.
25
32
  *
26
- * 2. **Set `ALFE_CLAUDE_BRIDGE=1`.** The Phase-1 CLI hooks
33
+ * 3. **Set `ALFE_CLAUDE_BRIDGE=1`.** The Phase-1 CLI hooks
27
34
  * (`alfe claude hook ...` in `packages/cli/src/commands/claude.ts`) check
28
35
  * `process.env.ALFE_CLAUDE_BRIDGE` FIRST and no-op when set, so the host —
29
36
  * not the interactive-session hooks — owns memory recall/capture for bridged
30
37
  * turns. Omitting this double-captures every message. The env var NAME is a
31
38
  * cross-phase contract; do not rename without updating the CLI.
32
39
  */
33
- /** Anthropic env vars that force API-key / proxy auth and MUST be scrubbed. */
40
+ /** Claude auth env vars that MUST NOT be inherited by a `claude` child. */
34
41
  const SCRUBBED_ANTHROPIC_ENV_VARS = [
35
42
  "ANTHROPIC_API_KEY",
36
43
  "ANTHROPIC_BASE_URL",
37
- "ANTHROPIC_AUTH_TOKEN"
44
+ "ANTHROPIC_AUTH_TOKEN",
45
+ "CLAUDE_CODE_OAUTH_TOKEN"
46
+ ];
47
+ /**
48
+ * Provider-selection and endpoint overrides that would route `claude` away from
49
+ * the Anthropic API. Dropped whenever an agent credential is injected, so an
50
+ * inherited `CLAUDE_CODE_USE_BEDROCK=1` (say) can't send the turn to Bedrock
51
+ * and ignore the stored credential. Without a credential they are left alone:
52
+ * the machine's own `claude` setup is the fallback, whatever provider it uses.
53
+ */
54
+ const PROVIDER_OVERRIDE_ENV_VARS = [
55
+ "CLAUDE_CODE_USE_BEDROCK",
56
+ "CLAUDE_CODE_USE_VERTEX",
57
+ "CLAUDE_CODE_USE_FOUNDRY",
58
+ "CLAUDE_CODE_SKIP_BEDROCK_AUTH",
59
+ "CLAUDE_CODE_SKIP_VERTEX_AUTH",
60
+ "CLAUDE_CODE_SKIP_FOUNDRY_AUTH",
61
+ "ANTHROPIC_BEDROCK_BASE_URL",
62
+ "ANTHROPIC_VERTEX_BASE_URL",
63
+ "ANTHROPIC_VERTEX_PROJECT_ID",
64
+ "ANTHROPIC_FOUNDRY_BASE_URL",
65
+ "ANTHROPIC_FOUNDRY_RESOURCE",
66
+ "ANTHROPIC_FOUNDRY_API_KEY",
67
+ "AWS_BEARER_TOKEN_BEDROCK"
38
68
  ];
39
69
  /** Cross-phase contract marker (see header + packages/cli/src/commands/claude.ts). */
40
70
  const BRIDGE_ENV_VAR = "ALFE_CLAUDE_BRIDGE";
71
+ /** The single env var each credential kind is exported as. */
72
+ const CREDENTIAL_ENV_VAR = {
73
+ oauth_token: "CLAUDE_CODE_OAUTH_TOKEN",
74
+ api_key: "ANTHROPIC_API_KEY"
75
+ };
41
76
  /**
42
77
  * Build the env handed to a spawned `claude` child. Pure — takes the base env
43
78
  * explicitly so it unit-tests without touching `process.env`.
44
79
  *
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.
80
+ * Order: merge `base` + `extra` → drop every scrubbed var (plus provider
81
+ * overrides when a credential is injected) → set the bridge
82
+ * marker → set the agent credential (if any). Neither `base` nor `extra` can
83
+ * smuggle an auth variable in or override the injected credential.
51
84
  */
52
- function buildChildEnv(base, extra) {
85
+ function buildChildEnv(base, extra, credential) {
53
86
  const merged = {
54
87
  ...base,
55
- ...extra,
56
- [BRIDGE_ENV_VAR]: "1"
88
+ ...extra
57
89
  };
58
90
  const scrubbed = new Set(SCRUBBED_ANTHROPIC_ENV_VARS);
91
+ if (credential) for (const key of PROVIDER_OVERRIDE_ENV_VARS) scrubbed.add(key);
59
92
  const env = {};
60
93
  for (const [key, value] of Object.entries(merged)) if (!scrubbed.has(key)) env[key] = value;
94
+ env[BRIDGE_ENV_VAR] = "1";
95
+ if (credential) env[CREDENTIAL_ENV_VAR[credential.kind]] = credential.value;
61
96
  return env;
62
97
  }
63
98
  //#endregion
99
+ //#region src/credential.ts
100
+ /**
101
+ * The agent's Claude credential, on the host side: fetching it, checking it
102
+ * against Anthropic, classifying auth failures, and reporting health.
103
+ *
104
+ * Contract (see DEVELOPING.md "Claude credential"):
105
+ * - Fetched with the agent API key (`GET /agent/claude-credential`) on every
106
+ * host start. The daemon restarts the host when the credential changes, so
107
+ * a start IS the refetch. A failed fetch retries with backoff forever; the
108
+ * host never exits over it (that would crash-loop the daemon).
109
+ * - The value lives only in memory and in each `claude` child env
110
+ * (`buildChildEnv`). Never logged, never written to disk.
111
+ * - Health is checked once per start with the cheapest call that actually
112
+ * reaches Anthropic. `claude auth status` does NOT: with an env token it
113
+ * reports `loggedIn: true` for any string (verified on claude 2.1.284), so
114
+ * the probe is a one-turn `claude -p` on haiku with no tools, a one-line
115
+ * system prompt and thinking off (measured 381 input + 4 output tokens,
116
+ * $0.0004 at list price, under a second).
117
+ */
118
+ function sleepFor(ms, signal) {
119
+ return new Promise((resolve) => {
120
+ const timer = setTimeout(done, ms);
121
+ function done() {
122
+ clearTimeout(timer);
123
+ signal.removeEventListener("abort", done);
124
+ resolve();
125
+ }
126
+ signal.addEventListener("abort", done, { once: true });
127
+ });
128
+ }
129
+ /**
130
+ * Fetch until it answers. `{ credential: null }` = nothing stored (a real
131
+ * answer); `null` = aborted before any answer.
132
+ */
133
+ async function loadCredentialWithRetry(fetchCredential, opts) {
134
+ const base = opts.baseDelayMs ?? 1e3;
135
+ const max = opts.maxDelayMs ?? 6e4;
136
+ const random = opts.random ?? Math.random;
137
+ const sleep = opts.sleep ?? sleepFor;
138
+ const aborted = () => opts.signal.aborted;
139
+ for (let attempt = 0;; attempt += 1) {
140
+ if (aborted()) return null;
141
+ try {
142
+ const credential = await fetchCredential();
143
+ opts.log.info(credential ? `[claude-host] Claude credential loaded (${credential.kind})` : "[claude-host] no Claude credential stored for this agent");
144
+ return { credential };
145
+ } catch (err) {
146
+ if (aborted()) return null;
147
+ const cap = Math.min(max, base * 2 ** attempt);
148
+ const delay = Math.round(cap * (.5 + random() / 2));
149
+ opts.log.warn(`[claude-host] could not load the Claude credential (${err instanceof Error ? err.message : String(err)}); retrying in ${String(delay)}ms`);
150
+ await sleep(delay, opts.signal);
151
+ }
152
+ }
153
+ }
154
+ /**
155
+ * Messages `claude` prints when Anthropic (or the CLI) refuses the credential.
156
+ * Captured from claude 2.1.284 (`src/__fixtures__/turn-auth-*.stream.jsonl`)
157
+ * plus the documented expiry message. Rate limits, overload and timeouts are
158
+ * deliberately NOT here — they say nothing about the credential.
159
+ */
160
+ const AUTH_FAILURE_MARKERS = [
161
+ "authentication_failed",
162
+ "failed to authenticate",
163
+ "invalid api key",
164
+ "invalid x-api-key",
165
+ "oauth access token is invalid",
166
+ "oauth token has expired",
167
+ "oauth token has been revoked",
168
+ "invalid bearer token",
169
+ "not logged in",
170
+ "please run /login"
171
+ ];
172
+ function isAuthFailureText(text) {
173
+ const lower = text.toLowerCase();
174
+ return AUTH_FAILURE_MARKERS.some((marker) => lower.includes(marker));
175
+ }
176
+ /** HTTP statuses on a `result` event that mean "credential refused". */
177
+ function isAuthFailureStatus(status) {
178
+ return status === 401 || status === 403;
179
+ }
180
+ const AUTH_PROBE_TIMEOUT_MS = 6e4;
181
+ const MAX_PROBE_OUTPUT_CHARS = 64e3;
182
+ /** argv for the probe. No tools, no session file, no MCP, no settings files. */
183
+ function buildProbeArgs() {
184
+ return [
185
+ "-p",
186
+ "--input-format",
187
+ "text",
188
+ "--output-format",
189
+ "json",
190
+ "--model",
191
+ "haiku",
192
+ "--tools",
193
+ "",
194
+ "--system-prompt",
195
+ "Reply with the single word OK.",
196
+ "--max-turns",
197
+ "1",
198
+ "--no-session-persistence",
199
+ "--strict-mcp-config",
200
+ "--setting-sources",
201
+ ""
202
+ ];
203
+ }
204
+ function parseProbeOutput(stdout, stderr, exitCode) {
205
+ const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
206
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
207
+ let event;
208
+ try {
209
+ event = JSON.parse(lines[index]);
210
+ } catch {
211
+ continue;
212
+ }
213
+ if (!event || typeof event !== "object") continue;
214
+ const record = event;
215
+ if (record.type !== "result") continue;
216
+ if (record.is_error === false) return { result: "ok" };
217
+ const text = typeof record.result === "string" ? record.result : "";
218
+ if (isAuthFailureStatus(record.api_error_status) || isAuthFailureText(text)) return { result: "invalid" };
219
+ return {
220
+ result: "unknown",
221
+ reason: `probe failed (${typeof record.api_error_status === "number" ? `HTTP ${String(record.api_error_status)}` : "no auth error"})`
222
+ };
223
+ }
224
+ if (isAuthFailureText(stderr)) return { result: "invalid" };
225
+ return {
226
+ result: "unknown",
227
+ reason: `probe produced no result (exit ${String(exitCode)})`
228
+ };
229
+ }
230
+ /**
231
+ * One minimal real turn against Anthropic with the agent's credential (or,
232
+ * when none is stored, the machine's own login). Never throws.
233
+ */
234
+ function runAuthProbe(opts) {
235
+ const spawnImpl = opts.spawnImpl ?? node_child_process.spawn;
236
+ const env = buildChildEnv(opts.baseEnv, { MAX_THINKING_TOKENS: "0" }, opts.credential);
237
+ return new Promise((resolve) => {
238
+ if (opts.signal?.aborted) {
239
+ resolve({
240
+ result: "unknown",
241
+ reason: "host stopping"
242
+ });
243
+ return;
244
+ }
245
+ let settled = false;
246
+ let stdout = "";
247
+ let stderr = "";
248
+ const finish = (outcome) => {
249
+ if (settled) return;
250
+ settled = true;
251
+ clearTimeout(timer);
252
+ opts.signal?.removeEventListener("abort", onAbort);
253
+ resolve(outcome);
254
+ };
255
+ const kill = () => {
256
+ try {
257
+ child.kill("SIGKILL");
258
+ } catch {}
259
+ };
260
+ function onAbort() {
261
+ kill();
262
+ finish({
263
+ result: "unknown",
264
+ reason: "host stopping"
265
+ });
266
+ }
267
+ let child;
268
+ try {
269
+ child = spawnImpl(opts.claudeBin ?? "claude", buildProbeArgs(), {
270
+ cwd: opts.cwd,
271
+ env,
272
+ stdio: [
273
+ "pipe",
274
+ "pipe",
275
+ "pipe"
276
+ ]
277
+ });
278
+ } catch (err) {
279
+ resolve({
280
+ result: "unknown",
281
+ reason: `could not start claude: ${err instanceof Error ? err.message : String(err)}`
282
+ });
283
+ return;
284
+ }
285
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
286
+ const timer = setTimeout(() => {
287
+ kill();
288
+ finish({
289
+ result: "unknown",
290
+ reason: "probe timed out"
291
+ });
292
+ }, opts.timeoutMs ?? 6e4);
293
+ timer.unref();
294
+ const stdoutDecoder = new node_string_decoder.StringDecoder("utf8");
295
+ const stderrDecoder = new node_string_decoder.StringDecoder("utf8");
296
+ child.stdout?.on("data", (chunk) => {
297
+ stdout = `${stdout}${stdoutDecoder.write(chunk)}`.slice(-MAX_PROBE_OUTPUT_CHARS);
298
+ });
299
+ child.stderr?.on("data", (chunk) => {
300
+ stderr = `${stderr}${stderrDecoder.write(chunk)}`.slice(-MAX_PROBE_OUTPUT_CHARS);
301
+ });
302
+ child.on("error", (err) => {
303
+ finish({
304
+ result: "unknown",
305
+ reason: `claude process error: ${err.message}`
306
+ });
307
+ });
308
+ child.on("close", (code) => {
309
+ finish(parseProbeOutput(stdout, stderr, code));
310
+ });
311
+ try {
312
+ child.stdin?.end("ping");
313
+ } catch {}
314
+ });
315
+ }
316
+ /** An unchanged status is re-sent at most this often (a liveness signal). */
317
+ const STATUS_REPORT_INTERVAL_MS = 15 * 6e4;
318
+ /**
319
+ * Sends health reports on change, or when the same report is older than
320
+ * {@link STATUS_REPORT_INTERVAL_MS}. Sends are serialized; a failed send is
321
+ * not remembered, so the next signal retries. Never throws.
322
+ */
323
+ var CredentialStatusReporter = class {
324
+ lastKey = null;
325
+ lastSentAt = 0;
326
+ chain = Promise.resolve();
327
+ now;
328
+ log;
329
+ constructor(send, opts) {
330
+ this.send = send;
331
+ this.log = opts.log;
332
+ this.now = opts.now ?? Date.now;
333
+ }
334
+ report(report) {
335
+ this.chain = this.chain.then(() => this.deliver(report));
336
+ return this.chain;
337
+ }
338
+ async deliver(report) {
339
+ const key = JSON.stringify([
340
+ report.status,
341
+ report.revision ?? null,
342
+ report.detail ?? null
343
+ ]);
344
+ if (key === this.lastKey && this.now() - this.lastSentAt < 9e5) return;
345
+ try {
346
+ const { accepted } = await this.send(report);
347
+ this.lastKey = key;
348
+ this.lastSentAt = this.now();
349
+ this.log.info(`[claude-host] Claude credential status ${report.status} ${accepted ? "reported" : "ignored (credential changed since)"}`);
350
+ } catch (err) {
351
+ this.log.warn(`[claude-host] could not report Claude credential status ${report.status}: ${err instanceof Error ? err.message : String(err)}`);
352
+ }
353
+ }
354
+ };
355
+ //#endregion
64
356
  //#region src/stream-mapper.ts
357
+ /**
358
+ * Claude Code `--output-format stream-json` → Alfe chat-activity mapper.
359
+ *
360
+ * This is the load-bearing core of the bridge. It consumes the newline-delimited
361
+ * JSON events emitted by `claude -p ... --output-format stream-json
362
+ * --include-partial-messages` and translates them into the SAME wire shapes the
363
+ * `@alfe.ai/openclaw-chat` plugin emits, so the chat service / adapter / UI
364
+ * treat a Claude Code turn identically to an OpenClaw turn.
365
+ *
366
+ * Ground truth: a real captured turn (thinking + Read tool + text + result) at
367
+ * `src/__fixtures__/turn-read-tool.stream.jsonl`. The mapper test runs against
368
+ * that fixture (TEST-FIRST).
369
+ *
370
+ * Event model (verified against the fixture + Claude Code stream-json docs):
371
+ * - `{type:'system', subtype:'init', session_id}` — carries the claude
372
+ * session id we persist for `--resume`.
373
+ * - `{type:'stream_event', event:{type:'content_block_delta', delta:{
374
+ * type:'thinking_delta', thinking}}}` — a thinking chunk.
375
+ * - `{type:'stream_event', event:{type:'content_block_delta', delta:{
376
+ * type:'text_delta', text}}}` — a visible-text chunk (accumulate → the
377
+ * CUMULATIVE snapshot the adapter length-slices).
378
+ * - `{type:'assistant', message:{content:[{type:'tool_use', id, name,
379
+ * input}]}}` — a tool CALL. The non-streaming `assistant` message has the
380
+ * COMPLETE id/name/input (the streaming `content_block_start` has empty
381
+ * input, filled by `input_json_delta`), so we correlate off THIS.
382
+ * - `{type:'user', message:{content:[{type:'tool_result', tool_use_id,
383
+ * content, is_error?}]}}` — a tool RESULT. `tool_use_id === tool_use.id`.
384
+ * - `{type:'result', is_error, result, duration_ms}` — the turn end.
385
+ *
386
+ * The mapper is a PURE reducer over one event: it takes the running state and an
387
+ * event, mutates the state, and returns the list of `MapperEmission`s to send.
388
+ * It performs NO IO — the host does the sending / persisting. `progressText` /
389
+ * the 250ms mid-tool throttle from openclaw-chat is inert here: Claude Code has
390
+ * no mid-tool progress analog, so we deliberately don't implement it.
391
+ */
65
392
  function createMapperState() {
66
393
  return {
67
394
  claudeSessionId: null,
68
395
  seq: 0,
69
396
  visibleText: "",
70
397
  pendingTools: /* @__PURE__ */ new Map(),
71
- ended: false
398
+ ended: false,
399
+ authFailed: false
72
400
  };
73
401
  }
74
402
  const MAX_ARGS_CHARS = 2e3;
@@ -183,6 +511,7 @@ function mapStreamEvent(state, raw, nowMs) {
183
511
  return [];
184
512
  }
185
513
  if (type === "assistant") {
514
+ if (evt.error === "authentication_failed") state.authFailed = true;
186
515
  const content = asRecord$1(evt.message)?.content;
187
516
  if (!Array.isArray(content)) return [];
188
517
  const emissions = [];
@@ -262,6 +591,7 @@ function mapStreamEvent(state, raw, nowMs) {
262
591
  const durationRaw = evt.duration_ms;
263
592
  const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? Math.max(0, durationRaw) : void 0;
264
593
  const errorMessage = isError ? asString(evt.result) ?? asString(evt.subtype) ?? "Claude Code turn failed" : void 0;
594
+ const authFailed = isError && (state.authFailed || isAuthFailureStatus(evt.api_error_status) || isAuthFailureText(asString(evt.result) ?? ""));
265
595
  return [{
266
596
  type: "turn-end",
267
597
  seq: state.seq++,
@@ -269,7 +599,8 @@ function mapStreamEvent(state, raw, nowMs) {
269
599
  isError,
270
600
  finalText: isError ? "" : finalText,
271
601
  ...durationMs !== void 0 ? { durationMs } : {},
272
- ...errorMessage ? { errorMessage } : {}
602
+ ...errorMessage ? { errorMessage } : {},
603
+ ...authFailed ? { authFailed: true } : {}
273
604
  }];
274
605
  }
275
606
  return [];
@@ -408,6 +739,30 @@ const DEFAULT_ALLOWED_TOOLS = [
408
739
  "Task",
409
740
  "TodoWrite"
410
741
  ];
742
+ /**
743
+ * Managed VMs (`ALFE_MANAGED=1|true`) add `Bash` to the allow-list. The VM is
744
+ * the agent's own machine (no user data beside the agent's workspace), and
745
+ * shell access is what makes a managed coding agent useful. Still
746
+ * `--permission-mode dontAsk` (never bypass), and the managed VM runs as root:
747
+ * claude refuses only `--dangerously-skip-permissions` under root, not
748
+ * `dontAsk`. `Edit`/`Write` are already in the default set; `MultiEdit` is not
749
+ * a tool in the pinned claude (2.1.x), so it is not listed.
750
+ */
751
+ const MANAGED_ALLOWED_TOOLS = [...DEFAULT_ALLOWED_TOOLS, "Bash"];
752
+ /**
753
+ * Deny rules layered on the managed allow-list (`--disallowedTools`, which
754
+ * wins over `--allowedTools`). They keep the agent from re-running Google
755
+ * Workspace CLI auth and from reading its token directory with the Read tool.
756
+ * They are NOT a security boundary: with Bash allowed, `cat`, `cp`, `env` and
757
+ * friends can still read anything the root user can. Verified on claude
758
+ * 2.1.284: a `Bash(<prefix>:*)` rule denies the matching command under
759
+ * `dontAsk` while other Bash commands run.
760
+ */
761
+ const MANAGED_DISALLOWED_TOOLS = [
762
+ "Bash(gws auth)",
763
+ "Bash(gws auth:*)",
764
+ "Read(~/.config/gws-*/**)"
765
+ ];
411
766
  const CLAUDE_PERMISSION_MODES = [
412
767
  "acceptEdits",
413
768
  "auto",
@@ -456,12 +811,16 @@ var SessionDriver = class {
456
811
  abortCurrent = null;
457
812
  bin;
458
813
  baseEnv;
814
+ credential;
815
+ managed;
459
816
  log;
460
817
  /** Injected spawn — tests replace this with a fake child. */
461
818
  spawnImpl;
462
819
  constructor(opts) {
463
820
  this.bin = opts?.claudeBin ?? DEFAULT_CLAUDE_BIN;
464
821
  this.baseEnv = opts?.baseEnv ?? process.env;
822
+ this.credential = opts?.credential ?? null;
823
+ this.managed = opts?.managed ?? false;
465
824
  this.log = opts?.logger ?? console;
466
825
  this.spawnImpl = opts?.__spawn ?? node_child_process.spawn;
467
826
  }
@@ -485,7 +844,7 @@ var SessionDriver = class {
485
844
  return this.runPerTurn(params);
486
845
  }
487
846
  buildArgs(params) {
488
- const allowed = (params.allowedTools ?? DEFAULT_ALLOWED_TOOLS).join(",");
847
+ const allowed = (params.allowedTools ?? (this.managed ? MANAGED_ALLOWED_TOOLS : DEFAULT_ALLOWED_TOOLS)).join(",");
489
848
  const permissionMode = params.permissionMode ?? "dontAsk";
490
849
  if (!CLAUDE_PERMISSION_MODES.includes(permissionMode)) throw new Error(`Unsupported Claude permission mode: ${permissionMode}`);
491
850
  const args = [
@@ -499,8 +858,10 @@ var SessionDriver = class {
499
858
  "--permission-mode",
500
859
  permissionMode,
501
860
  "--allowedTools",
502
- allowed
861
+ allowed,
862
+ "--strict-mcp-config"
503
863
  ];
864
+ if (this.managed) args.push("--disallowedTools", MANAGED_DISALLOWED_TOOLS.join(","));
504
865
  if (params.model) args.push("--model", params.model);
505
866
  if (params.claudeSessionId) args.push("--resume", params.claudeSessionId);
506
867
  if (params.mcpConfigPath) args.push("--mcp-config", params.mcpConfigPath);
@@ -518,7 +879,7 @@ var SessionDriver = class {
518
879
  let stderr = "";
519
880
  let rateLimited = false;
520
881
  const args = this.buildArgs(params);
521
- const env = buildChildEnv(this.baseEnv);
882
+ const env = buildChildEnv(this.baseEnv, void 0, this.credential);
522
883
  return new Promise((resolve) => {
523
884
  let child;
524
885
  try {
@@ -556,18 +917,21 @@ var SessionDriver = class {
556
917
  this.abortCurrent = null;
557
918
  const end = emissions.find((e) => e.type === "turn-end");
558
919
  const endIsError = end?.type === "turn-end" ? end.isError : false;
920
+ const endAuthFailed = end?.type === "turn-end" && end.authFailed === true;
559
921
  const endText = end?.type === "turn-end" ? end.finalText : state.visibleText;
560
922
  const endErrMsg = end?.type === "turn-end" ? end.errorMessage : void 0;
561
923
  const finalIsError = isError || endIsError;
562
924
  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
925
  const retainedEmissions = latestTextEmission ? [...emissions, latestTextEmission].sort((a, b) => a.seq - b.seq) : emissions;
926
+ const authFailed = finalIsError && !aborted && terminationReason === null && (end ? endAuthFailed : isAuthFailureText(stderr));
564
927
  resolve({
565
928
  claudeSessionId: state.claudeSessionId,
566
929
  emissions: retainedEmissions,
567
930
  isError: finalIsError,
568
931
  finalText: finalIsError ? "" : endText,
569
932
  ...finalErrMsg ? { errorMessage: finalErrMsg } : {},
570
- aborted
933
+ aborted,
934
+ ...authFailed ? { authFailed: true } : {}
571
935
  });
572
936
  };
573
937
  const requestTermination = (reason) => {
@@ -619,9 +983,10 @@ var SessionDriver = class {
619
983
  }
620
984
  }
621
985
  });
986
+ const stderrDecoder = new node_string_decoder.StringDecoder("utf8");
622
987
  child.stderr.on("data", (chunk) => {
623
988
  if (settled) return;
624
- const text = chunk.toString("utf-8");
989
+ const text = stderrDecoder.write(chunk);
625
990
  rateLimited ||= looksRateLimited(text);
626
991
  stderr = `${stderr}${text}`.slice(-MAX_STDERR_CHARS);
627
992
  });
@@ -719,8 +1084,14 @@ function asSessionData(value, expectedSessionId) {
719
1084
  return (item.role === "user" || item.role === "assistant") && typeof item.content === "string" && typeof item.timestamp === "number" && Number.isFinite(item.timestamp);
720
1085
  })) return null;
721
1086
  if (record.activity !== void 0 && !Array.isArray(record.activity)) return null;
1087
+ if (record.memory !== void 0 && !isMemoryCursor(record.memory)) delete record.memory;
722
1088
  return value;
723
1089
  }
1090
+ function isMemoryCursor(value) {
1091
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1092
+ const cursor = value;
1093
+ return Number.isSafeInteger(cursor.ingestEpoch) && cursor.ingestEpoch >= 0 && Number.isSafeInteger(cursor.lastIngestedIndex) && cursor.lastIngestedIndex >= -1;
1094
+ }
724
1095
  async function cleanupOldSessions() {
725
1096
  if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
726
1097
  const dir = sessionsDir();
@@ -780,17 +1151,38 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
780
1151
  userId,
781
1152
  createdAt: now,
782
1153
  updatedAt: now,
783
- messages: []
1154
+ messages: [],
1155
+ memory: {
1156
+ ingestEpoch: await nextIngestEpoch(),
1157
+ lastIngestedIndex: -1
1158
+ }
784
1159
  };
785
1160
  await saveSession(session);
786
1161
  cleanupOldSessions();
787
1162
  return session;
788
1163
  }
789
1164
  const writeLocks = /* @__PURE__ */ new Map();
1165
+ /** Run `fn` under the per-session write lock and return its result. */
1166
+ async function withSessionLock(sessionId, fn) {
1167
+ const prev = writeLocks.get(sessionId) ?? Promise.resolve();
1168
+ let result;
1169
+ const next = prev.catch(() => void 0).then(async () => {
1170
+ result = await fn();
1171
+ }).finally(() => {
1172
+ if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
1173
+ });
1174
+ writeLocks.set(sessionId, next);
1175
+ await next;
1176
+ return result;
1177
+ }
1178
+ /**
1179
+ * Append a message. Returns its index (position in `messages`), the stable
1180
+ * memory-ingest index, or -1 when the session does not exist.
1181
+ */
790
1182
  async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
791
- const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
1183
+ return withSessionLock(sessionId, async () => {
792
1184
  const session = await getSession(sessionId);
793
- if (!session) return;
1185
+ if (!session) return -1;
794
1186
  session.messages.push({
795
1187
  role,
796
1188
  content,
@@ -801,11 +1193,145 @@ async function addMessage(sessionId, role, content, senderId, senderName, compon
801
1193
  ...attachments?.length ? { attachments } : {}
802
1194
  });
803
1195
  await saveSession(session);
804
- }).finally(() => {
805
- if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
1196
+ return session.messages.length - 1;
1197
+ });
1198
+ }
1199
+ const MAX_MEMORY_MESSAGE_BYTES = 6e4;
1200
+ /** Allowance per message for role/index/JSON structure. */
1201
+ const MEMORY_MESSAGE_OVERHEAD_BYTES = 64;
1202
+ function serialisedBytes(text) {
1203
+ return Buffer.byteLength(JSON.stringify(text), "utf8");
1204
+ }
1205
+ /**
1206
+ * Longest prefix of `text` whose JSON-serialised UTF-8 size is at most
1207
+ * `maxBytes`, never splitting a surrogate pair.
1208
+ */
1209
+ function capSerialisedBytes(text, maxBytes) {
1210
+ if (serialisedBytes(text) <= maxBytes) return text;
1211
+ let lo = 0;
1212
+ let hi = text.length;
1213
+ while (lo < hi) {
1214
+ const mid = Math.ceil((lo + hi) / 2);
1215
+ if (serialisedBytes(text.slice(0, mid)) <= maxBytes) lo = mid;
1216
+ else hi = mid - 1;
1217
+ }
1218
+ let end = lo;
1219
+ const last = text.charCodeAt(end - 1);
1220
+ if (end > 0 && last >= 55296 && last <= 56319) end -= 1;
1221
+ return text.slice(0, end);
1222
+ }
1223
+ let epochChain = Promise.resolve();
1224
+ function epochStatePath() {
1225
+ return (0, node_path.join)(sessionsDir(), ".ingest-epoch");
1226
+ }
1227
+ async function readLastEpoch() {
1228
+ try {
1229
+ const parsed = JSON.parse(await (0, node_fs_promises.readFile)(epochStatePath(), "utf-8"));
1230
+ const epoch = parsed && typeof parsed === "object" ? parsed.epoch : void 0;
1231
+ return Number.isSafeInteger(epoch) && epoch >= 0 ? epoch : 0;
1232
+ } catch {
1233
+ return 0;
1234
+ }
1235
+ }
1236
+ /**
1237
+ * Next conversation-lineage epoch: `max(Date.now(), lastIssued + 1)`, persisted
1238
+ * (same guard as openclaw-memory-cloud's `ingest-epoch.ts`). The memory service
1239
+ * treats an OLDER epoch than the one it stored as stale and drops the batch, so
1240
+ * a clock that stepped backwards must never produce a smaller epoch.
1241
+ */
1242
+ async function nextIngestEpoch() {
1243
+ const run = async () => {
1244
+ await ensureDir();
1245
+ const epoch = Math.max(Date.now(), await readLastEpoch() + 1);
1246
+ await writeTextAtomically(epochStatePath(), `${JSON.stringify({ epoch })}\n`);
1247
+ return epoch;
1248
+ };
1249
+ const result = epochChain.then(run, run);
1250
+ epochChain = result.catch(() => void 0);
1251
+ return result;
1252
+ }
1253
+ /**
1254
+ * Cursor for a session written before the cursor existed. The old host
1255
+ * ingested every turn as indices 0 and 1, so the service stored exactly the
1256
+ * messages at positions 0/1 (when the session had them) and dropped the rest.
1257
+ * Start at 2 so the dropped backlog is backfilled through the batch bound.
1258
+ */
1259
+ async function initialCursor(session) {
1260
+ return {
1261
+ ingestEpoch: await nextIngestEpoch(),
1262
+ lastIngestedIndex: session.messages.length >= 2 ? 1 : -1
1263
+ };
1264
+ }
1265
+ /**
1266
+ * Give a pre-cursor session its cursor. Call BEFORE appending the turn's user
1267
+ * message, so a turn that then fails still leaves that message pending.
1268
+ * No-op for a session that already has one (or does not exist).
1269
+ */
1270
+ async function ensureMemoryCursor(sessionId) {
1271
+ await withSessionLock(sessionId, async () => {
1272
+ const session = await getSession(sessionId);
1273
+ if (!session || session.memory) return;
1274
+ session.memory = await initialCursor(session);
1275
+ await saveSession(session);
1276
+ });
1277
+ }
1278
+ /**
1279
+ * The messages not yet accepted by the memory service, oldest first, bounded
1280
+ * by count and serialised bytes (always at least one message when any is
1281
+ * pending). Whitespace-only messages are skipped (the service rejects empty
1282
+ * content) but still covered: `throughIndex` is the last index this batch
1283
+ * accounts for, sent or skipped. Leftovers go out with the next turn's batch.
1284
+ */
1285
+ async function pendingMemoryBatch(sessionId, opts = {}) {
1286
+ const maxMessages = opts.maxMessages ?? 20;
1287
+ const maxBytes = opts.maxBytes ?? 2e5;
1288
+ return withSessionLock(sessionId, async () => {
1289
+ const session = await getSession(sessionId);
1290
+ if (!session) return null;
1291
+ if (!session.memory) {
1292
+ session.memory = await initialCursor(session);
1293
+ await saveSession(session);
1294
+ }
1295
+ const messages = [];
1296
+ let throughIndex = session.memory.lastIngestedIndex;
1297
+ let bytes = 0;
1298
+ for (let index = session.memory.lastIngestedIndex + 1; index < session.messages.length && messages.length < maxMessages; index += 1) {
1299
+ const message = session.messages[index];
1300
+ if (message.content.trim() === "") {
1301
+ throughIndex = index;
1302
+ continue;
1303
+ }
1304
+ const content = capSerialisedBytes(message.content, MAX_MEMORY_MESSAGE_BYTES);
1305
+ const size = serialisedBytes(content) + MEMORY_MESSAGE_OVERHEAD_BYTES;
1306
+ if (messages.length > 0 && bytes + size > maxBytes) break;
1307
+ bytes += size;
1308
+ messages.push({
1309
+ role: message.role,
1310
+ content,
1311
+ index
1312
+ });
1313
+ throughIndex = index;
1314
+ }
1315
+ return {
1316
+ ingestEpoch: session.memory.ingestEpoch,
1317
+ messages,
1318
+ throughIndex
1319
+ };
1320
+ });
1321
+ }
1322
+ /**
1323
+ * Record that the memory service has dealt with messages up to `lastIndex`.
1324
+ * Ignored unless it moves the cursor forward within the same epoch, so a late
1325
+ * or duplicate acknowledgement can never move it back.
1326
+ */
1327
+ async function markMemoryIngested(sessionId, ingestEpoch, lastIndex) {
1328
+ await withSessionLock(sessionId, async () => {
1329
+ const session = await getSession(sessionId);
1330
+ const cursor = session?.memory;
1331
+ if (!session || cursor?.ingestEpoch !== ingestEpoch || lastIndex <= cursor.lastIngestedIndex || lastIndex >= session.messages.length) return;
1332
+ cursor.lastIngestedIndex = lastIndex;
1333
+ await saveSession(session);
806
1334
  });
807
- writeLocks.set(sessionId, next);
808
- await next;
809
1335
  }
810
1336
  const MAX_ACTIVITY_PER_SESSION = 500;
811
1337
  const MAX_ACTIVITY_THINKING_CHARS = 16e3;
@@ -815,7 +1341,7 @@ const MAX_ACTIVITY_THINKING_CHARS = 16e3;
815
1341
  */
816
1342
  async function appendActivity(sessionId, entries) {
817
1343
  if (entries.length === 0) return;
818
- const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
1344
+ await withSessionLock(sessionId, async () => {
819
1345
  const session = await getSession(sessionId);
820
1346
  if (!session) return;
821
1347
  const capped = entries.map((e) => e.kind === "thinking" && e.text.length > MAX_ACTIVITY_THINKING_CHARS ? {
@@ -825,11 +1351,7 @@ async function appendActivity(sessionId, entries) {
825
1351
  const combined = [...session.activity ?? [], ...capped];
826
1352
  session.activity = combined.length > MAX_ACTIVITY_PER_SESSION ? combined.slice(combined.length - MAX_ACTIVITY_PER_SESSION) : combined;
827
1353
  await saveSession(session);
828
- }).finally(() => {
829
- if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
830
1354
  });
831
- writeLocks.set(sessionId, next);
832
- await next;
833
1355
  }
834
1356
  async function listSessions(filters, limit = 50) {
835
1357
  await ensureDir();
@@ -871,6 +1393,196 @@ async function listSessions(filters, limit = 50) {
871
1393
  return summaries.slice(0, boundedLimit);
872
1394
  }
873
1395
  //#endregion
1396
+ //#region src/persona.ts
1397
+ /**
1398
+ * Host-owned `CLAUDE.md` — the persona Claude Code auto-loads from its cwd.
1399
+ *
1400
+ * The agent's persona lives in the workspace persona files (IDENTITY.md,
1401
+ * SOUL.md, AGENTS.md, USER.md, BOOTSTRAP.md). `alfe setup` seeds them from the
1402
+ * agent's template (seed-if-missing, agent-owned — the same semantics OpenClaw
1403
+ * uses), and the agent may edit them itself. OpenClaw reads those files live
1404
+ * every turn; Claude Code only reads `CLAUDE.md`. So the host regenerates
1405
+ * `CLAUDE.md` from them on start and before every turn, which keeps it as
1406
+ * current as OpenClaw's view of the same files.
1407
+ *
1408
+ * File layout:
1409
+ *
1410
+ * <!-- alfe:managed:start -->
1411
+ * <!-- Managed by Alfe. ... -->
1412
+ *
1413
+ * <persona sections>
1414
+ * <!-- alfe:managed:end. Add your own notes below this line; Alfe keeps them. -->
1415
+ * <user notes, kept verbatim>
1416
+ *
1417
+ * Everything above the end marker is overwritten. Everything after it is
1418
+ * preserved, so self-hosted users can add local notes.
1419
+ */
1420
+ /** Persona files, in the order they appear in `CLAUDE.md`. */
1421
+ const PERSONA_FILES = [
1422
+ "IDENTITY.md",
1423
+ "SOUL.md",
1424
+ "AGENTS.md",
1425
+ "USER.md",
1426
+ "BOOTSTRAP.md"
1427
+ ];
1428
+ /**
1429
+ * Start-line prefix. The full line records the sha256 of the managed region
1430
+ * the host last wrote (`<!-- alfe:managed:start sha256=<hex> -->`), so a hand
1431
+ * edit above the end marker is detected and backed up before it is replaced.
1432
+ */
1433
+ const MANAGED_START = "<!-- alfe:managed:start";
1434
+ const MANAGED_END = "<!-- alfe:managed:end. Add your own notes below this line; Alfe keeps them. -->";
1435
+ 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. -->";
1436
+ /** Matches the end marker even if a future version changes its trailing text. */
1437
+ const END_PREFIX = "<!-- alfe:managed:end";
1438
+ /** Separator between persona sections (also what the old CLI seed used). */
1439
+ const SECTION_SEPARATOR = "\n\n---\n\n";
1440
+ /**
1441
+ * Claude Code loads `CLAUDE.md` into every turn's context, so the persona is
1442
+ * capped per file and in total. Truncation is stated in the file itself.
1443
+ */
1444
+ const MAX_PERSONA_FILE_CHARS = 2e4;
1445
+ const MAX_PERSONA_TOTAL_CHARS = 6e4;
1446
+ function truncate(text, max, note) {
1447
+ if (text.length <= max) return text;
1448
+ let end = max;
1449
+ const last = text.charCodeAt(end - 1);
1450
+ if (last >= 55296 && last <= 56319) end -= 1;
1451
+ return `${text.slice(0, end)}\n\n[Truncated by Alfe: ${note}; the rest is not loaded.]`;
1452
+ }
1453
+ /**
1454
+ * Join the present persona files (stable order, trimmed, blanks skipped),
1455
+ * capped per file and in total. Returns undefined when there is no persona
1456
+ * content.
1457
+ */
1458
+ function composePersonaBody(files) {
1459
+ const sections = [];
1460
+ for (const name of PERSONA_FILES) {
1461
+ const content = files[name]?.trim();
1462
+ if (content) sections.push(truncate(content, MAX_PERSONA_FILE_CHARS, `${name} is longer than ${String(MAX_PERSONA_FILE_CHARS)} characters`));
1463
+ }
1464
+ if (sections.length === 0) return void 0;
1465
+ return truncate(sections.join(SECTION_SEPARATOR), MAX_PERSONA_TOTAL_CHARS, `the persona is longer than ${String(MAX_PERSONA_TOTAL_CHARS)} characters`);
1466
+ }
1467
+ function sha256(text) {
1468
+ return (0, node_crypto.createHash)("sha256").update(text, "utf8").digest("hex");
1469
+ }
1470
+ /**
1471
+ * Render the next `CLAUDE.md` from the persona body and the current file.
1472
+ * Returns undefined when nothing should be written: there is no persona and
1473
+ * the existing file (if any) is not Alfe-managed.
1474
+ */
1475
+ function renderClaudeMd(body, existing) {
1476
+ const endAt = existing === void 0 ? -1 : existing.indexOf(END_PREFIX);
1477
+ let notes = "";
1478
+ if (existing !== void 0 && endAt >= 0) {
1479
+ const lineEnd = existing.indexOf("\n", endAt);
1480
+ notes = lineEnd < 0 ? "" : existing.slice(lineEnd + 1);
1481
+ } else if (existing !== void 0 && existing.trim() !== "" && existing.trim() !== body) notes = existing.endsWith("\n") ? existing : `${existing}\n`;
1482
+ if (body === void 0 && endAt < 0) return void 0;
1483
+ const region = `${MANAGED_NOTICE}\n\n${body ? `${body}\n` : ""}`;
1484
+ return `${MANAGED_START} sha256=${sha256(region)} -->\n${region}${MANAGED_END}\n${notes}`;
1485
+ }
1486
+ /**
1487
+ * True when the managed region of a host-written file no longer matches the
1488
+ * hash the host recorded, i.e. someone edited above the end marker. Files
1489
+ * without a recorded hash are treated as unedited.
1490
+ */
1491
+ function managedRegionEdited(existing) {
1492
+ const firstLineEnd = existing.indexOf("\n");
1493
+ const endAt = existing.indexOf(END_PREFIX);
1494
+ if (firstLineEnd < 0 || endAt < firstLineEnd) return false;
1495
+ const recorded = /^<!-- alfe:managed:start sha256=([0-9a-f]{64}) -->$/u.exec(existing.slice(0, firstLineEnd))?.[1];
1496
+ if (!recorded) return false;
1497
+ return sha256(existing.slice(firstLineEnd + 1, endAt)) !== recorded;
1498
+ }
1499
+ /**
1500
+ * True for an unmarked `CLAUDE.md` in the format the CLI used to seed:
1501
+ * persona sections joined by `---`, whose first section is still the content
1502
+ * of a current persona file. Such a file is a stale persona snapshot (often
1503
+ * including BOOTSTRAP.md), not user notes.
1504
+ */
1505
+ function isLegacyComposedClaudeMd(existing, files) {
1506
+ if (existing.includes(END_PREFIX)) return false;
1507
+ const first = existing.split(SECTION_SEPARATOR)[0]?.trim();
1508
+ if (!first) return false;
1509
+ return PERSONA_FILES.some((name) => files[name]?.trim() === first);
1510
+ }
1511
+ async function readOptional(path) {
1512
+ try {
1513
+ return await (0, node_fs_promises.readFile)(path, "utf-8");
1514
+ } catch (err) {
1515
+ if (err.code === "ENOENT") return void 0;
1516
+ throw err;
1517
+ }
1518
+ }
1519
+ function timestamp() {
1520
+ return `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/gu, "-")}-${(0, node_crypto.randomUUID)().slice(0, 8)}`;
1521
+ }
1522
+ /**
1523
+ * Regenerate `<workspace>/CLAUDE.md` from the workspace persona files. Creates
1524
+ * the workspace directory if missing (it is `claude`'s cwd). Skips the write
1525
+ * when the content is unchanged. Returns true when it wrote.
1526
+ *
1527
+ * Before overwriting it preserves anything that is not a plain host-written
1528
+ * file: an old CLI-composed persona snapshot is moved to `CLAUDE.md.pre-alfe`,
1529
+ * and a managed region edited by hand is copied to
1530
+ * `CLAUDE.md.edited-<timestamp>.bak`. Both are logged.
1531
+ */
1532
+ async function syncClaudeMd(workspace, log) {
1533
+ await (0, node_fs_promises.mkdir)(workspace, { recursive: true });
1534
+ const files = {};
1535
+ for (const name of PERSONA_FILES) {
1536
+ const text = await readOptional((0, node_path.join)(workspace, name));
1537
+ if (text !== void 0) files[name] = text;
1538
+ }
1539
+ const body = composePersonaBody(files);
1540
+ const target = (0, node_path.join)(workspace, "CLAUDE.md");
1541
+ let existing = await readOptional(target);
1542
+ if (existing !== void 0 && existing.trim() !== body && isLegacyComposedClaudeMd(existing, files)) {
1543
+ const aside = (0, node_fs.existsSync)(`${target}.pre-alfe`) ? `${target}.pre-alfe-${timestamp()}` : `${target}.pre-alfe`;
1544
+ await (0, node_fs_promises.rename)(target, aside);
1545
+ log?.warn(`[claude-host] moved an old setup-generated CLAUDE.md to ${aside}; the persona is now regenerated from the persona files`);
1546
+ existing = void 0;
1547
+ }
1548
+ const next = renderClaudeMd(body, existing);
1549
+ if (next === void 0 || next === existing) return false;
1550
+ if (existing !== void 0 && managedRegionEdited(existing)) {
1551
+ const backup = `${target}.edited-${timestamp()}.bak`;
1552
+ await writeTextAtomically(backup, existing);
1553
+ 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.`);
1554
+ }
1555
+ await writeTextAtomically(target, next);
1556
+ return true;
1557
+ }
1558
+ //#endregion
1559
+ //#region src/memory-recall.ts
1560
+ /**
1561
+ * Memory recall helpers. Mirrors `packages/openclaw-memory-cloud/src/auto-recall.ts`
1562
+ * so a claude-code agent recalls the same way an OpenClaw agent does: tier 1
1563
+ * (facts) always, plus tier 2 (topic context) when the message names a topic.
1564
+ * Copied rather than imported: the host must not depend on an OpenClaw plugin
1565
+ * package. Keep the two in step.
1566
+ */
1567
+ const STOP_WORDS = /^(The|And|But|For|With|This|That|What|How|Why|When|Where)$/;
1568
+ /**
1569
+ * Topic hint = the first capitalised word (a likely proper noun) after the
1570
+ * first word, longer than two characters and not a stop word, lower-cased.
1571
+ */
1572
+ function extractTopicHint(message) {
1573
+ const words = message.split(/\s+/);
1574
+ for (let i = 1; i < words.length; i++) {
1575
+ const word = words[i];
1576
+ if (word && word.length > 2 && /^[A-Z]/.test(word) && !STOP_WORDS.test(word)) return word.toLowerCase();
1577
+ }
1578
+ }
1579
+ /** Tier 2 (facts + topic context) needs a topic; otherwise tier 1 (facts). */
1580
+ function recallTier(topicHint) {
1581
+ return topicHint ? 2 : 1;
1582
+ }
1583
+ /** Recall is best-effort and must not hold up a turn. Matches auto-recall. */
1584
+ const RECALL_TIMEOUT_MS = 5e3;
1585
+ //#endregion
874
1586
  //#region src/host.ts
875
1587
  /**
876
1588
  * ClaudeCodeHost — the persistent chat bridge for a self-hosted Claude Code
@@ -903,8 +1615,6 @@ const VOICE_REPLY_SYSTEM_PROMPT = [
903
1615
  "- If the full answer is long or detailed, give the short spoken version and offer to send the details to the chat.",
904
1616
  "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
1617
  ].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
1618
  /**
909
1619
  * Short, AUTHORITATIVE persona pointer for `--append-system-prompt`. The full
910
1620
  * persona (SOUL/IDENTITY/AGENTS/…) is delivered as the workspace `CLAUDE.md`,
@@ -919,6 +1629,10 @@ const PERSONA_POINTER_SYSTEM_PROMPT = [
919
1629
  "Your identity, persona, voice, and operating instructions live in CLAUDE.md and the other files in your workspace.",
920
1630
  "Fully embody that persona in how you speak and act; respond AS that agent, not as a generic coding assistant."
921
1631
  ].join("\n");
1632
+ const CREDENTIAL_INVALID_MESSAGE = "This agent's Claude credential is invalid or expired. Update it in agent settings.";
1633
+ const LOCAL_LOGIN_FAILED_MESSAGE = "Claude Code is not signed in on this machine. Run `claude` and log in, or add a Claude credential in agent settings.";
1634
+ const CREDENTIAL_MISSING_MESSAGE = "This agent has no Claude credential. Add a Claude subscription token or an Anthropic API key in agent settings.";
1635
+ const CREDENTIAL_UNAVAILABLE_MESSAGE = "This agent's Claude credential couldn't be loaded yet. Try again in a moment.";
922
1636
  const MAX_AGENT_MESSAGE_BYTES = 256 * 1024;
923
1637
  const MAX_SESSION_KEY_CHARS = 768;
924
1638
  const MAX_IDENTIFIER_CHARS = 512;
@@ -1130,18 +1844,49 @@ var ClaudeCodeHost = class {
1130
1844
  claudeSessionSave = Promise.resolve();
1131
1845
  stopping = false;
1132
1846
  stopPromise = null;
1847
+ /**
1848
+ * Resolves once the first fetch has answered. Null until `start()` (the
1849
+ * direct handleRequest test seam runs on the local login, no wait).
1850
+ */
1851
+ credentialReady = null;
1852
+ resolveCredentialReady = () => void 0;
1853
+ /** Turns wait for the first fetch only until this time (start + wait). */
1854
+ credentialDeadline = 0;
1855
+ credentialLoaded = false;
1856
+ /** Turns waiting for the credential, before queue admission (chat.abort). */
1857
+ waitingTurns = /* @__PURE__ */ new Map();
1858
+ loaderRestartMs;
1859
+ /** In memory only. Never logged, never persisted. */
1860
+ credential = null;
1861
+ credentialAbort = new AbortController();
1862
+ reporter;
1863
+ probe;
1864
+ credentialWaitMs;
1865
+ credentialRetry;
1133
1866
  constructor(cfg, deps) {
1134
1867
  this.cfg = cfg;
1135
1868
  this.log = cfg.logger ?? console;
1136
1869
  this.claudeSessionsPath = cfg.claudeSessionsPath ?? (0, node_path.join)((0, node_os.homedir)(), ".alfe", "claude-code", "sessions.json");
1137
- this.makeDriver = deps?.makeDriver ?? (() => new SessionDriver({
1870
+ this.makeDriver = deps?.makeDriver ?? ((credential) => new SessionDriver({
1138
1871
  logger: this.log,
1139
- baseEnv: process.env
1872
+ baseEnv: process.env,
1873
+ credential,
1874
+ managed: cfg.managed === true
1140
1875
  }));
1141
1876
  this.api = deps?.api ?? new _alfe_ai_agent_api_client.AgentApiClient({
1142
1877
  apiKey: cfg.apiKey,
1143
1878
  apiUrl: cfg.apiUrl
1144
1879
  });
1880
+ this.probe = deps?.probe ?? ((credential, signal) => runAuthProbe({
1881
+ credential,
1882
+ baseEnv: process.env,
1883
+ cwd: (0, node_os.tmpdir)(),
1884
+ signal
1885
+ }));
1886
+ this.credentialWaitMs = deps?.credentialWaitMs ?? 15e3;
1887
+ this.loaderRestartMs = deps?.loaderRestartMs ?? 5e3;
1888
+ this.credentialRetry = deps?.credentialRetry ?? {};
1889
+ this.reporter = new CredentialStatusReporter((report) => this.api.reportClaudeCredentialStatus(report), { log: this.log });
1145
1890
  this.chat = deps?.chat ?? new _alfe_ai_chat.ChatServiceClient({
1146
1891
  wsUrl: cfg.wsUrl,
1147
1892
  apiKey: cfg.apiKey,
@@ -1158,12 +1903,137 @@ var ClaudeCodeHost = class {
1158
1903
  async start() {
1159
1904
  if (this.stopping) throw new Error("Claude Code host is stopping");
1160
1905
  await this.loadClaudeSessionIds();
1906
+ await this.syncPersona();
1907
+ this.credentialReady = new Promise((resolve) => {
1908
+ this.resolveCredentialReady = resolve;
1909
+ });
1910
+ this.credentialDeadline = Date.now() + this.credentialWaitMs;
1911
+ this.runCredentialLoader();
1161
1912
  this.chat.start();
1162
1913
  }
1914
+ /**
1915
+ * Run the fetch + check, restarting it after an unexpected throw so a bug
1916
+ * can never leave the host without a credential until the next restart.
1917
+ */
1918
+ runCredentialLoader() {
1919
+ this.loadCredential().catch((err) => {
1920
+ if (this.stopping) return;
1921
+ this.log.error(`[claude-host] credential load failed unexpectedly (${err instanceof Error ? err.message : String(err)}); restarting it`);
1922
+ setTimeout(() => {
1923
+ if (!this.stopping) this.runCredentialLoader();
1924
+ }, this.loaderRestartMs).unref();
1925
+ });
1926
+ }
1927
+ async loadCredential() {
1928
+ const loaded = await loadCredentialWithRetry(async () => {
1929
+ const credential = await this.api.getClaudeCredential();
1930
+ return credential ? {
1931
+ kind: credential.kind,
1932
+ value: credential.value,
1933
+ revision: credential.revision
1934
+ } : null;
1935
+ }, {
1936
+ signal: this.credentialAbort.signal,
1937
+ log: this.log,
1938
+ ...this.credentialRetry
1939
+ });
1940
+ if (!loaded) return;
1941
+ this.credential = loaded.credential;
1942
+ this.credentialLoaded = true;
1943
+ this.resolveCredentialReady();
1944
+ await this.checkCredential();
1945
+ }
1946
+ childCredential() {
1947
+ return this.credential ? {
1948
+ kind: this.credential.kind,
1949
+ value: this.credential.value
1950
+ } : null;
1951
+ }
1952
+ /** Start-up health check: one minimal real call, reported to the cloud. */
1953
+ async checkCredential() {
1954
+ if (!this.credential && this.cfg.managed) {
1955
+ await this.reporter.report({
1956
+ status: "missing",
1957
+ detail: "No Claude credential is stored for this agent"
1958
+ });
1959
+ return;
1960
+ }
1961
+ const outcome = await this.probe(this.childCredential(), this.credentialAbort.signal);
1962
+ if (this.stopping) return;
1963
+ if (outcome.result === "unknown") {
1964
+ this.log.warn(`[claude-host] Claude auth check inconclusive (${outcome.reason}); the next turn will tell`);
1965
+ return;
1966
+ }
1967
+ await this.reportAuth(outcome.result === "ok");
1968
+ }
1969
+ /** Report the credential (or the local login) as working or refused. */
1970
+ async reportAuth(ok) {
1971
+ if (!this.credentialLoaded) return;
1972
+ const revision = this.credential?.revision;
1973
+ if (ok) await this.reporter.report({
1974
+ status: "ok",
1975
+ ...revision ? { revision } : { detail: "Using this machine's claude login" }
1976
+ });
1977
+ else if (revision) await this.reporter.report({
1978
+ status: "invalid",
1979
+ detail: "Anthropic rejected the stored credential",
1980
+ revision
1981
+ });
1982
+ else await this.reporter.report({
1983
+ status: "missing",
1984
+ detail: "No Claude credential, and the claude login on this machine is not signed in"
1985
+ });
1986
+ }
1987
+ /**
1988
+ * Wait for the first credential answer BEFORE queue admission, so a waiting
1989
+ * turn holds no slot. Bounded by the start-up deadline: once it has passed,
1990
+ * later turns stop waiting until the load completes. Cancelled by
1991
+ * `chat.abort` or host stop through `cancellation`.
1992
+ */
1993
+ async awaitCredential(cancellation) {
1994
+ if (!this.credentialReady || this.credentialLoaded) return "ready";
1995
+ const remaining = this.credentialDeadline - Date.now();
1996
+ if (remaining <= 0) return "timeout";
1997
+ let timer;
1998
+ let onAbort;
1999
+ try {
2000
+ return await Promise.race([
2001
+ this.credentialReady.then(() => "ready"),
2002
+ new Promise((resolve) => {
2003
+ timer = setTimeout(() => {
2004
+ resolve("timeout");
2005
+ }, remaining);
2006
+ }),
2007
+ new Promise((resolve) => {
2008
+ onAbort = () => {
2009
+ resolve("aborted");
2010
+ };
2011
+ if (cancellation.aborted) onAbort();
2012
+ else cancellation.addEventListener("abort", onAbort, { once: true });
2013
+ })
2014
+ ]);
2015
+ } finally {
2016
+ if (timer) clearTimeout(timer);
2017
+ if (onAbort) cancellation.removeEventListener("abort", onAbort);
2018
+ }
2019
+ }
2020
+ /**
2021
+ * Instant credential gate inside the turn (no waiting: `handleAgent` already
2022
+ * waited). Returns a user-facing failure message, or null when the turn may
2023
+ * run. A host driven without `start()` runs on the local login.
2024
+ */
2025
+ credentialGateMessage() {
2026
+ if (!this.credentialReady) return null;
2027
+ if (!this.credentialLoaded) return CREDENTIAL_UNAVAILABLE_MESSAGE;
2028
+ if (!this.credential && this.cfg.managed) return CREDENTIAL_MISSING_MESSAGE;
2029
+ return null;
2030
+ }
1163
2031
  /** Stop admission, abort owned children, and give active turns time to settle. */
1164
2032
  stop() {
1165
2033
  if (this.stopPromise) return this.stopPromise;
1166
2034
  this.stopping = true;
2035
+ this.credentialAbort.abort();
2036
+ for (const waiting of this.waitingTurns.values()) for (const turn of waiting) turn.cancellation.abort();
1167
2037
  this.queues.stop();
1168
2038
  for (const { driver, cancellation } of this.runningTurns.values()) {
1169
2039
  cancellation.abort();
@@ -1252,6 +2122,29 @@ var ClaudeCodeHost = class {
1252
2122
  resolveCompletion(outcome);
1253
2123
  if (idem) this.pruneIdempotency();
1254
2124
  };
2125
+ if (this.credentialReady && !this.credentialLoaded) {
2126
+ const waiting = {
2127
+ runId: params.idempotencyKey ?? req.id,
2128
+ cancellation: new AbortController()
2129
+ };
2130
+ const set = this.waitingTurns.get(sessionKey) ?? /* @__PURE__ */ new Set();
2131
+ set.add(waiting);
2132
+ this.waitingTurns.set(sessionKey, set);
2133
+ let outcome;
2134
+ try {
2135
+ outcome = await this.awaitCredential(waiting.cancellation.signal);
2136
+ } finally {
2137
+ set.delete(waiting);
2138
+ if (set.size === 0) this.waitingTurns.delete(sessionKey);
2139
+ }
2140
+ if (outcome === "aborted") {
2141
+ complete({
2142
+ ok: true,
2143
+ payload: { sessionKey }
2144
+ });
2145
+ return;
2146
+ }
2147
+ }
1255
2148
  const coalesceKey = params.coalesce === "replace" ? sessionKey : void 0;
1256
2149
  try {
1257
2150
  await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params, complete), coalesceKey ? {
@@ -1285,17 +2178,29 @@ var ClaudeCodeHost = class {
1285
2178
  const runId = params.idempotencyKey ?? req.id;
1286
2179
  const isVoice = params.origin === "voice";
1287
2180
  await this.ensureSession(bareSessionId, params);
2181
+ await ensureMemoryCursor(bareSessionId);
1288
2182
  await addMessage(bareSessionId, "user", params.message, params.identityId ?? params.userId, params.displayName);
2183
+ const credentialProblem = this.credentialGateMessage();
2184
+ if (credentialProblem) {
2185
+ complete({
2186
+ ok: false,
2187
+ payload: { message: credentialProblem }
2188
+ });
2189
+ return;
2190
+ }
1289
2191
  let memoryContext = "";
1290
2192
  try {
1291
- memoryContext = (await this.api.memoryLoadContext()).formatted.slice(0, MAX_MEMORY_CONTEXT_CHARS);
2193
+ const topicHint = extractTopicHint(params.message);
2194
+ memoryContext = (await withTimeout(this.api.memoryLoadContext(recallTier(topicHint), topicHint), RECALL_TIMEOUT_MS)).formatted.slice(0, MAX_MEMORY_CONTEXT_CHARS);
1292
2195
  } catch (err) {
1293
2196
  this.log.warn(`[claude-host] memoryLoadContext failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1294
2197
  }
1295
2198
  if (this.stopping) throw new Error("Claude Code host is stopping");
2199
+ await this.syncPersona();
2200
+ const mcpConfigPath = this.cfg.mcpConfigPath && (0, node_fs.existsSync)(this.cfg.mcpConfigPath) ? this.cfg.mcpConfigPath : void 0;
1296
2201
  const appendSystemPrompt = this.buildSystemPrompt(memoryContext, isVoice);
1297
2202
  const claudeSessionId = this.claudeSessionIds.get(bareSessionId) ?? null;
1298
- const driver = this.makeDriver();
2203
+ const driver = this.makeDriver(this.childCredential());
1299
2204
  const cancellation = new AbortController();
1300
2205
  this.runningTurns.set(sessionKey, {
1301
2206
  runId,
@@ -1308,7 +2213,7 @@ var ClaudeCodeHost = class {
1308
2213
  prompt: params.message,
1309
2214
  claudeSessionId,
1310
2215
  cwd: this.cfg.workspace,
1311
- ...this.cfg.mcpConfigPath ? { mcpConfigPath: this.cfg.mcpConfigPath } : {},
2216
+ ...mcpConfigPath ? { mcpConfigPath } : {},
1312
2217
  ...appendSystemPrompt ? { appendSystemPrompt } : {},
1313
2218
  ...this.cfg.model ? { model: this.cfg.model } : {},
1314
2219
  onEmission: (emission) => {
@@ -1332,12 +2237,21 @@ var ClaudeCodeHost = class {
1332
2237
  return;
1333
2238
  }
1334
2239
  if (result.isError) {
2240
+ if (result.authFailed) {
2241
+ complete({
2242
+ ok: false,
2243
+ payload: { message: this.credential ? CREDENTIAL_INVALID_MESSAGE : LOCAL_LOGIN_FAILED_MESSAGE }
2244
+ });
2245
+ await this.reportAuth(false);
2246
+ return;
2247
+ }
1335
2248
  complete({
1336
2249
  ok: false,
1337
2250
  payload: { message: result.errorMessage ?? "Claude Code turn failed" }
1338
2251
  });
1339
2252
  return;
1340
2253
  }
2254
+ this.reportAuth(true);
1341
2255
  if (result.finalText) await addMessage(bareSessionId, "assistant", result.finalText);
1342
2256
  const notification = result.finalText ? {
1343
2257
  conversationId: bareSessionId,
@@ -1350,22 +2264,60 @@ var ClaudeCodeHost = class {
1350
2264
  payload: { sessionKey },
1351
2265
  ...notification ? { notification } : {}
1352
2266
  });
2267
+ await this.ingestPendingMemory(bareSessionId, params);
2268
+ }
2269
+ /**
2270
+ * Send every message the memory service has not accepted yet. Indices are
2271
+ * the messages' positions in the session file, so they advance across turns
2272
+ * and survive host restarts; the per-conversation `ingestEpoch` tells the
2273
+ * service when that index space restarted. Ingest under the BARE session
2274
+ * id: the same key the session-store filename and the memory backfill use,
2275
+ * so there is one memory lineage.
2276
+ *
2277
+ * Cursor rules:
2278
+ * - success: advance through the batch. If the server trimmed the batch
2279
+ * (`messageCount` < sent) it kept the NEWEST messages and its watermark
2280
+ * moved past the dropped head, so a resend would be filtered anyway;
2281
+ * advance and warn. The byte bounds keep this path unreachable normally.
2282
+ * - non-retryable 4xx (400/413/422): skip only the oldest message, so one
2283
+ * poison message cannot wedge the conversation.
2284
+ * - anything else (429, 5xx, network): leave the cursor; the next turn
2285
+ * resends.
2286
+ */
2287
+ async ingestPendingMemory(bareSessionId, params) {
2288
+ let batch = null;
1353
2289
  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 } : {},
2290
+ batch = await pendingMemoryBatch(bareSessionId);
2291
+ if (!batch) return;
2292
+ if (batch.messages.length === 0) {
2293
+ await markMemoryIngested(bareSessionId, batch.ingestEpoch, batch.throughIndex);
2294
+ return;
2295
+ }
2296
+ const userId = params.identityId ?? params.userId;
2297
+ const result = await this.api.memoryIngest(bareSessionId, batch.messages, {
2298
+ ...userId ? { userId } : {},
1365
2299
  ...params.displayName ? { userName: params.displayName } : {}
1366
- });
2300
+ }, batch.ingestEpoch);
2301
+ 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`);
2302
+ await markMemoryIngested(bareSessionId, batch.ingestEpoch, batch.throughIndex);
1367
2303
  } catch (err) {
1368
- this.log.warn(`[claude-host] memoryIngest failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
2304
+ const status = err?.status;
2305
+ const message = err instanceof Error ? err.message : String(err);
2306
+ if (batch && batch.messages.length > 0 && isPoisonIngestStatus(status)) {
2307
+ const oldest = batch.messages[0];
2308
+ this.log.warn(`[claude-host] memoryIngest rejected (${String(status)}); skipping message ${String(oldest.index)} so the conversation keeps capturing: ${message}`);
2309
+ await markMemoryIngested(bareSessionId, batch.ingestEpoch, oldest.index).catch(() => void 0);
2310
+ return;
2311
+ }
2312
+ this.log.warn(`[claude-host] memoryIngest failed (non-fatal, retried next turn): ${message}`);
2313
+ }
2314
+ }
2315
+ /** Regenerate CLAUDE.md from the workspace persona files. Never fatal. */
2316
+ async syncPersona() {
2317
+ try {
2318
+ if (await syncClaudeMd(this.cfg.workspace, this.log)) this.log.info("[claude-host] CLAUDE.md regenerated from persona files");
2319
+ } catch (err) {
2320
+ this.log.warn(`[claude-host] CLAUDE.md sync failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
1369
2321
  }
1370
2322
  }
1371
2323
  /**
@@ -1440,6 +2392,11 @@ var ClaudeCodeHost = class {
1440
2392
  active.driver.abort();
1441
2393
  aborted = true;
1442
2394
  }
2395
+ const waiting = sessionKey ? this.waitingTurns.get(sessionKey) : void 0;
2396
+ for (const turn of waiting ?? []) if (runId === void 0 || typeof runId === "string" && runId.length > 0 && runId.length <= 256 && turn.runId === runId) {
2397
+ turn.cancellation.abort();
2398
+ aborted = true;
2399
+ }
1443
2400
  this.chat.sendResponse(req.id, true, { aborted });
1444
2401
  }
1445
2402
  async handleSessionsList(req) {
@@ -1487,7 +2444,6 @@ var ClaudeCodeHost = class {
1487
2444
  const parts = [PERSONA_POINTER_SYSTEM_PROMPT];
1488
2445
  if (memoryContext) parts.push(`# Relevant memory\n\n${memoryContext}`);
1489
2446
  if (isVoice) parts.push(VOICE_REPLY_SYSTEM_PROMPT);
1490
- parts.push(CROSS_AGENT_TOOLS_SYSTEM_PROMPT);
1491
2447
  return parts.join("\n\n");
1492
2448
  }
1493
2449
  async buildHistoryPreamble(bareSessionId) {
@@ -1551,6 +2507,28 @@ var ClaudeCodeHost = class {
1551
2507
  }
1552
2508
  }
1553
2509
  };
2510
+ /**
2511
+ * Statuses where resending the same batch can never succeed. 429 and 5xx are
2512
+ * transient; 401/403 are credential problems that must not drop memories.
2513
+ */
2514
+ function isPoisonIngestStatus(status) {
2515
+ return status === 400 || status === 413 || status === 422;
2516
+ }
2517
+ /** Reject when `operation` outlives `timeoutMs` (the operation is not cancelled). */
2518
+ async function withTimeout(operation, timeoutMs) {
2519
+ let timer;
2520
+ const timeout = new Promise((_resolve, reject) => {
2521
+ timer = setTimeout(() => {
2522
+ reject(/* @__PURE__ */ new Error("Memory recall timed out"));
2523
+ }, timeoutMs);
2524
+ timer.unref();
2525
+ });
2526
+ try {
2527
+ return await Promise.race([operation, timeout]);
2528
+ } finally {
2529
+ if (timer) clearTimeout(timer);
2530
+ }
2531
+ }
1554
2532
  /** Strip the `agent:{agentId}:` prefix from a full server session key. */
1555
2533
  function stripAgentPrefix(sessionKey) {
1556
2534
  const parts = sessionKey.split(":");
@@ -1566,24 +2544,44 @@ function stripAgentPrefix(sessionKey) {
1566
2544
  * `main()` (which would start the process on import).
1567
2545
  */
1568
2546
  /**
1569
- * Build the host config. Throws (→ non-zero exit from the CLI) when the chat
1570
- * URL / api key can't resolve.
2547
+ * Pure builder (test seam). `home` is the claude-code runtime HOME
2548
+ * (`[runtimes.claude-code].workspace`, e.g. `~/.claude-code`), NOT the agent's
2549
+ * working directory and NOT `resolveConfig().workspacePath` (that follows the
2550
+ * ACTIVE runtime and is `~/.openclaw` on an env-only config). `claude` must run in the agent workspace
2551
+ * (`<home>/workspace`, where `CLAUDE.md` and the persona files live) so it
2552
+ * auto-loads the persona; the generated `--mcp-config` stays in the home where
2553
+ * `ClaudeCodeMcpSync` writes it.
1571
2554
  */
1572
- function resolveHostConfig() {
1573
- const resolved = (0, _alfe_ai_config.resolveConfig)();
1574
- const chat = (0, _alfe_ai_chat.resolveAlfeChat)({ apiKey: resolved.apiKey });
2555
+ function buildHostConfig(resolved, home, chat, env) {
1575
2556
  if (!chat.apiKey || !chat.chatWsUrl) throw new Error("Unable to resolve chat service URL / API key from ~/.alfe/config.toml. Run `alfe login`.");
2557
+ const model = env.ALFE_DEFAULT_MODEL?.trim();
2558
+ const managedFlag = env.ALFE_MANAGED?.trim().toLowerCase();
1576
2559
  return {
1577
2560
  wsUrl: chat.chatWsUrl,
1578
2561
  apiKey: chat.apiKey,
1579
2562
  apiUrl: resolved.apiUrl,
1580
- workspace: resolved.workspacePath,
1581
- mcpConfigPath: (0, node_path.join)((0, node_os.homedir)(), ".claude-code", "mcp-config.json"),
2563
+ workspace: (0, _alfe_ai_config.resolveAgentWorkspace)("claude-code", home),
2564
+ mcpConfigPath: (0, node_path.join)(home, "mcp-config.json"),
1582
2565
  claudeSessionsPath: (0, node_path.join)((0, node_os.homedir)(), ".alfe", "claude-code", "sessions.json"),
1583
- ...process.env.ALFE_DEFAULT_MODEL?.trim() ? { model: process.env.ALFE_DEFAULT_MODEL.trim() } : {}
2566
+ ...model ? { model } : {},
2567
+ managed: managedFlag === "1" || managedFlag === "true"
1584
2568
  };
1585
2569
  }
2570
+ /**
2571
+ * Build the host config. Throws (→ non-zero exit from the CLI) when the chat
2572
+ * URL / api key can't resolve.
2573
+ */
2574
+ function resolveHostConfig() {
2575
+ const resolved = (0, _alfe_ai_config.resolveConfig)();
2576
+ return buildHostConfig(resolved, (0, _alfe_ai_config.resolveRuntimeHome)("claude-code"), (0, _alfe_ai_chat.resolveAlfeChat)({ apiKey: resolved.apiKey }), process.env);
2577
+ }
1586
2578
  //#endregion
2579
+ Object.defineProperty(exports, "AUTH_PROBE_TIMEOUT_MS", {
2580
+ enumerable: true,
2581
+ get: function() {
2582
+ return AUTH_PROBE_TIMEOUT_MS;
2583
+ }
2584
+ });
1587
2585
  Object.defineProperty(exports, "BRIDGE_ENV_VAR", {
1588
2586
  enumerable: true,
1589
2587
  get: function() {
@@ -1596,12 +2594,42 @@ Object.defineProperty(exports, "CLAUDE_PERMISSION_MODES", {
1596
2594
  return CLAUDE_PERMISSION_MODES;
1597
2595
  }
1598
2596
  });
2597
+ Object.defineProperty(exports, "CREDENTIAL_ENV_VAR", {
2598
+ enumerable: true,
2599
+ get: function() {
2600
+ return CREDENTIAL_ENV_VAR;
2601
+ }
2602
+ });
2603
+ Object.defineProperty(exports, "CREDENTIAL_INVALID_MESSAGE", {
2604
+ enumerable: true,
2605
+ get: function() {
2606
+ return CREDENTIAL_INVALID_MESSAGE;
2607
+ }
2608
+ });
2609
+ Object.defineProperty(exports, "CREDENTIAL_MISSING_MESSAGE", {
2610
+ enumerable: true,
2611
+ get: function() {
2612
+ return CREDENTIAL_MISSING_MESSAGE;
2613
+ }
2614
+ });
2615
+ Object.defineProperty(exports, "CREDENTIAL_UNAVAILABLE_MESSAGE", {
2616
+ enumerable: true,
2617
+ get: function() {
2618
+ return CREDENTIAL_UNAVAILABLE_MESSAGE;
2619
+ }
2620
+ });
1599
2621
  Object.defineProperty(exports, "ClaudeCodeHost", {
1600
2622
  enumerable: true,
1601
2623
  get: function() {
1602
2624
  return ClaudeCodeHost;
1603
2625
  }
1604
2626
  });
2627
+ Object.defineProperty(exports, "CredentialStatusReporter", {
2628
+ enumerable: true,
2629
+ get: function() {
2630
+ return CredentialStatusReporter;
2631
+ }
2632
+ });
1605
2633
  Object.defineProperty(exports, "DEFAULT_ALLOWED_TOOLS", {
1606
2634
  enumerable: true,
1607
2635
  get: function() {
@@ -1614,12 +2642,36 @@ Object.defineProperty(exports, "DEFAULT_PERMISSION_MODE", {
1614
2642
  return DEFAULT_PERMISSION_MODE;
1615
2643
  }
1616
2644
  });
2645
+ Object.defineProperty(exports, "LOCAL_LOGIN_FAILED_MESSAGE", {
2646
+ enumerable: true,
2647
+ get: function() {
2648
+ return LOCAL_LOGIN_FAILED_MESSAGE;
2649
+ }
2650
+ });
2651
+ Object.defineProperty(exports, "MANAGED_ALLOWED_TOOLS", {
2652
+ enumerable: true,
2653
+ get: function() {
2654
+ return MANAGED_ALLOWED_TOOLS;
2655
+ }
2656
+ });
2657
+ Object.defineProperty(exports, "MANAGED_DISALLOWED_TOOLS", {
2658
+ enumerable: true,
2659
+ get: function() {
2660
+ return MANAGED_DISALLOWED_TOOLS;
2661
+ }
2662
+ });
1617
2663
  Object.defineProperty(exports, "SCRUBBED_ANTHROPIC_ENV_VARS", {
1618
2664
  enumerable: true,
1619
2665
  get: function() {
1620
2666
  return SCRUBBED_ANTHROPIC_ENV_VARS;
1621
2667
  }
1622
2668
  });
2669
+ Object.defineProperty(exports, "STATUS_REPORT_INTERVAL_MS", {
2670
+ enumerable: true,
2671
+ get: function() {
2672
+ return STATUS_REPORT_INTERVAL_MS;
2673
+ }
2674
+ });
1623
2675
  Object.defineProperty(exports, "SessionDriver", {
1624
2676
  enumerable: true,
1625
2677
  get: function() {
@@ -1662,6 +2714,12 @@ Object.defineProperty(exports, "buildChildEnv", {
1662
2714
  return buildChildEnv;
1663
2715
  }
1664
2716
  });
2717
+ Object.defineProperty(exports, "buildProbeArgs", {
2718
+ enumerable: true,
2719
+ get: function() {
2720
+ return buildProbeArgs;
2721
+ }
2722
+ });
1665
2723
  Object.defineProperty(exports, "collectTurnActivityRecords", {
1666
2724
  enumerable: true,
1667
2725
  get: function() {
@@ -1686,12 +2744,24 @@ Object.defineProperty(exports, "getSession", {
1686
2744
  return getSession;
1687
2745
  }
1688
2746
  });
2747
+ Object.defineProperty(exports, "isAuthFailureText", {
2748
+ enumerable: true,
2749
+ get: function() {
2750
+ return isAuthFailureText;
2751
+ }
2752
+ });
1689
2753
  Object.defineProperty(exports, "listSessions", {
1690
2754
  enumerable: true,
1691
2755
  get: function() {
1692
2756
  return listSessions;
1693
2757
  }
1694
2758
  });
2759
+ Object.defineProperty(exports, "loadCredentialWithRetry", {
2760
+ enumerable: true,
2761
+ get: function() {
2762
+ return loadCredentialWithRetry;
2763
+ }
2764
+ });
1695
2765
  Object.defineProperty(exports, "mapStreamEvent", {
1696
2766
  enumerable: true,
1697
2767
  get: function() {
@@ -1704,12 +2774,24 @@ Object.defineProperty(exports, "mapStreamEvents", {
1704
2774
  return mapStreamEvents;
1705
2775
  }
1706
2776
  });
2777
+ Object.defineProperty(exports, "parseProbeOutput", {
2778
+ enumerable: true,
2779
+ get: function() {
2780
+ return parseProbeOutput;
2781
+ }
2782
+ });
1707
2783
  Object.defineProperty(exports, "resolveHostConfig", {
1708
2784
  enumerable: true,
1709
2785
  get: function() {
1710
2786
  return resolveHostConfig;
1711
2787
  }
1712
2788
  });
2789
+ Object.defineProperty(exports, "runAuthProbe", {
2790
+ enumerable: true,
2791
+ get: function() {
2792
+ return runAuthProbe;
2793
+ }
2794
+ });
1713
2795
  Object.defineProperty(exports, "saveSession", {
1714
2796
  enumerable: true,
1715
2797
  get: function() {