@alfe.ai/claude-code-host 0.1.6 → 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.
@@ -5,6 +5,7 @@ let node_path = require("node:path");
5
5
  let node_os = require("node:os");
6
6
  let node_child_process = require("node:child_process");
7
7
  let node_readline = require("node:readline");
8
+ let node_crypto = require("node:crypto");
8
9
  let node_fs = require("node:fs");
9
10
  let _alfe_ai_config = require("@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)) return content.map((c) => {
110
- const r = asRecord(c);
111
- return r?.type === "text" && typeof r.text === "string" ? r.text : "";
112
- }).join("");
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 resultRaw = toolResultText(b.content);
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 <prompt> --resume <claudeSessionId> --output-format stream-json
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 default` (NEVER `bypassPermissions` /
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
- /** Sane default permission posture. NEVER `bypassPermissions`. */
392
- const DEFAULT_PERMISSION_MODE = "default";
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
- if (this.child) try {
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
- params.prompt,
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
- params.permissionMode ?? "default",
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
- const stderrChunks = [];
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
- try {
507
- child.stdin.end();
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 = (0, node_readline.createInterface)({ input: child.stdout });
517
549
  const finish = (isError, errorMessage) => {
518
550
  if (settled) return;
519
551
  settled = true;
520
552
  clearTimeout(timeout);
521
- this.child = null;
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
- (0, node_readline.createInterface)({ input: child.stdout }).on("line", (line) => {
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
- emissions.push(emission);
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
- stderrChunks.push(chunk.toString("utf-8"));
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
- finish(true, `claude process error: ${err.message}`);
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 = (0, node_path.dirname)(path);
662
+ await (0, node_fs_promises.mkdir)(parent, { recursive: true });
663
+ const temporary = (0, node_path.join)(parent, `.${(0, node_path.basename)(path)}.${String(process.pid)}.${(0, node_crypto.randomUUID)()}.tmp`);
664
+ try {
665
+ await (0, node_fs_promises.writeFile)(temporary, contents, {
666
+ encoding: "utf-8",
667
+ mode: 384
668
+ });
669
+ await (0, node_fs_promises.rename)(temporary, path);
670
+ } catch (error) {
671
+ await (0, node_fs_promises.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 (0, node_path.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 = (0, node_path.join)(dir, file);
624
733
  if (now - (await (0, node_fs_promises.stat)(filePath)).mtimeMs > MAX_AGE_MS) await (0, node_fs_promises.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 (0, node_fs_promises.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 (0, node_fs_promises.writeFile)(sessionPath(session.sessionId), JSON.stringify(session, null, 2), "utf-8");
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 (0, node_fs_promises.readFile)((0, node_path.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
- return summaries.slice(0, limit);
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
- draining = /* @__PURE__ */ new Set();
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.drain(key);
1042
+ this.markReady(key);
1043
+ this.pump();
845
1044
  });
846
1045
  }
847
- async drain(key) {
848
- if (this.draining.has(key)) return;
849
- this.draining.add(key);
850
- try {
851
- const backlog = this.backlogs.get(key);
852
- while (backlog && backlog.length > 0) {
853
- const entry = backlog.shift();
854
- if (!entry) break;
855
- try {
856
- await entry.run();
857
- entry.resolve();
858
- } catch (err) {
859
- entry.reject(err);
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.draining.delete(key);
864
- if (this.backlogs.get(key)?.length === 0) this.backlogs.delete(key);
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
- let n = 0;
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 the chat WS (SIGTERM path). */
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
- if (idem) this.pruneIdempotency();
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
- await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params), coalesceKey ? {
976
- coalesceKey,
977
- onSuperseded: () => {
978
- this.chat.sendResponse(req.id, true, {
979
- superseded: true,
980
- sessionKey
981
- });
982
- }
983
- } : void 0);
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.claudeSessionIds.set(bareSessionId, result.claudeSessionId);
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.channel,
1124
- tenantId: params.tenantId,
1125
- userId: params.userId
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.sessionId) {
1132
- this.chat.sendResponse(req.id, false, { message: "Missing sessionId" });
1133
- return;
1134
- }
1135
- const session = await getSession(params.sessionId);
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: params.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
- if (await getSession(bareSessionId)) return;
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 (0, node_fs_promises.readFile)(this.claudeSessionsPath, "utf-8");
1181
- const parsed = JSON.parse(raw);
1182
- for (const [k, v] of Object.entries(parsed)) if (typeof v === "string") this.claudeSessionIds.set(k, v);
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
- const obj = {};
1187
- for (const [k, v] of this.claudeSessionIds) obj[k] = v;
1188
- try {
1189
- await (0, node_fs_promises.mkdir)((0, node_path.dirname)(this.claudeSessionsPath), { recursive: true });
1190
- await (0, node_fs_promises.writeFile)(this.claudeSessionsPath, JSON.stringify(obj, null, 2), "utf-8");
1191
- } catch (err) {
1192
- this.log.warn(`[claude-host] failed to persist claude session ids: ${err instanceof Error ? err.message : String(err)}`);
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. */
@@ -1236,6 +1524,12 @@ Object.defineProperty(exports, "BRIDGE_ENV_VAR", {
1236
1524
  return BRIDGE_ENV_VAR;
1237
1525
  }
1238
1526
  });
1527
+ Object.defineProperty(exports, "CLAUDE_PERMISSION_MODES", {
1528
+ enumerable: true,
1529
+ get: function() {
1530
+ return CLAUDE_PERMISSION_MODES;
1531
+ }
1532
+ });
1239
1533
  Object.defineProperty(exports, "ClaudeCodeHost", {
1240
1534
  enumerable: true,
1241
1535
  get: function() {
@@ -1266,6 +1560,12 @@ Object.defineProperty(exports, "SessionDriver", {
1266
1560
  return SessionDriver;
1267
1561
  }
1268
1562
  });
1563
+ Object.defineProperty(exports, "TERMINATION_GRACE_MS", {
1564
+ enumerable: true,
1565
+ get: function() {
1566
+ return TERMINATION_GRACE_MS;
1567
+ }
1568
+ });
1269
1569
  Object.defineProperty(exports, "TURN_TIMEOUT_MS", {
1270
1570
  enumerable: true,
1271
1571
  get: function() {