@alfe.ai/claude-code-host 0.1.5 → 0.1.7
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 +1 -5
- package/dist/cli.js +1 -5
- package/dist/host-config.cjs +416 -116
- package/dist/host-config.js +407 -119
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +1064 -745
- package/dist/index.d.ts +1064 -745
- package/dist/index.js +2 -2
- package/package.json +4 -4
package/dist/host-config.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
2
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";
|
|
3
|
+
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { existsSync } from "node:fs";
|
|
9
10
|
import { resolveConfig } from "@alfe.ai/config";
|
|
10
11
|
//#region src/child-env.ts
|
|
@@ -72,6 +73,9 @@ function createMapperState() {
|
|
|
72
73
|
}
|
|
73
74
|
const MAX_ARGS_CHARS = 2e3;
|
|
74
75
|
const MAX_RESULT_CHARS = 4e3;
|
|
76
|
+
const MAX_THINKING_CHARS = 16e3;
|
|
77
|
+
const MAX_PENDING_TOOLS = 256;
|
|
78
|
+
const MAX_PERSISTED_TOOLS = 500;
|
|
75
79
|
function capText(value, max) {
|
|
76
80
|
if (value.length <= max) return {
|
|
77
81
|
text: value,
|
|
@@ -94,8 +98,8 @@ function safeJson(value, max) {
|
|
|
94
98
|
if (!raw) return void 0;
|
|
95
99
|
return capText(raw, max);
|
|
96
100
|
}
|
|
97
|
-
function asRecord(v) {
|
|
98
|
-
return v && typeof v === "object" ? v : void 0;
|
|
101
|
+
function asRecord$1(v) {
|
|
102
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
|
|
99
103
|
}
|
|
100
104
|
function asString(v) {
|
|
101
105
|
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
@@ -105,11 +109,26 @@ function asString(v) {
|
|
|
105
109
|
* or an array of `{type:'text', text}` parts (Anthropic block form).
|
|
106
110
|
*/
|
|
107
111
|
function toolResultText(content) {
|
|
108
|
-
if (typeof content === "string") return content;
|
|
109
|
-
if (Array.isArray(content))
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
if (typeof content === "string") return capText(content, MAX_RESULT_CHARS);
|
|
113
|
+
if (Array.isArray(content)) {
|
|
114
|
+
let text = "";
|
|
115
|
+
let truncated = false;
|
|
116
|
+
for (const [index, part] of content.entries()) {
|
|
117
|
+
const record = asRecord$1(part);
|
|
118
|
+
if (record?.type !== "text" || typeof record.text !== "string") continue;
|
|
119
|
+
const remaining = MAX_RESULT_CHARS - text.length;
|
|
120
|
+
if (record.text.length > remaining) truncated = true;
|
|
121
|
+
if (remaining > 0) text += record.text.slice(0, remaining);
|
|
122
|
+
if (text.length >= MAX_RESULT_CHARS) {
|
|
123
|
+
truncated ||= index < content.length - 1;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return text ? {
|
|
128
|
+
text,
|
|
129
|
+
truncated
|
|
130
|
+
} : void 0;
|
|
131
|
+
}
|
|
113
132
|
}
|
|
114
133
|
/**
|
|
115
134
|
* Map ONE stream-json event → zero or more emissions, mutating `state`.
|
|
@@ -118,8 +137,9 @@ function toolResultText(content) {
|
|
|
118
137
|
* don't all carry ms epochs); the fixture test injects a fixed clock.
|
|
119
138
|
*/
|
|
120
139
|
function mapStreamEvent(state, raw, nowMs) {
|
|
121
|
-
const evt = asRecord(raw);
|
|
140
|
+
const evt = asRecord$1(raw);
|
|
122
141
|
if (!evt) return [];
|
|
142
|
+
if (state.ended) return [];
|
|
123
143
|
const type = evt.type;
|
|
124
144
|
if (type === "system" && evt.subtype === "init") {
|
|
125
145
|
const sid = asString(evt.session_id);
|
|
@@ -127,10 +147,10 @@ function mapStreamEvent(state, raw, nowMs) {
|
|
|
127
147
|
return [];
|
|
128
148
|
}
|
|
129
149
|
if (type === "stream_event") {
|
|
130
|
-
const inner = asRecord(evt.event);
|
|
150
|
+
const inner = asRecord$1(evt.event);
|
|
131
151
|
if (!inner) return [];
|
|
132
152
|
if (inner.type === "content_block_delta") {
|
|
133
|
-
const delta = asRecord(inner.delta);
|
|
153
|
+
const delta = asRecord$1(inner.delta);
|
|
134
154
|
if (!delta) return [];
|
|
135
155
|
if (delta.type === "thinking_delta") {
|
|
136
156
|
const text = asString(delta.thinking);
|
|
@@ -141,7 +161,7 @@ function mapStreamEvent(state, raw, nowMs) {
|
|
|
141
161
|
ts: nowMs,
|
|
142
162
|
activity: {
|
|
143
163
|
kind: "thinking",
|
|
144
|
-
text,
|
|
164
|
+
text: text.slice(0, MAX_THINKING_CHARS),
|
|
145
165
|
delta: true,
|
|
146
166
|
ts: nowMs
|
|
147
167
|
}
|
|
@@ -163,17 +183,17 @@ function mapStreamEvent(state, raw, nowMs) {
|
|
|
163
183
|
return [];
|
|
164
184
|
}
|
|
165
185
|
if (type === "assistant") {
|
|
166
|
-
const content = asRecord(evt.message)?.content;
|
|
186
|
+
const content = asRecord$1(evt.message)?.content;
|
|
167
187
|
if (!Array.isArray(content)) return [];
|
|
168
188
|
const emissions = [];
|
|
169
189
|
for (const block of content) {
|
|
170
|
-
const b = asRecord(block);
|
|
190
|
+
const b = asRecord$1(block);
|
|
171
191
|
if (b?.type !== "tool_use") continue;
|
|
172
192
|
const id = asString(b.id);
|
|
173
193
|
const name = asString(b.name);
|
|
174
194
|
if (!id || !name) continue;
|
|
175
195
|
const args = safeJson(b.input, MAX_ARGS_CHARS);
|
|
176
|
-
state.pendingTools.set(id, {
|
|
196
|
+
if (state.pendingTools.has(id) || state.pendingTools.size < MAX_PENDING_TOOLS) state.pendingTools.set(id, {
|
|
177
197
|
name,
|
|
178
198
|
argsText: args?.text,
|
|
179
199
|
argsTruncated: args?.truncated,
|
|
@@ -198,18 +218,17 @@ function mapStreamEvent(state, raw, nowMs) {
|
|
|
198
218
|
return emissions;
|
|
199
219
|
}
|
|
200
220
|
if (type === "user") {
|
|
201
|
-
const content = asRecord(evt.message)?.content;
|
|
221
|
+
const content = asRecord$1(evt.message)?.content;
|
|
202
222
|
if (!Array.isArray(content)) return [];
|
|
203
223
|
const emissions = [];
|
|
204
224
|
for (const block of content) {
|
|
205
|
-
const b = asRecord(block);
|
|
225
|
+
const b = asRecord$1(block);
|
|
206
226
|
if (b?.type !== "tool_result") continue;
|
|
207
227
|
const toolUseId = asString(b.tool_use_id);
|
|
208
228
|
if (!toolUseId) continue;
|
|
209
229
|
const pending = state.pendingTools.get(toolUseId);
|
|
210
230
|
const isError = b.is_error === true;
|
|
211
|
-
const
|
|
212
|
-
const result = resultRaw !== void 0 ? capText(resultRaw, MAX_RESULT_CHARS) : void 0;
|
|
231
|
+
const result = toolResultText(b.content);
|
|
213
232
|
const durationMs = pending ? Math.max(0, nowMs - pending.startTs) : void 0;
|
|
214
233
|
const truncated = {};
|
|
215
234
|
if (pending?.argsTruncated) truncated.args = true;
|
|
@@ -241,7 +260,7 @@ function mapStreamEvent(state, raw, nowMs) {
|
|
|
241
260
|
const isError = evt.is_error === true;
|
|
242
261
|
const finalText = asString(evt.result) ?? state.visibleText;
|
|
243
262
|
const durationRaw = evt.duration_ms;
|
|
244
|
-
const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : void 0;
|
|
263
|
+
const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? Math.max(0, durationRaw) : void 0;
|
|
245
264
|
const errorMessage = isError ? asString(evt.result) ?? asString(evt.subtype) ?? "Claude Code turn failed" : void 0;
|
|
246
265
|
return [{
|
|
247
266
|
type: "turn-end",
|
|
@@ -286,10 +305,11 @@ function collectTurnActivityRecords(emissions) {
|
|
|
286
305
|
if (e.type !== "chat-activity") continue;
|
|
287
306
|
if (e.activity.kind === "thinking") {
|
|
288
307
|
if (!thinking.text) thinking.ts = e.ts;
|
|
289
|
-
thinking.text += e.activity.text;
|
|
308
|
+
if (thinking.text.length < MAX_THINKING_CHARS) thinking.text += e.activity.text.slice(0, MAX_THINKING_CHARS - thinking.text.length);
|
|
290
309
|
} else {
|
|
291
310
|
const a = e.activity;
|
|
292
311
|
const prev = tools.get(a.toolCallId);
|
|
312
|
+
if (!prev && tools.size >= MAX_PERSISTED_TOOLS) continue;
|
|
293
313
|
const status = a.status === "running" ? prev?.status ?? "interrupted" : a.status;
|
|
294
314
|
tools.set(a.toolCallId, {
|
|
295
315
|
kind: "tool",
|
|
@@ -324,7 +344,7 @@ function collectTurnActivityRecords(emissions) {
|
|
|
324
344
|
* TWO modes (Phase 2 plan, decision 2026-07-12):
|
|
325
345
|
*
|
|
326
346
|
* 1. **chat (per-turn spawn):** each Alfe turn spawns a fresh
|
|
327
|
-
* `claude -p
|
|
347
|
+
* `claude -p --resume <claudeSessionId> --output-format stream-json
|
|
328
348
|
* --include-partial-messages ...`, streams its stdout line-by-line through
|
|
329
349
|
* `mapStreamEvent`, and resolves when the terminal `result` event lands (or
|
|
330
350
|
* the child exits). Each conversation carries its own claude session lineage
|
|
@@ -371,7 +391,7 @@ const DEFAULT_CLAUDE_BIN = "claude";
|
|
|
371
391
|
* posture). An inbound chat message could try to induce a destructive shell
|
|
372
392
|
* command, and these turns run unattended on the user's machine. We therefore
|
|
373
393
|
* pass an explicit allow-list of read/analysis/edit tools and DENY Bash by
|
|
374
|
-
* default, and use `--permission-mode
|
|
394
|
+
* default, and use `--permission-mode dontAsk` (NEVER `bypassPermissions` /
|
|
375
395
|
* `--dangerously-skip-permissions`). See DEVELOPING.md → "Tool-permission
|
|
376
396
|
* posture". Bash is intentionally absent — a bridged turn cannot run arbitrary
|
|
377
397
|
* shell. Callers that want a wider set pass `allowedTools` explicitly.
|
|
@@ -388,8 +408,19 @@ const DEFAULT_ALLOWED_TOOLS = [
|
|
|
388
408
|
"Task",
|
|
389
409
|
"TodoWrite"
|
|
390
410
|
];
|
|
391
|
-
|
|
392
|
-
|
|
411
|
+
const CLAUDE_PERMISSION_MODES = [
|
|
412
|
+
"acceptEdits",
|
|
413
|
+
"auto",
|
|
414
|
+
"manual",
|
|
415
|
+
"dontAsk",
|
|
416
|
+
"plan"
|
|
417
|
+
];
|
|
418
|
+
/**
|
|
419
|
+
* Headless-safe permission posture supported by current Claude CLI releases.
|
|
420
|
+
* Allow-listed tools run; any permission prompt the bridge cannot answer is
|
|
421
|
+
* denied. `bypassPermissions` is deliberately not representable.
|
|
422
|
+
*/
|
|
423
|
+
const DEFAULT_PERMISSION_MODE = "dontAsk";
|
|
393
424
|
/**
|
|
394
425
|
* Hard cap on a single turn (E2). Deliberately BELOW the chat service's RPC
|
|
395
426
|
* timeout (`AGENT_TIMEOUT_SEC=300` → 305s in daemon-server) so the host settles
|
|
@@ -399,6 +430,10 @@ const DEFAULT_PERMISSION_MODE = "default";
|
|
|
399
430
|
* cap, but the late settle is harmlessly no-op'd by the chat service.
|
|
400
431
|
*/
|
|
401
432
|
const TURN_TIMEOUT_MS = 3e5;
|
|
433
|
+
/** Grace after SIGINT before SIGKILL + local settlement. Keeps E2 under 305s. */
|
|
434
|
+
const TERMINATION_GRACE_MS = 2e3;
|
|
435
|
+
const MAX_STDERR_CHARS = 16e3;
|
|
436
|
+
const MAX_RETAINED_ACTIVITY_EMISSIONS = 1e3;
|
|
402
437
|
/** Substrings that mark a subscription rate-limit → fail fast (E2). */
|
|
403
438
|
const RATE_LIMIT_MARKERS = [
|
|
404
439
|
"rate limit",
|
|
@@ -418,6 +453,7 @@ function looksRateLimited(text) {
|
|
|
418
453
|
*/
|
|
419
454
|
var SessionDriver = class {
|
|
420
455
|
child = null;
|
|
456
|
+
abortCurrent = null;
|
|
421
457
|
bin;
|
|
422
458
|
baseEnv;
|
|
423
459
|
log;
|
|
@@ -438,28 +474,30 @@ var SessionDriver = class {
|
|
|
438
474
|
* child's exit drives `runTurn` to settle with `aborted: true`.
|
|
439
475
|
*/
|
|
440
476
|
abort() {
|
|
441
|
-
|
|
442
|
-
this.child.kill("SIGINT");
|
|
443
|
-
} catch {}
|
|
477
|
+
this.abortCurrent?.();
|
|
444
478
|
}
|
|
445
479
|
/**
|
|
446
480
|
* Run ONE turn. Modes reconcile to the same per-turn spawn (see header
|
|
447
481
|
* VERIFICATION) — `voice` is accepted for API symmetry and future warm mode.
|
|
448
482
|
*/
|
|
449
483
|
async runTurn(params, mode = "chat") {
|
|
484
|
+
if (this.busy) throw new Error("SessionDriver is already running a turn");
|
|
450
485
|
return this.runPerTurn(params);
|
|
451
486
|
}
|
|
452
487
|
buildArgs(params) {
|
|
453
488
|
const allowed = (params.allowedTools ?? DEFAULT_ALLOWED_TOOLS).join(",");
|
|
489
|
+
const permissionMode = params.permissionMode ?? "dontAsk";
|
|
490
|
+
if (!CLAUDE_PERMISSION_MODES.includes(permissionMode)) throw new Error(`Unsupported Claude permission mode: ${permissionMode}`);
|
|
454
491
|
const args = [
|
|
455
492
|
"-p",
|
|
456
|
-
|
|
493
|
+
"--input-format",
|
|
494
|
+
"text",
|
|
457
495
|
"--output-format",
|
|
458
496
|
"stream-json",
|
|
459
497
|
"--include-partial-messages",
|
|
460
498
|
"--verbose",
|
|
461
499
|
"--permission-mode",
|
|
462
|
-
|
|
500
|
+
permissionMode,
|
|
463
501
|
"--allowedTools",
|
|
464
502
|
allowed
|
|
465
503
|
];
|
|
@@ -473,9 +511,12 @@ var SessionDriver = class {
|
|
|
473
511
|
const now = params.now ?? (() => Date.now());
|
|
474
512
|
const state = createMapperState();
|
|
475
513
|
const emissions = [];
|
|
514
|
+
let latestTextEmission = null;
|
|
476
515
|
let aborted = false;
|
|
477
516
|
let settled = false;
|
|
478
|
-
|
|
517
|
+
let terminationReason = null;
|
|
518
|
+
let stderr = "";
|
|
519
|
+
let rateLimited = false;
|
|
479
520
|
const args = this.buildArgs(params);
|
|
480
521
|
const env = buildChildEnv(this.baseEnv);
|
|
481
522
|
return new Promise((resolve) => {
|
|
@@ -503,39 +544,63 @@ var SessionDriver = class {
|
|
|
503
544
|
return;
|
|
504
545
|
}
|
|
505
546
|
this.child = child;
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
} catch {}
|
|
509
|
-
const timeout = setTimeout(() => {
|
|
510
|
-
this.log.warn("[session-driver] turn exceeded the time cap — aborting");
|
|
511
|
-
try {
|
|
512
|
-
child.kill("SIGINT");
|
|
513
|
-
} catch {}
|
|
514
|
-
finish(true, "Turn exceeded the time cap");
|
|
515
|
-
}, TURN_TIMEOUT_MS);
|
|
516
|
-
timeout.unref();
|
|
547
|
+
let escalation;
|
|
548
|
+
const rl = createInterface({ input: child.stdout });
|
|
517
549
|
const finish = (isError, errorMessage) => {
|
|
518
550
|
if (settled) return;
|
|
519
551
|
settled = true;
|
|
520
552
|
clearTimeout(timeout);
|
|
521
|
-
|
|
553
|
+
if (escalation) clearTimeout(escalation);
|
|
554
|
+
rl.close();
|
|
555
|
+
if (this.child === child) this.child = null;
|
|
556
|
+
this.abortCurrent = null;
|
|
522
557
|
const end = emissions.find((e) => e.type === "turn-end");
|
|
523
558
|
const endIsError = end?.type === "turn-end" ? end.isError : false;
|
|
524
559
|
const endText = end?.type === "turn-end" ? end.finalText : state.visibleText;
|
|
525
560
|
const endErrMsg = end?.type === "turn-end" ? end.errorMessage : void 0;
|
|
526
|
-
const stderr = stderrChunks.join("");
|
|
527
561
|
const finalIsError = isError || endIsError;
|
|
528
|
-
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);
|
|
562
|
+
const finalErrMsg = errorMessage ?? endErrMsg ?? (finalIsError ? rateLimited || looksRateLimited(stderr) ? "Claude subscription rate limit hit — try again shortly" : stderr.trim().slice(0, 500) || "Claude Code turn failed" : void 0);
|
|
563
|
+
const retainedEmissions = latestTextEmission ? [...emissions, latestTextEmission].sort((a, b) => a.seq - b.seq) : emissions;
|
|
529
564
|
resolve({
|
|
530
565
|
claudeSessionId: state.claudeSessionId,
|
|
531
|
-
emissions,
|
|
566
|
+
emissions: retainedEmissions,
|
|
532
567
|
isError: finalIsError,
|
|
533
568
|
finalText: finalIsError ? "" : endText,
|
|
534
569
|
...finalErrMsg ? { errorMessage: finalErrMsg } : {},
|
|
535
570
|
aborted
|
|
536
571
|
});
|
|
537
572
|
};
|
|
538
|
-
|
|
573
|
+
const requestTermination = (reason) => {
|
|
574
|
+
if (settled || terminationReason) return;
|
|
575
|
+
terminationReason = reason;
|
|
576
|
+
aborted = reason === "abort";
|
|
577
|
+
if (reason === "timeout") this.log.warn("[session-driver] turn exceeded the time cap — aborting");
|
|
578
|
+
try {
|
|
579
|
+
child.kill("SIGINT");
|
|
580
|
+
} catch {}
|
|
581
|
+
escalation = setTimeout(() => {
|
|
582
|
+
if (settled) return;
|
|
583
|
+
try {
|
|
584
|
+
child.kill("SIGKILL");
|
|
585
|
+
} catch {}
|
|
586
|
+
finish(reason === "timeout", reason === "timeout" ? "Turn exceeded the time cap" : void 0);
|
|
587
|
+
}, TERMINATION_GRACE_MS);
|
|
588
|
+
escalation.unref();
|
|
589
|
+
};
|
|
590
|
+
this.abortCurrent = () => {
|
|
591
|
+
requestTermination("abort");
|
|
592
|
+
};
|
|
593
|
+
const timeout = setTimeout(() => {
|
|
594
|
+
requestTermination("timeout");
|
|
595
|
+
}, TURN_TIMEOUT_MS);
|
|
596
|
+
timeout.unref();
|
|
597
|
+
try {
|
|
598
|
+
child.stdin.end(params.prompt);
|
|
599
|
+
} catch {
|
|
600
|
+
requestTermination("abort");
|
|
601
|
+
}
|
|
602
|
+
rl.on("line", (line) => {
|
|
603
|
+
if (settled || terminationReason) return;
|
|
539
604
|
const trimmed = line.trim();
|
|
540
605
|
if (!trimmed) return;
|
|
541
606
|
let evt;
|
|
@@ -545,7 +610,8 @@ var SessionDriver = class {
|
|
|
545
610
|
return;
|
|
546
611
|
}
|
|
547
612
|
for (const emission of mapStreamEvent(state, evt, now())) {
|
|
548
|
-
|
|
613
|
+
if (emission.type === "chat-delta") latestTextEmission = emission;
|
|
614
|
+
else if (emission.type === "turn-end" || emissions.length < MAX_RETAINED_ACTIVITY_EMISSIONS) emissions.push(emission);
|
|
549
615
|
try {
|
|
550
616
|
params.onEmission(emission);
|
|
551
617
|
} catch (err) {
|
|
@@ -554,17 +620,26 @@ var SessionDriver = class {
|
|
|
554
620
|
}
|
|
555
621
|
});
|
|
556
622
|
child.stderr.on("data", (chunk) => {
|
|
557
|
-
|
|
623
|
+
if (settled) return;
|
|
624
|
+
const text = chunk.toString("utf-8");
|
|
625
|
+
rateLimited ||= looksRateLimited(text);
|
|
626
|
+
stderr = `${stderr}${text}`.slice(-MAX_STDERR_CHARS);
|
|
558
627
|
});
|
|
559
628
|
child.on("error", (err) => {
|
|
560
|
-
|
|
629
|
+
if (terminationReason === "abort") finish(false);
|
|
630
|
+
else if (terminationReason === "timeout") finish(true, "Turn exceeded the time cap");
|
|
631
|
+
else finish(true, `claude process error: ${err.message}`);
|
|
561
632
|
});
|
|
562
633
|
child.on("close", (code, signal) => {
|
|
563
|
-
if (signal === "SIGINT" || signal === "SIGTERM") {
|
|
634
|
+
if (terminationReason === "abort" || signal === "SIGINT" || signal === "SIGTERM") {
|
|
564
635
|
aborted = true;
|
|
565
636
|
finish(false);
|
|
566
637
|
return;
|
|
567
638
|
}
|
|
639
|
+
if (terminationReason === "timeout") {
|
|
640
|
+
finish(true, "Turn exceeded the time cap");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
568
643
|
const sawResult = emissions.some((e) => e.type === "turn-end");
|
|
569
644
|
if (code !== 0 && !sawResult) {
|
|
570
645
|
finish(true);
|
|
@@ -576,6 +651,28 @@ var SessionDriver = class {
|
|
|
576
651
|
}
|
|
577
652
|
};
|
|
578
653
|
//#endregion
|
|
654
|
+
//#region src/atomic-write.ts
|
|
655
|
+
/**
|
|
656
|
+
* Replace a local state file atomically. The temporary file deliberately does
|
|
657
|
+
* not end in `.json`, so session scanners never mistake an interrupted write
|
|
658
|
+
* for a real session.
|
|
659
|
+
*/
|
|
660
|
+
async function writeTextAtomically(path, contents) {
|
|
661
|
+
const parent = dirname(path);
|
|
662
|
+
await mkdir(parent, { recursive: true });
|
|
663
|
+
const temporary = join(parent, `.${basename(path)}.${String(process.pid)}.${randomUUID()}.tmp`);
|
|
664
|
+
try {
|
|
665
|
+
await writeFile(temporary, contents, {
|
|
666
|
+
encoding: "utf-8",
|
|
667
|
+
mode: 384
|
|
668
|
+
});
|
|
669
|
+
await rename(temporary, path);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
await unlink(temporary).catch(() => void 0);
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
579
676
|
//#region src/session/session-store.ts
|
|
580
677
|
/**
|
|
581
678
|
* Session Store — persists chat sessions to the local filesystem.
|
|
@@ -612,6 +709,18 @@ function sessionPath(sessionId) {
|
|
|
612
709
|
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
613
710
|
return join(sessionsDir(), `${safe}.json`);
|
|
614
711
|
}
|
|
712
|
+
function asSessionData(value, expectedSessionId) {
|
|
713
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
714
|
+
const record = value;
|
|
715
|
+
if (typeof record.sessionId !== "string" || expectedSessionId !== void 0 && record.sessionId !== expectedSessionId || typeof record.agentId !== "string" || typeof record.channel !== "string" || typeof record.createdAt !== "string" || typeof record.updatedAt !== "string" || !Array.isArray(record.messages)) return null;
|
|
716
|
+
if (!record.messages.every((message) => {
|
|
717
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return false;
|
|
718
|
+
const item = message;
|
|
719
|
+
return (item.role === "user" || item.role === "assistant") && typeof item.content === "string" && typeof item.timestamp === "number" && Number.isFinite(item.timestamp);
|
|
720
|
+
})) return null;
|
|
721
|
+
if (record.activity !== void 0 && !Array.isArray(record.activity)) return null;
|
|
722
|
+
return value;
|
|
723
|
+
}
|
|
615
724
|
async function cleanupOldSessions() {
|
|
616
725
|
if (Date.now() - lastCleanupAt < CLEANUP_INTERVAL_MS) return;
|
|
617
726
|
const dir = sessionsDir();
|
|
@@ -623,6 +732,7 @@ async function cleanupOldSessions() {
|
|
|
623
732
|
const filePath = join(dir, file);
|
|
624
733
|
if (now - (await stat(filePath)).mtimeMs > MAX_AGE_MS) await unlink(filePath);
|
|
625
734
|
} catch {}
|
|
735
|
+
lastCleanupAt = Date.now();
|
|
626
736
|
return;
|
|
627
737
|
}
|
|
628
738
|
const fileStats = [];
|
|
@@ -650,7 +760,7 @@ async function cleanupOldSessions() {
|
|
|
650
760
|
async function getSession(sessionId) {
|
|
651
761
|
try {
|
|
652
762
|
const data = await readFile(sessionPath(sessionId), "utf-8");
|
|
653
|
-
return JSON.parse(data);
|
|
763
|
+
return asSessionData(JSON.parse(data), sessionId);
|
|
654
764
|
} catch {
|
|
655
765
|
return null;
|
|
656
766
|
}
|
|
@@ -658,7 +768,7 @@ async function getSession(sessionId) {
|
|
|
658
768
|
async function saveSession(session) {
|
|
659
769
|
await ensureDir();
|
|
660
770
|
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
661
|
-
await
|
|
771
|
+
await writeTextAtomically(sessionPath(session.sessionId), JSON.stringify(session, null, 2));
|
|
662
772
|
}
|
|
663
773
|
async function createSession(sessionId, agentId, channel, tenantId, userId) {
|
|
664
774
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -734,7 +844,8 @@ async function listSessions(filters, limit = 50) {
|
|
|
734
844
|
const summaries = [];
|
|
735
845
|
for (const file of jsonFiles) try {
|
|
736
846
|
const data = await readFile(join(dir, file), "utf-8");
|
|
737
|
-
const session = JSON.parse(data);
|
|
847
|
+
const session = asSessionData(JSON.parse(data));
|
|
848
|
+
if (!session) continue;
|
|
738
849
|
if (filters?.agentId && session.agentId !== filters.agentId) continue;
|
|
739
850
|
if (filters?.channel && session.channel !== filters.channel) continue;
|
|
740
851
|
if (filters?.tenantId && session.tenantId !== filters.tenantId) continue;
|
|
@@ -756,7 +867,8 @@ async function listSessions(filters, limit = 50) {
|
|
|
756
867
|
const aTime = a.lastMessageAt ?? a.createdAt;
|
|
757
868
|
return (b.lastMessageAt ?? b.createdAt).localeCompare(aTime);
|
|
758
869
|
});
|
|
759
|
-
|
|
870
|
+
const boundedLimit = Number.isFinite(limit) ? Math.min(100, Math.max(1, Math.trunc(limit))) : 50;
|
|
871
|
+
return summaries.slice(0, boundedLimit);
|
|
760
872
|
}
|
|
761
873
|
//#endregion
|
|
762
874
|
//#region src/host.ts
|
|
@@ -807,6 +919,72 @@ const PERSONA_POINTER_SYSTEM_PROMPT = [
|
|
|
807
919
|
"Your identity, persona, voice, and operating instructions live in CLAUDE.md and the other files in your workspace.",
|
|
808
920
|
"Fully embody that persona in how you speak and act; respond AS that agent, not as a generic coding assistant."
|
|
809
921
|
].join("\n");
|
|
922
|
+
const MAX_AGENT_MESSAGE_BYTES = 256 * 1024;
|
|
923
|
+
const MAX_SESSION_KEY_CHARS = 768;
|
|
924
|
+
const MAX_IDENTIFIER_CHARS = 512;
|
|
925
|
+
const MAX_MEMORY_CONTEXT_CHARS = 64 * 1024;
|
|
926
|
+
const MAX_CLAUDE_SESSION_IDS = 1e3;
|
|
927
|
+
const MAX_IDEMPOTENCY_KEYS = 1e4;
|
|
928
|
+
const SHUTDOWN_GRACE_MS = 3e3;
|
|
929
|
+
function asRecord(value) {
|
|
930
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
931
|
+
}
|
|
932
|
+
function hasControlCharacters(value) {
|
|
933
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
934
|
+
const code = value.charCodeAt(index);
|
|
935
|
+
if (code < 32 || code === 127) return true;
|
|
936
|
+
}
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
function readBoundedString(record, key, maxChars, required = false) {
|
|
940
|
+
const value = record[key];
|
|
941
|
+
if (value === void 0 && !required) return void 0;
|
|
942
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxChars || hasControlCharacters(value)) throw new Error(`Invalid ${key}`);
|
|
943
|
+
return value;
|
|
944
|
+
}
|
|
945
|
+
function parseAgentRpcParams(value) {
|
|
946
|
+
const record = asRecord(value);
|
|
947
|
+
if (!record) throw new Error("Invalid agent params");
|
|
948
|
+
const message = record.message;
|
|
949
|
+
if (typeof message !== "string" || message.length === 0 || Buffer.byteLength(message, "utf-8") > MAX_AGENT_MESSAGE_BYTES) throw new Error("Invalid message");
|
|
950
|
+
const agentId = readBoundedString(record, "agentId", 128, true);
|
|
951
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(agentId)) throw new Error("Invalid agentId");
|
|
952
|
+
const sessionKey = readBoundedString(record, "sessionKey", MAX_SESSION_KEY_CHARS, true);
|
|
953
|
+
const prefix = `agent:${agentId}:`;
|
|
954
|
+
if (!sessionKey.startsWith(prefix) || sessionKey.length === prefix.length) throw new Error("Invalid sessionKey");
|
|
955
|
+
const bareSessionId = stripAgentPrefix(sessionKey);
|
|
956
|
+
const conversationId = readBoundedString(record, "conversationId", MAX_IDENTIFIER_CHARS);
|
|
957
|
+
if (conversationId !== void 0 && conversationId !== bareSessionId) throw new Error("conversationId does not match sessionKey");
|
|
958
|
+
if (record.coalesce !== void 0 && record.coalesce !== "replace") throw new Error("Invalid coalesce mode");
|
|
959
|
+
if (record.origin !== void 0 && record.origin !== "voice") throw new Error("Invalid origin");
|
|
960
|
+
if (record.conversationType !== void 0 && record.conversationType !== "direct" && record.conversationType !== "group") throw new Error("Invalid conversationType");
|
|
961
|
+
return {
|
|
962
|
+
message,
|
|
963
|
+
agentId,
|
|
964
|
+
sessionKey,
|
|
965
|
+
...conversationId ? { conversationId } : {},
|
|
966
|
+
...record.coalesce === "replace" ? { coalesce: "replace" } : {},
|
|
967
|
+
...record.origin === "voice" ? { origin: "voice" } : {},
|
|
968
|
+
...record.conversationType === "direct" || record.conversationType === "group" ? { conversationType: record.conversationType } : {},
|
|
969
|
+
...copyOptionalStrings(record, [
|
|
970
|
+
["idempotencyKey", 256],
|
|
971
|
+
["clientType", 64],
|
|
972
|
+
["userId", MAX_IDENTIFIER_CHARS],
|
|
973
|
+
["identityId", MAX_IDENTIFIER_CHARS],
|
|
974
|
+
["tenantId", MAX_IDENTIFIER_CHARS],
|
|
975
|
+
["displayName", 256],
|
|
976
|
+
["chatMessageId", MAX_IDENTIFIER_CHARS]
|
|
977
|
+
])
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function copyOptionalStrings(record, fields) {
|
|
981
|
+
const result = {};
|
|
982
|
+
for (const [key, maxChars] of fields) {
|
|
983
|
+
const value = readBoundedString(record, key, maxChars);
|
|
984
|
+
if (value !== void 0) result[key] = value;
|
|
985
|
+
}
|
|
986
|
+
return result;
|
|
987
|
+
}
|
|
810
988
|
/**
|
|
811
989
|
* One FIFO backlog PER claude-session key. Unlike openclaw-chat's single
|
|
812
990
|
* per-daemon queue, the Claude Code host spawns a distinct `claude -p` per
|
|
@@ -817,9 +995,23 @@ const PERSONA_POINTER_SYSTEM_PROMPT = [
|
|
|
817
995
|
*/
|
|
818
996
|
var PerSessionQueues = class {
|
|
819
997
|
backlogs = /* @__PURE__ */ new Map();
|
|
820
|
-
|
|
998
|
+
runningKeys = /* @__PURE__ */ new Set();
|
|
999
|
+
readyKeys = [];
|
|
1000
|
+
readyKeySet = /* @__PURE__ */ new Set();
|
|
1001
|
+
activeCount = 0;
|
|
1002
|
+
queuedCount = 0;
|
|
1003
|
+
stoppedError = null;
|
|
1004
|
+
idleWaiters = [];
|
|
1005
|
+
constructor(maxConcurrent = 4, maxQueued = 100) {
|
|
1006
|
+
this.maxConcurrent = maxConcurrent;
|
|
1007
|
+
this.maxQueued = maxQueued;
|
|
1008
|
+
}
|
|
821
1009
|
enqueue(key, task, opts) {
|
|
822
1010
|
return new Promise((resolve, reject) => {
|
|
1011
|
+
if (this.stoppedError) {
|
|
1012
|
+
reject(this.stoppedError);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
823
1015
|
const entry = {
|
|
824
1016
|
run: task,
|
|
825
1017
|
coalesceKey: opts?.coalesceKey,
|
|
@@ -835,40 +1027,83 @@ var PerSessionQueues = class {
|
|
|
835
1027
|
queued.onSuperseded();
|
|
836
1028
|
} catch {}
|
|
837
1029
|
queued.resolve();
|
|
1030
|
+
this.queuedCount -= 1;
|
|
838
1031
|
} else survivors.push(queued);
|
|
839
1032
|
backlog.length = 0;
|
|
840
1033
|
backlog.push(...survivors);
|
|
841
1034
|
}
|
|
1035
|
+
if (this.queuedCount >= this.maxQueued) {
|
|
1036
|
+
reject(/* @__PURE__ */ new Error("Claude Code turn queue is full"));
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
842
1039
|
backlog.push(entry);
|
|
1040
|
+
this.queuedCount += 1;
|
|
843
1041
|
this.backlogs.set(key, backlog);
|
|
844
|
-
this.
|
|
1042
|
+
this.markReady(key);
|
|
1043
|
+
this.pump();
|
|
845
1044
|
});
|
|
846
1045
|
}
|
|
847
|
-
|
|
848
|
-
if (this.
|
|
849
|
-
this.
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1046
|
+
markReady(key) {
|
|
1047
|
+
if (this.runningKeys.has(key) || this.readyKeySet.has(key)) return;
|
|
1048
|
+
if ((this.backlogs.get(key)?.length ?? 0) === 0) return;
|
|
1049
|
+
this.readyKeySet.add(key);
|
|
1050
|
+
this.readyKeys.push(key);
|
|
1051
|
+
}
|
|
1052
|
+
pump() {
|
|
1053
|
+
while (this.activeCount < this.maxConcurrent && this.readyKeys.length > 0) {
|
|
1054
|
+
const key = this.readyKeys.shift();
|
|
1055
|
+
if (!key) break;
|
|
1056
|
+
this.readyKeySet.delete(key);
|
|
1057
|
+
if (this.runningKeys.has(key)) continue;
|
|
1058
|
+
const entry = this.backlogs.get(key)?.shift();
|
|
1059
|
+
if (!entry) {
|
|
1060
|
+
this.backlogs.delete(key);
|
|
1061
|
+
continue;
|
|
861
1062
|
}
|
|
1063
|
+
this.queuedCount -= 1;
|
|
1064
|
+
this.runningKeys.add(key);
|
|
1065
|
+
this.activeCount += 1;
|
|
1066
|
+
this.runOne(key, entry);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async runOne(key, entry) {
|
|
1070
|
+
try {
|
|
1071
|
+
await entry.run();
|
|
1072
|
+
entry.resolve();
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
entry.reject(err);
|
|
862
1075
|
} finally {
|
|
863
|
-
this.
|
|
864
|
-
|
|
1076
|
+
this.runningKeys.delete(key);
|
|
1077
|
+
this.activeCount -= 1;
|
|
1078
|
+
if (this.backlogs.get(key)?.length) this.markReady(key);
|
|
1079
|
+
else this.backlogs.delete(key);
|
|
1080
|
+
this.resolveIdleIfNeeded();
|
|
1081
|
+
this.pump();
|
|
865
1082
|
}
|
|
866
1083
|
}
|
|
1084
|
+
stop(error = /* @__PURE__ */ new Error("Claude Code host is stopping")) {
|
|
1085
|
+
if (this.stoppedError) return;
|
|
1086
|
+
this.stoppedError = error;
|
|
1087
|
+
for (const backlog of this.backlogs.values()) for (const entry of backlog) entry.reject(error);
|
|
1088
|
+
this.backlogs.clear();
|
|
1089
|
+
this.readyKeys = [];
|
|
1090
|
+
this.readyKeySet.clear();
|
|
1091
|
+
this.queuedCount = 0;
|
|
1092
|
+
this.resolveIdleIfNeeded();
|
|
1093
|
+
}
|
|
1094
|
+
whenIdle() {
|
|
1095
|
+
if (this.activeCount === 0) return Promise.resolve();
|
|
1096
|
+
return new Promise((resolve) => this.idleWaiters.push(resolve));
|
|
1097
|
+
}
|
|
1098
|
+
resolveIdleIfNeeded() {
|
|
1099
|
+
if (this.activeCount !== 0) return;
|
|
1100
|
+
const waiters = this.idleWaiters;
|
|
1101
|
+
this.idleWaiters = [];
|
|
1102
|
+
for (const resolve of waiters) resolve();
|
|
1103
|
+
}
|
|
867
1104
|
/** Test/introspection: total queued (not-yet-started) turns across all keys. */
|
|
868
1105
|
backlogLength() {
|
|
869
|
-
|
|
870
|
-
for (const b of this.backlogs.values()) n += b.length;
|
|
871
|
-
return n;
|
|
1106
|
+
return this.queuedCount;
|
|
872
1107
|
}
|
|
873
1108
|
};
|
|
874
1109
|
var ClaudeCodeHost = class {
|
|
@@ -893,6 +1128,9 @@ var ClaudeCodeHost = class {
|
|
|
893
1128
|
seenIdempotencyKeys = /* @__PURE__ */ new Map();
|
|
894
1129
|
/** RPC sessionKey → resolved SessionDriver so chat.abort can SIGINT the child. */
|
|
895
1130
|
activeDriverBySession = /* @__PURE__ */ new Map();
|
|
1131
|
+
claudeSessionSave = Promise.resolve();
|
|
1132
|
+
stopping = false;
|
|
1133
|
+
stopPromise = null;
|
|
896
1134
|
constructor(cfg, deps) {
|
|
897
1135
|
this.cfg = cfg;
|
|
898
1136
|
this.log = cfg.logger ?? console;
|
|
@@ -919,18 +1157,38 @@ var ClaudeCodeHost = class {
|
|
|
919
1157
|
}
|
|
920
1158
|
/** Start the chat WS. Non-blocking — the client reconnects internally. */
|
|
921
1159
|
async start() {
|
|
1160
|
+
if (this.stopping) throw new Error("Claude Code host is stopping");
|
|
922
1161
|
await this.loadClaudeSessionIds();
|
|
923
1162
|
this.chat.start();
|
|
924
1163
|
}
|
|
925
|
-
/** Stop
|
|
1164
|
+
/** Stop admission, abort owned children, and give active turns time to settle. */
|
|
926
1165
|
stop() {
|
|
1166
|
+
if (this.stopPromise) return this.stopPromise;
|
|
1167
|
+
this.stopping = true;
|
|
1168
|
+
this.queues.stop();
|
|
1169
|
+
for (const driver of this.activeDriverBySession.values()) driver.abort();
|
|
927
1170
|
this.chat.stop();
|
|
1171
|
+
this.stopPromise = (async () => {
|
|
1172
|
+
let timer;
|
|
1173
|
+
try {
|
|
1174
|
+
await Promise.race([this.queues.whenIdle(), new Promise((resolve) => {
|
|
1175
|
+
timer = setTimeout(resolve, SHUTDOWN_GRACE_MS);
|
|
1176
|
+
})]);
|
|
1177
|
+
} finally {
|
|
1178
|
+
if (timer) clearTimeout(timer);
|
|
1179
|
+
}
|
|
1180
|
+
})();
|
|
1181
|
+
return this.stopPromise;
|
|
928
1182
|
}
|
|
929
1183
|
/**
|
|
930
1184
|
* Inbound RPC dispatch. Invoked by the chat client's `onRequest` in
|
|
931
1185
|
* production; also the test seam (drive `agent`/`chat.abort`/… directly).
|
|
932
1186
|
*/
|
|
933
1187
|
async handleRequest(req) {
|
|
1188
|
+
if (this.stopping) {
|
|
1189
|
+
this.chat.sendResponse(req.id, false, { message: "Claude Code host is stopping" });
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
934
1192
|
try {
|
|
935
1193
|
switch (req.method) {
|
|
936
1194
|
case "agent":
|
|
@@ -959,28 +1217,31 @@ var ClaudeCodeHost = class {
|
|
|
959
1217
|
}
|
|
960
1218
|
}
|
|
961
1219
|
async handleAgent(req) {
|
|
962
|
-
const params = req.params;
|
|
1220
|
+
const params = parseAgentRpcParams(req.params);
|
|
963
1221
|
const sessionKey = params.sessionKey;
|
|
964
|
-
if (typeof sessionKey !== "string" || !sessionKey) {
|
|
965
|
-
this.chat.sendResponse(req.id, false, { message: "Missing sessionKey" });
|
|
966
|
-
return;
|
|
967
|
-
}
|
|
968
1222
|
const idem = params.idempotencyKey;
|
|
1223
|
+
if (idem) this.pruneIdempotency();
|
|
969
1224
|
if (idem && this.seenIdempotencyKeys.has(idem)) {
|
|
970
1225
|
this.chat.sendResponse(req.id, true, { sessionKey });
|
|
971
1226
|
return;
|
|
972
1227
|
}
|
|
973
|
-
|
|
1228
|
+
const claimedAt = idem ? Date.now() : void 0;
|
|
1229
|
+
if (idem && claimedAt !== void 0) this.seenIdempotencyKeys.set(idem, claimedAt);
|
|
974
1230
|
const coalesceKey = params.coalesce === "replace" ? sessionKey : void 0;
|
|
975
|
-
|
|
976
|
-
coalesceKey
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1231
|
+
try {
|
|
1232
|
+
await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params), coalesceKey ? {
|
|
1233
|
+
coalesceKey,
|
|
1234
|
+
onSuperseded: () => {
|
|
1235
|
+
this.chat.sendResponse(req.id, true, {
|
|
1236
|
+
superseded: true,
|
|
1237
|
+
sessionKey
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
} : void 0);
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
if (idem && this.seenIdempotencyKeys.get(idem) === claimedAt) this.seenIdempotencyKeys.delete(idem);
|
|
1243
|
+
throw error;
|
|
1244
|
+
}
|
|
984
1245
|
}
|
|
985
1246
|
async runAgentTurn(req, params) {
|
|
986
1247
|
const sessionKey = params.sessionKey;
|
|
@@ -991,10 +1252,11 @@ var ClaudeCodeHost = class {
|
|
|
991
1252
|
await addMessage(bareSessionId, "user", params.message, params.identityId ?? params.userId, params.displayName);
|
|
992
1253
|
let memoryContext = "";
|
|
993
1254
|
try {
|
|
994
|
-
memoryContext = (await this.api.memoryLoadContext()).formatted;
|
|
1255
|
+
memoryContext = (await this.api.memoryLoadContext()).formatted.slice(0, MAX_MEMORY_CONTEXT_CHARS);
|
|
995
1256
|
} catch (err) {
|
|
996
1257
|
this.log.warn(`[claude-host] memoryLoadContext failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
997
1258
|
}
|
|
1259
|
+
if (this.stopping) throw new Error("Claude Code host is stopping");
|
|
998
1260
|
const appendSystemPrompt = this.buildSystemPrompt(memoryContext, isVoice);
|
|
999
1261
|
const claudeSessionId = this.claudeSessionIds.get(bareSessionId) ?? null;
|
|
1000
1262
|
const driver = this.makeDriver();
|
|
@@ -1019,7 +1281,7 @@ var ClaudeCodeHost = class {
|
|
|
1019
1281
|
if (params.idempotencyKey) this.seenIdempotencyKeys.set(params.idempotencyKey, Date.now());
|
|
1020
1282
|
}
|
|
1021
1283
|
if (result.claudeSessionId && result.claudeSessionId !== claudeSessionId) {
|
|
1022
|
-
this.
|
|
1284
|
+
this.rememberClaudeSessionId(bareSessionId, result.claudeSessionId);
|
|
1023
1285
|
await this.saveClaudeSessionIds();
|
|
1024
1286
|
}
|
|
1025
1287
|
const activityRecords = collectTurnActivityRecords(result.emissions);
|
|
@@ -1061,7 +1323,7 @@ var ClaudeCodeHost = class {
|
|
|
1061
1323
|
*/
|
|
1062
1324
|
async runWithResumeFallback(driver, bareSessionId, _sessionKey, _runId, baseParams, isVoice) {
|
|
1063
1325
|
const first = await driver.runTurn(baseParams, isVoice ? "voice" : "chat");
|
|
1064
|
-
if (!(baseParams.claudeSessionId !== null && first.isError && first.claudeSessionId === null && first.emissions.length === 0)) return first;
|
|
1326
|
+
if (!(baseParams.claudeSessionId !== null && first.isError && first.claudeSessionId === null && first.emissions.length === 0) || this.stopping) return first;
|
|
1065
1327
|
this.log.warn(`[claude-host] --resume failed for ${bareSessionId}; starting a fresh claude session`);
|
|
1066
1328
|
this.claudeSessionIds.delete(bareSessionId);
|
|
1067
1329
|
const history = await this.buildHistoryPreamble(bareSessionId);
|
|
@@ -1118,24 +1380,25 @@ var ClaudeCodeHost = class {
|
|
|
1118
1380
|
this.chat.sendResponse(req.id, true, { aborted });
|
|
1119
1381
|
}
|
|
1120
1382
|
async handleSessionsList(req) {
|
|
1121
|
-
const params = req.params;
|
|
1383
|
+
const params = asRecord(req.params);
|
|
1384
|
+
if (!params) throw new Error("Invalid sessions.list params");
|
|
1122
1385
|
const sessions = await listSessions({
|
|
1123
|
-
channel: params
|
|
1124
|
-
tenantId: params
|
|
1125
|
-
userId: params
|
|
1386
|
+
channel: readBoundedString(params, "channel", 64),
|
|
1387
|
+
tenantId: readBoundedString(params, "tenantId", MAX_IDENTIFIER_CHARS),
|
|
1388
|
+
userId: readBoundedString(params, "userId", MAX_IDENTIFIER_CHARS)
|
|
1126
1389
|
});
|
|
1127
1390
|
this.chat.sendResponse(req.id, true, { sessions });
|
|
1128
1391
|
}
|
|
1129
1392
|
async handleSessionsGet(req) {
|
|
1130
|
-
const params = req.params;
|
|
1131
|
-
if (!params.
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
const session = await getSession(
|
|
1136
|
-
if (!session) {
|
|
1393
|
+
const params = asRecord(req.params);
|
|
1394
|
+
if (!params) throw new Error("Invalid sessions.get params");
|
|
1395
|
+
const sessionId = readBoundedString(params, "sessionId", MAX_IDENTIFIER_CHARS, true);
|
|
1396
|
+
const tenantId = readBoundedString(params, "tenantId", MAX_IDENTIFIER_CHARS);
|
|
1397
|
+
const userId = readBoundedString(params, "userId", MAX_IDENTIFIER_CHARS);
|
|
1398
|
+
const session = await getSession(sessionId);
|
|
1399
|
+
if (!session || tenantId !== void 0 && session.tenantId !== void 0 && session.tenantId !== tenantId || userId !== void 0 && session.userId !== void 0 && session.userId !== userId) {
|
|
1137
1400
|
this.chat.sendResponse(req.id, true, {
|
|
1138
|
-
sessionId
|
|
1401
|
+
sessionId,
|
|
1139
1402
|
messages: [],
|
|
1140
1403
|
activity: []
|
|
1141
1404
|
});
|
|
@@ -1172,29 +1435,54 @@ var ClaudeCodeHost = class {
|
|
|
1172
1435
|
return `Earlier in this conversation:\n${prior.map((m) => `${m.role === "assistant" ? "Assistant" : "User"}: ${m.content}`).join("\n")}`;
|
|
1173
1436
|
}
|
|
1174
1437
|
async ensureSession(bareSessionId, params) {
|
|
1175
|
-
|
|
1438
|
+
const existing = await getSession(bareSessionId);
|
|
1439
|
+
if (existing) {
|
|
1440
|
+
if (params.tenantId && existing.tenantId && params.tenantId !== existing.tenantId || params.userId && existing.userId && params.userId !== existing.userId) throw new Error("Session ownership metadata mismatch");
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1176
1443
|
await createSession(bareSessionId, params.agentId, params.clientType ?? "chat", params.tenantId, params.userId);
|
|
1177
1444
|
}
|
|
1178
1445
|
async loadClaudeSessionIds() {
|
|
1179
1446
|
try {
|
|
1180
1447
|
const raw = await readFile(this.claudeSessionsPath, "utf-8");
|
|
1181
|
-
const parsed = JSON.parse(raw);
|
|
1182
|
-
|
|
1448
|
+
const parsed = asRecord(JSON.parse(raw));
|
|
1449
|
+
if (!parsed) return;
|
|
1450
|
+
this.claudeSessionIds.clear();
|
|
1451
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
1452
|
+
if (this.claudeSessionIds.size >= MAX_CLAUDE_SESSION_IDS) break;
|
|
1453
|
+
if (key.length > 0 && key.length <= MAX_IDENTIFIER_CHARS && typeof value === "string" && value.length > 0 && value.length <= 256) this.claudeSessionIds.set(key, value);
|
|
1454
|
+
}
|
|
1183
1455
|
} catch {}
|
|
1184
1456
|
}
|
|
1185
1457
|
async saveClaudeSessionIds() {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1458
|
+
this.claudeSessionSave = this.claudeSessionSave.then(async () => {
|
|
1459
|
+
const obj = Object.create(null);
|
|
1460
|
+
for (const [key, value] of this.claudeSessionIds) obj[key] = value;
|
|
1461
|
+
try {
|
|
1462
|
+
await writeTextAtomically(this.claudeSessionsPath, JSON.stringify(obj, null, 2));
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
this.log.warn(`[claude-host] failed to persist claude session ids: ${err instanceof Error ? err.message : String(err)}`);
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
await this.claudeSessionSave;
|
|
1468
|
+
}
|
|
1469
|
+
rememberClaudeSessionId(sessionId, claudeSessionId) {
|
|
1470
|
+
this.claudeSessionIds.delete(sessionId);
|
|
1471
|
+
this.claudeSessionIds.set(sessionId, claudeSessionId);
|
|
1472
|
+
while (this.claudeSessionIds.size > MAX_CLAUDE_SESSION_IDS) {
|
|
1473
|
+
const oldest = this.claudeSessionIds.keys().next().value;
|
|
1474
|
+
if (!oldest) break;
|
|
1475
|
+
this.claudeSessionIds.delete(oldest);
|
|
1193
1476
|
}
|
|
1194
1477
|
}
|
|
1195
1478
|
pruneIdempotency() {
|
|
1196
1479
|
const cutoff = Date.now() - 10 * 6e4;
|
|
1197
1480
|
for (const [k, ts] of this.seenIdempotencyKeys) if (ts < cutoff) this.seenIdempotencyKeys.delete(k);
|
|
1481
|
+
while (this.seenIdempotencyKeys.size >= MAX_IDEMPOTENCY_KEYS) {
|
|
1482
|
+
const oldest = this.seenIdempotencyKeys.keys().next().value;
|
|
1483
|
+
if (!oldest) break;
|
|
1484
|
+
this.seenIdempotencyKeys.delete(oldest);
|
|
1485
|
+
}
|
|
1198
1486
|
}
|
|
1199
1487
|
};
|
|
1200
1488
|
/** Strip the `agent:{agentId}:` prefix from a full server session key. */
|
|
@@ -1230,4 +1518,4 @@ function resolveHostConfig() {
|
|
|
1230
1518
|
};
|
|
1231
1519
|
}
|
|
1232
1520
|
//#endregion
|
|
1233
|
-
export {
|
|
1521
|
+
export { buildChildEnv as C, SCRUBBED_ANTHROPIC_ENV_VARS as S, collectTurnActivityRecords as _, appendActivity as a, mapStreamEvents as b, listSessions as c, DEFAULT_ALLOWED_TOOLS as d, DEFAULT_PERMISSION_MODE as f, WARM_STREAMING_SUPPORTED as g, TURN_TIMEOUT_MS as h, addMessage as i, saveSession as l, TERMINATION_GRACE_MS as m, ClaudeCodeHost as n, createSession as o, SessionDriver as p, stripAgentPrefix as r, getSession as s, resolveHostConfig as t, CLAUDE_PERMISSION_MODES as u, createMapperState as v, BRIDGE_ENV_VAR as x, mapStreamEvent as y };
|