@alfe.ai/claude-code-host 0.1.12 → 0.1.13

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.
@@ -3,9 +3,9 @@ let _alfe_ai_chat = require("@alfe.ai/chat");
3
3
  let node_fs_promises = require("node:fs/promises");
4
4
  let node_path = require("node:path");
5
5
  let node_os = require("node:os");
6
+ let node_crypto = require("node:crypto");
6
7
  let node_child_process = require("node:child_process");
7
8
  let node_readline = require("node:readline");
8
- let node_crypto = require("node:crypto");
9
9
  let node_fs = require("node:fs");
10
10
  let _alfe_ai_config = require("@alfe.ai/config");
11
11
  //#region src/child-env.ts
@@ -925,6 +925,7 @@ const MAX_IDENTIFIER_CHARS = 512;
925
925
  const MAX_MEMORY_CONTEXT_CHARS = 64 * 1024;
926
926
  const MAX_CLAUDE_SESSION_IDS = 1e3;
927
927
  const MAX_IDEMPOTENCY_KEYS = 1e4;
928
+ const MAX_IDEMPOTENCY_RESULT_BYTES = 8 * 1024 * 1024;
928
929
  const SHUTDOWN_GRACE_MS = 3e3;
929
930
  function asRecord(value) {
930
931
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
@@ -1122,12 +1123,10 @@ var ClaudeCodeHost = class {
1122
1123
  /** Alfe sessionId → claude session id map (mirrors ~/.alfe/claude-code/sessions.json). */
1123
1124
  claudeSessionIds = /* @__PURE__ */ new Map();
1124
1125
  claudeSessionsPath;
1125
- /** RPC sessionKey → currently RUNNING request id (for chat.abort targeting). */
1126
+ /** Session → one running turn's wire identity and owned child. */
1126
1127
  runningTurns = /* @__PURE__ */ new Map();
1127
- /** idempotencyKey → settled request id, for E3 duplicate-RPC dedupe. */
1128
- seenIdempotencyKeys = /* @__PURE__ */ new Map();
1129
- /** RPC sessionKey → resolved SessionDriver so chat.abort can SIGINT the child. */
1130
- activeDriverBySession = /* @__PURE__ */ new Map();
1128
+ /** Process-local admission/result replay. In-flight records are never evicted. */
1129
+ agentOperations = /* @__PURE__ */ new Map();
1131
1130
  claudeSessionSave = Promise.resolve();
1132
1131
  stopping = false;
1133
1132
  stopPromise = null;
@@ -1166,7 +1165,10 @@ var ClaudeCodeHost = class {
1166
1165
  if (this.stopPromise) return this.stopPromise;
1167
1166
  this.stopping = true;
1168
1167
  this.queues.stop();
1169
- for (const driver of this.activeDriverBySession.values()) driver.abort();
1168
+ for (const { driver, cancellation } of this.runningTurns.values()) {
1169
+ cancellation.abort();
1170
+ driver.abort();
1171
+ }
1170
1172
  this.chat.stop();
1171
1173
  this.stopPromise = (async () => {
1172
1174
  let timer;
@@ -1204,7 +1206,7 @@ var ClaudeCodeHost = class {
1204
1206
  await this.handleSessionsGet(req);
1205
1207
  return;
1206
1208
  case "runtime.activity":
1207
- this.chat.sendResponse(req.id, true, { active: this.activeDriverBySession.size > 0 });
1209
+ this.chat.sendResponse(req.id, true, { active: this.runningTurns.size > 0 });
1208
1210
  return;
1209
1211
  default:
1210
1212
  this.chat.sendResponse(req.id, false, { message: `Unknown method: ${req.method}` });
@@ -1221,29 +1223,63 @@ var ClaudeCodeHost = class {
1221
1223
  const sessionKey = params.sessionKey;
1222
1224
  const idem = params.idempotencyKey;
1223
1225
  if (idem) this.pruneIdempotency();
1224
- if (idem && this.seenIdempotencyKeys.has(idem)) {
1225
- this.chat.sendResponse(req.id, true, { sessionKey });
1226
+ const fingerprint = (0, node_crypto.createHash)("sha256").update(JSON.stringify(params)).digest("hex");
1227
+ const existing = idem ? this.agentOperations.get(idem) : void 0;
1228
+ if (existing) {
1229
+ if (existing.fingerprint !== fingerprint) throw new Error("Idempotency key already belongs to a different agent request");
1230
+ if (existing.outcome) this.deliverAgentOutcome(req.id, existing.outcome);
1231
+ else {
1232
+ const outcome = await existing.completion;
1233
+ this.chat.sendResponse(req.id, outcome.ok, outcome.payload);
1234
+ }
1226
1235
  return;
1227
1236
  }
1228
- const claimedAt = idem ? Date.now() : void 0;
1229
- if (idem && claimedAt !== void 0) this.seenIdempotencyKeys.set(idem, claimedAt);
1237
+ let resolveCompletion;
1238
+ const operation = {
1239
+ fingerprint,
1240
+ completion: new Promise((resolve) => {
1241
+ resolveCompletion = resolve;
1242
+ }),
1243
+ resultBytes: 0
1244
+ };
1245
+ if (idem) this.agentOperations.set(idem, operation);
1246
+ const complete = (outcome) => {
1247
+ if (operation.outcome) return;
1248
+ operation.outcome = outcome;
1249
+ operation.settledAt = Date.now();
1250
+ operation.resultBytes = Buffer.byteLength(JSON.stringify(outcome), "utf-8");
1251
+ this.deliverAgentOutcome(req.id, outcome);
1252
+ resolveCompletion(outcome);
1253
+ if (idem) this.pruneIdempotency();
1254
+ };
1230
1255
  const coalesceKey = params.coalesce === "replace" ? sessionKey : void 0;
1231
1256
  try {
1232
- await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params), coalesceKey ? {
1257
+ await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params, complete), coalesceKey ? {
1233
1258
  coalesceKey,
1234
1259
  onSuperseded: () => {
1235
- this.chat.sendResponse(req.id, true, {
1236
- superseded: true,
1237
- sessionKey
1260
+ complete({
1261
+ ok: true,
1262
+ payload: {
1263
+ superseded: true,
1264
+ sessionKey
1265
+ }
1238
1266
  });
1239
1267
  }
1240
1268
  } : void 0);
1241
1269
  } catch (error) {
1242
- if (idem && this.seenIdempotencyKeys.get(idem) === claimedAt) this.seenIdempotencyKeys.delete(idem);
1243
- throw error;
1270
+ const message = error instanceof Error ? error.message : String(error);
1271
+ this.log.error(`[claude-host] agent operation failed: ${message}`);
1272
+ complete({
1273
+ ok: false,
1274
+ payload: { message }
1275
+ });
1244
1276
  }
1245
1277
  }
1246
- async runAgentTurn(req, params) {
1278
+ deliverAgentOutcome(requestId, outcome) {
1279
+ if (outcome.notification) this.chat.notify("agent-message", outcome.notification);
1280
+ this.chat.sendResponse(requestId, outcome.ok, outcome.payload);
1281
+ }
1282
+ async runAgentTurn(req, params, complete) {
1247
1283
  const sessionKey = params.sessionKey;
1248
1284
  const bareSessionId = params.conversationId ?? stripAgentPrefix(sessionKey);
1249
1285
  const runId = params.idempotencyKey ?? req.id;
@@ -1260,11 +1296,15 @@ var ClaudeCodeHost = class {
1260
1296
  const appendSystemPrompt = this.buildSystemPrompt(memoryContext, isVoice);
1261
1297
  const claudeSessionId = this.claudeSessionIds.get(bareSessionId) ?? null;
1262
1298
  const driver = this.makeDriver();
1263
- this.runningTurns.set(sessionKey, req.id);
1264
- this.activeDriverBySession.set(sessionKey, driver);
1299
+ const cancellation = new AbortController();
1300
+ this.runningTurns.set(sessionKey, {
1301
+ runId,
1302
+ driver,
1303
+ cancellation
1304
+ });
1265
1305
  let result;
1266
1306
  try {
1267
- result = await this.runWithResumeFallback(driver, bareSessionId, sessionKey, runId, {
1307
+ result = await this.runWithResumeFallback(driver, bareSessionId, cancellation.signal, {
1268
1308
  prompt: params.message,
1269
1309
  claudeSessionId,
1270
1310
  cwd: this.cfg.workspace,
@@ -1277,8 +1317,6 @@ var ClaudeCodeHost = class {
1277
1317
  }, isVoice);
1278
1318
  } finally {
1279
1319
  this.runningTurns.delete(sessionKey);
1280
- this.activeDriverBySession.delete(sessionKey);
1281
- if (params.idempotencyKey) this.seenIdempotencyKeys.set(params.idempotencyKey, Date.now());
1282
1320
  }
1283
1321
  if (result.claudeSessionId && result.claudeSessionId !== claudeSessionId) {
1284
1322
  this.rememberClaudeSessionId(bareSessionId, result.claudeSessionId);
@@ -1286,19 +1324,32 @@ var ClaudeCodeHost = class {
1286
1324
  }
1287
1325
  const activityRecords = collectTurnActivityRecords(result.emissions);
1288
1326
  if (activityRecords.length) await appendActivity(bareSessionId, activityRecords);
1289
- if (result.aborted) return;
1327
+ if (result.aborted) {
1328
+ complete({
1329
+ ok: true,
1330
+ payload: { sessionKey }
1331
+ });
1332
+ return;
1333
+ }
1290
1334
  if (result.isError) {
1291
- this.chat.sendResponse(req.id, false, { message: result.errorMessage ?? "Claude Code turn failed" });
1335
+ complete({
1336
+ ok: false,
1337
+ payload: { message: result.errorMessage ?? "Claude Code turn failed" }
1338
+ });
1292
1339
  return;
1293
1340
  }
1294
1341
  if (result.finalText) await addMessage(bareSessionId, "assistant", result.finalText);
1295
- if (result.finalText) this.chat.notify("agent-message", {
1342
+ const notification = result.finalText ? {
1296
1343
  conversationId: bareSessionId,
1297
1344
  text: result.finalText,
1298
1345
  sessionKey,
1299
1346
  messageId: params.chatMessageId ?? req.id
1347
+ } : void 0;
1348
+ complete({
1349
+ ok: true,
1350
+ payload: { sessionKey },
1351
+ ...notification ? { notification } : {}
1300
1352
  });
1301
- this.chat.sendResponse(req.id, true, { sessionKey });
1302
1353
  try {
1303
1354
  const messages = [{
1304
1355
  role: "user",
@@ -1321,18 +1372,30 @@ var ClaudeCodeHost = class {
1321
1372
  * Run a turn; on a `--resume` failure (stale/missing claude session id) start
1322
1373
  * fresh and prepend the last-N stored messages so the model keeps context.
1323
1374
  */
1324
- async runWithResumeFallback(driver, bareSessionId, _sessionKey, _runId, baseParams, isVoice) {
1375
+ async runWithResumeFallback(driver, bareSessionId, cancellation, baseParams, isVoice) {
1376
+ const cancelled = () => cancellation.aborted || this.stopping;
1377
+ const aborted = (result) => ({
1378
+ claudeSessionId: result?.claudeSessionId ?? null,
1379
+ emissions: result?.emissions ?? [],
1380
+ isError: false,
1381
+ finalText: "",
1382
+ aborted: true
1383
+ });
1384
+ if (cancelled()) return aborted();
1325
1385
  const first = await driver.runTurn(baseParams, isVoice ? "voice" : "chat");
1326
- if (!(baseParams.claudeSessionId !== null && first.isError && first.claudeSessionId === null && first.emissions.length === 0) || this.stopping) return first;
1386
+ if (cancelled()) return aborted(first);
1387
+ if (!(baseParams.claudeSessionId !== null && first.isError && first.claudeSessionId === null && first.emissions.length === 0) || first.aborted) return first;
1327
1388
  this.log.warn(`[claude-host] --resume failed for ${bareSessionId}; starting a fresh claude session`);
1328
1389
  this.claudeSessionIds.delete(bareSessionId);
1329
1390
  const history = await this.buildHistoryPreamble(bareSessionId);
1391
+ if (cancelled()) return aborted(first);
1330
1392
  const freshPrompt = history ? `${history}\n\n---\n\n${baseParams.prompt}` : baseParams.prompt;
1331
- return driver.runTurn({
1393
+ const fresh = await driver.runTurn({
1332
1394
  ...baseParams,
1333
1395
  claudeSessionId: null,
1334
1396
  prompt: freshPrompt
1335
1397
  }, isVoice ? "voice" : "chat");
1398
+ return cancelled() ? aborted(fresh) : fresh;
1336
1399
  }
1337
1400
  forwardEmission(runId, sessionKey, emission) {
1338
1401
  if (emission.type === "chat-delta") {
@@ -1368,13 +1431,13 @@ var ClaudeCodeHost = class {
1368
1431
  }
1369
1432
  }
1370
1433
  handleChatAbort(req) {
1371
- const { sessionKey } = req.params;
1434
+ const { sessionKey, runId } = req.params;
1372
1435
  let aborted = false;
1373
- const driver = sessionKey ? this.activeDriverBySession.get(sessionKey) : void 0;
1374
- if (sessionKey && driver) {
1375
- driver.abort();
1376
- const runningReqId = this.runningTurns.get(sessionKey);
1377
- if (runningReqId) this.chat.sendResponse(runningReqId, true, { sessionKey });
1436
+ const active = sessionKey ? this.runningTurns.get(sessionKey) : void 0;
1437
+ const matchesRun = runId === void 0 || typeof runId === "string" && runId.length > 0 && runId.length <= 256 && active?.runId === runId;
1438
+ if (sessionKey && active && matchesRun) {
1439
+ active.cancellation.abort();
1440
+ active.driver.abort();
1378
1441
  aborted = true;
1379
1442
  }
1380
1443
  this.chat.sendResponse(req.id, true, { aborted });
@@ -1477,11 +1540,14 @@ var ClaudeCodeHost = class {
1477
1540
  }
1478
1541
  pruneIdempotency() {
1479
1542
  const cutoff = Date.now() - 10 * 6e4;
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);
1543
+ let resultBytes = 0;
1544
+ for (const [key, operation] of this.agentOperations) if (operation.settledAt !== void 0 && operation.settledAt < cutoff) this.agentOperations.delete(key);
1545
+ else resultBytes += operation.resultBytes;
1546
+ for (const [key, operation] of this.agentOperations) {
1547
+ if (this.agentOperations.size < MAX_IDEMPOTENCY_KEYS && resultBytes <= MAX_IDEMPOTENCY_RESULT_BYTES) break;
1548
+ if (operation.settledAt === void 0) continue;
1549
+ this.agentOperations.delete(key);
1550
+ resultBytes -= operation.resultBytes;
1485
1551
  }
1486
1552
  }
1487
1553
  };
@@ -3,9 +3,9 @@ import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
3
3
  import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { homedir } from "node:os";
6
+ import { createHash, randomUUID } from "node:crypto";
6
7
  import { spawn } from "node:child_process";
7
8
  import { createInterface } from "node:readline";
8
- import { randomUUID } from "node:crypto";
9
9
  import { existsSync } from "node:fs";
10
10
  import { resolveConfig } from "@alfe.ai/config";
11
11
  //#region src/child-env.ts
@@ -925,6 +925,7 @@ const MAX_IDENTIFIER_CHARS = 512;
925
925
  const MAX_MEMORY_CONTEXT_CHARS = 64 * 1024;
926
926
  const MAX_CLAUDE_SESSION_IDS = 1e3;
927
927
  const MAX_IDEMPOTENCY_KEYS = 1e4;
928
+ const MAX_IDEMPOTENCY_RESULT_BYTES = 8 * 1024 * 1024;
928
929
  const SHUTDOWN_GRACE_MS = 3e3;
929
930
  function asRecord(value) {
930
931
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
@@ -1122,12 +1123,10 @@ var ClaudeCodeHost = class {
1122
1123
  /** Alfe sessionId → claude session id map (mirrors ~/.alfe/claude-code/sessions.json). */
1123
1124
  claudeSessionIds = /* @__PURE__ */ new Map();
1124
1125
  claudeSessionsPath;
1125
- /** RPC sessionKey → currently RUNNING request id (for chat.abort targeting). */
1126
+ /** Session → one running turn's wire identity and owned child. */
1126
1127
  runningTurns = /* @__PURE__ */ new Map();
1127
- /** idempotencyKey → settled request id, for E3 duplicate-RPC dedupe. */
1128
- seenIdempotencyKeys = /* @__PURE__ */ new Map();
1129
- /** RPC sessionKey → resolved SessionDriver so chat.abort can SIGINT the child. */
1130
- activeDriverBySession = /* @__PURE__ */ new Map();
1128
+ /** Process-local admission/result replay. In-flight records are never evicted. */
1129
+ agentOperations = /* @__PURE__ */ new Map();
1131
1130
  claudeSessionSave = Promise.resolve();
1132
1131
  stopping = false;
1133
1132
  stopPromise = null;
@@ -1166,7 +1165,10 @@ var ClaudeCodeHost = class {
1166
1165
  if (this.stopPromise) return this.stopPromise;
1167
1166
  this.stopping = true;
1168
1167
  this.queues.stop();
1169
- for (const driver of this.activeDriverBySession.values()) driver.abort();
1168
+ for (const { driver, cancellation } of this.runningTurns.values()) {
1169
+ cancellation.abort();
1170
+ driver.abort();
1171
+ }
1170
1172
  this.chat.stop();
1171
1173
  this.stopPromise = (async () => {
1172
1174
  let timer;
@@ -1204,7 +1206,7 @@ var ClaudeCodeHost = class {
1204
1206
  await this.handleSessionsGet(req);
1205
1207
  return;
1206
1208
  case "runtime.activity":
1207
- this.chat.sendResponse(req.id, true, { active: this.activeDriverBySession.size > 0 });
1209
+ this.chat.sendResponse(req.id, true, { active: this.runningTurns.size > 0 });
1208
1210
  return;
1209
1211
  default:
1210
1212
  this.chat.sendResponse(req.id, false, { message: `Unknown method: ${req.method}` });
@@ -1221,29 +1223,63 @@ var ClaudeCodeHost = class {
1221
1223
  const sessionKey = params.sessionKey;
1222
1224
  const idem = params.idempotencyKey;
1223
1225
  if (idem) this.pruneIdempotency();
1224
- if (idem && this.seenIdempotencyKeys.has(idem)) {
1225
- this.chat.sendResponse(req.id, true, { sessionKey });
1226
+ const fingerprint = createHash("sha256").update(JSON.stringify(params)).digest("hex");
1227
+ const existing = idem ? this.agentOperations.get(idem) : void 0;
1228
+ if (existing) {
1229
+ if (existing.fingerprint !== fingerprint) throw new Error("Idempotency key already belongs to a different agent request");
1230
+ if (existing.outcome) this.deliverAgentOutcome(req.id, existing.outcome);
1231
+ else {
1232
+ const outcome = await existing.completion;
1233
+ this.chat.sendResponse(req.id, outcome.ok, outcome.payload);
1234
+ }
1226
1235
  return;
1227
1236
  }
1228
- const claimedAt = idem ? Date.now() : void 0;
1229
- if (idem && claimedAt !== void 0) this.seenIdempotencyKeys.set(idem, claimedAt);
1237
+ let resolveCompletion;
1238
+ const operation = {
1239
+ fingerprint,
1240
+ completion: new Promise((resolve) => {
1241
+ resolveCompletion = resolve;
1242
+ }),
1243
+ resultBytes: 0
1244
+ };
1245
+ if (idem) this.agentOperations.set(idem, operation);
1246
+ const complete = (outcome) => {
1247
+ if (operation.outcome) return;
1248
+ operation.outcome = outcome;
1249
+ operation.settledAt = Date.now();
1250
+ operation.resultBytes = Buffer.byteLength(JSON.stringify(outcome), "utf-8");
1251
+ this.deliverAgentOutcome(req.id, outcome);
1252
+ resolveCompletion(outcome);
1253
+ if (idem) this.pruneIdempotency();
1254
+ };
1230
1255
  const coalesceKey = params.coalesce === "replace" ? sessionKey : void 0;
1231
1256
  try {
1232
- await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params), coalesceKey ? {
1257
+ await this.queues.enqueue(sessionKey, () => this.runAgentTurn(req, params, complete), coalesceKey ? {
1233
1258
  coalesceKey,
1234
1259
  onSuperseded: () => {
1235
- this.chat.sendResponse(req.id, true, {
1236
- superseded: true,
1237
- sessionKey
1260
+ complete({
1261
+ ok: true,
1262
+ payload: {
1263
+ superseded: true,
1264
+ sessionKey
1265
+ }
1238
1266
  });
1239
1267
  }
1240
1268
  } : void 0);
1241
1269
  } catch (error) {
1242
- if (idem && this.seenIdempotencyKeys.get(idem) === claimedAt) this.seenIdempotencyKeys.delete(idem);
1243
- throw error;
1270
+ const message = error instanceof Error ? error.message : String(error);
1271
+ this.log.error(`[claude-host] agent operation failed: ${message}`);
1272
+ complete({
1273
+ ok: false,
1274
+ payload: { message }
1275
+ });
1244
1276
  }
1245
1277
  }
1246
- async runAgentTurn(req, params) {
1278
+ deliverAgentOutcome(requestId, outcome) {
1279
+ if (outcome.notification) this.chat.notify("agent-message", outcome.notification);
1280
+ this.chat.sendResponse(requestId, outcome.ok, outcome.payload);
1281
+ }
1282
+ async runAgentTurn(req, params, complete) {
1247
1283
  const sessionKey = params.sessionKey;
1248
1284
  const bareSessionId = params.conversationId ?? stripAgentPrefix(sessionKey);
1249
1285
  const runId = params.idempotencyKey ?? req.id;
@@ -1260,11 +1296,15 @@ var ClaudeCodeHost = class {
1260
1296
  const appendSystemPrompt = this.buildSystemPrompt(memoryContext, isVoice);
1261
1297
  const claudeSessionId = this.claudeSessionIds.get(bareSessionId) ?? null;
1262
1298
  const driver = this.makeDriver();
1263
- this.runningTurns.set(sessionKey, req.id);
1264
- this.activeDriverBySession.set(sessionKey, driver);
1299
+ const cancellation = new AbortController();
1300
+ this.runningTurns.set(sessionKey, {
1301
+ runId,
1302
+ driver,
1303
+ cancellation
1304
+ });
1265
1305
  let result;
1266
1306
  try {
1267
- result = await this.runWithResumeFallback(driver, bareSessionId, sessionKey, runId, {
1307
+ result = await this.runWithResumeFallback(driver, bareSessionId, cancellation.signal, {
1268
1308
  prompt: params.message,
1269
1309
  claudeSessionId,
1270
1310
  cwd: this.cfg.workspace,
@@ -1277,8 +1317,6 @@ var ClaudeCodeHost = class {
1277
1317
  }, isVoice);
1278
1318
  } finally {
1279
1319
  this.runningTurns.delete(sessionKey);
1280
- this.activeDriverBySession.delete(sessionKey);
1281
- if (params.idempotencyKey) this.seenIdempotencyKeys.set(params.idempotencyKey, Date.now());
1282
1320
  }
1283
1321
  if (result.claudeSessionId && result.claudeSessionId !== claudeSessionId) {
1284
1322
  this.rememberClaudeSessionId(bareSessionId, result.claudeSessionId);
@@ -1286,19 +1324,32 @@ var ClaudeCodeHost = class {
1286
1324
  }
1287
1325
  const activityRecords = collectTurnActivityRecords(result.emissions);
1288
1326
  if (activityRecords.length) await appendActivity(bareSessionId, activityRecords);
1289
- if (result.aborted) return;
1327
+ if (result.aborted) {
1328
+ complete({
1329
+ ok: true,
1330
+ payload: { sessionKey }
1331
+ });
1332
+ return;
1333
+ }
1290
1334
  if (result.isError) {
1291
- this.chat.sendResponse(req.id, false, { message: result.errorMessage ?? "Claude Code turn failed" });
1335
+ complete({
1336
+ ok: false,
1337
+ payload: { message: result.errorMessage ?? "Claude Code turn failed" }
1338
+ });
1292
1339
  return;
1293
1340
  }
1294
1341
  if (result.finalText) await addMessage(bareSessionId, "assistant", result.finalText);
1295
- if (result.finalText) this.chat.notify("agent-message", {
1342
+ const notification = result.finalText ? {
1296
1343
  conversationId: bareSessionId,
1297
1344
  text: result.finalText,
1298
1345
  sessionKey,
1299
1346
  messageId: params.chatMessageId ?? req.id
1347
+ } : void 0;
1348
+ complete({
1349
+ ok: true,
1350
+ payload: { sessionKey },
1351
+ ...notification ? { notification } : {}
1300
1352
  });
1301
- this.chat.sendResponse(req.id, true, { sessionKey });
1302
1353
  try {
1303
1354
  const messages = [{
1304
1355
  role: "user",
@@ -1321,18 +1372,30 @@ var ClaudeCodeHost = class {
1321
1372
  * Run a turn; on a `--resume` failure (stale/missing claude session id) start
1322
1373
  * fresh and prepend the last-N stored messages so the model keeps context.
1323
1374
  */
1324
- async runWithResumeFallback(driver, bareSessionId, _sessionKey, _runId, baseParams, isVoice) {
1375
+ async runWithResumeFallback(driver, bareSessionId, cancellation, baseParams, isVoice) {
1376
+ const cancelled = () => cancellation.aborted || this.stopping;
1377
+ const aborted = (result) => ({
1378
+ claudeSessionId: result?.claudeSessionId ?? null,
1379
+ emissions: result?.emissions ?? [],
1380
+ isError: false,
1381
+ finalText: "",
1382
+ aborted: true
1383
+ });
1384
+ if (cancelled()) return aborted();
1325
1385
  const first = await driver.runTurn(baseParams, isVoice ? "voice" : "chat");
1326
- if (!(baseParams.claudeSessionId !== null && first.isError && first.claudeSessionId === null && first.emissions.length === 0) || this.stopping) return first;
1386
+ if (cancelled()) return aborted(first);
1387
+ if (!(baseParams.claudeSessionId !== null && first.isError && first.claudeSessionId === null && first.emissions.length === 0) || first.aborted) return first;
1327
1388
  this.log.warn(`[claude-host] --resume failed for ${bareSessionId}; starting a fresh claude session`);
1328
1389
  this.claudeSessionIds.delete(bareSessionId);
1329
1390
  const history = await this.buildHistoryPreamble(bareSessionId);
1391
+ if (cancelled()) return aborted(first);
1330
1392
  const freshPrompt = history ? `${history}\n\n---\n\n${baseParams.prompt}` : baseParams.prompt;
1331
- return driver.runTurn({
1393
+ const fresh = await driver.runTurn({
1332
1394
  ...baseParams,
1333
1395
  claudeSessionId: null,
1334
1396
  prompt: freshPrompt
1335
1397
  }, isVoice ? "voice" : "chat");
1398
+ return cancelled() ? aborted(fresh) : fresh;
1336
1399
  }
1337
1400
  forwardEmission(runId, sessionKey, emission) {
1338
1401
  if (emission.type === "chat-delta") {
@@ -1368,13 +1431,13 @@ var ClaudeCodeHost = class {
1368
1431
  }
1369
1432
  }
1370
1433
  handleChatAbort(req) {
1371
- const { sessionKey } = req.params;
1434
+ const { sessionKey, runId } = req.params;
1372
1435
  let aborted = false;
1373
- const driver = sessionKey ? this.activeDriverBySession.get(sessionKey) : void 0;
1374
- if (sessionKey && driver) {
1375
- driver.abort();
1376
- const runningReqId = this.runningTurns.get(sessionKey);
1377
- if (runningReqId) this.chat.sendResponse(runningReqId, true, { sessionKey });
1436
+ const active = sessionKey ? this.runningTurns.get(sessionKey) : void 0;
1437
+ const matchesRun = runId === void 0 || typeof runId === "string" && runId.length > 0 && runId.length <= 256 && active?.runId === runId;
1438
+ if (sessionKey && active && matchesRun) {
1439
+ active.cancellation.abort();
1440
+ active.driver.abort();
1378
1441
  aborted = true;
1379
1442
  }
1380
1443
  this.chat.sendResponse(req.id, true, { aborted });
@@ -1477,11 +1540,14 @@ var ClaudeCodeHost = class {
1477
1540
  }
1478
1541
  pruneIdempotency() {
1479
1542
  const cutoff = Date.now() - 10 * 6e4;
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);
1543
+ let resultBytes = 0;
1544
+ for (const [key, operation] of this.agentOperations) if (operation.settledAt !== void 0 && operation.settledAt < cutoff) this.agentOperations.delete(key);
1545
+ else resultBytes += operation.resultBytes;
1546
+ for (const [key, operation] of this.agentOperations) {
1547
+ if (this.agentOperations.size < MAX_IDEMPOTENCY_KEYS && resultBytes <= MAX_IDEMPOTENCY_RESULT_BYTES) break;
1548
+ if (operation.settledAt === void 0) continue;
1549
+ this.agentOperations.delete(key);
1550
+ resultBytes -= operation.resultBytes;
1485
1551
  }
1486
1552
  }
1487
1553
  };
package/dist/index.d.cts CHANGED
@@ -2396,12 +2396,10 @@ declare class ClaudeCodeHost {
2396
2396
  /** Alfe sessionId → claude session id map (mirrors ~/.alfe/claude-code/sessions.json). */
2397
2397
  private readonly claudeSessionIds;
2398
2398
  private claudeSessionsPath;
2399
- /** RPC sessionKey → currently RUNNING request id (for chat.abort targeting). */
2399
+ /** Session → one running turn's wire identity and owned child. */
2400
2400
  private readonly runningTurns;
2401
- /** idempotencyKey → settled request id, for E3 duplicate-RPC dedupe. */
2402
- private readonly seenIdempotencyKeys;
2403
- /** RPC sessionKey → resolved SessionDriver so chat.abort can SIGINT the child. */
2404
- private activeDriverBySession;
2401
+ /** Process-local admission/result replay. In-flight records are never evicted. */
2402
+ private readonly agentOperations;
2405
2403
  private claudeSessionSave;
2406
2404
  private stopping;
2407
2405
  private stopPromise;
@@ -2421,6 +2419,7 @@ declare class ClaudeCodeHost {
2421
2419
  */
2422
2420
  handleRequest(req: RpcRequest): Promise<void>;
2423
2421
  private handleAgent;
2422
+ private deliverAgentOutcome;
2424
2423
  private runAgentTurn;
2425
2424
  /**
2426
2425
  * Run a turn; on a `--resume` failure (stale/missing claude session id) start
package/dist/index.d.ts CHANGED
@@ -2396,12 +2396,10 @@ declare class ClaudeCodeHost {
2396
2396
  /** Alfe sessionId → claude session id map (mirrors ~/.alfe/claude-code/sessions.json). */
2397
2397
  private readonly claudeSessionIds;
2398
2398
  private claudeSessionsPath;
2399
- /** RPC sessionKey → currently RUNNING request id (for chat.abort targeting). */
2399
+ /** Session → one running turn's wire identity and owned child. */
2400
2400
  private readonly runningTurns;
2401
- /** idempotencyKey → settled request id, for E3 duplicate-RPC dedupe. */
2402
- private readonly seenIdempotencyKeys;
2403
- /** RPC sessionKey → resolved SessionDriver so chat.abort can SIGINT the child. */
2404
- private activeDriverBySession;
2401
+ /** Process-local admission/result replay. In-flight records are never evicted. */
2402
+ private readonly agentOperations;
2405
2403
  private claudeSessionSave;
2406
2404
  private stopping;
2407
2405
  private stopPromise;
@@ -2421,6 +2419,7 @@ declare class ClaudeCodeHost {
2421
2419
  */
2422
2420
  handleRequest(req: RpcRequest): Promise<void>;
2423
2421
  private handleAgent;
2422
+ private deliverAgentOutcome;
2424
2423
  private runAgentTurn;
2425
2424
  /**
2426
2425
  * Run a turn; on a `--resume` failure (stale/missing claude session id) start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/claude-code-host",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Chat bridge that makes a self-hosted Claude Code agent reachable from Alfe chat on the user's Claude subscription",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",