@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.
@@ -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 };