@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.
- package/dist/cli.js +1 -1
- package/dist/host-config.cjs +1592 -96
- package/dist/host-config.js +1444 -98
- package/dist/index.cjs +25 -0
- package/dist/index.d.cts +535 -46
- package/dist/index.d.ts +535 -46
- package/dist/index.js +2 -2
- package/package.json +4 -4
package/dist/host-config.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Every turn runs on exactly ONE auth source (`ChildAuth`), resolved by the
|
|
17
|
+
* host (see DEVELOPING.md "Auth source resolution"):
|
|
18
|
+
*
|
|
19
|
+
* - `credential` — the agent's stored Claude credential, set as exactly one
|
|
20
|
+
* variable: `CLAUDE_CODE_OAUTH_TOKEN` (subscription token from
|
|
21
|
+
* `claude setup-token`) or `ANTHROPIC_API_KEY` (Anthropic API key).
|
|
22
|
+
* - `local_login` — self-hosted only: no auth variable, so `claude` uses the
|
|
23
|
+
* machine's own login (and whatever provider the operator configured).
|
|
24
|
+
* - `ai_proxy` — the Alfe AI proxy via the daemon's loopback proxy:
|
|
25
|
+
* `ANTHROPIC_BASE_URL` = the local proxy, `ANTHROPIC_AUTH_TOKEN` = a
|
|
26
|
+
* placeholder the local proxy replaces with the daemon's agent key, and
|
|
27
|
+
* every model alias pinned to an Alfe registry id (the proxy routes exact
|
|
28
|
+
* registry ids only; the CLI's built-in aliases resolve to dated ids that
|
|
29
|
+
* are a 409 there).
|
|
17
30
|
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
|
877
|
-
*
|
|
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
|
|
883
|
-
*
|
|
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
|
-
|
|
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.
|
|
1301
|
-
|
|
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.
|
|
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
|
-
...
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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]
|
|
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.
|
|
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
|
-
*
|
|
1570
|
-
*
|
|
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
|
|
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:
|
|
1581
|
-
mcpConfigPath: join(
|
|
2897
|
+
workspace: resolveAgentWorkspace("claude-code", home),
|
|
2898
|
+
mcpConfigPath: join(home, "mcp-config.json"),
|
|
1582
2899
|
claudeSessionsPath: join(homedir(), ".alfe", "claude-code", "sessions.json"),
|
|
1583
|
-
...
|
|
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 {
|
|
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 };
|