@alfe.ai/claude-code-host 0.1.0
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.cjs +49 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +50 -0
- package/dist/host-config.cjs +1346 -0
- package/dist/host-config.js +1221 -0
- package/dist/index.cjs +23 -0
- package/dist/index.d.cts +2055 -0
- package/dist/index.d.ts +2055 -0
- package/dist/index.js +2 -0
- package/package.json +46 -0
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
2
|
+
import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
|
|
3
|
+
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { resolveConfig } from "@alfe.ai/config";
|
|
10
|
+
//#region src/child-env.ts
|
|
11
|
+
/**
|
|
12
|
+
* Child-process environment construction for spawned `claude` CLI processes.
|
|
13
|
+
*
|
|
14
|
+
* TWO non-negotiable invariants (Phase 2 plan, audit B2 + the hard
|
|
15
|
+
* constraints):
|
|
16
|
+
*
|
|
17
|
+
* 1. **Scrub `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` /
|
|
18
|
+
* `ANTHROPIC_AUTH_TOKEN`.** A self-hosted Mac very often has these set (for
|
|
19
|
+
* other tooling). If ANY of them reaches `claude`, the CLI silently switches
|
|
20
|
+
* from the user's Claude subscription (OAuth) to API-key/proxy auth — which
|
|
21
|
+
* is exactly what this whole runtime exists to avoid (no Agent SDK, no
|
|
22
|
+
* proxy, subscription only). The break is SILENT: the turn "works" but bills
|
|
23
|
+
* the wrong plane. So we strip them unconditionally from the child env.
|
|
24
|
+
*
|
|
25
|
+
* 2. **Set `ALFE_CLAUDE_BRIDGE=1`.** The Phase-1 CLI hooks
|
|
26
|
+
* (`alfe claude hook ...` in `packages/cli/src/commands/claude.ts`) check
|
|
27
|
+
* `process.env.ALFE_CLAUDE_BRIDGE` FIRST and no-op when set, so the host —
|
|
28
|
+
* not the interactive-session hooks — owns memory recall/capture for bridged
|
|
29
|
+
* turns. Omitting this double-captures every message. The env var NAME is a
|
|
30
|
+
* cross-phase contract; do not rename without updating the CLI.
|
|
31
|
+
*/
|
|
32
|
+
/** Anthropic env vars that force API-key / proxy auth and MUST be scrubbed. */
|
|
33
|
+
const SCRUBBED_ANTHROPIC_ENV_VARS = [
|
|
34
|
+
"ANTHROPIC_API_KEY",
|
|
35
|
+
"ANTHROPIC_BASE_URL",
|
|
36
|
+
"ANTHROPIC_AUTH_TOKEN"
|
|
37
|
+
];
|
|
38
|
+
/** Cross-phase contract marker (see header + packages/cli/src/commands/claude.ts). */
|
|
39
|
+
const BRIDGE_ENV_VAR = "ALFE_CLAUDE_BRIDGE";
|
|
40
|
+
/**
|
|
41
|
+
* Build the env handed to a spawned `claude` child. Pure — takes the base env
|
|
42
|
+
* explicitly so it unit-tests without touching `process.env`.
|
|
43
|
+
*
|
|
44
|
+
* - Every scrubbed Anthropic var is DELETED (not blanked) so `claude`'s
|
|
45
|
+
* env-detection treats it as absent.
|
|
46
|
+
* - `ALFE_CLAUDE_BRIDGE=1` is set.
|
|
47
|
+
* - Any caller-supplied `extra` entries are layered last (e.g. a per-turn
|
|
48
|
+
* marker) but can NEVER re-introduce a scrubbed var — the scrub runs after
|
|
49
|
+
* the merge as the final gate.
|
|
50
|
+
*/
|
|
51
|
+
function buildChildEnv(base, extra) {
|
|
52
|
+
const merged = {
|
|
53
|
+
...base,
|
|
54
|
+
...extra,
|
|
55
|
+
[BRIDGE_ENV_VAR]: "1"
|
|
56
|
+
};
|
|
57
|
+
const scrubbed = new Set(SCRUBBED_ANTHROPIC_ENV_VARS);
|
|
58
|
+
const env = {};
|
|
59
|
+
for (const [key, value] of Object.entries(merged)) if (!scrubbed.has(key)) env[key] = value;
|
|
60
|
+
return env;
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/stream-mapper.ts
|
|
64
|
+
function createMapperState() {
|
|
65
|
+
return {
|
|
66
|
+
claudeSessionId: null,
|
|
67
|
+
seq: 0,
|
|
68
|
+
visibleText: "",
|
|
69
|
+
pendingTools: /* @__PURE__ */ new Map(),
|
|
70
|
+
ended: false
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const MAX_ARGS_CHARS = 2e3;
|
|
74
|
+
const MAX_RESULT_CHARS = 4e3;
|
|
75
|
+
function capText(value, max) {
|
|
76
|
+
if (value.length <= max) return {
|
|
77
|
+
text: value,
|
|
78
|
+
truncated: false
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
text: value.slice(0, max),
|
|
82
|
+
truncated: true
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function safeJson(value, max) {
|
|
86
|
+
if (value === void 0 || value === null) return void 0;
|
|
87
|
+
let raw;
|
|
88
|
+
if (typeof value === "string") raw = value;
|
|
89
|
+
else try {
|
|
90
|
+
raw = JSON.stringify(value);
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!raw) return void 0;
|
|
95
|
+
return capText(raw, max);
|
|
96
|
+
}
|
|
97
|
+
function asRecord(v) {
|
|
98
|
+
return v && typeof v === "object" ? v : void 0;
|
|
99
|
+
}
|
|
100
|
+
function asString(v) {
|
|
101
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Extract text from a tool_result `content` field, which can be a plain string
|
|
105
|
+
* or an array of `{type:'text', text}` parts (Anthropic block form).
|
|
106
|
+
*/
|
|
107
|
+
function toolResultText(content) {
|
|
108
|
+
if (typeof content === "string") return content;
|
|
109
|
+
if (Array.isArray(content)) return content.map((c) => {
|
|
110
|
+
const r = asRecord(c);
|
|
111
|
+
return r?.type === "text" && typeof r.text === "string" ? r.text : "";
|
|
112
|
+
}).join("");
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Map ONE stream-json event → zero or more emissions, mutating `state`.
|
|
116
|
+
*
|
|
117
|
+
* `nowMs` lets the host stamp real wall-clock timestamps (Claude Code events
|
|
118
|
+
* don't all carry ms epochs); the fixture test injects a fixed clock.
|
|
119
|
+
*/
|
|
120
|
+
function mapStreamEvent(state, raw, nowMs) {
|
|
121
|
+
const evt = asRecord(raw);
|
|
122
|
+
if (!evt) return [];
|
|
123
|
+
const type = evt.type;
|
|
124
|
+
if (type === "system" && evt.subtype === "init") {
|
|
125
|
+
const sid = asString(evt.session_id);
|
|
126
|
+
if (sid) state.claudeSessionId = sid;
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
if (type === "stream_event") {
|
|
130
|
+
const inner = asRecord(evt.event);
|
|
131
|
+
if (!inner) return [];
|
|
132
|
+
if (inner.type === "content_block_delta") {
|
|
133
|
+
const delta = asRecord(inner.delta);
|
|
134
|
+
if (!delta) return [];
|
|
135
|
+
if (delta.type === "thinking_delta") {
|
|
136
|
+
const text = asString(delta.thinking);
|
|
137
|
+
if (!text) return [];
|
|
138
|
+
return [{
|
|
139
|
+
type: "chat-activity",
|
|
140
|
+
seq: state.seq++,
|
|
141
|
+
ts: nowMs,
|
|
142
|
+
activity: {
|
|
143
|
+
kind: "thinking",
|
|
144
|
+
text,
|
|
145
|
+
delta: true,
|
|
146
|
+
ts: nowMs
|
|
147
|
+
}
|
|
148
|
+
}];
|
|
149
|
+
}
|
|
150
|
+
if (delta.type === "text_delta") {
|
|
151
|
+
const text = asString(delta.text);
|
|
152
|
+
if (text === void 0) return [];
|
|
153
|
+
state.visibleText += text;
|
|
154
|
+
return [{
|
|
155
|
+
type: "chat-delta",
|
|
156
|
+
seq: state.seq++,
|
|
157
|
+
ts: nowMs,
|
|
158
|
+
text: state.visibleText
|
|
159
|
+
}];
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
if (type === "assistant") {
|
|
166
|
+
const content = asRecord(evt.message)?.content;
|
|
167
|
+
if (!Array.isArray(content)) return [];
|
|
168
|
+
const emissions = [];
|
|
169
|
+
for (const block of content) {
|
|
170
|
+
const b = asRecord(block);
|
|
171
|
+
if (b?.type !== "tool_use") continue;
|
|
172
|
+
const id = asString(b.id);
|
|
173
|
+
const name = asString(b.name);
|
|
174
|
+
if (!id || !name) continue;
|
|
175
|
+
const args = safeJson(b.input, MAX_ARGS_CHARS);
|
|
176
|
+
state.pendingTools.set(id, {
|
|
177
|
+
name,
|
|
178
|
+
argsText: args?.text,
|
|
179
|
+
argsTruncated: args?.truncated,
|
|
180
|
+
startTs: nowMs
|
|
181
|
+
});
|
|
182
|
+
const activity = {
|
|
183
|
+
kind: "tool",
|
|
184
|
+
toolCallId: id,
|
|
185
|
+
name,
|
|
186
|
+
status: "running",
|
|
187
|
+
...args ? { argsText: args.text } : {},
|
|
188
|
+
...args?.truncated ? { truncated: { args: true } } : {},
|
|
189
|
+
ts: nowMs
|
|
190
|
+
};
|
|
191
|
+
emissions.push({
|
|
192
|
+
type: "chat-activity",
|
|
193
|
+
seq: state.seq++,
|
|
194
|
+
ts: nowMs,
|
|
195
|
+
activity
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return emissions;
|
|
199
|
+
}
|
|
200
|
+
if (type === "user") {
|
|
201
|
+
const content = asRecord(evt.message)?.content;
|
|
202
|
+
if (!Array.isArray(content)) return [];
|
|
203
|
+
const emissions = [];
|
|
204
|
+
for (const block of content) {
|
|
205
|
+
const b = asRecord(block);
|
|
206
|
+
if (b?.type !== "tool_result") continue;
|
|
207
|
+
const toolUseId = asString(b.tool_use_id);
|
|
208
|
+
if (!toolUseId) continue;
|
|
209
|
+
const pending = state.pendingTools.get(toolUseId);
|
|
210
|
+
const isError = b.is_error === true;
|
|
211
|
+
const resultRaw = toolResultText(b.content);
|
|
212
|
+
const result = resultRaw !== void 0 ? capText(resultRaw, MAX_RESULT_CHARS) : void 0;
|
|
213
|
+
const durationMs = pending ? Math.max(0, nowMs - pending.startTs) : void 0;
|
|
214
|
+
const truncated = {};
|
|
215
|
+
if (pending?.argsTruncated) truncated.args = true;
|
|
216
|
+
if (result?.truncated) truncated.result = true;
|
|
217
|
+
const activity = {
|
|
218
|
+
kind: "tool",
|
|
219
|
+
toolCallId: toolUseId,
|
|
220
|
+
name: pending?.name ?? "tool",
|
|
221
|
+
status: isError ? "failed" : "done",
|
|
222
|
+
...pending?.argsText ? { argsText: pending.argsText } : {},
|
|
223
|
+
...result ? { resultText: result.text } : {},
|
|
224
|
+
isError,
|
|
225
|
+
...durationMs !== void 0 ? { durationMs } : {},
|
|
226
|
+
...truncated.args || truncated.result ? { truncated } : {},
|
|
227
|
+
ts: nowMs
|
|
228
|
+
};
|
|
229
|
+
state.pendingTools.delete(toolUseId);
|
|
230
|
+
emissions.push({
|
|
231
|
+
type: "chat-activity",
|
|
232
|
+
seq: state.seq++,
|
|
233
|
+
ts: nowMs,
|
|
234
|
+
activity
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return emissions;
|
|
238
|
+
}
|
|
239
|
+
if (type === "result") {
|
|
240
|
+
state.ended = true;
|
|
241
|
+
const isError = evt.is_error === true;
|
|
242
|
+
const finalText = asString(evt.result) ?? state.visibleText;
|
|
243
|
+
const durationRaw = evt.duration_ms;
|
|
244
|
+
const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : void 0;
|
|
245
|
+
const errorMessage = isError ? asString(evt.result) ?? asString(evt.subtype) ?? "Claude Code turn failed" : void 0;
|
|
246
|
+
return [{
|
|
247
|
+
type: "turn-end",
|
|
248
|
+
seq: state.seq++,
|
|
249
|
+
ts: nowMs,
|
|
250
|
+
isError,
|
|
251
|
+
finalText: isError ? "" : finalText,
|
|
252
|
+
...durationMs !== void 0 ? { durationMs } : {},
|
|
253
|
+
...errorMessage ? { errorMessage } : {}
|
|
254
|
+
}];
|
|
255
|
+
}
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Convenience: run the reducer over a whole array of parsed events (e.g. a
|
|
260
|
+
* fixture). Returns the final state + all emissions in order. The host does NOT
|
|
261
|
+
* use this at runtime (it maps line-by-line as they stream), but it's the
|
|
262
|
+
* cleanest thing to assert against in a fixture test.
|
|
263
|
+
*/
|
|
264
|
+
function mapStreamEvents(events, clock = () => Date.now()) {
|
|
265
|
+
const state = createMapperState();
|
|
266
|
+
const emissions = [];
|
|
267
|
+
for (const evt of events) for (const e of mapStreamEvent(state, evt, clock())) emissions.push(e);
|
|
268
|
+
return {
|
|
269
|
+
state,
|
|
270
|
+
emissions
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Terminalize the mapper's in-turn activity into persisted `ChatActivityRecord`
|
|
275
|
+
* form (session store). Tools still pending at flush time (no tool_result seen)
|
|
276
|
+
* are persisted as `interrupted`. Thinking deltas are concatenated into ONE
|
|
277
|
+
* record. This mirrors openclaw-chat's per-turn accumulator flush.
|
|
278
|
+
*/
|
|
279
|
+
function collectTurnActivityRecords(emissions) {
|
|
280
|
+
const thinking = {
|
|
281
|
+
text: "",
|
|
282
|
+
ts: 0
|
|
283
|
+
};
|
|
284
|
+
const tools = /* @__PURE__ */ new Map();
|
|
285
|
+
for (const e of emissions) {
|
|
286
|
+
if (e.type !== "chat-activity") continue;
|
|
287
|
+
if (e.activity.kind === "thinking") {
|
|
288
|
+
if (!thinking.text) thinking.ts = e.ts;
|
|
289
|
+
thinking.text += e.activity.text;
|
|
290
|
+
} else {
|
|
291
|
+
const a = e.activity;
|
|
292
|
+
const prev = tools.get(a.toolCallId);
|
|
293
|
+
const status = a.status === "running" ? prev?.status ?? "interrupted" : a.status;
|
|
294
|
+
tools.set(a.toolCallId, {
|
|
295
|
+
kind: "tool",
|
|
296
|
+
toolCallId: a.toolCallId,
|
|
297
|
+
name: a.name,
|
|
298
|
+
status,
|
|
299
|
+
...prev?.argsText ?? a.argsText ? { argsText: prev?.argsText ?? a.argsText } : {},
|
|
300
|
+
...a.resultText ?? prev?.resultText ? { resultText: a.resultText ?? prev?.resultText } : {},
|
|
301
|
+
...(a.isError ?? prev?.isError) !== void 0 ? { isError: a.isError ?? prev?.isError } : {},
|
|
302
|
+
...(a.durationMs ?? prev?.durationMs) !== void 0 ? { durationMs: a.durationMs ?? prev?.durationMs } : {},
|
|
303
|
+
...a.truncated ?? prev?.truncated ? { truncated: a.truncated ?? prev?.truncated } : {},
|
|
304
|
+
ts: prev?.ts ?? a.ts ?? 0
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const records = [];
|
|
309
|
+
if (thinking.text) records.push({
|
|
310
|
+
kind: "thinking",
|
|
311
|
+
text: thinking.text,
|
|
312
|
+
ts: thinking.ts
|
|
313
|
+
});
|
|
314
|
+
for (const tool of tools.values()) records.push(tool);
|
|
315
|
+
records.sort((a, b) => a.ts - b.ts);
|
|
316
|
+
return records;
|
|
317
|
+
}
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/session-driver.ts
|
|
320
|
+
/**
|
|
321
|
+
* SessionDriver — spawns the `claude` CLI and turns its `--output-format
|
|
322
|
+
* stream-json` output into the mapper emissions the host forwards to chat.
|
|
323
|
+
*
|
|
324
|
+
* TWO modes (Phase 2 plan, decision 2026-07-12):
|
|
325
|
+
*
|
|
326
|
+
* 1. **chat (per-turn spawn):** each Alfe turn spawns a fresh
|
|
327
|
+
* `claude -p <prompt> --resume <claudeSessionId> --output-format stream-json
|
|
328
|
+
* --include-partial-messages ...`, streams its stdout line-by-line through
|
|
329
|
+
* `mapStreamEvent`, and resolves when the terminal `result` event lands (or
|
|
330
|
+
* the child exits). Each conversation carries its own claude session lineage
|
|
331
|
+
* (via `--resume`), so distinct conversations run as distinct subprocesses —
|
|
332
|
+
* real concurrency. This is the ONLY mode the chat path uses.
|
|
333
|
+
*
|
|
334
|
+
* 2. **voice (warm-streaming):** a single persistent `claude` process is held
|
|
335
|
+
* for the call and fed utterances over stdin as `--input-format stream-json`
|
|
336
|
+
* user messages, so each utterance skips the per-turn cold start.
|
|
337
|
+
* See `VERIFICATION` below — on the currently-pinned CLI, streaming-input
|
|
338
|
+
* did NOT behave as a persistent multi-turn channel in a headless
|
|
339
|
+
* `-p`/`--print` run, so this mode FALLS BACK to per-turn spawn and is
|
|
340
|
+
* documented as such. The seam is retained so a future CLI that supports a
|
|
341
|
+
* persistent stdin channel can light it up without touching the host.
|
|
342
|
+
*
|
|
343
|
+
* The driver performs NO chat-protocol work — it only runs `claude` and yields
|
|
344
|
+
* `MapperEmission`s + the resolved claude session id. The host owns all wire
|
|
345
|
+
* shapes, session-store writes, and memory calls.
|
|
346
|
+
*
|
|
347
|
+
* ── VERIFICATION (`--input-format stream-json`, 2026-07-12) ──
|
|
348
|
+
* `claude -p --input-format stream-json --output-format stream-json` accepts a
|
|
349
|
+
* newline-delimited stream of `{"type":"user","message":{...}}` frames on
|
|
350
|
+
* stdin, but in `-p`/`--print` (headless) mode the process still terminates
|
|
351
|
+
* after the FIRST turn's `result` — it does not stay open awaiting a second
|
|
352
|
+
* user frame the way an interactive session does. There is no headless flag to
|
|
353
|
+
* keep the process resident across turns on the pinned CLI. So warm-streaming
|
|
354
|
+
* cannot preserve one continuous multi-turn `claude` process today; the voice
|
|
355
|
+
* mode therefore uses the SAME per-turn spawn as chat (subscription auth +
|
|
356
|
+
* correct session lineage are preserved because `--resume` threads the claude
|
|
357
|
+
* session id). If a future CLI adds a resident headless stdin channel, flip
|
|
358
|
+
* `WARM_STREAMING_SUPPORTED` and implement `runWarmTurn`.
|
|
359
|
+
*/
|
|
360
|
+
/**
|
|
361
|
+
* `--input-format stream-json` does not keep a headless `claude -p` process
|
|
362
|
+
* resident across turns on the pinned CLI (see header VERIFICATION). Until a
|
|
363
|
+
* CLI ships a resident headless stdin channel, warm-streaming falls back to
|
|
364
|
+
* per-turn spawn. Flip this (and implement a resident path) when that lands.
|
|
365
|
+
*/
|
|
366
|
+
const WARM_STREAMING_SUPPORTED = false;
|
|
367
|
+
/** The `claude` binary name. Overridable for tests via the constructor. */
|
|
368
|
+
const DEFAULT_CLAUDE_BIN = "claude";
|
|
369
|
+
/**
|
|
370
|
+
* Restricted tool set for unattended bridged turns (Phase 2 tool-permission
|
|
371
|
+
* posture). An inbound chat message could try to induce a destructive shell
|
|
372
|
+
* command, and these turns run unattended on the user's machine. We therefore
|
|
373
|
+
* pass an explicit allow-list of read/analysis/edit tools and DENY Bash by
|
|
374
|
+
* default, and use `--permission-mode default` (NEVER `bypassPermissions` /
|
|
375
|
+
* `--dangerously-skip-permissions`). See DEVELOPING.md → "Tool-permission
|
|
376
|
+
* posture". Bash is intentionally absent — a bridged turn cannot run arbitrary
|
|
377
|
+
* shell. Callers that want a wider set pass `allowedTools` explicitly.
|
|
378
|
+
*/
|
|
379
|
+
const DEFAULT_ALLOWED_TOOLS = [
|
|
380
|
+
"Read",
|
|
381
|
+
"Grep",
|
|
382
|
+
"Glob",
|
|
383
|
+
"LS",
|
|
384
|
+
"Edit",
|
|
385
|
+
"Write",
|
|
386
|
+
"WebFetch",
|
|
387
|
+
"WebSearch",
|
|
388
|
+
"Task",
|
|
389
|
+
"TodoWrite"
|
|
390
|
+
];
|
|
391
|
+
/** Sane default permission posture. NEVER `bypassPermissions`. */
|
|
392
|
+
const DEFAULT_PERMISSION_MODE = "default";
|
|
393
|
+
/**
|
|
394
|
+
* Hard cap on a single turn (E2). Deliberately BELOW the chat service's RPC
|
|
395
|
+
* timeout (`AGENT_TIMEOUT_SEC=300` → 305s in daemon-server) so the host settles
|
|
396
|
+
* a maxed-out turn *before* the caller rejects it — an equal cap races the
|
|
397
|
+
* caller and can surface a spurious "stuck then error" flash. Inject-origin
|
|
398
|
+
* turns have an even shorter caller timeout (~125s); those may over-run to this
|
|
399
|
+
* cap, but the late settle is harmlessly no-op'd by the chat service.
|
|
400
|
+
*/
|
|
401
|
+
const TURN_TIMEOUT_MS = 3e5;
|
|
402
|
+
/** Substrings that mark a subscription rate-limit → fail fast (E2). */
|
|
403
|
+
const RATE_LIMIT_MARKERS = [
|
|
404
|
+
"rate limit",
|
|
405
|
+
"rate_limit",
|
|
406
|
+
"429",
|
|
407
|
+
"overloaded"
|
|
408
|
+
];
|
|
409
|
+
function looksRateLimited(text) {
|
|
410
|
+
const lower = text.toLowerCase();
|
|
411
|
+
return RATE_LIMIT_MARKERS.some((m) => lower.includes(m));
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Spawns and drives `claude` subprocesses. Tracks a SINGLE in-flight child, so
|
|
415
|
+
* the host creates ONE driver PER TURN (`ClaudeCodeHost.makeDriver`) — a driver
|
|
416
|
+
* shared across concurrent sessions would let the last spawn overwrite the
|
|
417
|
+
* `child` slot and cross-abort / mis-probe the others.
|
|
418
|
+
*/
|
|
419
|
+
var SessionDriver = class {
|
|
420
|
+
child = null;
|
|
421
|
+
bin;
|
|
422
|
+
baseEnv;
|
|
423
|
+
log;
|
|
424
|
+
/** Injected spawn — tests replace this with a fake child. */
|
|
425
|
+
spawnImpl;
|
|
426
|
+
constructor(opts) {
|
|
427
|
+
this.bin = opts?.claudeBin ?? DEFAULT_CLAUDE_BIN;
|
|
428
|
+
this.baseEnv = opts?.baseEnv ?? process.env;
|
|
429
|
+
this.log = opts?.logger ?? console;
|
|
430
|
+
this.spawnImpl = opts?.__spawn ?? spawn;
|
|
431
|
+
}
|
|
432
|
+
/** True while a `claude` child is running — backs the host's B4 drain probe. */
|
|
433
|
+
get busy() {
|
|
434
|
+
return this.child !== null;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Abort the in-flight turn by SIGINT-ing the child (B3). No-op if idle. The
|
|
438
|
+
* child's exit drives `runTurn` to settle with `aborted: true`.
|
|
439
|
+
*/
|
|
440
|
+
abort() {
|
|
441
|
+
if (this.child) try {
|
|
442
|
+
this.child.kill("SIGINT");
|
|
443
|
+
} catch {}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Run ONE turn. Modes reconcile to the same per-turn spawn (see header
|
|
447
|
+
* VERIFICATION) — `voice` is accepted for API symmetry and future warm mode.
|
|
448
|
+
*/
|
|
449
|
+
async runTurn(params, mode = "chat") {
|
|
450
|
+
return this.runPerTurn(params);
|
|
451
|
+
}
|
|
452
|
+
buildArgs(params) {
|
|
453
|
+
const allowed = (params.allowedTools ?? DEFAULT_ALLOWED_TOOLS).join(",");
|
|
454
|
+
const args = [
|
|
455
|
+
"-p",
|
|
456
|
+
params.prompt,
|
|
457
|
+
"--output-format",
|
|
458
|
+
"stream-json",
|
|
459
|
+
"--include-partial-messages",
|
|
460
|
+
"--verbose",
|
|
461
|
+
"--permission-mode",
|
|
462
|
+
params.permissionMode ?? "default",
|
|
463
|
+
"--allowedTools",
|
|
464
|
+
allowed
|
|
465
|
+
];
|
|
466
|
+
if (params.claudeSessionId) args.push("--resume", params.claudeSessionId);
|
|
467
|
+
if (params.mcpConfigPath) args.push("--mcp-config", params.mcpConfigPath);
|
|
468
|
+
if (params.appendSystemPrompt) args.push("--append-system-prompt", params.appendSystemPrompt);
|
|
469
|
+
return args;
|
|
470
|
+
}
|
|
471
|
+
runPerTurn(params) {
|
|
472
|
+
const now = params.now ?? (() => Date.now());
|
|
473
|
+
const state = createMapperState();
|
|
474
|
+
const emissions = [];
|
|
475
|
+
let aborted = false;
|
|
476
|
+
let settled = false;
|
|
477
|
+
const stderrChunks = [];
|
|
478
|
+
const args = this.buildArgs(params);
|
|
479
|
+
const env = buildChildEnv(this.baseEnv);
|
|
480
|
+
return new Promise((resolve) => {
|
|
481
|
+
let child;
|
|
482
|
+
try {
|
|
483
|
+
child = this.spawnImpl(this.bin, args, {
|
|
484
|
+
cwd: params.cwd,
|
|
485
|
+
env,
|
|
486
|
+
stdio: [
|
|
487
|
+
"pipe",
|
|
488
|
+
"pipe",
|
|
489
|
+
"pipe"
|
|
490
|
+
]
|
|
491
|
+
});
|
|
492
|
+
} catch (err) {
|
|
493
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
494
|
+
resolve({
|
|
495
|
+
claudeSessionId: state.claudeSessionId,
|
|
496
|
+
emissions,
|
|
497
|
+
isError: true,
|
|
498
|
+
finalText: "",
|
|
499
|
+
errorMessage: `Failed to start claude: ${message}`,
|
|
500
|
+
aborted: false
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.child = child;
|
|
505
|
+
const timeout = setTimeout(() => {
|
|
506
|
+
this.log.warn("[session-driver] turn exceeded the time cap — aborting");
|
|
507
|
+
try {
|
|
508
|
+
child.kill("SIGINT");
|
|
509
|
+
} catch {}
|
|
510
|
+
finish(true, "Turn exceeded the time cap");
|
|
511
|
+
}, TURN_TIMEOUT_MS);
|
|
512
|
+
timeout.unref();
|
|
513
|
+
const finish = (isError, errorMessage) => {
|
|
514
|
+
if (settled) return;
|
|
515
|
+
settled = true;
|
|
516
|
+
clearTimeout(timeout);
|
|
517
|
+
this.child = null;
|
|
518
|
+
const end = emissions.find((e) => e.type === "turn-end");
|
|
519
|
+
const endIsError = end?.type === "turn-end" ? end.isError : false;
|
|
520
|
+
const endText = end?.type === "turn-end" ? end.finalText : state.visibleText;
|
|
521
|
+
const endErrMsg = end?.type === "turn-end" ? end.errorMessage : void 0;
|
|
522
|
+
const stderr = stderrChunks.join("");
|
|
523
|
+
const finalIsError = isError || endIsError;
|
|
524
|
+
const finalErrMsg = errorMessage ?? endErrMsg ?? (finalIsError ? looksRateLimited(stderr) ? "Claude subscription rate limit hit — try again shortly" : stderr.trim().slice(0, 500) || "Claude Code turn failed" : void 0);
|
|
525
|
+
resolve({
|
|
526
|
+
claudeSessionId: state.claudeSessionId,
|
|
527
|
+
emissions,
|
|
528
|
+
isError: finalIsError,
|
|
529
|
+
finalText: finalIsError ? "" : endText,
|
|
530
|
+
...finalErrMsg ? { errorMessage: finalErrMsg } : {},
|
|
531
|
+
aborted
|
|
532
|
+
});
|
|
533
|
+
};
|
|
534
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
535
|
+
const trimmed = line.trim();
|
|
536
|
+
if (!trimmed) return;
|
|
537
|
+
let evt;
|
|
538
|
+
try {
|
|
539
|
+
evt = JSON.parse(trimmed);
|
|
540
|
+
} catch {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
for (const emission of mapStreamEvent(state, evt, now())) {
|
|
544
|
+
emissions.push(emission);
|
|
545
|
+
try {
|
|
546
|
+
params.onEmission(emission);
|
|
547
|
+
} catch (err) {
|
|
548
|
+
this.log.warn(`[session-driver] onEmission threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
child.stderr.on("data", (chunk) => {
|
|
553
|
+
stderrChunks.push(chunk.toString("utf-8"));
|
|
554
|
+
});
|
|
555
|
+
child.on("error", (err) => {
|
|
556
|
+
finish(true, `claude process error: ${err.message}`);
|
|
557
|
+
});
|
|
558
|
+
child.on("close", (code, signal) => {
|
|
559
|
+
if (signal === "SIGINT" || signal === "SIGTERM") {
|
|
560
|
+
aborted = true;
|
|
561
|
+
finish(false);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const sawResult = emissions.some((e) => e.type === "turn-end");
|
|
565
|
+
if (code !== 0 && !sawResult) {
|
|
566
|
+
finish(true);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
finish(false);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
//#endregion
|
|
575
|
+
//#region src/session/session-store.ts
|
|
576
|
+
/**
|
|
577
|
+
* Session Store — persists chat sessions to the local filesystem.
|
|
578
|
+
*
|
|
579
|
+
* COPIED from `packages/openclaw-chat/src/session-store.ts` (Phase 2 plan,
|
|
580
|
+
* audit D1-D4) and trimmed for the Claude Code host:
|
|
581
|
+
* - inherits the `messages:[]` not-found stub contract and the per-session
|
|
582
|
+
* write-lock (both load-bearing — see the warm-slot crash rationale in
|
|
583
|
+
* services/chat).
|
|
584
|
+
* - OMITS `recordRoute` / `routes` — those keyed OpenClaw's own transcript,
|
|
585
|
+
* which does NOT exist here. The Claude Code host is the sole author of
|
|
586
|
+
* history: it `addMessage`s user + assistant and `appendActivity`s per
|
|
587
|
+
* turn, so `sessions.list` / `sessions.get` and memory backfill work with
|
|
588
|
+
* no external transcript.
|
|
589
|
+
*
|
|
590
|
+
* Storage layout (IDENTICAL to openclaw-chat — the memory backfill reads these
|
|
591
|
+
* files directly):
|
|
592
|
+
* ~/.alfe/sessions/chat/{sessionId}.json
|
|
593
|
+
*
|
|
594
|
+
* where `sessionId = conversationId ?? sessionKey`.
|
|
595
|
+
*/
|
|
596
|
+
function sessionsDir() {
|
|
597
|
+
return process.env.ALFE_SESSIONS_DIR ?? join(homedir(), ".alfe", "sessions", "chat");
|
|
598
|
+
}
|
|
599
|
+
const MAX_SESSIONS = 1e3;
|
|
600
|
+
const MAX_AGE_MS = 720 * 60 * 60 * 1e3;
|
|
601
|
+
const CLEANUP_INTERVAL_MS = 36e5;
|
|
602
|
+
let lastCleanupAt = 0;
|
|
603
|
+
async function ensureDir() {
|
|
604
|
+
const dir = sessionsDir();
|
|
605
|
+
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
|
|
606
|
+
}
|
|
607
|
+
function sessionPath(sessionId) {
|
|
608
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
609
|
+
return join(sessionsDir(), `${safe}.json`);
|
|
610
|
+
}
|
|
611
|
+
async function cleanupOldSessions() {
|
|
612
|
+
if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
|
|
613
|
+
const dir = sessionsDir();
|
|
614
|
+
try {
|
|
615
|
+
const jsonFiles = (await readdir(dir)).filter((f) => f.endsWith(".json"));
|
|
616
|
+
if (jsonFiles.length <= MAX_SESSIONS) {
|
|
617
|
+
const now = Date.now();
|
|
618
|
+
for (const file of jsonFiles) try {
|
|
619
|
+
const filePath = join(dir, file);
|
|
620
|
+
if (now - (await stat(filePath)).mtimeMs > MAX_AGE_MS) await unlink(filePath);
|
|
621
|
+
} catch {}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const fileStats = [];
|
|
625
|
+
for (const file of jsonFiles) try {
|
|
626
|
+
const filePath = join(dir, file);
|
|
627
|
+
const fileStat = await stat(filePath);
|
|
628
|
+
fileStats.push({
|
|
629
|
+
path: filePath,
|
|
630
|
+
mtimeMs: fileStat.mtimeMs
|
|
631
|
+
});
|
|
632
|
+
} catch {}
|
|
633
|
+
fileStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
634
|
+
const now = Date.now();
|
|
635
|
+
let remaining = fileStats.length;
|
|
636
|
+
for (const entry of fileStats) {
|
|
637
|
+
if (!(now - entry.mtimeMs > MAX_AGE_MS) && !(remaining > MAX_SESSIONS)) break;
|
|
638
|
+
try {
|
|
639
|
+
await unlink(entry.path);
|
|
640
|
+
remaining--;
|
|
641
|
+
} catch {}
|
|
642
|
+
}
|
|
643
|
+
lastCleanupAt = Date.now();
|
|
644
|
+
} catch {}
|
|
645
|
+
}
|
|
646
|
+
async function getSession(sessionId) {
|
|
647
|
+
try {
|
|
648
|
+
const data = await readFile(sessionPath(sessionId), "utf-8");
|
|
649
|
+
return JSON.parse(data);
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async function saveSession(session) {
|
|
655
|
+
await ensureDir();
|
|
656
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
657
|
+
await writeFile(sessionPath(session.sessionId), JSON.stringify(session, null, 2), "utf-8");
|
|
658
|
+
}
|
|
659
|
+
async function createSession(sessionId, agentId, channel, tenantId, userId) {
|
|
660
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
661
|
+
const session = {
|
|
662
|
+
sessionId,
|
|
663
|
+
agentId,
|
|
664
|
+
channel,
|
|
665
|
+
tenantId,
|
|
666
|
+
userId,
|
|
667
|
+
createdAt: now,
|
|
668
|
+
updatedAt: now,
|
|
669
|
+
messages: []
|
|
670
|
+
};
|
|
671
|
+
await saveSession(session);
|
|
672
|
+
cleanupOldSessions();
|
|
673
|
+
return session;
|
|
674
|
+
}
|
|
675
|
+
const writeLocks = /* @__PURE__ */ new Map();
|
|
676
|
+
async function addMessage(sessionId, role, content, senderId, senderName, components, attachments) {
|
|
677
|
+
const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
678
|
+
const session = await getSession(sessionId);
|
|
679
|
+
if (!session) return;
|
|
680
|
+
session.messages.push({
|
|
681
|
+
role,
|
|
682
|
+
content,
|
|
683
|
+
timestamp: Date.now(),
|
|
684
|
+
...senderId ? { senderId } : {},
|
|
685
|
+
...senderName ? { senderName } : {},
|
|
686
|
+
...components?.length ? { components } : {},
|
|
687
|
+
...attachments?.length ? { attachments } : {}
|
|
688
|
+
});
|
|
689
|
+
await saveSession(session);
|
|
690
|
+
}).finally(() => {
|
|
691
|
+
if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
|
|
692
|
+
});
|
|
693
|
+
writeLocks.set(sessionId, next);
|
|
694
|
+
await next;
|
|
695
|
+
}
|
|
696
|
+
const MAX_ACTIVITY_PER_SESSION = 500;
|
|
697
|
+
const MAX_ACTIVITY_THINKING_CHARS = 16e3;
|
|
698
|
+
/**
|
|
699
|
+
* Append a turn's terminalized activity records. Called ONCE per turn (from the
|
|
700
|
+
* dispatch `finally`) — never per-delta.
|
|
701
|
+
*/
|
|
702
|
+
async function appendActivity(sessionId, entries) {
|
|
703
|
+
if (entries.length === 0) return;
|
|
704
|
+
const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
705
|
+
const session = await getSession(sessionId);
|
|
706
|
+
if (!session) return;
|
|
707
|
+
const capped = entries.map((e) => e.kind === "thinking" && e.text.length > MAX_ACTIVITY_THINKING_CHARS ? {
|
|
708
|
+
...e,
|
|
709
|
+
text: e.text.slice(0, MAX_ACTIVITY_THINKING_CHARS)
|
|
710
|
+
} : e);
|
|
711
|
+
const combined = [...session.activity ?? [], ...capped];
|
|
712
|
+
session.activity = combined.length > MAX_ACTIVITY_PER_SESSION ? combined.slice(combined.length - MAX_ACTIVITY_PER_SESSION) : combined;
|
|
713
|
+
await saveSession(session);
|
|
714
|
+
}).finally(() => {
|
|
715
|
+
if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
|
|
716
|
+
});
|
|
717
|
+
writeLocks.set(sessionId, next);
|
|
718
|
+
await next;
|
|
719
|
+
}
|
|
720
|
+
async function listSessions(filters, limit = 50) {
|
|
721
|
+
await ensureDir();
|
|
722
|
+
const dir = sessionsDir();
|
|
723
|
+
let files;
|
|
724
|
+
try {
|
|
725
|
+
files = await readdir(dir);
|
|
726
|
+
} catch {
|
|
727
|
+
return [];
|
|
728
|
+
}
|
|
729
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
730
|
+
const summaries = [];
|
|
731
|
+
for (const file of jsonFiles) try {
|
|
732
|
+
const data = await readFile(join(dir, file), "utf-8");
|
|
733
|
+
const session = JSON.parse(data);
|
|
734
|
+
if (filters?.agentId && session.agentId !== filters.agentId) continue;
|
|
735
|
+
if (filters?.channel && session.channel !== filters.channel) continue;
|
|
736
|
+
if (filters?.tenantId && session.tenantId !== filters.tenantId) continue;
|
|
737
|
+
if (filters?.userId && session.userId !== filters.userId) continue;
|
|
738
|
+
const lastMsg = session.messages.at(-1);
|
|
739
|
+
summaries.push({
|
|
740
|
+
sessionId: session.sessionId,
|
|
741
|
+
agentId: session.agentId,
|
|
742
|
+
channel: session.channel,
|
|
743
|
+
...session.tenantId ? { tenantId: session.tenantId } : {},
|
|
744
|
+
...session.userId ? { userId: session.userId } : {},
|
|
745
|
+
createdAt: session.createdAt,
|
|
746
|
+
lastMessageAt: lastMsg ? new Date(lastMsg.timestamp).toISOString() : void 0,
|
|
747
|
+
preview: lastMsg?.content.slice(0, 100),
|
|
748
|
+
messageCount: session.messages.length
|
|
749
|
+
});
|
|
750
|
+
} catch {}
|
|
751
|
+
summaries.sort((a, b) => {
|
|
752
|
+
const aTime = a.lastMessageAt ?? a.createdAt;
|
|
753
|
+
return (b.lastMessageAt ?? b.createdAt).localeCompare(aTime);
|
|
754
|
+
});
|
|
755
|
+
return summaries.slice(0, limit);
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/host.ts
|
|
759
|
+
/**
|
|
760
|
+
* ClaudeCodeHost — the persistent chat bridge for a self-hosted Claude Code
|
|
761
|
+
* agent.
|
|
762
|
+
*
|
|
763
|
+
* It holds ONE `ChatServiceClient` (reused verbatim from `@alfe.ai/chat`, with
|
|
764
|
+
* NO custom reconnect — the client already backs off with jitter and guards
|
|
765
|
+
* against orphan sockets), registers the inbound RPC handlers the chat service
|
|
766
|
+
* calls, and drives the `SessionDriver` to run `claude -p` turns on the user's
|
|
767
|
+
* subscription.
|
|
768
|
+
*
|
|
769
|
+
* Every wire shape here is byte-for-byte the shape the `@alfe.ai/openclaw-chat`
|
|
770
|
+
* plugin emits, so the chat service / adapter / UI treat a Claude Code turn
|
|
771
|
+
* identically to an OpenClaw turn. The invariant tags (B1–B4, C1–C5, E1–E3)
|
|
772
|
+
* reference the Phase 2 plan and `services/chat/src/{adapter,daemon-server}.ts`.
|
|
773
|
+
*
|
|
774
|
+
* Method dispatch (mirrors `openclaw-chat` plugin `onRequest`):
|
|
775
|
+
* - `agent` → per-claude-session FIFO queue → SessionDriver turn (C4)
|
|
776
|
+
* - `chat.abort` → SIGINT the child + settle (B3)
|
|
777
|
+
* - `sessions.list`/`sessions.get` → session-store reads (unqueued)
|
|
778
|
+
* - `runtime.activity` → drain probe: `{ active: busy }` (B4)
|
|
779
|
+
* - anything else → `sendResponse(id, false, { message })`
|
|
780
|
+
*/
|
|
781
|
+
/** Voice-mode speech-style guidance (origin:'voice'). Mirrors VOICE_REPLY_SYSTEM_PROMPT. */
|
|
782
|
+
const VOICE_REPLY_SYSTEM_PROMPT = [
|
|
783
|
+
"This message arrived over a LIVE VOICE CALL — your reply is read aloud by text-to-speech.",
|
|
784
|
+
"Speak the way a helpful person would on a phone call:",
|
|
785
|
+
"- Keep it brief and conversational — usually 1-3 short sentences.",
|
|
786
|
+
"- Plain spoken language only: no markdown, no bullet or numbered lists, no headings, no code blocks, no URLs or link syntax.",
|
|
787
|
+
"- If the full answer is long or detailed, give the short spoken version and offer to send the details to the chat.",
|
|
788
|
+
"This is guidance on speaking STYLE only. Do NOT skip tool calls you would otherwise make, and never omit information the user actually needs."
|
|
789
|
+
].join("\n");
|
|
790
|
+
/** Cross-agent tool steering (every turn). Mirrors CROSS_AGENT_TOOLS_SYSTEM_PROMPT. */
|
|
791
|
+
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");
|
|
792
|
+
/** Persona files read from the workspace, in precedence order (first wins as lead). */
|
|
793
|
+
const PERSONA_FILES = ["SOUL.md", "IDENTITY.md"];
|
|
794
|
+
/**
|
|
795
|
+
* One FIFO backlog PER claude-session key. Unlike openclaw-chat's single
|
|
796
|
+
* per-daemon queue, the Claude Code host spawns a distinct `claude -p` per
|
|
797
|
+
* conversation, so distinct sessions run CONCURRENTLY — the queue only
|
|
798
|
+
* serialises turns that target the SAME session (concurrent `--resume <sameId>`
|
|
799
|
+
* would corrupt the transcript). The queue is keyed on the RPC `sessionKey`
|
|
800
|
+
* (which is 1:1 with a conversation and thus its claude session lineage).
|
|
801
|
+
*/
|
|
802
|
+
var PerSessionQueues = class {
|
|
803
|
+
backlogs = /* @__PURE__ */ new Map();
|
|
804
|
+
draining = /* @__PURE__ */ new Set();
|
|
805
|
+
enqueue(key, task, opts) {
|
|
806
|
+
return new Promise((resolve, reject) => {
|
|
807
|
+
const entry = {
|
|
808
|
+
run: task,
|
|
809
|
+
coalesceKey: opts?.coalesceKey,
|
|
810
|
+
onSuperseded: opts?.onSuperseded ?? (() => void 0),
|
|
811
|
+
resolve,
|
|
812
|
+
reject
|
|
813
|
+
};
|
|
814
|
+
const backlog = this.backlogs.get(key) ?? [];
|
|
815
|
+
if (entry.coalesceKey) {
|
|
816
|
+
const survivors = [];
|
|
817
|
+
for (const queued of backlog) if (queued.coalesceKey === entry.coalesceKey) {
|
|
818
|
+
try {
|
|
819
|
+
queued.onSuperseded();
|
|
820
|
+
} catch {}
|
|
821
|
+
queued.resolve();
|
|
822
|
+
} else survivors.push(queued);
|
|
823
|
+
backlog.length = 0;
|
|
824
|
+
backlog.push(...survivors);
|
|
825
|
+
}
|
|
826
|
+
backlog.push(entry);
|
|
827
|
+
this.backlogs.set(key, backlog);
|
|
828
|
+
this.drain(key);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async drain(key) {
|
|
832
|
+
if (this.draining.has(key)) return;
|
|
833
|
+
this.draining.add(key);
|
|
834
|
+
try {
|
|
835
|
+
const backlog = this.backlogs.get(key);
|
|
836
|
+
while (backlog && backlog.length > 0) {
|
|
837
|
+
const entry = backlog.shift();
|
|
838
|
+
if (!entry) break;
|
|
839
|
+
try {
|
|
840
|
+
await entry.run();
|
|
841
|
+
entry.resolve();
|
|
842
|
+
} catch (err) {
|
|
843
|
+
entry.reject(err);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
} finally {
|
|
847
|
+
this.draining.delete(key);
|
|
848
|
+
if (this.backlogs.get(key)?.length === 0) this.backlogs.delete(key);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/** Test/introspection: total queued (not-yet-started) turns across all keys. */
|
|
852
|
+
backlogLength() {
|
|
853
|
+
let n = 0;
|
|
854
|
+
for (const b of this.backlogs.values()) n += b.length;
|
|
855
|
+
return n;
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
var ClaudeCodeHost = class {
|
|
859
|
+
chat;
|
|
860
|
+
/**
|
|
861
|
+
* Factory for a per-turn `SessionDriver`. A driver tracks a single in-flight
|
|
862
|
+
* `child`, so distinct sessions running concurrently (C4) each get their OWN
|
|
863
|
+
* driver — a shared singleton would cross-abort and mis-probe (its one `child`
|
|
864
|
+
* slot would be overwritten by whichever session spawned last).
|
|
865
|
+
*/
|
|
866
|
+
makeDriver;
|
|
867
|
+
api;
|
|
868
|
+
log;
|
|
869
|
+
cfg;
|
|
870
|
+
queues = new PerSessionQueues();
|
|
871
|
+
/** Alfe sessionId → claude session id map (mirrors ~/.alfe/claude-code/sessions.json). */
|
|
872
|
+
claudeSessionIds = /* @__PURE__ */ new Map();
|
|
873
|
+
claudeSessionsPath;
|
|
874
|
+
/** RPC sessionKey → currently RUNNING request id (for chat.abort targeting). */
|
|
875
|
+
runningTurns = /* @__PURE__ */ new Map();
|
|
876
|
+
/** idempotencyKey → settled request id, for E3 duplicate-RPC dedupe. */
|
|
877
|
+
seenIdempotencyKeys = /* @__PURE__ */ new Map();
|
|
878
|
+
/** RPC sessionKey → resolved SessionDriver so chat.abort can SIGINT the child. */
|
|
879
|
+
activeDriverBySession = /* @__PURE__ */ new Map();
|
|
880
|
+
constructor(cfg, deps) {
|
|
881
|
+
this.cfg = cfg;
|
|
882
|
+
this.log = cfg.logger ?? console;
|
|
883
|
+
this.claudeSessionsPath = cfg.claudeSessionsPath ?? join(homedir(), ".alfe", "claude-code", "sessions.json");
|
|
884
|
+
this.makeDriver = deps?.makeDriver ?? (() => new SessionDriver({
|
|
885
|
+
logger: this.log,
|
|
886
|
+
baseEnv: process.env
|
|
887
|
+
}));
|
|
888
|
+
this.api = deps?.api ?? new AgentApiClient({
|
|
889
|
+
apiKey: cfg.apiKey,
|
|
890
|
+
apiUrl: cfg.apiUrl
|
|
891
|
+
});
|
|
892
|
+
this.chat = deps?.chat ?? new ChatServiceClient({
|
|
893
|
+
wsUrl: cfg.wsUrl,
|
|
894
|
+
apiKey: cfg.apiKey,
|
|
895
|
+
logger: this.log,
|
|
896
|
+
onRequest: (req) => {
|
|
897
|
+
this.handleRequest(req);
|
|
898
|
+
},
|
|
899
|
+
onConnectionChange: (connected) => {
|
|
900
|
+
this.log.info(`[claude-host] chat ${connected ? "connected" : "disconnected"}`);
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
/** Start the chat WS. Non-blocking — the client reconnects internally. */
|
|
905
|
+
async start() {
|
|
906
|
+
await this.loadClaudeSessionIds();
|
|
907
|
+
this.chat.start();
|
|
908
|
+
}
|
|
909
|
+
/** Stop the chat WS (SIGTERM path). */
|
|
910
|
+
stop() {
|
|
911
|
+
this.chat.stop();
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Inbound RPC dispatch. Invoked by the chat client's `onRequest` in
|
|
915
|
+
* production; also the test seam (drive `agent`/`chat.abort`/… directly).
|
|
916
|
+
*/
|
|
917
|
+
async handleRequest(req) {
|
|
918
|
+
try {
|
|
919
|
+
switch (req.method) {
|
|
920
|
+
case "agent":
|
|
921
|
+
await this.handleAgent(req);
|
|
922
|
+
return;
|
|
923
|
+
case "chat.abort":
|
|
924
|
+
this.handleChatAbort(req);
|
|
925
|
+
return;
|
|
926
|
+
case "sessions.list":
|
|
927
|
+
await this.handleSessionsList(req);
|
|
928
|
+
return;
|
|
929
|
+
case "sessions.get":
|
|
930
|
+
await this.handleSessionsGet(req);
|
|
931
|
+
return;
|
|
932
|
+
case "runtime.activity":
|
|
933
|
+
this.chat.sendResponse(req.id, true, { active: this.activeDriverBySession.size > 0 });
|
|
934
|
+
return;
|
|
935
|
+
default:
|
|
936
|
+
this.chat.sendResponse(req.id, false, { message: `Unknown method: ${req.method}` });
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
} catch (err) {
|
|
940
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
941
|
+
this.log.error(`[claude-host] handler for ${req.method} threw: ${message}`);
|
|
942
|
+
this.chat.sendResponse(req.id, false, { message });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async handleAgent(req) {
|
|
946
|
+
const params = req.params;
|
|
947
|
+
const sessionKey = params.sessionKey;
|
|
948
|
+
if (typeof sessionKey !== "string" || !sessionKey) {
|
|
949
|
+
this.chat.sendResponse(req.id, false, { message: "Missing sessionKey" });
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const idem = params.idempotencyKey;
|
|
953
|
+
if (idem && this.seenIdempotencyKeys.has(idem)) {
|
|
954
|
+
this.chat.sendResponse(req.id, true, { sessionKey });
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (idem) this.pruneIdempotency();
|
|
958
|
+
const coalesceKey = params.coalesce === "replace" ? sessionKey : void 0;
|
|
959
|
+
await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params), coalesceKey ? {
|
|
960
|
+
coalesceKey,
|
|
961
|
+
onSuperseded: () => {
|
|
962
|
+
this.chat.sendResponse(req.id, true, {
|
|
963
|
+
superseded: true,
|
|
964
|
+
sessionKey
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
} : void 0);
|
|
968
|
+
}
|
|
969
|
+
async runAgentTurn(req, params) {
|
|
970
|
+
const sessionKey = params.sessionKey;
|
|
971
|
+
const bareSessionId = params.conversationId ?? stripAgentPrefix(sessionKey);
|
|
972
|
+
const runId = params.idempotencyKey ?? req.id;
|
|
973
|
+
const isVoice = params.origin === "voice";
|
|
974
|
+
await this.ensureSession(bareSessionId, params);
|
|
975
|
+
await addMessage(bareSessionId, "user", params.message, params.identityId ?? params.userId, params.displayName);
|
|
976
|
+
let memoryContext = "";
|
|
977
|
+
try {
|
|
978
|
+
memoryContext = (await this.api.memoryLoadContext()).formatted;
|
|
979
|
+
} catch (err) {
|
|
980
|
+
this.log.warn(`[claude-host] memoryLoadContext failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
981
|
+
}
|
|
982
|
+
const appendSystemPrompt = await this.buildSystemPrompt(memoryContext, isVoice);
|
|
983
|
+
const claudeSessionId = this.claudeSessionIds.get(bareSessionId) ?? null;
|
|
984
|
+
const driver = this.makeDriver();
|
|
985
|
+
this.runningTurns.set(sessionKey, req.id);
|
|
986
|
+
this.activeDriverBySession.set(sessionKey, driver);
|
|
987
|
+
let result;
|
|
988
|
+
try {
|
|
989
|
+
result = await this.runWithResumeFallback(driver, bareSessionId, sessionKey, runId, {
|
|
990
|
+
prompt: params.message,
|
|
991
|
+
claudeSessionId,
|
|
992
|
+
cwd: this.cfg.workspace,
|
|
993
|
+
...this.cfg.mcpConfigPath ? { mcpConfigPath: this.cfg.mcpConfigPath } : {},
|
|
994
|
+
...appendSystemPrompt ? { appendSystemPrompt } : {},
|
|
995
|
+
onEmission: (emission) => {
|
|
996
|
+
this.forwardEmission(runId, sessionKey, emission);
|
|
997
|
+
}
|
|
998
|
+
}, isVoice);
|
|
999
|
+
} finally {
|
|
1000
|
+
this.runningTurns.delete(sessionKey);
|
|
1001
|
+
this.activeDriverBySession.delete(sessionKey);
|
|
1002
|
+
if (params.idempotencyKey) this.seenIdempotencyKeys.set(params.idempotencyKey, Date.now());
|
|
1003
|
+
}
|
|
1004
|
+
if (result.claudeSessionId && result.claudeSessionId !== claudeSessionId) {
|
|
1005
|
+
this.claudeSessionIds.set(bareSessionId, result.claudeSessionId);
|
|
1006
|
+
await this.saveClaudeSessionIds();
|
|
1007
|
+
}
|
|
1008
|
+
const activityRecords = collectTurnActivityRecords(result.emissions);
|
|
1009
|
+
if (activityRecords.length) await appendActivity(bareSessionId, activityRecords);
|
|
1010
|
+
if (result.aborted) return;
|
|
1011
|
+
if (result.isError) {
|
|
1012
|
+
this.chat.sendResponse(req.id, false, { message: result.errorMessage ?? "Claude Code turn failed" });
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (result.finalText) await addMessage(bareSessionId, "assistant", result.finalText);
|
|
1016
|
+
if (result.finalText) this.chat.notify("agent-message", {
|
|
1017
|
+
conversationId: bareSessionId,
|
|
1018
|
+
text: result.finalText,
|
|
1019
|
+
sessionKey,
|
|
1020
|
+
messageId: params.chatMessageId ?? req.id
|
|
1021
|
+
});
|
|
1022
|
+
this.chat.sendResponse(req.id, true, { sessionKey });
|
|
1023
|
+
try {
|
|
1024
|
+
const messages = [{
|
|
1025
|
+
role: "user",
|
|
1026
|
+
content: params.message,
|
|
1027
|
+
index: 0
|
|
1028
|
+
}, ...result.finalText ? [{
|
|
1029
|
+
role: "assistant",
|
|
1030
|
+
content: result.finalText,
|
|
1031
|
+
index: 1
|
|
1032
|
+
}] : []];
|
|
1033
|
+
await this.api.memoryIngest(bareSessionId, messages, {
|
|
1034
|
+
...params.identityId ?? params.userId ? { userId: params.identityId ?? params.userId } : {},
|
|
1035
|
+
...params.displayName ? { userName: params.displayName } : {}
|
|
1036
|
+
});
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
this.log.warn(`[claude-host] memoryIngest failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Run a turn; on a `--resume` failure (stale/missing claude session id) start
|
|
1043
|
+
* fresh and prepend the last-N stored messages so the model keeps context.
|
|
1044
|
+
*/
|
|
1045
|
+
async runWithResumeFallback(driver, bareSessionId, _sessionKey, _runId, baseParams, isVoice) {
|
|
1046
|
+
const first = await driver.runTurn(baseParams, isVoice ? "voice" : "chat");
|
|
1047
|
+
if (!(baseParams.claudeSessionId !== null && first.isError && first.claudeSessionId === null && first.emissions.length === 0)) return first;
|
|
1048
|
+
this.log.warn(`[claude-host] --resume failed for ${bareSessionId}; starting a fresh claude session`);
|
|
1049
|
+
this.claudeSessionIds.delete(bareSessionId);
|
|
1050
|
+
const history = await this.buildHistoryPreamble(bareSessionId);
|
|
1051
|
+
const freshPrompt = history ? `${history}\n\n---\n\n${baseParams.prompt}` : baseParams.prompt;
|
|
1052
|
+
return driver.runTurn({
|
|
1053
|
+
...baseParams,
|
|
1054
|
+
claudeSessionId: null,
|
|
1055
|
+
prompt: freshPrompt
|
|
1056
|
+
}, isVoice ? "voice" : "chat");
|
|
1057
|
+
}
|
|
1058
|
+
forwardEmission(runId, sessionKey, emission) {
|
|
1059
|
+
if (emission.type === "chat-delta") {
|
|
1060
|
+
this.chat.sendEvent("chat", {
|
|
1061
|
+
runId,
|
|
1062
|
+
sessionKey,
|
|
1063
|
+
seq: emission.seq,
|
|
1064
|
+
state: "delta",
|
|
1065
|
+
message: {
|
|
1066
|
+
role: "assistant",
|
|
1067
|
+
content: [{
|
|
1068
|
+
type: "text",
|
|
1069
|
+
text: emission.text
|
|
1070
|
+
}],
|
|
1071
|
+
timestamp: emission.ts
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (emission.type === "chat-activity") {
|
|
1077
|
+
this.chat.sendEvent("chat-activity", {
|
|
1078
|
+
runId,
|
|
1079
|
+
sessionKey,
|
|
1080
|
+
seq: emission.seq,
|
|
1081
|
+
state: "activity",
|
|
1082
|
+
activity: {
|
|
1083
|
+
...emission.activity,
|
|
1084
|
+
ts: emission.ts,
|
|
1085
|
+
seq: emission.seq
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
handleChatAbort(req) {
|
|
1092
|
+
const { sessionKey } = req.params;
|
|
1093
|
+
let aborted = false;
|
|
1094
|
+
const driver = sessionKey ? this.activeDriverBySession.get(sessionKey) : void 0;
|
|
1095
|
+
if (sessionKey && driver) {
|
|
1096
|
+
driver.abort();
|
|
1097
|
+
const runningReqId = this.runningTurns.get(sessionKey);
|
|
1098
|
+
if (runningReqId) this.chat.sendResponse(runningReqId, true, { sessionKey });
|
|
1099
|
+
aborted = true;
|
|
1100
|
+
}
|
|
1101
|
+
this.chat.sendResponse(req.id, true, { aborted });
|
|
1102
|
+
}
|
|
1103
|
+
async handleSessionsList(req) {
|
|
1104
|
+
const params = req.params;
|
|
1105
|
+
const sessions = await listSessions(params);
|
|
1106
|
+
this.chat.sendResponse(req.id, true, { sessions });
|
|
1107
|
+
}
|
|
1108
|
+
async handleSessionsGet(req) {
|
|
1109
|
+
const params = req.params;
|
|
1110
|
+
if (!params.sessionId) {
|
|
1111
|
+
this.chat.sendResponse(req.id, false, { message: "Missing sessionId" });
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
const session = await getSession(params.sessionId);
|
|
1115
|
+
if (!session) {
|
|
1116
|
+
this.chat.sendResponse(req.id, true, {
|
|
1117
|
+
sessionId: params.sessionId,
|
|
1118
|
+
messages: [],
|
|
1119
|
+
activity: []
|
|
1120
|
+
});
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
this.chat.sendResponse(req.id, true, {
|
|
1124
|
+
sessionId: session.sessionId,
|
|
1125
|
+
agentId: session.agentId,
|
|
1126
|
+
channel: session.channel,
|
|
1127
|
+
createdAt: session.createdAt,
|
|
1128
|
+
messages: session.messages.map((m) => ({
|
|
1129
|
+
id: `msg-${String(m.timestamp)}`,
|
|
1130
|
+
role: m.role,
|
|
1131
|
+
content: m.content,
|
|
1132
|
+
timestamp: m.timestamp,
|
|
1133
|
+
...m.components?.length ? { components: m.components } : {},
|
|
1134
|
+
...m.attachments?.length ? { attachments: m.attachments } : {}
|
|
1135
|
+
})),
|
|
1136
|
+
activity: session.activity ?? []
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
async buildSystemPrompt(memoryContext, isVoice) {
|
|
1140
|
+
const parts = [];
|
|
1141
|
+
const persona = await this.readPersona();
|
|
1142
|
+
if (persona) parts.push(persona);
|
|
1143
|
+
if (memoryContext) parts.push(`# Relevant memory\n\n${memoryContext}`);
|
|
1144
|
+
if (isVoice) parts.push(VOICE_REPLY_SYSTEM_PROMPT);
|
|
1145
|
+
parts.push(CROSS_AGENT_TOOLS_SYSTEM_PROMPT);
|
|
1146
|
+
return parts.join("\n\n");
|
|
1147
|
+
}
|
|
1148
|
+
async readPersona() {
|
|
1149
|
+
const chunks = [];
|
|
1150
|
+
for (const file of PERSONA_FILES) try {
|
|
1151
|
+
const text = await readFile(join(this.cfg.workspace, file), "utf-8");
|
|
1152
|
+
if (text.trim()) chunks.push(text.trim());
|
|
1153
|
+
} catch {}
|
|
1154
|
+
return chunks.join("\n\n");
|
|
1155
|
+
}
|
|
1156
|
+
async buildHistoryPreamble(bareSessionId) {
|
|
1157
|
+
const session = await getSession(bareSessionId);
|
|
1158
|
+
if (!session || session.messages.length === 0) return "";
|
|
1159
|
+
const prior = session.messages.slice(0, -1).slice(-10);
|
|
1160
|
+
if (prior.length === 0) return "";
|
|
1161
|
+
return `Earlier in this conversation:\n${prior.map((m) => `${m.role === "assistant" ? "Assistant" : "User"}: ${m.content}`).join("\n")}`;
|
|
1162
|
+
}
|
|
1163
|
+
async ensureSession(bareSessionId, params) {
|
|
1164
|
+
if (await getSession(bareSessionId)) return;
|
|
1165
|
+
await createSession(bareSessionId, params.agentId, params.clientType ?? "chat", params.tenantId, params.identityId ?? params.userId);
|
|
1166
|
+
}
|
|
1167
|
+
async loadClaudeSessionIds() {
|
|
1168
|
+
try {
|
|
1169
|
+
const raw = await readFile(this.claudeSessionsPath, "utf-8");
|
|
1170
|
+
const parsed = JSON.parse(raw);
|
|
1171
|
+
for (const [k, v] of Object.entries(parsed)) if (typeof v === "string") this.claudeSessionIds.set(k, v);
|
|
1172
|
+
} catch {}
|
|
1173
|
+
}
|
|
1174
|
+
async saveClaudeSessionIds() {
|
|
1175
|
+
const obj = {};
|
|
1176
|
+
for (const [k, v] of this.claudeSessionIds) obj[k] = v;
|
|
1177
|
+
try {
|
|
1178
|
+
await mkdir(dirname(this.claudeSessionsPath), { recursive: true });
|
|
1179
|
+
await writeFile(this.claudeSessionsPath, JSON.stringify(obj, null, 2), "utf-8");
|
|
1180
|
+
} catch (err) {
|
|
1181
|
+
this.log.warn(`[claude-host] failed to persist claude session ids: ${err instanceof Error ? err.message : String(err)}`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
pruneIdempotency() {
|
|
1185
|
+
const cutoff = Date.now() - 10 * 6e4;
|
|
1186
|
+
for (const [k, ts] of this.seenIdempotencyKeys) if (ts < cutoff) this.seenIdempotencyKeys.delete(k);
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
/** Strip the `agent:{agentId}:` prefix from a full server session key. */
|
|
1190
|
+
function stripAgentPrefix(sessionKey) {
|
|
1191
|
+
const parts = sessionKey.split(":");
|
|
1192
|
+
if (parts.length >= 3 && parts[0] === "agent") return parts.slice(2).join(":");
|
|
1193
|
+
return sessionKey;
|
|
1194
|
+
}
|
|
1195
|
+
//#endregion
|
|
1196
|
+
//#region src/host-config.ts
|
|
1197
|
+
/**
|
|
1198
|
+
* Resolve the host runtime config from `~/.alfe/config.toml` + token-derived
|
|
1199
|
+
* endpoints. Kept OUT of `cli.ts` so the importable library surface
|
|
1200
|
+
* (`index.ts`) can re-export it without pulling in `cli.ts`'s top-level
|
|
1201
|
+
* `main()` (which would start the process on import).
|
|
1202
|
+
*/
|
|
1203
|
+
/**
|
|
1204
|
+
* Build the host config. Throws (→ non-zero exit from the CLI) when the chat
|
|
1205
|
+
* URL / api key can't resolve.
|
|
1206
|
+
*/
|
|
1207
|
+
function resolveHostConfig() {
|
|
1208
|
+
const resolved = resolveConfig();
|
|
1209
|
+
const chat = resolveAlfeChat({ apiKey: resolved.apiKey });
|
|
1210
|
+
if (!chat.apiKey || !chat.chatWsUrl) throw new Error("Unable to resolve chat service URL / API key from ~/.alfe/config.toml. Run `alfe login`.");
|
|
1211
|
+
return {
|
|
1212
|
+
wsUrl: chat.chatWsUrl,
|
|
1213
|
+
apiKey: chat.apiKey,
|
|
1214
|
+
apiUrl: resolved.apiUrl,
|
|
1215
|
+
workspace: resolved.workspacePath,
|
|
1216
|
+
mcpConfigPath: join(homedir(), ".claude-code", "mcp-config.json"),
|
|
1217
|
+
claudeSessionsPath: join(homedir(), ".alfe", "claude-code", "sessions.json")
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
//#endregion
|
|
1221
|
+
export { mapStreamEvent as _, appendActivity as a, SCRUBBED_ANTHROPIC_ENV_VARS as b, listSessions as c, DEFAULT_PERMISSION_MODE as d, SessionDriver as f, createMapperState as g, collectTurnActivityRecords as h, addMessage as i, saveSession as l, WARM_STREAMING_SUPPORTED as m, ClaudeCodeHost as n, createSession as o, TURN_TIMEOUT_MS as p, stripAgentPrefix as r, getSession as s, resolveHostConfig as t, DEFAULT_ALLOWED_TOOLS as u, mapStreamEvents as v, buildChildEnv as x, BRIDGE_ENV_VAR as y };
|