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