@kiturone/kapibala 0.0.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -103,6 +103,7 @@ __export(index_exports, {
103
103
  createLogId: () => createLogId,
104
104
  createMessageId: () => createMessageId,
105
105
  createRunCommandTool: () => createRunCommandTool,
106
+ createSessionError: () => createSessionError,
106
107
  createSpawnAgentTool: () => createSpawnAgentTool,
107
108
  createWaitAgentTool: () => createWaitAgentTool,
108
109
  defineTool: () => defineTool,
@@ -158,7 +159,6 @@ __export(index_exports, {
158
159
  sanitizeModelErrorMessage: () => sanitizeModelErrorMessage,
159
160
  sanitizeSessionTitle: () => sanitizeSessionTitle,
160
161
  scanSkillRoot: () => scanSkillRoot,
161
- sharedWorkspaceLeaseCoordinator: () => sharedWorkspaceLeaseCoordinator,
162
162
  skillDigest: () => skillDigest,
163
163
  skillFailure: () => skillFailure,
164
164
  skillPathKey: () => skillPathKey,
@@ -308,6 +308,21 @@ var KapibalaError = class extends Error {
308
308
  this.dispatchOutcome = options?.dispatchOutcome;
309
309
  }
310
310
  };
311
+ var SESSION_SAFE_MESSAGES = {
312
+ SESSION_LOCKED: "\u4F1A\u8BDD\u5DF2\u88AB\u5176\u4ED6\u6301\u6709\u8005\u5360\u7528",
313
+ SESSION_RECOVERY_IN_PROGRESS: "\u4F1A\u8BDD\u6B63\u5728\u6062\u590D",
314
+ SESSION_LOCK_OWNER_CHANGED: "\u4F1A\u8BDD\u9501\u6301\u6709\u8005\u5DF2\u53D8\u5316",
315
+ SESSION_NOT_FOUND: "\u672A\u627E\u5230\u6307\u5B9A\u4F1A\u8BDD",
316
+ SESSION_ID_AMBIGUOUS: "\u4F1A\u8BDD\u524D\u7F00\u4E0D\u552F\u4E00",
317
+ HISTORY_PAGE_INVALID: "\u5386\u53F2\u9875\u53C2\u6570\u65E0\u6548"
318
+ };
319
+ function createSessionError(code, message) {
320
+ return new KapibalaError(message, {
321
+ code,
322
+ safeMessage: SESSION_SAFE_MESSAGES[code],
323
+ retryPolicy: "after_user_action"
324
+ });
325
+ }
311
326
  var PreDispatchError = class extends KapibalaError {
312
327
  constructor(message, options) {
313
328
  super(message, {
@@ -696,15 +711,50 @@ var import_node_path = __toESM(require("path"), 1);
696
711
 
697
712
  // src/context/jsonl-lines.ts
698
713
  var import_node_fs = __toESM(require("fs"), 1);
699
- function* readJsonlLines(file) {
714
+ function assertHistoryReadBudget(bytes, options) {
715
+ const limit = options.maxReadBytes;
716
+ if (limit === void 0) return;
717
+ if (!Number.isSafeInteger(limit) || limit <= 0)
718
+ throw new KapibalaError("Invalid history read budget", { code: "CONFIG_INVALID" });
719
+ if (bytes > limit)
720
+ throw new KapibalaError("History exceeds the configured read budget", {
721
+ code: "HISTORY_READ_LIMIT",
722
+ retryPolicy: "after_user_action",
723
+ safeMessage: "\u5386\u53F2\u6587\u4EF6\u8D85\u8FC7\u8BFB\u53D6\u9884\u7B97\uFF0C\u539F\u6587\u4EF6\u4FDD\u6301\u4E0D\u53D8"
724
+ });
725
+ }
726
+ function readHistoryBytes(file, options = {}) {
727
+ const fd = import_node_fs.default.openSync(file, "r");
728
+ try {
729
+ assertHistoryReadBudget(import_node_fs.default.fstatSync(fd).size, options);
730
+ const chunks = [];
731
+ const buffer = Buffer.allocUnsafe(64 * 1024);
732
+ let total = 0;
733
+ while (true) {
734
+ const length = import_node_fs.default.readSync(fd, buffer, 0, buffer.length, null);
735
+ if (!length) break;
736
+ total += length;
737
+ assertHistoryReadBudget(total, options);
738
+ chunks.push(Buffer.from(buffer.subarray(0, length)));
739
+ }
740
+ return Buffer.concat(chunks, total);
741
+ } finally {
742
+ import_node_fs.default.closeSync(fd);
743
+ }
744
+ }
745
+ function* readJsonlLines(file, options = {}) {
700
746
  const fd = import_node_fs.default.openSync(file, "r");
701
747
  const buffer = Buffer.allocUnsafe(64 * 1024);
702
748
  let pending = Buffer.alloc(0);
703
749
  let offset = 0;
750
+ let total = 0;
704
751
  try {
752
+ assertHistoryReadBudget(import_node_fs.default.fstatSync(fd).size, options);
705
753
  while (true) {
706
754
  const length = import_node_fs.default.readSync(fd, buffer, 0, buffer.length, null);
707
755
  if (!length) break;
756
+ total += length;
757
+ assertHistoryReadBudget(total, options);
708
758
  pending = Buffer.concat([pending, buffer.subarray(0, length)]);
709
759
  let start = 0;
710
760
  let end = pending.indexOf(10, start);
@@ -738,7 +788,7 @@ var SESSION_SCHEMA_VERSION_V2 = 2;
738
788
  var SESSION_SCHEMA_VERSION = SESSION_SCHEMA_VERSION_V2;
739
789
  var MAIN_BRANCH_ID = "main";
740
790
  function isGlobalControlRecordType(type) {
741
- return type === "header" || type === "branch_created" || type === "branch_selected" || type === "title" || type === "archive_state" || type === "usage" || type === "legacy_import";
791
+ return type === "header" || type === "branch_created" || type === "branch_selected" || type === "title" || type === "archive_state" || type === "pin_state" || type === "usage" || type === "legacy_import";
742
792
  }
743
793
  var types = /* @__PURE__ */ new Set([
744
794
  "header",
@@ -757,7 +807,8 @@ var types = /* @__PURE__ */ new Set([
757
807
  "branch_created",
758
808
  "branch_selected",
759
809
  "branch_summary",
760
- "archive_state"
810
+ "archive_state",
811
+ "pin_state"
761
812
  ]);
762
813
  function supportsSessionState(store) {
763
814
  return "appendRecord" in store && typeof store.appendRecord === "function" && "loadState" in store && typeof store.loadState === "function";
@@ -840,6 +891,13 @@ function assertClosedToolTransaction(records, targetRecordId) {
840
891
  }
841
892
  }
842
893
  var SessionStore = class _SessionStore {
894
+ constructor(filePath, schemaVersion = SESSION_SCHEMA_VERSION_V2, readOptions = {}) {
895
+ this.readOptions = readOptions;
896
+ assertHistoryReadBudget(0, readOptions);
897
+ this.filePath = import_node_path.default.resolve(filePath);
898
+ this.schemaVersion = schemaVersion;
899
+ }
900
+ readOptions;
843
901
  filePath;
844
902
  queue = Promise.resolve();
845
903
  records = [];
@@ -849,10 +907,6 @@ var SessionStore = class _SessionStore {
849
907
  schemaVersion = SESSION_SCHEMA_VERSION_V2;
850
908
  activeBranchId = "main";
851
909
  branchLeaves = /* @__PURE__ */ new Map();
852
- constructor(filePath, schemaVersion = SESSION_SCHEMA_VERSION_V2) {
853
- this.filePath = import_node_path.default.resolve(filePath);
854
- this.schemaVersion = schemaVersion;
855
- }
856
910
  /** 获取当前会话文件使用的 Schema 版本(1 或 2)。 */
857
911
  getSchemaVersion() {
858
912
  return this.schemaVersion;
@@ -875,8 +929,8 @@ var SessionStore = class _SessionStore {
875
929
  this.onDiagnostic = onDiagnostic;
876
930
  }
877
931
  /** 以独占创建方式安装新头部(默认生成 Schema v2 格式),已有文件明确失败,绝不覆盖其它会话。 */
878
- static async create(filePath, header) {
879
- const store = new _SessionStore(filePath, SESSION_SCHEMA_VERSION_V2);
932
+ static async create(filePath, header, options = {}) {
933
+ const store = new _SessionStore(filePath, SESSION_SCHEMA_VERSION_V2, { ...options });
880
934
  import_node_fs2.default.mkdirSync(import_node_path.default.dirname(store.filePath), { recursive: true });
881
935
  const record = store.makeRecord("header", {
882
936
  ...header,
@@ -897,8 +951,8 @@ var SessionStore = class _SessionStore {
897
951
  return store;
898
952
  }
899
953
  /** 打开并验证已有会话;尾部残片先保存到隔离文件,中部损坏与未知版本拒绝恢复。 */
900
- static async open(filePath) {
901
- const store = new _SessionStore(filePath);
954
+ static async open(filePath, options = {}) {
955
+ const store = new _SessionStore(filePath, SESSION_SCHEMA_VERSION_V2, { ...options });
902
956
  store.records = store.readFile(true);
903
957
  store.refreshStamp();
904
958
  return store;
@@ -992,6 +1046,24 @@ var SessionStore = class _SessionStore {
992
1046
  }
993
1047
  return false;
994
1048
  }
1049
+ /**
1050
+ * 追加会话置顶控制记录。
1051
+ *
1052
+ * 约束:
1053
+ * - 置顶状态记录为全局控制记录(parentId 为 null,不推进分支上下文叶子);
1054
+ * - payload 结构为 { pinned: boolean },最后一次写入生效;
1055
+ * - 置顶只影响列表排序,不改变正文活动时间,因此不会让会话挤进「最近」;
1056
+ * - v1 历史会话禁止变更置顶状态。
1057
+ */
1058
+ async setPinnedState(pinned) {
1059
+ if (this.isReadOnly()) {
1060
+ throw new KapibalaError("Cannot modify pin state of legacy v1 session", {
1061
+ code: "LEGACY_SESSION_READ_ONLY",
1062
+ safeMessage: "\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684\u7F6E\u9876\u72B6\u6001"
1063
+ });
1064
+ }
1065
+ return this.appendRecord("pin_state", { pinned });
1066
+ }
995
1067
  /**
996
1068
  * 获取当前会话中所有已知分支的元数据与状态列表。
997
1069
  */
@@ -1283,14 +1355,15 @@ ${summaryText}` }]
1283
1355
  }
1284
1356
  /** 校验每个完整记录,只有最后非空行的 JSON 残片可隔离,语义损坏绝不作为残片吞掉。 */
1285
1357
  readFile(recover) {
1286
- const iterator = readJsonlLines(this.filePath);
1358
+ const iterator = readJsonlLines(this.filePath, this.readOptions);
1287
1359
  let lastTerminated = true;
1288
1360
  let blankSeen = false;
1289
1361
  const records = [];
1290
1362
  const identities = /* @__PURE__ */ new Set();
1291
1363
  const messages = /* @__PURE__ */ new Set();
1292
- this.branchLeaves.clear();
1293
- this.activeBranchId = MAIN_BRANCH_ID;
1364
+ const branchLeaves = /* @__PURE__ */ new Map();
1365
+ let activeBranchId = MAIN_BRANCH_ID;
1366
+ let schemaVersion = this.schemaVersion;
1294
1367
  let detectedSchemaVersion = null;
1295
1368
  let i = -1;
1296
1369
  for (const line of iterator) {
@@ -1305,7 +1378,7 @@ ${summaryText}` }]
1305
1378
  try {
1306
1379
  record = JSON.parse(line.text);
1307
1380
  } catch {
1308
- if (!recover || this.schemaVersion === SESSION_SCHEMA_VERSION_V1 || !records.length || Array.from(iterator).some((next) => next.text.trim()))
1381
+ if (!recover || schemaVersion === SESSION_SCHEMA_VERSION_V1 || !records.length || Array.from(iterator).some((next) => next.text.trim()))
1309
1382
  throw new Error(`Session corrupt at line ${i + 1}`);
1310
1383
  import_node_fs2.default.writeFileSync(
1311
1384
  `${this.filePath}.corrupt-${createMessageId()}`,
@@ -1329,7 +1402,7 @@ ${summaryText}` }]
1329
1402
  throw new Error("Unsupported session schema version");
1330
1403
  }
1331
1404
  detectedSchemaVersion = record.schemaVersion;
1332
- this.schemaVersion = detectedSchemaVersion;
1405
+ schemaVersion = detectedSchemaVersion;
1333
1406
  } else if (record.schemaVersion !== detectedSchemaVersion) {
1334
1407
  throw new Error(`Session corrupt schema version mismatch at line ${i + 1}`);
1335
1408
  }
@@ -1346,11 +1419,11 @@ ${summaryText}` }]
1346
1419
  } else if (record.type === "header") {
1347
1420
  throw new Error("Session corrupt: repeated header");
1348
1421
  }
1349
- if (this.schemaVersion === SESSION_SCHEMA_VERSION_V1) {
1422
+ if (schemaVersion === SESSION_SCHEMA_VERSION_V1) {
1350
1423
  if (record.parentId !== (records.at(-1)?.recordId ?? null)) {
1351
1424
  throw new Error(`Session corrupt parent at line ${i + 1}`);
1352
1425
  }
1353
- this.branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1426
+ branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1354
1427
  } else {
1355
1428
  if (typeof record.seq !== "number" || record.seq !== records.length) {
1356
1429
  throw new Error(`Session corrupt seq at line ${i + 1}`);
@@ -1364,37 +1437,37 @@ ${summaryText}` }]
1364
1437
  throw new Error(`Session corrupt control record parent at line ${i + 1}`);
1365
1438
  }
1366
1439
  if (record.type === "header") {
1367
- this.branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1368
- this.activeBranchId = MAIN_BRANCH_ID;
1440
+ branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1441
+ activeBranchId = MAIN_BRANCH_ID;
1369
1442
  } else if (record.type === "branch_created") {
1370
1443
  const bId = record.payload.branchId;
1371
1444
  const baseId = record.payload.baseRecordId;
1372
1445
  if (typeof bId !== "string" || !bId) {
1373
1446
  throw new Error(`Session corrupt branch_created branchId at line ${i + 1}`);
1374
1447
  }
1375
- if (this.branchLeaves.has(bId)) {
1448
+ if (branchLeaves.has(bId)) {
1376
1449
  throw new Error(`Duplicate branch ID: ${bId}`);
1377
1450
  }
1378
1451
  if (typeof baseId !== "string" || !identities.has(baseId)) {
1379
1452
  throw new Error(`Session corrupt branch_created baseRecordId at line ${i + 1}`);
1380
1453
  }
1381
- this.branchLeaves.set(bId, baseId);
1454
+ branchLeaves.set(bId, baseId);
1382
1455
  } else if (record.type === "branch_selected") {
1383
1456
  const bId = record.payload.branchId;
1384
- if (typeof bId !== "string" || !this.branchLeaves.has(bId)) {
1457
+ if (typeof bId !== "string" || !branchLeaves.has(bId)) {
1385
1458
  throw new Error(`Selected branch not found: ${bId}`);
1386
1459
  }
1387
- this.activeBranchId = bId;
1460
+ activeBranchId = bId;
1388
1461
  }
1389
1462
  } else {
1390
1463
  const bId = record.branchId;
1391
- if (typeof bId !== "string" || !this.branchLeaves.has(bId)) {
1464
+ if (typeof bId !== "string" || !branchLeaves.has(bId)) {
1392
1465
  throw new Error(`Session corrupt branchId at line ${i + 1}`);
1393
1466
  }
1394
1467
  if (typeof record.parentId !== "string" || !identities.has(record.parentId)) {
1395
1468
  throw new Error(`Session corrupt parent at line ${i + 1}`);
1396
1469
  }
1397
- this.branchLeaves.set(bId, record.recordId);
1470
+ branchLeaves.set(bId, record.recordId);
1398
1471
  }
1399
1472
  }
1400
1473
  identities.add(record.recordId);
@@ -1413,7 +1486,7 @@ ${summaryText}` }]
1413
1486
  if (!records.length) {
1414
1487
  throw new Error("Session corrupt header");
1415
1488
  }
1416
- if (recover && !lastTerminated && records.length === i + 1 && this.schemaVersion !== SESSION_SCHEMA_VERSION_V1) {
1489
+ if (recover && !lastTerminated && records.length === i + 1 && schemaVersion !== SESSION_SCHEMA_VERSION_V1) {
1417
1490
  const fd = import_node_fs2.default.openSync(this.filePath, "a");
1418
1491
  try {
1419
1492
  import_node_fs2.default.writeFileSync(fd, "\n");
@@ -1422,6 +1495,9 @@ ${summaryText}` }]
1422
1495
  import_node_fs2.default.closeSync(fd);
1423
1496
  }
1424
1497
  }
1498
+ this.branchLeaves = branchLeaves;
1499
+ this.activeBranchId = activeBranchId;
1500
+ this.schemaVersion = schemaVersion;
1425
1501
  return records;
1426
1502
  }
1427
1503
  };
@@ -1453,19 +1529,23 @@ var SessionLock = class _SessionLock {
1453
1529
  if (error.code !== "EEXIST") throw error;
1454
1530
  const stale = _SessionLock.readOwner(lockPath);
1455
1531
  if (!stale || !_SessionLock.isDead(stale.pid))
1456
- throw new Error(
1532
+ throw createSessionError(
1533
+ "SESSION_LOCKED",
1457
1534
  `Session locked by a live or unknown owner${stale ? ` (pid=${stale.pid})` : ""}`
1458
1535
  );
1459
1536
  try {
1460
1537
  import_node_fs3.default.mkdirSync(recovery);
1461
1538
  } catch {
1462
- throw new Error("Session locked: recovery in progress");
1539
+ throw createSessionError(
1540
+ "SESSION_RECOVERY_IN_PROGRESS",
1541
+ "Session locked: recovery in progress"
1542
+ );
1463
1543
  }
1464
1544
  try {
1465
1545
  import_node_fs3.default.writeFileSync(import_node_path2.default.join(recovery, "owner.json"), JSON.stringify(owner), { flag: "wx" });
1466
1546
  const current = _SessionLock.readOwner(lockPath);
1467
1547
  if (current?.nonce !== stale.nonce || current.pid !== stale.pid || !_SessionLock.isDead(current.pid)) {
1468
- throw new Error("Session locked: owner changed");
1548
+ throw createSessionError("SESSION_LOCK_OWNER_CHANGED", "Session locked: owner changed");
1469
1549
  }
1470
1550
  const quarantine = `${lockPath}.stale-${(0, import_node_crypto4.randomUUID)()}`;
1471
1551
  import_node_fs3.default.renameSync(lockPath, quarantine);
@@ -1485,7 +1565,8 @@ var SessionLock = class _SessionLock {
1485
1565
  }
1486
1566
  if (installError.code === "EEXIST") {
1487
1567
  const winner = _SessionLock.readOwner(lockPath);
1488
- throw new Error(
1568
+ throw createSessionError(
1569
+ "SESSION_LOCKED",
1489
1570
  `Session locked by a live or unknown owner${winner ? ` (pid=${winner.pid})` : ""}`
1490
1571
  );
1491
1572
  }
@@ -1519,7 +1600,7 @@ var SessionLock = class _SessionLock {
1519
1600
  if (this.released) return;
1520
1601
  const current = _SessionLock.readOwner(this.lockPath);
1521
1602
  if (current?.nonce !== this.owner.nonce || current.pid !== this.owner.pid)
1522
- throw new Error("Session lock owner changed");
1603
+ throw createSessionError("SESSION_LOCK_OWNER_CHANGED", "Session lock owner changed");
1523
1604
  import_node_fs3.default.unlinkSync(import_node_path2.default.join(this.lockPath, "owner.json"));
1524
1605
  import_node_fs3.default.rmdirSync(this.lockPath);
1525
1606
  this.released = true;
@@ -1550,7 +1631,11 @@ var SessionLock = class _SessionLock {
1550
1631
  /** 无残留或残留进程已死亡时放行;确认存活的回收进行中则明确失败。 */
1551
1632
  static ensureNoLiveRecovery(recovery) {
1552
1633
  _SessionLock.clearAbandonedRecovery(recovery);
1553
- if (import_node_fs3.default.existsSync(recovery)) throw new Error("Session locked: recovery in progress");
1634
+ if (import_node_fs3.default.existsSync(recovery))
1635
+ throw createSessionError(
1636
+ "SESSION_RECOVERY_IN_PROGRESS",
1637
+ "Session locked: recovery in progress"
1638
+ );
1554
1639
  }
1555
1640
  /** 仅 ESRCH 表示确定死亡;PID 复用或 EPERM 均保持原锁。 */
1556
1641
  static isDead(pid) {
@@ -1700,18 +1785,21 @@ function resolveWorkspaceContext(input) {
1700
1785
  function normalizedProjectRoot(root) {
1701
1786
  return process.platform === "win32" ? root.replaceAll("\\", "/").toLowerCase() : root;
1702
1787
  }
1703
- function resolveSessionProject(cwd) {
1788
+ function resolveSessionProject(cwd, mode = "git") {
1704
1789
  const realCwd = toNativePhysicalPath(cwd);
1705
1790
  let root = realCwd;
1706
1791
  let gitBranch;
1707
1792
  let gitWorktree = false;
1708
1793
  try {
1709
- root = toNativePhysicalPath(
1794
+ const gitRoot = toNativePhysicalPath(
1710
1795
  (0, import_node_child_process.execFileSync)("git", ["-C", realCwd, "rev-parse", "--show-toplevel"], {
1711
1796
  encoding: "utf8",
1712
1797
  stdio: ["ignore", "pipe", "ignore"]
1713
1798
  }).trim()
1714
1799
  );
1800
+ if (mode === "git") root = gitRoot;
1801
+ if (normalizedProjectRoot(gitRoot) !== normalizedProjectRoot(root))
1802
+ return { projectRoot: root, cwd: realCwd, projectKey: computeProjectKey(root) };
1715
1803
  gitWorktree = true;
1716
1804
  gitBranch = (0, import_node_child_process.execFileSync)("git", ["-C", realCwd, "symbolic-ref", "--short", "-q", "HEAD"], {
1717
1805
  encoding: "utf8",
@@ -1719,11 +1807,10 @@ function resolveSessionProject(cwd) {
1719
1807
  }).trim() || void 0;
1720
1808
  } catch {
1721
1809
  }
1722
- const normalized = normalizedProjectRoot(root);
1723
1810
  return {
1724
1811
  projectRoot: root,
1725
1812
  cwd: realCwd,
1726
- projectKey: (0, import_node_crypto6.createHash)("sha256").update(normalized).digest("hex"),
1813
+ projectKey: computeProjectKey(root),
1727
1814
  gitBranch,
1728
1815
  gitWorktree
1729
1816
  };
@@ -1741,7 +1828,10 @@ var SessionManager = class {
1741
1828
  directory;
1742
1829
  handles = /* @__PURE__ */ new Map();
1743
1830
  diagnostic;
1831
+ readOptions;
1744
1832
  constructor(options = {}) {
1833
+ this.readOptions = { maxReadBytes: options.maxReadBytes };
1834
+ assertHistoryReadBudget(0, this.readOptions);
1745
1835
  const rootCandidate = options.workspaceRoot ?? options.projectRoot ?? options.cwd;
1746
1836
  if (!rootCandidate) {
1747
1837
  throw new KapibalaError("Workspace root is required for SessionManager", {
@@ -1749,7 +1839,7 @@ var SessionManager = class {
1749
1839
  safeMessage: "\u5FC5\u987B\u663E\u5F0F\u6307\u5B9A\u5DE5\u4F5C\u533A\u6839\u8DEF\u5F84\u6216\u9879\u76EE\u76EE\u5F55"
1750
1840
  });
1751
1841
  }
1752
- this.project = resolveSessionProject(rootCandidate);
1842
+ this.project = resolveSessionProject(rootCandidate, options.projectRootMode);
1753
1843
  this.directory = import_node_path4.default.join(
1754
1844
  options.homeDirectory ?? import_node_os.default.homedir(),
1755
1845
  ".kapibala",
@@ -1765,12 +1855,16 @@ var SessionManager = class {
1765
1855
  const file = import_node_path4.default.join(this.directory, `${Date.now()}_${conversationId}.jsonl`);
1766
1856
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1767
1857
  try {
1768
- const store = await SessionStore.create(file, {
1769
- conversationId,
1770
- projectRoot: this.project.projectRoot,
1771
- initialCwd: this.project.cwd,
1772
- gitBranch: this.project.gitBranch
1773
- });
1858
+ const store = await SessionStore.create(
1859
+ file,
1860
+ {
1861
+ conversationId,
1862
+ projectRoot: this.project.projectRoot,
1863
+ initialCwd: this.project.cwd,
1864
+ gitBranch: this.project.gitBranch
1865
+ },
1866
+ this.readOptions
1867
+ );
1774
1868
  return await this.attach(store, lock);
1775
1869
  } catch (error) {
1776
1870
  await lock.release();
@@ -1779,89 +1873,146 @@ var SessionManager = class {
1779
1873
  }
1780
1874
  /**
1781
1875
  * 按正文活动排序分页;有效 sidecar 只读元数据,不读取完整 JSONL。默认排除已归档会话。
1782
- * pageSize 由调用方显式给出时必须在 1–100 之间;非法页参数直接失败,不静默修正。
1876
+ * pageSize 由调用方显式给出时必须在 1–100 之间;filter 在分页前筛选元数据,调用方须自行处理敏感信息。
1877
+ * 置顶项整体前置、组内仍按活动时间排序;排序只作用于本方法的返回值,不影响 continueRecent。
1783
1878
  */
1784
1879
  async list(page = 1, options) {
1785
1880
  const pageSize = options?.pageSize ?? 20;
1786
1881
  if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
1787
- throw new Error("History page must be a positive integer");
1882
+ throw createSessionError("HISTORY_PAGE_INVALID", "History page must be a positive integer");
1788
1883
  const all = await this.allMetadata();
1789
- const filtered = options?.includeArchived ? all : all.filter((m) => !m.archived);
1884
+ const visible = options?.includeArchived ? all : all.filter((m) => !m.archived);
1885
+ const filtered = options?.filter ? visible.filter(options.filter) : visible;
1886
+ const ordered = [
1887
+ ...filtered.filter((item) => item.pinned),
1888
+ ...filtered.filter((item) => !item.pinned)
1889
+ ];
1790
1890
  return {
1791
- items: filtered.slice((page - 1) * pageSize, page * pageSize),
1792
- total: filtered.length,
1891
+ items: ordered.slice((page - 1) * pageSize, page * pageSize),
1892
+ total: ordered.length,
1793
1893
  page,
1794
- pages: Math.max(1, Math.ceil(filtered.length / pageSize))
1894
+ pages: Math.max(1, Math.ceil(ordered.length / pageSize))
1795
1895
  };
1796
1896
  }
1797
1897
  /** 解析完整 ID 或唯一前缀对应的会话文件与 ID。 */
1798
1898
  resolveSession(id) {
1799
- if (!id || !/^[a-zA-Z0-9-]+$/.test(id)) throw new Error("Session not found");
1899
+ if (!id || !/^[a-zA-Z0-9-]+$/.test(id))
1900
+ throw createSessionError("SESSION_NOT_FOUND", "Session not found");
1800
1901
  const all = import_node_fs5.default.readdirSync(this.directory).filter((name) => /^\d+_[a-zA-Z0-9-]+\.jsonl$/.test(name)).map((fileName) => ({
1801
1902
  fileName,
1802
1903
  conversationId: fileName.slice(fileName.indexOf("_") + 1, -6)
1803
1904
  }));
1804
1905
  const exact = all.find((m) => m.conversationId === id || m.fileName === id);
1805
1906
  const matches = exact ? [exact] : all.filter((m) => m.conversationId.startsWith(id));
1806
- if (!matches.length) throw new Error(`Session not found: ${id}`);
1807
- if (matches.length !== 1) throw new Error(`Ambiguous session prefix: ${id}`);
1907
+ if (!matches.length) throw createSessionError("SESSION_NOT_FOUND", `Session not found: ${id}`);
1908
+ if (matches.length !== 1)
1909
+ throw createSessionError("SESSION_ID_AMBIGUOUS", `Ambiguous session prefix: ${id}`);
1808
1910
  return matches[0];
1809
1911
  }
1810
1912
  /**
1811
- * 逻辑归档指定会话。
1913
+ * 归档、恢复、重命名与置顶都向正文追加一条全局控制记录,共用同一套前置约束。
1812
1914
  *
1813
1915
  * 约束:
1814
1916
  * - 必须取得会话独占锁;
1815
- * - 若会话当前正处于活动打开状态,拒绝归档并抛出 SESSION_IN_USE;
1816
- * - 向会话正文追加 archive_state { archived: true } 并刷新 sidecar 缓存。
1917
+ * - 若会话当前正处于活动打开状态,拒绝并抛出 SESSION_IN_USE(调用方先关闭句柄);
1918
+ * - v1 只读历史不能变更,明确以 LEGACY_SESSION_READ_ONLY 失败而不是静默忽略;
1919
+ * - 写入后按正文重建 sidecar,状态只以正文记录为准。
1817
1920
  */
1818
- async archiveSession(id) {
1921
+ async appendControlRecord(id, subject, append) {
1819
1922
  const { fileName, conversationId } = this.resolveSession(id);
1820
1923
  if (this.handles.has(conversationId)) {
1821
- throw new KapibalaError("Session is currently active or in use, cannot archive", {
1924
+ throw new KapibalaError("Session is currently active or in use", {
1822
1925
  code: "SESSION_IN_USE",
1823
- safeMessage: "\u4F1A\u8BDD\u6B63\u5728\u4F7F\u7528\u4E2D\uFF0C\u65E0\u6CD5\u5F52\u6863"
1926
+ safeMessage: `\u4F1A\u8BDD\u6B63\u5728\u4F7F\u7528\u4E2D\uFF0C\u65E0\u6CD5${subject}`
1824
1927
  });
1825
1928
  }
1826
1929
  const targetFile = import_node_path4.default.join(this.directory, fileName);
1827
1930
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1828
1931
  try {
1829
- const store = await SessionStore.open(targetFile);
1932
+ const store = await SessionStore.open(targetFile, this.readOptions);
1830
1933
  if (store.isReadOnly()) {
1831
- throw new KapibalaError("Cannot archive legacy v1 session", {
1934
+ throw new KapibalaError("Cannot modify legacy v1 session state", {
1832
1935
  code: "LEGACY_SESSION_READ_ONLY",
1833
- safeMessage: "\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684\u5F52\u6863\u72B6\u6001"
1936
+ safeMessage: `\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684${subject}\u72B6\u6001`
1834
1937
  });
1835
1938
  }
1836
- await store.setArchiveState(true);
1837
- const records = await store.readRecords();
1838
- const meta = this.buildMetadata(targetFile, records);
1839
- meta.archived = true;
1840
- this.writeMetadata(targetFile, meta);
1939
+ await append(store);
1940
+ this.writeMetadata(targetFile, this.buildMetadata(targetFile, await store.readRecords()));
1841
1941
  } finally {
1842
1942
  await lock.release();
1843
1943
  }
1844
1944
  }
1845
1945
  /**
1846
- * 恢复已逻辑归档的会话。
1946
+ * 逻辑归档指定会话:向正文追加 archive_state { archived: true } 并刷新 sidecar 缓存。
1947
+ */
1948
+ async archiveSession(id) {
1949
+ await this.appendControlRecord(id, "\u5F52\u6863", (store) => store.setArchiveState(true));
1950
+ }
1951
+ /**
1952
+ * 恢复已逻辑归档的会话:追加 archive_state { archived: false },正文保持不动。
1847
1953
  */
1848
1954
  async restoreSession(id) {
1955
+ await this.appendControlRecord(id, "\u5F52\u6863", (store) => store.setArchiveState(false));
1956
+ }
1957
+ /**
1958
+ * 单独重命名指定会话,不保留句柄:追加 title 记录后立即释放写锁。
1959
+ *
1960
+ * 与 `rename` 的分工:本方法面向「没有打开该会话」的宿主(桌面侧栏、设置页),
1961
+ * 因此不接受活动句柄;已打开的会话由宿主经自己的 Store 追加,避免同进程锁冲突。
1962
+ */
1963
+ async renameSession(id, title) {
1964
+ const value = title.trim();
1965
+ if (!value) throw new Error("Session title cannot be empty");
1966
+ await this.appendControlRecord(
1967
+ id,
1968
+ "\u91CD\u547D\u540D",
1969
+ (store) => store.appendRecord("title", { title: sanitizeSessionTitle(value) })
1970
+ );
1971
+ }
1972
+ /**
1973
+ * 切换会话置顶:追加 pin_state { pinned } 控制记录,只在列表排序时前置。
1974
+ *
1975
+ * 置顶不是「最近活动」,因此不写 run_started、也不改 lastActivityAt;
1976
+ * 关闭置顶后会话回到按活动时间排序的原有位置。
1977
+ */
1978
+ async pinSession(id, pinned) {
1979
+ await this.appendControlRecord(id, "\u7F6E\u9876", (store) => store.setPinnedState(pinned));
1980
+ }
1981
+ /**
1982
+ * 物理删除指定会话的正文与 sidecar 缓存。
1983
+ *
1984
+ * 约束与边界:
1985
+ * - 删除不可逆,本方法不提供回收站或软删除,调用方必须先取得用户确认;
1986
+ * - 本 Manager 已持有句柄的会话拒绝(SESSION_IN_USE),避免删掉正在追加的文件;
1987
+ * - 其它进程持锁时以 SESSION_LOCKED 失败,不抢占他人写入权,也不做「先等一会儿」的重试;
1988
+ * - 只删该会话自己的 `.jsonl` 与 `.meta.json`;桶目录内其它文件(含 `*.tmp-*` 残片)不动;
1989
+ * - 删除前复核首条头部身份与桶归属:文件名匹配但正文身份不符的残留文件删除会放大错误;
1990
+ * - 缓存先删、正文后删:缓存本可重建,若正文删除失败该会话会在下次浏览时自动重建缓存。
1991
+ */
1992
+ async deleteSession(id) {
1849
1993
  const { fileName, conversationId } = this.resolveSession(id);
1994
+ if (this.handles.has(conversationId)) {
1995
+ throw new KapibalaError("Session is currently active or in use, cannot delete", {
1996
+ code: "SESSION_IN_USE",
1997
+ safeMessage: "\u4F1A\u8BDD\u6B63\u5728\u4F7F\u7528\u4E2D\uFF0C\u65E0\u6CD5\u5220\u9664"
1998
+ });
1999
+ }
1850
2000
  const targetFile = import_node_path4.default.join(this.directory, fileName);
1851
2001
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1852
2002
  try {
1853
- const store = await SessionStore.open(targetFile);
1854
- if (store.isReadOnly()) {
1855
- throw new KapibalaError("Cannot restore legacy v1 session", {
1856
- code: "LEGACY_SESSION_READ_ONLY",
1857
- safeMessage: "\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684\u5F52\u6863\u72B6\u6001"
1858
- });
2003
+ const headerLines = this.readDiscoveryRecords(targetFile);
2004
+ let header;
2005
+ try {
2006
+ header = headerLines.next().value.payload;
2007
+ } finally {
2008
+ headerLines.return(void 0);
1859
2009
  }
1860
- await store.setArchiveState(false);
1861
- const records = await store.readRecords();
1862
- const meta = this.buildMetadata(targetFile, records);
1863
- meta.archived = false;
1864
- this.writeMetadata(targetFile, meta);
2010
+ if (header.conversationId !== conversationId || normalizedProjectRoot(header.projectRoot) !== normalizedProjectRoot(this.project.projectRoot))
2011
+ throw new Error("Foreign session identity");
2012
+ const metadata = this.metadataPath(targetFile);
2013
+ if (import_node_fs5.default.existsSync(metadata)) import_node_fs5.default.unlinkSync(metadata);
2014
+ import_node_fs5.default.unlinkSync(targetFile);
2015
+ syncSessionDirectory(this.directory);
1865
2016
  } finally {
1866
2017
  await lock.release();
1867
2018
  }
@@ -1873,6 +2024,11 @@ var SessionManager = class {
1873
2024
  const file = import_node_path4.default.join(this.directory, fileName);
1874
2025
  try {
1875
2026
  let meta = this.readMetadata(file);
2027
+ if (this.readOptions.maxReadBytes !== void 0 && import_node_fs5.default.statSync(file).size > this.readOptions.maxReadBytes) {
2028
+ meta ??= this.readOversizedMetadata(file);
2029
+ all.push({ ...meta, unavailableReason: "HISTORY_READ_LIMIT" });
2030
+ continue;
2031
+ }
1876
2032
  if (!meta) {
1877
2033
  const records = this.readDiscoveryRecords(file);
1878
2034
  meta = this.buildMetadata(file, records);
@@ -1883,7 +2039,17 @@ var SessionManager = class {
1883
2039
  }
1884
2040
  }
1885
2041
  all.push(meta);
1886
- } catch {
2042
+ } catch (error) {
2043
+ if (error instanceof KapibalaError && error.code === "HISTORY_READ_LIMIT") {
2044
+ try {
2045
+ all.push({
2046
+ ...this.readOversizedMetadata(file),
2047
+ unavailableReason: "HISTORY_READ_LIMIT"
2048
+ });
2049
+ continue;
2050
+ } catch {
2051
+ }
2052
+ }
1887
2053
  this.report(`Cannot discover session: ${fileName}`);
1888
2054
  }
1889
2055
  }
@@ -1933,7 +2099,10 @@ var SessionManager = class {
1933
2099
  }
1934
2100
  if (header.conversationId !== meta.conversationId || normalizedProjectRoot(header.projectRoot) !== normalizedProjectRoot(this.project.projectRoot))
1935
2101
  throw new Error("Foreign session identity");
1936
- const store = await SessionStore.open(import_node_path4.default.join(this.directory, meta.fileName));
2102
+ const store = await SessionStore.open(
2103
+ import_node_path4.default.join(this.directory, meta.fileName),
2104
+ this.readOptions
2105
+ );
1937
2106
  if ((await store.loadState()).header.conversationId !== meta.conversationId)
1938
2107
  throw new Error("Session identity mismatch");
1939
2108
  await store.load();
@@ -1945,7 +2114,9 @@ var SessionManager = class {
1945
2114
  }
1946
2115
  /** 继续最近有正文的会话;没有历史才新建,显式打开失败绝不回退为空会话。 */
1947
2116
  async continueRecent(options) {
1948
- const latest = (await this.allMetadata()).find((m) => m.messageCount > 0);
2117
+ const latest = (await this.allMetadata()).find(
2118
+ (m) => m.messageCount > 0 || m.unavailableReason
2119
+ );
1949
2120
  if (latest)
1950
2121
  return this.open(latest.conversationId, {
1951
2122
  autoUpgradeLegacy: options?.autoUpgradeLegacy ?? true
@@ -1953,11 +2124,21 @@ var SessionManager = class {
1953
2124
  this.report("No previous session with content; created a new session");
1954
2125
  return this.create();
1955
2126
  }
1956
- /** 标题作为独立耐久记录;标题或浏览不改变正文活动排序。 */
2127
+ /**
2128
+ * 重命名已打开或待打开的会话;标题作为独立耐久记录,标题或浏览都不改变正文活动排序。
2129
+ *
2130
+ * 未持有句柄时临时打开并在追加后释放:只改标题不该把会话一直挂在写锁上,
2131
+ * 否则同进程后续的归档、删除与置顶都会被自己占住的锁挡掉。
2132
+ */
1957
2133
  async rename(id, title) {
1958
2134
  if (!title.trim()) throw new Error("Session title cannot be empty");
2135
+ const alreadyOpen = this.handles.has(this.resolveSession(id).conversationId);
1959
2136
  const handle = await this.open(id, { autoUpgradeLegacy: true });
1960
- await handle.store.appendRecord("title", { title: sanitizeSessionTitle(title) });
2137
+ try {
2138
+ await handle.store.appendRecord("title", { title: sanitizeSessionTitle(title) });
2139
+ } finally {
2140
+ if (!alreadyOpen) await handle.release();
2141
+ }
1961
2142
  }
1962
2143
  /** 等候所有存储队列后释放句柄;宿主必须先结束 AgentSession 的工具/摘要清理。 */
1963
2144
  async close() {
@@ -1977,7 +2158,7 @@ var SessionManager = class {
1977
2158
  for (const source of sources) {
1978
2159
  if (!import_node_fs5.default.existsSync(source)) continue;
1979
2160
  const before = import_node_fs5.default.statSync(source);
1980
- const bytes = import_node_fs5.default.readFileSync(source);
2161
+ const bytes = readHistoryBytes(source, this.readOptions);
1981
2162
  const identity = (0, import_node_crypto6.createHash)("sha256").update(import_node_fs5.default.realpathSync.native(source)).update("\0").update(bytes).digest("hex");
1982
2163
  const importLock = await SessionLock.acquire(
1983
2164
  import_node_path4.default.join(this.directory, `import-${identity}.lock`)
@@ -2003,19 +2184,23 @@ var SessionManager = class {
2003
2184
  const id = createMessageId();
2004
2185
  const target = import_node_path4.default.join(this.directory, `${Date.now()}_${id}.jsonl`);
2005
2186
  temp = `${target}.tmp-${createMessageId()}`;
2006
- const store = await SessionStore.create(temp, {
2007
- conversationId: id,
2008
- projectRoot: this.project.projectRoot,
2009
- initialCwd: this.project.cwd,
2010
- gitBranch: this.project.gitBranch,
2011
- importIdentity: identity
2012
- });
2187
+ const store = await SessionStore.create(
2188
+ temp,
2189
+ {
2190
+ conversationId: id,
2191
+ projectRoot: this.project.projectRoot,
2192
+ initialCwd: this.project.cwd,
2193
+ gitBranch: this.project.gitBranch,
2194
+ importIdentity: identity
2195
+ },
2196
+ this.readOptions
2197
+ );
2013
2198
  await store.appendRecord("legacy_import", { source, identity });
2014
2199
  await store.appendRecord("title", { title: "\u65E7\u5386\u53F2\u5BFC\u5165" });
2015
2200
  for (const message of identifyHistory(raw, identity)) await store.append(message);
2016
2201
  await store.load();
2017
2202
  const after = import_node_fs5.default.statSync(source);
2018
- if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || !import_node_fs5.default.readFileSync(source).equals(bytes)) {
2203
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || !readHistoryBytes(source, this.readOptions).equals(bytes)) {
2019
2204
  this.report("Legacy history is changing; import postponed");
2020
2205
  continue;
2021
2206
  }
@@ -2063,17 +2248,22 @@ var SessionManager = class {
2063
2248
  }));
2064
2249
  const exact = all.find((m) => m.conversationId === idOrFileName);
2065
2250
  const matches = exact ? [exact] : all.filter((m) => m.conversationId.startsWith(idOrFileName));
2066
- if (!matches.length) throw new Error(`Session not found: ${idOrFileName}`);
2067
- if (matches.length !== 1) throw new Error(`Ambiguous session prefix: ${idOrFileName}`);
2251
+ if (!matches.length)
2252
+ throw createSessionError("SESSION_NOT_FOUND", `Session not found: ${idOrFileName}`);
2253
+ if (matches.length !== 1)
2254
+ throw createSessionError(
2255
+ "SESSION_ID_AMBIGUOUS",
2256
+ `Ambiguous session prefix: ${idOrFileName}`
2257
+ );
2068
2258
  sourceFileName = matches[0].fileName;
2069
2259
  }
2070
2260
  const sourcePath = import_node_path4.default.join(this.directory, sourceFileName);
2071
2261
  if (!import_node_fs5.default.existsSync(sourcePath)) {
2072
- throw new Error(`Session not found: ${idOrFileName}`);
2262
+ throw createSessionError("SESSION_NOT_FOUND", `Session not found: ${idOrFileName}`);
2073
2263
  }
2074
2264
  const nativeSource = toNativePhysicalPath(sourcePath);
2075
2265
  const beforeStat = import_node_fs5.default.statSync(nativeSource);
2076
- const bytes = import_node_fs5.default.readFileSync(nativeSource);
2266
+ const bytes = readHistoryBytes(nativeSource, this.readOptions);
2077
2267
  const upgradeIdentity = (0, import_node_crypto6.createHash)("sha256").update(nativeSource).update("\0").update(bytes).digest("hex");
2078
2268
  const existingMeta = (await this.allMetadata()).find(
2079
2269
  (m) => m.upgradeIdentity === upgradeIdentity
@@ -2092,7 +2282,7 @@ var SessionManager = class {
2092
2282
  if (doubleCheck) {
2093
2283
  return this.open(doubleCheck.conversationId);
2094
2284
  }
2095
- const sourceStore = await SessionStore.open(nativeSource);
2285
+ const sourceStore = await SessionStore.open(nativeSource, this.readOptions);
2096
2286
  if (sourceStore.getSchemaVersion() !== SESSION_SCHEMA_VERSION_V1) {
2097
2287
  const state = await sourceStore.loadState();
2098
2288
  return this.open(state.header.conversationId);
@@ -2159,7 +2349,7 @@ var SessionManager = class {
2159
2349
  import_node_fs5.default.closeSync(fd);
2160
2350
  }
2161
2351
  const afterStat = import_node_fs5.default.statSync(nativeSource);
2162
- if (beforeStat.size !== afterStat.size || beforeStat.mtimeMs !== afterStat.mtimeMs || !import_node_fs5.default.readFileSync(nativeSource).equals(bytes)) {
2352
+ if (beforeStat.size !== afterStat.size || beforeStat.mtimeMs !== afterStat.mtimeMs || !readHistoryBytes(nativeSource, this.readOptions).equals(bytes)) {
2163
2353
  throw new Error("Source legacy session changed during upgrade; retry aborted");
2164
2354
  }
2165
2355
  import_node_fs5.default.renameSync(tempPath, targetPath);
@@ -2322,7 +2512,7 @@ var SessionManager = class {
2322
2512
  store.setAppendObserver(
2323
2513
  (record) => {
2324
2514
  const previous = JSON.parse(
2325
- import_node_fs5.default.readFileSync(this.metadataPath(store.filePath), "utf8")
2515
+ readHistoryBytes(this.metadataPath(store.filePath), this.readOptions).toString("utf8")
2326
2516
  );
2327
2517
  const currentPrev = record.prevRecordId ?? record.parentId;
2328
2518
  if (previous.conversationId !== header.conversationId || currentPrev !== null && currentPrev !== (previous.logRevision ?? previous.revision))
@@ -2367,10 +2557,28 @@ var SessionManager = class {
2367
2557
  this.handles.set(header.conversationId, handle);
2368
2558
  return handle;
2369
2559
  }
2560
+ /** 超限列表只解析有界头部并核对桶身份,不读取正文、不生成可误用的耐久缓存。 */
2561
+ readOversizedMetadata(file) {
2562
+ const fd = import_node_fs5.default.openSync(file, "r");
2563
+ try {
2564
+ const buffer = Buffer.alloc(Math.min(this.readOptions.maxReadBytes, 64 * 1024));
2565
+ const length = import_node_fs5.default.readSync(fd, buffer, 0, buffer.length, 0);
2566
+ const end = buffer.subarray(0, length).indexOf(10);
2567
+ if (end < 0) throw new Error("Session header exceeds discovery budget");
2568
+ const header = JSON.parse(buffer.subarray(0, end).toString("utf8"));
2569
+ if (header.type !== "header" || ![1, 2].includes(header.schemaVersion))
2570
+ throw new Error("Session corrupt header");
2571
+ return { ...this.buildMetadata(file, [header]), title: "\u5386\u53F2\u8D85\u8FC7\u8BFB\u53D6\u9884\u7B97" };
2572
+ } finally {
2573
+ import_node_fs5.default.closeSync(fd);
2574
+ }
2575
+ }
2370
2576
  /** 缓存通过文件名、身份、统计与正文 stat 校验;失败交由正文重建。 */
2371
2577
  readMetadata(file) {
2372
2578
  try {
2373
- const meta = JSON.parse(import_node_fs5.default.readFileSync(this.metadataPath(file), "utf8"));
2579
+ const meta = JSON.parse(
2580
+ readHistoryBytes(this.metadataPath(file), this.readOptions).toString("utf8")
2581
+ );
2374
2582
  const stat = import_node_fs5.default.statSync(file);
2375
2583
  if ((meta.schemaVersion === 1 || meta.schemaVersion === 2) && meta.fileName === import_node_path4.default.basename(file) && typeof meta.conversationId === "string" && import_node_path4.default.basename(file).endsWith(`_${meta.conversationId}.jsonl`) && normalizedProjectRoot(meta.projectRoot) === normalizedProjectRoot(this.project.projectRoot) && typeof meta.title === "string" && Number.isFinite(meta.createdAt) && Number.isFinite(meta.lastActivityAt) && Number.isSafeInteger(meta.messageCount) && meta.messageCount >= 0 && typeof meta.revision === "string" && meta.size === stat.size && meta.mtimeMs === stat.mtimeMs)
2376
2584
  return meta;
@@ -2380,7 +2588,7 @@ var SessionManager = class {
2380
2588
  }
2381
2589
  /** 浏览仅解析完整记录;严格打开由 SessionStore 验证,浏览不能改写残片。 */
2382
2590
  *readDiscoveryRecords(file) {
2383
- const iterator = readJsonlLines(file);
2591
+ const iterator = readJsonlLines(file, this.readOptions);
2384
2592
  let first = true;
2385
2593
  let blankSeen = false;
2386
2594
  for (const line of iterator) {
@@ -2435,7 +2643,8 @@ var SessionManager = class {
2435
2643
  contentRevision: first.value.recordId,
2436
2644
  activeBranchId: MAIN_BRANCH_ID,
2437
2645
  activeLeafId: first.value.recordId,
2438
- archived: false
2646
+ archived: false,
2647
+ pinned: false
2439
2648
  };
2440
2649
  const branchLeaves = /* @__PURE__ */ new Map();
2441
2650
  branchLeaves.set(MAIN_BRANCH_ID, first.value.recordId);
@@ -2488,6 +2697,8 @@ var SessionManager = class {
2488
2697
  if (bId) meta.activeBranchId = bId;
2489
2698
  } else if (record.type === "archive_state") {
2490
2699
  meta.archived = Boolean(record.payload.archived);
2700
+ } else if (record.type === "pin_state") {
2701
+ meta.pinned = Boolean(record.payload.pinned);
2491
2702
  }
2492
2703
  if (branchLeaves && !isGlobalControlRecordType(record.type)) {
2493
2704
  const bId = record.branchId ?? meta.activeBranchId ?? MAIN_BRANCH_ID;
@@ -2994,6 +3205,7 @@ var CrossProjectSessionCatalog = class {
2994
3205
  let title = "\u65B0\u4F1A\u8BDD";
2995
3206
  let activeBranchId = MAIN_BRANCH_ID;
2996
3207
  let archived = false;
3208
+ let pinned = false;
2997
3209
  let lastRecordId = headerRecord.recordId;
2998
3210
  for (const line of iterator) {
2999
3211
  if (!line.text.trim()) continue;
@@ -3014,6 +3226,8 @@ var CrossProjectSessionCatalog = class {
3014
3226
  title = String(rec.payload.title);
3015
3227
  } else if (rec.type === "archive_state") {
3016
3228
  archived = Boolean(rec.payload.archived);
3229
+ } else if (rec.type === "pin_state") {
3230
+ pinned = Boolean(rec.payload.pinned);
3017
3231
  } else if (rec.type === "branch_selected") {
3018
3232
  activeBranchId = String(rec.payload.branchId);
3019
3233
  }
@@ -3032,6 +3246,7 @@ var CrossProjectSessionCatalog = class {
3032
3246
  title,
3033
3247
  activeBranchId,
3034
3248
  archived,
3249
+ pinned,
3035
3250
  size: stat.size,
3036
3251
  mtimeMs: stat.mtimeMs,
3037
3252
  revision: lastRecordId,
@@ -3438,7 +3653,6 @@ var WorkspaceLeaseCoordinator = class {
3438
3653
  }
3439
3654
  }
3440
3655
  };
3441
- var sharedWorkspaceLeaseCoordinator = new WorkspaceLeaseCoordinator();
3442
3656
 
3443
3657
  // src/context/manager.ts
3444
3658
  var import_node_crypto10 = require("crypto");
@@ -6170,7 +6384,8 @@ function stopProcessTree(child, nativeGroup) {
6170
6384
  try {
6171
6385
  process.kill(-child.pid, "SIGTERM");
6172
6386
  return true;
6173
- } catch {
6387
+ } catch (error) {
6388
+ if (error.code === "ESRCH") return true;
6174
6389
  try {
6175
6390
  child.kill("SIGTERM");
6176
6391
  } catch {
@@ -6191,7 +6406,8 @@ function forceProcessTree(child, nativeGroup) {
6191
6406
  try {
6192
6407
  process.kill(-child.pid, "SIGKILL");
6193
6408
  return true;
6194
- } catch {
6409
+ } catch (error) {
6410
+ if (error.code === "ESRCH") return true;
6195
6411
  try {
6196
6412
  child.kill("SIGKILL");
6197
6413
  } catch {
@@ -6212,13 +6428,31 @@ async function confirmProcessTreeStopped(child, terminationSucceeded, nativeGrou
6212
6428
  if (process.platform === "win32") return true;
6213
6429
  const deadline = Date.now() + 1e3;
6214
6430
  while (Date.now() < deadline) {
6431
+ let exists;
6215
6432
  try {
6216
6433
  process.kill(-child.pid, 0);
6434
+ exists = true;
6217
6435
  } catch (error) {
6218
- if (error.code === "ESRCH") return true;
6219
- return false;
6436
+ const code = error.code;
6437
+ if (code === "ESRCH") return true;
6438
+ if (code !== "EPERM") return false;
6439
+ exists = true;
6440
+ }
6441
+ if (groupHasLiveProcess(child.pid)) {
6442
+ await new Promise((resolve) => setTimeout(resolve, 50));
6443
+ continue;
6220
6444
  }
6221
- await new Promise((resolve) => setTimeout(resolve, 50));
6445
+ return true;
6446
+ }
6447
+ return false;
6448
+ }
6449
+ function groupHasLiveProcess(pgid) {
6450
+ const result = (0, import_node_child_process3.spawnSync)("ps", ["-axo", "pgid=", "stat="], { timeout: 2e3, encoding: "utf8" });
6451
+ if (result.status !== 0 || result.error) return true;
6452
+ for (const row of result.stdout.split("\n")) {
6453
+ const columns = row.trim().split(/\s+/);
6454
+ if (columns.length < 2 || Number(columns[0]) !== pgid) continue;
6455
+ if (!columns[1].startsWith("Z")) return true;
6222
6456
  }
6223
6457
  return false;
6224
6458
  }
@@ -7586,90 +7820,419 @@ var HookRegistry = class {
7586
7820
  };
7587
7821
 
7588
7822
  // src/runtime/executor/index.ts
7589
- var import_node_crypto16 = require("crypto");
7590
7823
  var import_node_path17 = __toESM(require("path"), 1);
7591
- var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
7592
- var ToolExecutor = class {
7593
- hardDenial;
7594
- onToolResult;
7595
- tools;
7596
- hooks;
7597
- workspaceRoot;
7598
- executionCwd;
7599
- sandbox;
7600
- leaseCoordinator;
7601
- rootDir;
7602
- signal;
7603
- logger;
7604
- toolTimeoutMs;
7605
- eventLogger;
7606
- sessionId;
7607
- runId;
7608
- branchId;
7609
- rootTaskId;
7610
- depth;
7611
- agentId;
7612
- committedActions = [];
7613
- rollbackActions = [];
7614
- mode;
7615
- permissionRules;
7616
- approvalChannel;
7617
- approvalCache;
7618
- policy = new PermissionPolicy();
7619
- neverFailures = /* @__PURE__ */ new Set();
7620
- cancellation = new AbortController();
7621
- constructor(options) {
7622
- this.hardDenial = options.hardDenial;
7623
- this.onToolResult = options.onToolResult;
7624
- this.tools = options.tools;
7625
- this.hooks = options.hooks;
7626
- this.workspaceRoot = options.workspaceRoot ?? options.rootDir;
7627
- this.executionCwd = options.executionCwd ?? options.rootDir;
7628
- this.sandbox = options.sandbox;
7629
- this.leaseCoordinator = options.leaseCoordinator;
7630
- this.rootDir = options.rootDir;
7631
- this.signal = options.signal ? AbortSignal.any([options.signal, this.cancellation.signal]) : this.cancellation.signal;
7632
- this.logger = options.logger;
7633
- this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
7634
- this.eventLogger = options.eventLogger;
7635
- this.sessionId = options.sessionId;
7636
- this.runId = options.runId;
7637
- this.branchId = options.branchId;
7638
- this.rootTaskId = options.rootTaskId;
7639
- this.depth = options.depth;
7640
- this.agentId = options.agentId;
7641
- this.mode = options.mode ?? "Approval";
7642
- this.permissionRules = options.permissionRules ?? [];
7643
- this.approvalChannel = options.approvalChannel;
7644
- this.approvalCache = options.approvalCache ?? new SessionApprovalCache();
7645
- }
7646
- /**
7647
- * 父工具事务提交成功后调用,按注册顺序执行所有提交动作。
7648
- * 动作执行成功后逐项清理对应回滚动作,若某项失败保留剩余回滚动作。
7649
- */
7650
- async commit() {
7651
- while (this.committedActions.length > 0) {
7652
- const action = this.committedActions.shift();
7653
- await action();
7654
- this.rollbackActions.shift();
7824
+
7825
+ // src/runtime/executor/approval.ts
7826
+ var import_node_crypto16 = require("crypto");
7827
+ async function resolveToolApproval(deps) {
7828
+ const {
7829
+ call,
7830
+ finalInput,
7831
+ tool,
7832
+ rootDir,
7833
+ executionCwd,
7834
+ mode,
7835
+ permissionRules,
7836
+ approvalChannel,
7837
+ approvalCache,
7838
+ hardDenial,
7839
+ neverFailures,
7840
+ policy,
7841
+ signal,
7842
+ audit,
7843
+ failure
7844
+ } = deps;
7845
+ let shell = deps.shell;
7846
+ const executionIdentity = deps.executionIdentity;
7847
+ let auditFields = {};
7848
+ for (let attempt = 0; attempt < 2; attempt++) {
7849
+ const request = {
7850
+ toolName: call.name,
7851
+ capabilities: tool.metadata?.permissions ?? [],
7852
+ input: finalInput,
7853
+ rootDir,
7854
+ ...shell ? { shell } : {},
7855
+ ...executionIdentity ? { executionIdentity } : {},
7856
+ // 解释器内建查询与内容已绑定摘要的工作区直引脚本可确认不受外部脚本变更影响,
7857
+ // 允许会话级批准;其余 shell 命令仅允许单次批准。
7858
+ // 非 shell 工具(含深度绑定 executionIdentity 的外部工具)允许会话级批准。
7859
+ sessionAllowed: !shell || /^(?:pwd|get-location)$/i.test(shell.command.trim()) || shell.scriptDigest !== void 0
7860
+ };
7861
+ const repeatKey = (0, import_node_crypto16.createHash)("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex");
7862
+ if (neverFailures.has(repeatKey)) {
7863
+ try {
7864
+ await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
7865
+ await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
7866
+ } catch {
7867
+ return {
7868
+ result: failure(
7869
+ "Audit unavailable; tool was not started",
7870
+ "AUDIT_UNAVAILABLE",
7871
+ "after_user_action",
7872
+ {
7873
+ version: 1,
7874
+ dispatchOutcome: "not_sent",
7875
+ hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7876
+ }
7877
+ )
7878
+ };
7879
+ }
7880
+ return {
7881
+ result: failure(
7882
+ "Identical failed tool call cannot be repeated in this run",
7883
+ "REPEAT_BLOCKED"
7884
+ )
7885
+ };
7655
7886
  }
7656
- this.rollbackActions.length = 0;
7657
- }
7658
- /**
7659
- * 父工具事务提交失败或取消时调用,按逆序执行所有回滚清理动作。
7660
- */
7661
- async rollback() {
7662
- const actions = [...this.rollbackActions].reverse();
7663
- this.committedActions.length = 0;
7664
- this.rollbackActions.length = 0;
7665
- for (const action of actions) {
7887
+ const hardDenied = hardDenial?.(tool, finalInput);
7888
+ const cached = approvalCache.get(request);
7889
+ const decision = policy.decide({
7890
+ hardDenied: hardDenied !== void 0,
7891
+ mode: typeof mode === "function" ? mode() : mode,
7892
+ toolName: call.name,
7893
+ capabilities: request.capabilities,
7894
+ dangerous: tool.metadata?.dangerous,
7895
+ cachedApproval: cached === "allow",
7896
+ cachedDenial: cached === "deny",
7897
+ rules: permissionRules,
7898
+ ...shell ? { shell } : {}
7899
+ });
7900
+ let permitted = decision.decision === "allow";
7901
+ let source = decision.source;
7902
+ let approvalChoice;
7903
+ if (decision.decision === "ask") {
7904
+ let choice = "deny_once";
7666
7905
  try {
7667
- await action();
7906
+ choice = await approvalChannel?.requestApproval(request, signal) ?? "deny_once";
7907
+ } catch {
7908
+ choice = "cancel";
7909
+ }
7910
+ approvalChoice = choice;
7911
+ if (choice === "allow_session" && request.sessionAllowed) approvalCache.set(request, "allow");
7912
+ if (choice === "deny_session") approvalCache.set(request, "deny");
7913
+ permitted = choice === "allow_once" || choice === "allow_session" && request.sessionAllowed;
7914
+ source = approvalChannel ? "human" : "non_interactive";
7915
+ }
7916
+ auditFields = shell ? {
7917
+ shell: shell.interpreter,
7918
+ cwdDigest: (0, import_node_crypto16.createHash)("sha256").update(shell.cwd).digest("hex"),
7919
+ executableDigest: shell.executableDigest,
7920
+ executionFingerprint: shell.executionFingerprint,
7921
+ ...commandAuditSummary(shell.command)
7922
+ } : executionIdentity ? {
7923
+ serverIdentity: executionIdentity.serverIdentity,
7924
+ connectionGeneration: executionIdentity.connectionGeneration,
7925
+ catalogEpoch: executionIdentity.catalogEpoch,
7926
+ toolDigest: executionIdentity.toolDigest
7927
+ } : {};
7928
+ try {
7929
+ await audit("tool.decided", {
7930
+ decision: permitted ? "allow" : "deny",
7931
+ decisionSource: source,
7932
+ ...approvalChoice ? { approvalChoice } : {},
7933
+ ...hardDenied?.fields ?? {},
7934
+ ...auditFields
7935
+ });
7936
+ } catch {
7937
+ return {
7938
+ result: failure(
7939
+ "Audit unavailable; tool was not started",
7940
+ "AUDIT_UNAVAILABLE",
7941
+ "after_user_action",
7942
+ {
7943
+ version: 1,
7944
+ dispatchOutcome: "not_sent",
7945
+ hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7946
+ }
7947
+ )
7948
+ };
7949
+ }
7950
+ if (!permitted || signal?.aborted) {
7951
+ const code = signal?.aborted ? "ABORTED" : hardDenied?.code ?? "PERMISSION_DENIED";
7952
+ try {
7953
+ await audit("tool.finished", { status: "denied", errorCode: code });
7668
7954
  } catch {
7955
+ return {
7956
+ result: failure("Audit result unavailable", "AUDIT_UNAVAILABLE", "after_user_action", {
7957
+ version: 1,
7958
+ dispatchOutcome: "not_sent",
7959
+ hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7960
+ })
7961
+ };
7669
7962
  }
7963
+ return {
7964
+ result: failure(
7965
+ signal?.aborted ? "Operation cancelled" : hardDenied?.message ?? "Tool permission denied",
7966
+ code,
7967
+ signal?.aborted ? "after_user_action" : hardDenied?.retryPolicy ?? "after_user_action"
7968
+ )
7969
+ };
7670
7970
  }
7671
- }
7672
- /** 中止本轮正在等待或执行的工具;调用方仍须等待 runAll 完成资源回收及结果审计。 */
7971
+ if (shell) {
7972
+ try {
7973
+ const next = tool.approvalScope?.(finalInput, rootDir, executionCwd);
7974
+ if (next?.executionFingerprint !== shell.executionFingerprint) {
7975
+ if (attempt === 0 && next) {
7976
+ try {
7977
+ await audit("tool.target_changed", { status: "redeciding" });
7978
+ } catch {
7979
+ return {
7980
+ result: failure(
7981
+ "Audit unavailable; tool was not started",
7982
+ "AUDIT_UNAVAILABLE",
7983
+ "after_user_action",
7984
+ {
7985
+ version: 1,
7986
+ dispatchOutcome: "not_sent",
7987
+ hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7988
+ }
7989
+ )
7990
+ };
7991
+ }
7992
+ shell = next;
7993
+ continue;
7994
+ }
7995
+ await audit("tool.finished", { status: "target_changed", errorCode: "TARGET_CHANGED" });
7996
+ return {
7997
+ result: failure(
7998
+ "Execution target changed after approval",
7999
+ "TARGET_CHANGED",
8000
+ "after_user_action"
8001
+ )
8002
+ };
8003
+ }
8004
+ } catch {
8005
+ return {
8006
+ result: failure(
8007
+ "Execution target changed after approval",
8008
+ "TARGET_CHANGED",
8009
+ "after_user_action"
8010
+ )
8011
+ };
8012
+ }
8013
+ }
8014
+ if (executionIdentity) {
8015
+ try {
8016
+ const nextIdentity = tool.executionIdentity?.(finalInput, rootDir);
8017
+ if (!nextIdentity || nextIdentity.serverIdentity !== executionIdentity.serverIdentity || nextIdentity.connectionGeneration !== executionIdentity.connectionGeneration || nextIdentity.catalogEpoch !== executionIdentity.catalogEpoch || nextIdentity.toolDigest !== executionIdentity.toolDigest) {
8018
+ await audit("tool.finished", {
8019
+ status: "identity_changed",
8020
+ errorCode: "MCP_TOOL_CHANGED"
8021
+ });
8022
+ return {
8023
+ result: failure(
8024
+ "Tool execution identity changed after approval",
8025
+ "MCP_TOOL_CHANGED",
8026
+ "after_user_action",
8027
+ {
8028
+ version: 1,
8029
+ dispatchOutcome: "not_sent",
8030
+ hostError: { phase: "validation", code: "MCP_TOOL_CHANGED" }
8031
+ }
8032
+ )
8033
+ };
8034
+ }
8035
+ } catch {
8036
+ return {
8037
+ result: failure(
8038
+ "Tool execution identity check failed after approval",
8039
+ "MCP_TOOL_CHANGED",
8040
+ "after_user_action",
8041
+ {
8042
+ version: 1,
8043
+ dispatchOutcome: "not_sent"
8044
+ }
8045
+ )
8046
+ };
8047
+ }
8048
+ }
8049
+ break;
8050
+ }
8051
+ return { shell, auditFields };
8052
+ }
8053
+
8054
+ // src/runtime/executor/result.ts
8055
+ var import_node_crypto17 = require("crypto");
8056
+ async function closeToolResult(deps) {
8057
+ const { call, tool, finalInput, hookCtx, hooks, neverFailures, audit, onToolResult, startedAt } = deps;
8058
+ const resultBlock = {
8059
+ type: "tool_result",
8060
+ toolUseId: call.id,
8061
+ content: deps.output.content,
8062
+ isError: deps.output.isError,
8063
+ ...deps.output.errorCode ? { errorCode: deps.output.errorCode, retryPolicy: deps.output.retryPolicy } : {},
8064
+ execution: deps.output.execution
8065
+ };
8066
+ if (resultBlock.isError && resultBlock.errorCode) {
8067
+ resultBlock.content = JSON.stringify({
8068
+ code: resultBlock.errorCode,
8069
+ retryPolicy: resultBlock.retryPolicy,
8070
+ message: deps.output.content
8071
+ });
8072
+ }
8073
+ try {
8074
+ for (const hook of hooks.get("tool:after")) {
8075
+ await hook(
8076
+ hookCtx,
8077
+ { id: call.id, name: call.name, input: finalInput },
8078
+ { output: deps.output.content, isError: deps.output.isError }
8079
+ );
8080
+ }
8081
+ } catch {
8082
+ resultBlock.content = JSON.stringify({
8083
+ code: "HOOK_ERROR",
8084
+ retryPolicy: "after_user_action",
8085
+ message: "Tool finished but result processing failed"
8086
+ });
8087
+ resultBlock.isError = true;
8088
+ resultBlock.errorCode = "HOOK_ERROR";
8089
+ resultBlock.retryPolicy = "after_user_action";
8090
+ resultBlock.execution = {
8091
+ version: 1,
8092
+ dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "failed",
8093
+ hostError: {
8094
+ phase: "hook",
8095
+ code: "HOOK_ERROR"
8096
+ }
8097
+ };
8098
+ }
8099
+ resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
8100
+ if (resultBlock.isError && resultBlock.retryPolicy === "never") {
8101
+ neverFailures.add(
8102
+ (0, import_node_crypto17.createHash)("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex")
8103
+ );
8104
+ }
8105
+ try {
8106
+ await audit("tool.finished", {
8107
+ status: resultBlock.isError ? "failed" : "completed",
8108
+ ...resultBlock.errorCode ? { errorCode: resultBlock.errorCode } : {},
8109
+ durationMs: resultBlock.durationMs
8110
+ });
8111
+ } catch {
8112
+ resultBlock.isError = true;
8113
+ resultBlock.errorCode = "OUTCOME_UNKNOWN";
8114
+ resultBlock.retryPolicy = "after_user_action";
8115
+ resultBlock.content = JSON.stringify({
8116
+ code: "OUTCOME_UNKNOWN",
8117
+ retryPolicy: "after_user_action",
8118
+ message: "Tool outcome is unknown because the audit result could not be written"
8119
+ });
8120
+ resultBlock.execution = {
8121
+ version: 1,
8122
+ dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "succeeded",
8123
+ hostError: {
8124
+ phase: "audit",
8125
+ code: "AUDIT_UNAVAILABLE"
8126
+ }
8127
+ };
8128
+ return resultBlock;
8129
+ }
8130
+ await onToolResult?.(tool, finalInput, resultBlock);
8131
+ return resultBlock;
8132
+ }
8133
+
8134
+ // src/runtime/executor/timeout.ts
8135
+ function withToolTimeout(promise, toolName, timeoutMs, executionController) {
8136
+ let timer;
8137
+ const timeout = new Promise((_, reject) => {
8138
+ timer = setTimeout(() => {
8139
+ const error = new KapibalaError(`Tool '${toolName}' timed out after ${timeoutMs}ms`, {
8140
+ code: "OUTCOME_UNKNOWN",
8141
+ retryPolicy: "after_user_action",
8142
+ safeMessage: "Tool timed out; completion of side effects is unknown"
8143
+ });
8144
+ reject(error);
8145
+ executionController.abort(error);
8146
+ }, timeoutMs);
8147
+ });
8148
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
8149
+ }
8150
+
8151
+ // src/runtime/executor/types.ts
8152
+ var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
8153
+
8154
+ // src/runtime/executor/index.ts
8155
+ var ToolExecutor = class {
8156
+ hardDenial;
8157
+ onToolResult;
8158
+ tools;
8159
+ hooks;
8160
+ workspaceRoot;
8161
+ executionCwd;
8162
+ sandbox;
8163
+ leaseCoordinator;
8164
+ rootDir;
8165
+ signal;
8166
+ logger;
8167
+ toolTimeoutMs;
8168
+ eventLogger;
8169
+ sessionId;
8170
+ runId;
8171
+ branchId;
8172
+ rootTaskId;
8173
+ depth;
8174
+ agentId;
8175
+ committedActions = [];
8176
+ rollbackActions = [];
8177
+ mode;
8178
+ permissionRules;
8179
+ approvalChannel;
8180
+ approvalCache;
8181
+ policy = new PermissionPolicy();
8182
+ neverFailures = /* @__PURE__ */ new Set();
8183
+ cancellation = new AbortController();
8184
+ constructor(options) {
8185
+ this.hardDenial = options.hardDenial;
8186
+ this.onToolResult = options.onToolResult;
8187
+ this.tools = options.tools;
8188
+ this.hooks = options.hooks;
8189
+ this.workspaceRoot = options.workspaceRoot ?? options.rootDir;
8190
+ this.executionCwd = options.executionCwd ?? options.rootDir;
8191
+ this.sandbox = options.sandbox;
8192
+ this.leaseCoordinator = options.leaseCoordinator;
8193
+ this.rootDir = options.rootDir;
8194
+ this.signal = options.signal ? AbortSignal.any([options.signal, this.cancellation.signal]) : this.cancellation.signal;
8195
+ this.logger = options.logger;
8196
+ this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
8197
+ this.eventLogger = options.eventLogger;
8198
+ this.sessionId = options.sessionId;
8199
+ this.runId = options.runId;
8200
+ this.branchId = options.branchId;
8201
+ this.rootTaskId = options.rootTaskId;
8202
+ this.depth = options.depth;
8203
+ this.agentId = options.agentId;
8204
+ this.mode = options.mode ?? "Approval";
8205
+ this.permissionRules = options.permissionRules ?? [];
8206
+ this.approvalChannel = options.approvalChannel;
8207
+ this.approvalCache = options.approvalCache ?? new SessionApprovalCache();
8208
+ }
8209
+ /**
8210
+ * 父工具事务提交成功后调用,按注册顺序执行所有提交动作。
8211
+ * 动作执行成功后逐项清理对应回滚动作,若某项失败保留剩余回滚动作。
8212
+ */
8213
+ async commit() {
8214
+ while (this.committedActions.length > 0) {
8215
+ const action = this.committedActions.shift();
8216
+ await action();
8217
+ this.rollbackActions.shift();
8218
+ }
8219
+ this.rollbackActions.length = 0;
8220
+ }
8221
+ /**
8222
+ * 父工具事务提交失败或取消时调用,按逆序执行所有回滚清理动作。
8223
+ */
8224
+ async rollback() {
8225
+ const actions = [...this.rollbackActions].reverse();
8226
+ this.committedActions.length = 0;
8227
+ this.rollbackActions.length = 0;
8228
+ for (const action of actions) {
8229
+ try {
8230
+ await action();
8231
+ } catch {
8232
+ }
8233
+ }
8234
+ }
8235
+ /** 中止本轮正在等待或执行的工具;调用方仍须等待 runAll 完成资源回收及结果审计。 */
7673
8236
  cancel() {
7674
8237
  this.cancellation.abort();
7675
8238
  }
@@ -7904,191 +8467,28 @@ var ToolExecutor = class {
7904
8467
  }
7905
8468
  return failure("Invalid tool execution identity", "INVALID_TARGET");
7906
8469
  }
7907
- let auditFields = {};
7908
- for (let attempt = 0; attempt < 2; attempt++) {
7909
- const request = {
7910
- toolName: call.name,
7911
- capabilities: tool.metadata?.permissions ?? [],
7912
- input: finalInput,
7913
- rootDir: this.rootDir,
7914
- ...shell ? { shell } : {},
7915
- ...executionIdentity ? { executionIdentity } : {},
7916
- // 解释器内建查询与内容已绑定摘要的工作区直引脚本可确认不受外部脚本变更影响,
7917
- // 允许会话级批准;其余 shell 命令仅允许单次批准。
7918
- // 非 shell 工具(含深度绑定 executionIdentity 的外部工具)允许会话级批准。
7919
- sessionAllowed: !shell || /^(?:pwd|get-location)$/i.test(shell.command.trim()) || shell.scriptDigest !== void 0
7920
- };
7921
- const repeatKey = (0, import_node_crypto16.createHash)("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex");
7922
- if (this.neverFailures.has(repeatKey)) {
7923
- try {
7924
- await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
7925
- await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
7926
- } catch {
7927
- return failure(
7928
- "Audit unavailable; tool was not started",
7929
- "AUDIT_UNAVAILABLE",
7930
- "after_user_action",
7931
- {
7932
- version: 1,
7933
- dispatchOutcome: "not_sent",
7934
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7935
- }
7936
- );
7937
- }
7938
- return failure(
7939
- "Identical failed tool call cannot be repeated in this run",
7940
- "REPEAT_BLOCKED"
7941
- );
7942
- }
7943
- const hardDenied = this.hardDenial?.(tool, finalInput);
7944
- const cached = this.approvalCache.get(request);
7945
- const decision = this.policy.decide({
7946
- hardDenied: hardDenied !== void 0,
7947
- mode: typeof this.mode === "function" ? this.mode() : this.mode,
7948
- toolName: call.name,
7949
- capabilities: request.capabilities,
7950
- dangerous: tool.metadata?.dangerous,
7951
- cachedApproval: cached === "allow",
7952
- cachedDenial: cached === "deny",
7953
- rules: this.permissionRules,
7954
- ...shell ? { shell } : {}
7955
- });
7956
- let permitted = decision.decision === "allow";
7957
- let source = decision.source;
7958
- let approvalChoice;
7959
- if (decision.decision === "ask") {
7960
- let choice = "deny_once";
7961
- try {
7962
- choice = await this.approvalChannel?.requestApproval(request, this.signal) ?? "deny_once";
7963
- } catch {
7964
- choice = "cancel";
7965
- }
7966
- approvalChoice = choice;
7967
- if (choice === "allow_session" && request.sessionAllowed)
7968
- this.approvalCache.set(request, "allow");
7969
- if (choice === "deny_session") this.approvalCache.set(request, "deny");
7970
- permitted = choice === "allow_once" || choice === "allow_session" && request.sessionAllowed;
7971
- source = this.approvalChannel ? "human" : "non_interactive";
7972
- }
7973
- auditFields = shell ? {
7974
- shell: shell.interpreter,
7975
- cwdDigest: (0, import_node_crypto16.createHash)("sha256").update(shell.cwd).digest("hex"),
7976
- executableDigest: shell.executableDigest,
7977
- executionFingerprint: shell.executionFingerprint,
7978
- ...commandAuditSummary(shell.command)
7979
- } : executionIdentity ? {
7980
- serverIdentity: executionIdentity.serverIdentity,
7981
- connectionGeneration: executionIdentity.connectionGeneration,
7982
- catalogEpoch: executionIdentity.catalogEpoch,
7983
- toolDigest: executionIdentity.toolDigest
7984
- } : {};
7985
- try {
7986
- await audit("tool.decided", {
7987
- decision: permitted ? "allow" : "deny",
7988
- decisionSource: source,
7989
- ...approvalChoice ? { approvalChoice } : {},
7990
- ...hardDenied?.fields ?? {},
7991
- ...auditFields
7992
- });
7993
- } catch {
7994
- return failure(
7995
- "Audit unavailable; tool was not started",
7996
- "AUDIT_UNAVAILABLE",
7997
- "after_user_action",
7998
- {
7999
- version: 1,
8000
- dispatchOutcome: "not_sent",
8001
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
8002
- }
8003
- );
8004
- }
8005
- if (!permitted || this.signal?.aborted) {
8006
- const code = this.signal?.aborted ? "ABORTED" : hardDenied?.code ?? "PERMISSION_DENIED";
8007
- try {
8008
- await audit("tool.finished", { status: "denied", errorCode: code });
8009
- } catch {
8010
- return failure("Audit result unavailable", "AUDIT_UNAVAILABLE", "after_user_action", {
8011
- version: 1,
8012
- dispatchOutcome: "not_sent",
8013
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
8014
- });
8015
- }
8016
- return failure(
8017
- this.signal?.aborted ? "Operation cancelled" : hardDenied?.message ?? "Tool permission denied",
8018
- code,
8019
- this.signal?.aborted ? "after_user_action" : hardDenied?.retryPolicy ?? "after_user_action"
8020
- );
8021
- }
8022
- if (shell) {
8023
- try {
8024
- const next = tool.approvalScope?.(finalInput, this.rootDir, this.executionCwd);
8025
- if (next?.executionFingerprint !== shell.executionFingerprint) {
8026
- if (attempt === 0 && next) {
8027
- try {
8028
- await audit("tool.target_changed", { status: "redeciding" });
8029
- } catch {
8030
- return failure(
8031
- "Audit unavailable; tool was not started",
8032
- "AUDIT_UNAVAILABLE",
8033
- "after_user_action",
8034
- {
8035
- version: 1,
8036
- dispatchOutcome: "not_sent",
8037
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
8038
- }
8039
- );
8040
- }
8041
- shell = next;
8042
- continue;
8043
- }
8044
- await audit("tool.finished", { status: "target_changed", errorCode: "TARGET_CHANGED" });
8045
- return failure(
8046
- "Execution target changed after approval",
8047
- "TARGET_CHANGED",
8048
- "after_user_action"
8049
- );
8050
- }
8051
- } catch {
8052
- return failure(
8053
- "Execution target changed after approval",
8054
- "TARGET_CHANGED",
8055
- "after_user_action"
8056
- );
8057
- }
8058
- }
8059
- if (executionIdentity) {
8060
- try {
8061
- const nextIdentity = tool.executionIdentity?.(finalInput, this.rootDir);
8062
- if (!nextIdentity || nextIdentity.serverIdentity !== executionIdentity.serverIdentity || nextIdentity.connectionGeneration !== executionIdentity.connectionGeneration || nextIdentity.catalogEpoch !== executionIdentity.catalogEpoch || nextIdentity.toolDigest !== executionIdentity.toolDigest) {
8063
- await audit("tool.finished", {
8064
- status: "identity_changed",
8065
- errorCode: "MCP_TOOL_CHANGED"
8066
- });
8067
- return failure(
8068
- "Tool execution identity changed after approval",
8069
- "MCP_TOOL_CHANGED",
8070
- "after_user_action",
8071
- {
8072
- version: 1,
8073
- dispatchOutcome: "not_sent",
8074
- hostError: { phase: "validation", code: "MCP_TOOL_CHANGED" }
8075
- }
8076
- );
8077
- }
8078
- } catch {
8079
- return failure(
8080
- "Tool execution identity check failed after approval",
8081
- "MCP_TOOL_CHANGED",
8082
- "after_user_action",
8083
- {
8084
- version: 1,
8085
- dispatchOutcome: "not_sent"
8086
- }
8087
- );
8088
- }
8089
- }
8090
- break;
8091
- }
8470
+ const approval = await resolveToolApproval({
8471
+ call,
8472
+ finalInput,
8473
+ tool,
8474
+ shell,
8475
+ executionIdentity,
8476
+ rootDir: this.rootDir,
8477
+ executionCwd: this.executionCwd,
8478
+ mode: this.mode,
8479
+ permissionRules: this.permissionRules,
8480
+ approvalChannel: this.approvalChannel,
8481
+ approvalCache: this.approvalCache,
8482
+ hardDenial: this.hardDenial,
8483
+ neverFailures: this.neverFailures,
8484
+ policy: this.policy,
8485
+ signal: this.signal,
8486
+ audit,
8487
+ failure
8488
+ });
8489
+ if (approval.result) return approval.result;
8490
+ shell = approval.shell;
8491
+ const auditFields = approval.auditFields ?? {};
8092
8492
  try {
8093
8493
  await audit("tool.started", { status: "started", ...auditFields });
8094
8494
  } catch {
@@ -8117,6 +8517,7 @@ var ToolExecutor = class {
8117
8517
  this.signal?.addEventListener("abort", abortFromSession, { once: true });
8118
8518
  }
8119
8519
  const toolCtx = {
8520
+ leaseCoordinator: this.leaseCoordinator,
8120
8521
  workspaceRoot: this.workspaceRoot,
8121
8522
  executionCwd: this.executionCwd,
8122
8523
  sandbox: this.sandbox,
@@ -8147,9 +8548,10 @@ var ToolExecutor = class {
8147
8548
  { signal: executionController.signal }
8148
8549
  );
8149
8550
  }
8150
- const rawResult = tool.metadata?.managesTimeout ? await tool.execute(finalInput, toolCtx) : await this.withTimeout(
8551
+ const rawResult = tool.metadata?.managesTimeout ? await tool.execute(finalInput, toolCtx) : await withToolTimeout(
8151
8552
  tool.execute(finalInput, toolCtx),
8152
8553
  call.name,
8554
+ this.toolTimeoutMs,
8153
8555
  executionController
8154
8556
  );
8155
8557
  if (typeof tool.toExecutionResult === "function") {
@@ -8210,105 +8612,18 @@ var ToolExecutor = class {
8210
8612
  dispatchOutcome: outcome
8211
8613
  };
8212
8614
  }
8213
- const resultBlock = {
8214
- type: "tool_result",
8215
- toolUseId: call.id,
8216
- content: outputStr,
8217
- isError,
8218
- ...errorCode ? { errorCode, retryPolicy } : {},
8219
- execution
8220
- };
8221
- if (resultBlock.isError && resultBlock.errorCode) {
8222
- resultBlock.content = JSON.stringify({
8223
- code: resultBlock.errorCode,
8224
- retryPolicy: resultBlock.retryPolicy,
8225
- message: outputStr
8226
- });
8227
- }
8228
- try {
8229
- for (const hook of this.hooks.get("tool:after")) {
8230
- await hook(
8231
- hookCtx,
8232
- { id: call.id, name: call.name, input: finalInput },
8233
- { output: outputStr, isError }
8234
- );
8235
- }
8236
- } catch {
8237
- resultBlock.content = JSON.stringify({
8238
- code: "HOOK_ERROR",
8239
- retryPolicy: "after_user_action",
8240
- message: "Tool finished but result processing failed"
8241
- });
8242
- resultBlock.isError = true;
8243
- resultBlock.errorCode = "HOOK_ERROR";
8244
- resultBlock.retryPolicy = "after_user_action";
8245
- resultBlock.execution = {
8246
- version: 1,
8247
- dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "failed",
8248
- hostError: {
8249
- phase: "hook",
8250
- code: "HOOK_ERROR"
8251
- }
8252
- };
8253
- }
8254
- resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
8255
- if (resultBlock.isError && resultBlock.retryPolicy === "never") {
8256
- this.neverFailures.add(
8257
- (0, import_node_crypto16.createHash)("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex")
8258
- );
8259
- }
8260
- try {
8261
- await audit("tool.finished", {
8262
- status: resultBlock.isError ? "failed" : "completed",
8263
- ...resultBlock.errorCode ? { errorCode: resultBlock.errorCode } : {},
8264
- durationMs: resultBlock.durationMs
8265
- });
8266
- } catch {
8267
- resultBlock.isError = true;
8268
- resultBlock.errorCode = "OUTCOME_UNKNOWN";
8269
- resultBlock.retryPolicy = "after_user_action";
8270
- resultBlock.content = JSON.stringify({
8271
- code: "OUTCOME_UNKNOWN",
8272
- retryPolicy: "after_user_action",
8273
- message: "Tool outcome is unknown because the audit result could not be written"
8274
- });
8275
- resultBlock.execution = {
8276
- version: 1,
8277
- dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "succeeded",
8278
- hostError: {
8279
- phase: "audit",
8280
- code: "AUDIT_UNAVAILABLE"
8281
- }
8282
- };
8283
- return resultBlock;
8284
- }
8285
- await this.onToolResult?.(tool, finalInput, resultBlock);
8286
- return resultBlock;
8287
- }
8288
- /**
8289
- * 给工具执行加超时护栏:Promise.race 在超时后reject,循环拿到 isError 结果继续运转,
8290
- * 不会因某个异步工具(如未来接入的网络/exec 工具)挂起而永久卡死。
8291
- *
8292
- * 局限:同步 CPU 密集型实现会阻塞事件循环,timer 同样无法触发;
8293
- * 因此内置工具仍需避免执行用户可控的同步高复杂度计算。
8294
- */
8295
- withTimeout(promise, toolName, executionController) {
8296
- let timer;
8297
- const timeout = new Promise((_, reject) => {
8298
- timer = setTimeout(() => {
8299
- const error = new KapibalaError(
8300
- `Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`,
8301
- {
8302
- code: "OUTCOME_UNKNOWN",
8303
- retryPolicy: "after_user_action",
8304
- safeMessage: "Tool timed out; completion of side effects is unknown"
8305
- }
8306
- );
8307
- reject(error);
8308
- executionController.abort(error);
8309
- }, this.toolTimeoutMs);
8615
+ return closeToolResult({
8616
+ call,
8617
+ tool,
8618
+ finalInput,
8619
+ hookCtx,
8620
+ hooks: this.hooks,
8621
+ neverFailures: this.neverFailures,
8622
+ audit,
8623
+ onToolResult: this.onToolResult,
8624
+ startedAt,
8625
+ output: { content: outputStr, isError, errorCode, retryPolicy, execution }
8310
8626
  });
8311
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
8312
8627
  }
8313
8628
  /**
8314
8629
  * 构建当前会话在派生时刻的父权限策略快照。
@@ -8907,7 +9222,7 @@ var OpenAICompatibleProvider = class {
8907
9222
  };
8908
9223
 
8909
9224
  // src/models/anthropic/index.ts
8910
- var import_node_crypto17 = require("crypto");
9225
+ var import_node_crypto18 = require("crypto");
8911
9226
  function invalidResponse2() {
8912
9227
  throw new ModelError("Invalid Anthropic response state", {
8913
9228
  code: "MODEL_INVALID_RESPONSE",
@@ -8922,7 +9237,7 @@ function wireToolId(id) {
8922
9237
  stage: "request",
8923
9238
  retryable: false
8924
9239
  });
8925
- return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${(0, import_node_crypto17.createHash)("sha256").update(id).digest("hex").slice(0, 48)}`;
9240
+ return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${(0, import_node_crypto18.createHash)("sha256").update(id).digest("hex").slice(0, 48)}`;
8926
9241
  }
8927
9242
  var AnthropicProvider = class {
8928
9243
  name = "anthropic";
@@ -9288,7 +9603,7 @@ var AnthropicProvider = class {
9288
9603
  };
9289
9604
 
9290
9605
  // src/models/openai-responses/index.ts
9291
- var import_node_crypto18 = require("crypto");
9606
+ var import_node_crypto19 = require("crypto");
9292
9607
  function invalid(message = "Invalid Responses output Item") {
9293
9608
  throw new ModelError(message, {
9294
9609
  code: "MODEL_INVALID_RESPONSE",
@@ -9309,7 +9624,7 @@ function wireCallId(id) {
9309
9624
  stage: "request",
9310
9625
  retryable: false
9311
9626
  });
9312
- return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${(0, import_node_crypto18.createHash)("sha256").update(id).digest("hex").slice(0, 48)}`;
9627
+ return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${(0, import_node_crypto19.createHash)("sha256").update(id).digest("hex").slice(0, 48)}`;
9313
9628
  }
9314
9629
  function validateItem(item, final) {
9315
9630
  if (!item || typeof item !== "object" || !nonempty(item.id)) invalid();
@@ -10006,13 +10321,99 @@ Follow these key behavioral rules:
10006
10321
  }
10007
10322
  return lines.join("\n");
10008
10323
  }
10009
- getL3Environment() {
10010
- const platform = import_node_os6.default.platform();
10011
- return `# Environment Context:
10012
- - Current Working Directory (Workspace Root): ${this.rootDir}
10013
- - Operating System: ${platform}`;
10324
+ getL3Environment() {
10325
+ const platform = import_node_os6.default.platform();
10326
+ return `# Environment Context:
10327
+ - Current Working Directory (Workspace Root): ${this.rootDir}
10328
+ - Operating System: ${platform}`;
10329
+ }
10330
+ };
10331
+
10332
+ // src/runtime/loop/hook-integrity.ts
10333
+ function assertHookToolDefinitionsIntact(request, snapshot) {
10334
+ if (!request.tools) return;
10335
+ for (const t of request.tools) {
10336
+ const original = snapshot.get(t.name);
10337
+ if (!original) {
10338
+ throw new ModelError(`Hook injected tool '${t.name}' not present in snapshot`, {
10339
+ stage: "request",
10340
+ retryable: false
10341
+ });
10342
+ }
10343
+ if (t.description !== original.description || JSON.stringify(t.parameters) !== JSON.stringify(original.parameters)) {
10344
+ throw new ModelError(
10345
+ `Hook mutated tool definition for '${t.name}' (description or parameters drifted from snapshot)`,
10346
+ {
10347
+ stage: "request",
10348
+ retryable: false
10349
+ }
10350
+ );
10351
+ }
10352
+ }
10353
+ }
10354
+ function snapshotAssistantIntegrity(message) {
10355
+ return {
10356
+ identity: stableSerialize({ ...message, content: void 0 }),
10357
+ originalTools: stableSerialize(
10358
+ message.content.filter((block) => block.type === "tool_use" || block.type === "tool_result")
10359
+ ),
10360
+ originalContent: stableSerialize(message.content),
10361
+ privateOutput: message.content.some(
10362
+ (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10363
+ ),
10364
+ hasToolCalls: message.content.some((block) => block.type === "tool_use")
10365
+ };
10366
+ }
10367
+ function assertAssistantMessageUntampered(message, snapshot) {
10368
+ if (snapshot.identity !== stableSerialize({ ...message, content: void 0 }))
10369
+ throw new ModelError("Hook changed assistant message identity", {
10370
+ stage: "request",
10371
+ retryable: false
10372
+ });
10373
+ if (snapshot.originalTools !== stableSerialize(
10374
+ message.content.filter((block) => block.type === "tool_use" || block.type === "tool_result")
10375
+ ))
10376
+ throw new ModelError("Hook changed validated tool model output", {
10377
+ stage: "request",
10378
+ retryable: false
10379
+ });
10380
+ if (snapshot.originalContent !== stableSerialize(message.content)) {
10381
+ if (snapshot.privateOutput || message.content.some(
10382
+ (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10383
+ ))
10384
+ throw new ModelError("Hook changed signed or private model output", {
10385
+ stage: "request",
10386
+ retryable: false
10387
+ });
10388
+ if (snapshot.hasToolCalls)
10389
+ throw new ModelError("Hook changed validated tool model output", {
10390
+ stage: "request",
10391
+ retryable: false
10392
+ });
10393
+ for (const block of message.content)
10394
+ if (block.type === "text" || block.type === "tool_use") block.protocolMeta = void 0;
10395
+ }
10396
+ }
10397
+
10398
+ // src/runtime/loop/tool-call-validation.ts
10399
+ function assertToolCallsExecutable(history, toolCalls) {
10400
+ const knownCalls = new Set(
10401
+ history.flatMap(
10402
+ (message) => message.content.filter((block) => block.type === "tool_use").map((block) => block.id)
10403
+ )
10404
+ );
10405
+ for (const call of toolCalls) {
10406
+ if (!call.name?.trim() || !call.input || typeof call.input !== "object" || Array.isArray(call.input))
10407
+ throw new ModelError("Invalid tool call arguments or name; no tools executed", {
10408
+ code: "MODEL_INVALID_RESPONSE",
10409
+ stage: "stream",
10410
+ retryable: false
10411
+ });
10412
+ if (!call.id?.trim() || knownCalls.has(call.id))
10413
+ throw new ModelError("Duplicate tool call ID; no tools executed");
10414
+ knownCalls.add(call.id);
10014
10415
  }
10015
- };
10416
+ }
10016
10417
 
10017
10418
  // src/runtime/loop/index.ts
10018
10419
  var AgentLoop = class {
@@ -10096,26 +10497,7 @@ var AgentLoop = class {
10096
10497
  request = modified;
10097
10498
  }
10098
10499
  }
10099
- if (request.tools) {
10100
- for (const t of request.tools) {
10101
- const original = toolSnapshot.get(t.name);
10102
- if (!original) {
10103
- throw new ModelError(`Hook injected tool '${t.name}' not present in snapshot`, {
10104
- stage: "request",
10105
- retryable: false
10106
- });
10107
- }
10108
- if (t.description !== original.description || JSON.stringify(t.parameters) !== JSON.stringify(original.parameters)) {
10109
- throw new ModelError(
10110
- `Hook mutated tool definition for '${t.name}' (description or parameters drifted from snapshot)`,
10111
- {
10112
- stage: "request",
10113
- retryable: false
10114
- }
10115
- );
10116
- }
10117
- }
10118
- }
10500
+ assertHookToolDefinitionsIntact(request, toolSnapshot);
10119
10501
  if (this.prepareRequest) request = yield* this.prepareRequest(request, history);
10120
10502
  yield {
10121
10503
  type: "step_log",
@@ -10266,22 +10648,7 @@ var AgentLoop = class {
10266
10648
  stage: "stream",
10267
10649
  retryable: false
10268
10650
  });
10269
- const knownCalls = new Set(
10270
- history.flatMap(
10271
- (message) => message.content.filter((block) => block.type === "tool_use").map((block) => block.id)
10272
- )
10273
- );
10274
- for (const call of toolCalls) {
10275
- if (!call.name?.trim() || !call.input || typeof call.input !== "object" || Array.isArray(call.input))
10276
- throw new ModelError("Invalid tool call arguments or name; no tools executed", {
10277
- code: "MODEL_INVALID_RESPONSE",
10278
- stage: "stream",
10279
- retryable: false
10280
- });
10281
- if (!call.id?.trim() || knownCalls.has(call.id))
10282
- throw new ModelError("Duplicate tool call ID; no tools executed");
10283
- knownCalls.add(call.id);
10284
- }
10651
+ assertToolCallsExecutable(history, toolCalls);
10285
10652
  break;
10286
10653
  } catch (error) {
10287
10654
  if (!(error instanceof ContextOverflowError) || outputSeen || accumulatedText || accumulatedThinking || toolCalls.length || overflowRetried || !this.prepareRequest || this.signal?.aborted)
@@ -10394,47 +10761,9 @@ var AgentLoop = class {
10394
10761
  timestamp: Date.now()
10395
10762
  };
10396
10763
  validateProtocolContent(assistantMessage.content);
10397
- const originalContent = stableSerialize(assistantMessage.content);
10398
- const identity = stableSerialize({ ...assistantMessage, content: void 0 });
10399
- const originalTools = stableSerialize(
10400
- assistantMessage.content.filter(
10401
- (block) => block.type === "tool_use" || block.type === "tool_result"
10402
- )
10403
- );
10404
- const privateOutput = assistantMessage.content.some(
10405
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10406
- );
10764
+ const integrity = snapshotAssistantIntegrity(assistantMessage);
10407
10765
  await this.hooks.emit("model:after", hookCtx, { message: assistantMessage });
10408
- if (identity !== stableSerialize({ ...assistantMessage, content: void 0 }))
10409
- throw new ModelError("Hook changed assistant message identity", {
10410
- stage: "request",
10411
- retryable: false
10412
- });
10413
- if (originalTools !== stableSerialize(
10414
- assistantMessage.content.filter(
10415
- (block) => block.type === "tool_use" || block.type === "tool_result"
10416
- )
10417
- ))
10418
- throw new ModelError("Hook changed validated tool model output", {
10419
- stage: "request",
10420
- retryable: false
10421
- });
10422
- if (originalContent !== stableSerialize(assistantMessage.content)) {
10423
- if (privateOutput || assistantMessage.content.some(
10424
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10425
- ))
10426
- throw new ModelError("Hook changed signed or private model output", {
10427
- stage: "request",
10428
- retryable: false
10429
- });
10430
- if (toolCalls.length)
10431
- throw new ModelError("Hook changed validated tool model output", {
10432
- stage: "request",
10433
- retryable: false
10434
- });
10435
- for (const block of assistantMessage.content)
10436
- if (block.type === "text" || block.type === "tool_use") block.protocolMeta = void 0;
10437
- }
10766
+ assertAssistantMessageUntampered(assistantMessage, integrity);
10438
10767
  validateProtocolContent(assistantMessage.content);
10439
10768
  if (toolCalls.length === 0) {
10440
10769
  history.push(assistantMessage);
@@ -10839,6 +11168,7 @@ var SessionInbox = class _SessionInbox {
10839
11168
 
10840
11169
  // src/runtime/agent/runtime.ts
10841
11170
  var AgentRuntime = class {
11171
+ runnerContexts = /* @__PURE__ */ new Map();
10842
11172
  tasks = /* @__PURE__ */ new Map();
10843
11173
  controllers = /* @__PURE__ */ new Map();
10844
11174
  timers = /* @__PURE__ */ new Map();
@@ -10922,6 +11252,13 @@ var AgentRuntime = class {
10922
11252
  * - 登记为 preparing 状态并返回 accepted 回执,使父工具事务能立刻闭合。
10923
11253
  */
10924
11254
  async prepareSpawn(context, params) {
11255
+ const coordinator = context.leaseCoordinator;
11256
+ if (!coordinator || typeof coordinator !== "object" || typeof coordinator.isLocked !== "function" || typeof coordinator.getActiveLease !== "function" || typeof coordinator.tryAcquire !== "function" || typeof coordinator.acquire !== "function") {
11257
+ throw new KapibalaError("Spawn requires the parent workspace lease coordinator", {
11258
+ code: "CONFIG_INVALID",
11259
+ safeMessage: "\u5FC5\u987B\u663E\u5F0F\u4F20\u5165\u7236\u4F1A\u8BDD\u5199\u79DF\u7EA6\u534F\u8C03\u5668"
11260
+ });
11261
+ }
10925
11262
  if (this.isClosed) {
10926
11263
  throw new KapibalaError("AgentRuntime is closed", {
10927
11264
  code: "CONFIG_INVALID",
@@ -11064,6 +11401,7 @@ var AgentRuntime = class {
11064
11401
  timeoutMs
11065
11402
  })
11066
11403
  };
11404
+ this.runnerContexts.set(agentId, Object.freeze({ leaseCoordinator: context.leaseCoordinator }));
11067
11405
  this.tasks.set(agentId, taskRecord);
11068
11406
  this.totalActiveCount++;
11069
11407
  this.treeActiveCounts.set(rootTaskId, treeActive + 1);
@@ -11116,6 +11454,7 @@ var AgentRuntime = class {
11116
11454
  const task = this.tasks.get(agentId);
11117
11455
  if (!task) return;
11118
11456
  if (task.status === "preparing") {
11457
+ this.runnerContexts.delete(agentId);
11119
11458
  task.status = "cancelled";
11120
11459
  task.error = "Parent transaction aborted before commit";
11121
11460
  task.finishedAt = Date.now();
@@ -11141,7 +11480,10 @@ var AgentRuntime = class {
11141
11480
  this.timers.set(task.agentId, timer);
11142
11481
  let runError;
11143
11482
  try {
11144
- const result = await this.runner(task, controller.signal);
11483
+ const context = this.runnerContexts.get(task.agentId);
11484
+ if (!context)
11485
+ throw new KapibalaError("Runner context is unavailable", { code: "CONFIG_INVALID" });
11486
+ const result = await this.runner(task, controller.signal, context);
11145
11487
  if (task.status === "cancelling") {
11146
11488
  task.status = "cancelled";
11147
11489
  return;
@@ -11190,14 +11532,17 @@ var AgentRuntime = class {
11190
11532
  task.status = "failed";
11191
11533
  }
11192
11534
  task.error = runError.message;
11193
- await this.cascadeCancelChildren(task.agentId, "Parent task failed");
11194
11535
  } finally {
11536
+ this.runnerContexts.delete(task.agentId);
11195
11537
  this.clearTaskTimer(task.agentId);
11196
11538
  this.controllers.delete(task.agentId);
11197
11539
  if (!isTerminalTaskStatus(task.status)) {
11198
11540
  task.status = "failed";
11199
11541
  task.error = runError?.message ?? "Unknown execution failure";
11200
11542
  }
11543
+ if (task.status === "failed") {
11544
+ await this.cascadeCancelChildren(task.agentId, task.error ?? "Parent task failed");
11545
+ }
11201
11546
  task.finishedAt = Date.now();
11202
11547
  this.releaseTaskSlotOnce(task);
11203
11548
  this.outbox.enqueueFromTask(task);
@@ -11267,6 +11612,7 @@ var AgentRuntime = class {
11267
11612
  controller?.abort(new Error(reason));
11268
11613
  await this.cascadeCancelChildren(agentId, reason);
11269
11614
  const inFlight = this.runningPromises.get(agentId);
11615
+ if (!inFlight) this.runnerContexts.delete(agentId);
11270
11616
  if (inFlight) {
11271
11617
  const GRACE_PERIOD_MS = 5e3;
11272
11618
  let timer;
@@ -11375,6 +11721,7 @@ var AgentRuntime = class {
11375
11721
  }
11376
11722
  this.timers.clear();
11377
11723
  this.controllers.clear();
11724
+ this.runnerContexts.clear();
11378
11725
  this.stateListeners.clear();
11379
11726
  }
11380
11727
  /**
@@ -11515,9 +11862,15 @@ function createSpawnAgentTool(runtime) {
11515
11862
  required: ["description", "prompt", "role"]
11516
11863
  },
11517
11864
  async execute(input, ctx) {
11865
+ if (!ctx.leaseCoordinator)
11866
+ throw new KapibalaError("Missing parent lease coordinator", {
11867
+ code: "CONFIG_INVALID",
11868
+ safeMessage: "\u7F3A\u5C11\u7236\u4F1A\u8BDD\u5199\u79DF\u7EA6\u534F\u8C03\u5668"
11869
+ });
11518
11870
  const workspaceRoot = ctx.workspaceRoot ?? ctx.rootDir;
11519
11871
  const executionCwd = ctx.executionCwd ?? workspaceRoot;
11520
11872
  const spawnContext = {
11873
+ leaseCoordinator: ctx.leaseCoordinator,
11521
11874
  rootTaskId: ctx.rootTaskId,
11522
11875
  sessionId: ctx.sessionId ?? "session-default",
11523
11876
  branchId: ctx.branchId ?? "main",
@@ -11796,6 +12149,274 @@ function cancellableGenerator(create, external) {
11796
12149
  };
11797
12150
  }
11798
12151
 
12152
+ // src/context/session/persistence.ts
12153
+ async function persistAssistantMessage(event, store, markUnknownUsage) {
12154
+ if (store) await store.append(event.message);
12155
+ if (!event.usage) markUnknownUsage();
12156
+ }
12157
+ async function persistToolMessages(messages, store) {
12158
+ if (!store) return;
12159
+ for (const message of messages) await store.append(message);
12160
+ }
12161
+ function buildInterruptedToolMessage(assistantEvent) {
12162
+ return {
12163
+ id: createMessageId(),
12164
+ role: "tool",
12165
+ content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
12166
+ type: "tool_result",
12167
+ toolUseId: toolUse.id,
12168
+ content: JSON.stringify({
12169
+ code: "OUTCOME_UNKNOWN",
12170
+ retryPolicy: "after_user_action",
12171
+ message: "Tool execution was interrupted; outcome is unknown"
12172
+ }),
12173
+ isError: true,
12174
+ errorCode: "OUTCOME_UNKNOWN",
12175
+ retryPolicy: "after_user_action"
12176
+ })),
12177
+ timestamp: Date.now()
12178
+ };
12179
+ }
12180
+ function findClosedToolMessages(history, assistantEvent) {
12181
+ const assistantIndex = history.lastIndexOf(assistantEvent.message);
12182
+ if (assistantIndex < 0) return [];
12183
+ const expected = new Set(
12184
+ assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => toolUse.id)
12185
+ );
12186
+ const messages = [];
12187
+ for (let index2 = assistantIndex + 1; index2 < history.length; index2++) {
12188
+ const message = history[index2];
12189
+ if (message.role !== "tool") break;
12190
+ messages.push(message);
12191
+ for (const block of message.content) {
12192
+ if (block.type === "tool_result") expected.delete(block.toolUseId);
12193
+ }
12194
+ }
12195
+ return expected.size === 0 ? messages : [];
12196
+ }
12197
+
12198
+ // src/context/session/request-prepare.ts
12199
+ function verifyPrivateBlockIntegrity(projected, sourceView) {
12200
+ for (const message of projected) {
12201
+ const original = sourceView.find((source) => source.id === message.id);
12202
+ const privateBag = /* @__PURE__ */ new Map();
12203
+ const blockBag = /* @__PURE__ */ new Map();
12204
+ for (const block of original?.content ?? []) {
12205
+ const serialized = stableSerialize(block);
12206
+ if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin)
12207
+ privateBag.set(serialized, (privateBag.get(serialized) ?? 0) + 1);
12208
+ blockBag.set(serialized, (blockBag.get(serialized) ?? 0) + 1);
12209
+ }
12210
+ const take = (bag, serialized) => {
12211
+ const count = bag.get(serialized) ?? 0;
12212
+ if (!count) return false;
12213
+ if (count === 1) bag.delete(serialized);
12214
+ else bag.set(serialized, count - 1);
12215
+ return true;
12216
+ };
12217
+ for (const block of message.content) {
12218
+ if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin) {
12219
+ if (!original || !take(privateBag, stableSerialize(block)))
12220
+ throw new ModelError("Hook changed signed or private request provenance", {
12221
+ stage: "request",
12222
+ retryable: false
12223
+ });
12224
+ }
12225
+ if (block.type !== "text" && block.type !== "tool_use") continue;
12226
+ if (block.protocolMeta && !take(blockBag, stableSerialize(block)))
12227
+ block.protocolMeta = void 0;
12228
+ }
12229
+ }
12230
+ }
12231
+ function assertSignedContinuationStable(check) {
12232
+ const { candidate, previousRequest, history, runId, nativeBinding, projectHistory } = check;
12233
+ const frozen = Boolean(
12234
+ previousRequest && history.some(
12235
+ (message) => message.interactionId === runId && message.content.some(
12236
+ (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.signature && block.origin?.runId === runId)
12237
+ )
12238
+ )
12239
+ );
12240
+ if (frozen) {
12241
+ const generated = nativeBinding ? projectProtocolHistory(
12242
+ projectHistory(history).filter(
12243
+ (message) => message.interactionId === runId && message.role !== "user"
12244
+ ),
12245
+ nativeBinding,
12246
+ candidate.context
12247
+ ) : [];
12248
+ for (const original of generated) {
12249
+ const matches = candidate.messages.filter((message) => message.id === original.id);
12250
+ if (matches.length !== 1 || stableSerialize(matches[0]) !== stableSerialize(original))
12251
+ throw new ModelError("Signed continuation prefix or tool chain changed", {
12252
+ stage: "request",
12253
+ retryable: false
12254
+ });
12255
+ }
12256
+ }
12257
+ if (frozen && previousRequest && (stableSerialize(candidate.messages.slice(0, previousRequest.messages.length)) !== stableSerialize(previousRequest.messages) || candidate.systemPrompt !== previousRequest.systemPrompt || stableSerialize(candidate.tools) !== stableSerialize(previousRequest.tools)))
12258
+ throw new ModelError("Signed continuation prefix changed", {
12259
+ stage: "request",
12260
+ retryable: false
12261
+ });
12262
+ return frozen;
12263
+ }
12264
+
12265
+ // src/context/session/skills.ts
12266
+ function createSkillResourceAuditor(eventLogger, sessionId) {
12267
+ return async (source, input, page) => {
12268
+ try {
12269
+ await eventLogger.recordAudit({
12270
+ level: "info",
12271
+ event: "skill.resource_read",
12272
+ sessionId,
12273
+ runId: source.runId,
12274
+ fields: {
12275
+ skillName: source.name,
12276
+ contentDigest: source.contentDigest,
12277
+ invocationId: source.invocationId,
12278
+ pathDigest: skillDigest(input.path),
12279
+ resourceDigest: skillDigest(page.text),
12280
+ outputBytes: Buffer.byteLength(page.text),
12281
+ outputTruncated: page.truncated,
12282
+ offset: input.offset,
12283
+ startLine: input.startLine,
12284
+ endLine: input.endLine
12285
+ }
12286
+ });
12287
+ } catch {
12288
+ throw new KapibalaError("Skill resource audit unavailable", {
12289
+ code: "AUDIT_UNAVAILABLE",
12290
+ retryPolicy: "after_user_action",
12291
+ safeMessage: "\u6280\u80FD\u8D44\u6E90\u8BFB\u53D6\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
12292
+ });
12293
+ }
12294
+ };
12295
+ }
12296
+ async function commitSkillEvents(deps) {
12297
+ const { skillRuntime, eventLogger, sessionId, runId, persistence } = deps;
12298
+ if (!skillRuntime) return [];
12299
+ return skillRuntime.commit(async (source, messageId) => {
12300
+ try {
12301
+ await eventLogger.recordAudit({
12302
+ level: "info",
12303
+ event: "skill.loaded",
12304
+ sessionId,
12305
+ runId,
12306
+ fields: {
12307
+ skillName: source.name,
12308
+ skillScope: source.scope,
12309
+ contentDigest: source.contentDigest,
12310
+ renderedDigest: source.renderedDigest,
12311
+ pathDigest: skillDigest(source.filePath),
12312
+ caller: source.caller,
12313
+ invocationId: source.invocationId,
12314
+ messageId
12315
+ }
12316
+ });
12317
+ } catch {
12318
+ throw new KapibalaError("Skill commit audit unavailable", {
12319
+ code: "AUDIT_UNAVAILABLE",
12320
+ retryPolicy: "after_user_action",
12321
+ safeMessage: "\u6280\u80FD\u63D0\u4EA4\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
12322
+ });
12323
+ }
12324
+ }, persistence);
12325
+ }
12326
+ async function authorizeExplicitSkill(deps) {
12327
+ const {
12328
+ name,
12329
+ runId,
12330
+ signal,
12331
+ rootDir,
12332
+ mode,
12333
+ permissionRules,
12334
+ approvalChannel,
12335
+ approvalCache,
12336
+ eventLogger,
12337
+ sessionId
12338
+ } = deps;
12339
+ const request = {
12340
+ toolName: "load_skill",
12341
+ capabilities: ["fs:read"],
12342
+ input: { name },
12343
+ rootDir,
12344
+ sessionAllowed: true
12345
+ };
12346
+ const cached = approvalCache.get(request);
12347
+ const decision = new PermissionPolicy().decide({
12348
+ toolName: request.toolName,
12349
+ capabilities: request.capabilities,
12350
+ mode,
12351
+ rules: permissionRules,
12352
+ cachedApproval: cached === "allow",
12353
+ cachedDenial: cached === "deny"
12354
+ });
12355
+ let permitted = decision.decision === "allow";
12356
+ let approvalChoice;
12357
+ if (decision.decision === "ask") {
12358
+ try {
12359
+ approvalChoice = await approvalChannel?.requestApproval(request, signal) ?? "deny_once";
12360
+ } catch {
12361
+ approvalChoice = "cancel";
12362
+ }
12363
+ permitted = approvalChoice === "allow_once" || approvalChoice === "allow_session";
12364
+ if (approvalChoice === "allow_session") approvalCache.set(request, "allow");
12365
+ if (approvalChoice === "deny_session") approvalCache.set(request, "deny");
12366
+ }
12367
+ await eventLogger.recordAudit({
12368
+ level: "info",
12369
+ event: "skill.decided",
12370
+ sessionId,
12371
+ runId,
12372
+ fields: {
12373
+ skillName: name,
12374
+ caller: "user",
12375
+ decision: permitted && !signal?.aborted ? "allow" : "deny",
12376
+ decisionSource: decision.decision === "ask" ? approvalChannel ? "human" : "non_interactive" : decision.source,
12377
+ approvalChoice
12378
+ }
12379
+ });
12380
+ if (!permitted) throw skillFailure("SKILL_INVOCATION_FORBIDDEN", "\u5BBF\u4E3B\u6743\u9650\u7981\u6B62\u663E\u5F0F\u6280\u80FD\u8BFB\u53D6");
12381
+ signal?.throwIfAborted();
12382
+ }
12383
+
12384
+ // src/context/session/usage.ts
12385
+ function recordUsage(attemptId, usage, accountedAttempts, usageStats) {
12386
+ if (accountedAttempts.has(attemptId)) return "duplicate";
12387
+ if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
12388
+ (n) => Number.isSafeInteger(n) && n >= 0
12389
+ ))
12390
+ return "unknown";
12391
+ accountedAttempts.add(attemptId);
12392
+ usageStats.promptTokens += usage.promptTokens;
12393
+ usageStats.completionTokens += usage.completionTokens;
12394
+ usageStats.totalTokens += usage.totalTokens;
12395
+ return "recorded";
12396
+ }
12397
+ function recordSummaryUsage(attemptId, usage, summaryAttempts, summaryUsage) {
12398
+ if (summaryAttempts.has(attemptId)) return "duplicate";
12399
+ if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
12400
+ (n) => Number.isSafeInteger(n) && n >= 0
12401
+ ))
12402
+ return "unknown";
12403
+ summaryAttempts.add(attemptId);
12404
+ summaryUsage.promptTokens += usage.promptTokens;
12405
+ summaryUsage.completionTokens += usage.completionTokens;
12406
+ summaryUsage.totalTokens += usage.totalTokens;
12407
+ return "recorded";
12408
+ }
12409
+ function computeContextUsage(profile, usedTokens, estimatedUsage = false) {
12410
+ const contextWindow = resolveContextWindow(profile.contextWindow);
12411
+ return {
12412
+ usedTokens,
12413
+ limitTokens: contextWindow.tokens,
12414
+ percent: usedTokens === void 0 ? void 0 : usedTokens / contextWindow.tokens * 100,
12415
+ estimatedLimit: contextWindow.estimated,
12416
+ ...estimatedUsage ? { estimatedUsage: true } : {}
12417
+ };
12418
+ }
12419
+
11799
12420
  // src/context/session/index.ts
11800
12421
  var AgentSession = class {
11801
12422
  skillRegistry;
@@ -11880,41 +12501,27 @@ var AgentSession = class {
11880
12501
  this.cwd = resolvedWorkspace.executionCwd;
11881
12502
  this.projectKey = resolvedWorkspace.projectKey;
11882
12503
  this.sandbox = config.sandbox ?? new PathSandbox({ workspaceRoot: this.workspaceRoot });
11883
- this.leaseCoordinator = config.leaseCoordinator ?? sharedWorkspaceLeaseCoordinator;
12504
+ this.leaseCoordinator = config.leaseCoordinator ?? new WorkspaceLeaseCoordinator();
12505
+ this.userInstructionsPath = config.userInstructionsPath;
12506
+ const sinks = config.eventLogger ? void 0 : createDefaultLogSinks(config.loggingDirectory);
12507
+ const eventLogger = config.eventLogger ?? new StructuredLogger({
12508
+ operationSink: sinks.operationSink,
12509
+ auditSink: sinks.auditSink,
12510
+ debug: config.logger !== void 0,
12511
+ onDiagnostic: config.onDiagnostic ?? config.logger
12512
+ });
12513
+ this.eventLogger = {
12514
+ record: (input) => eventLogger.record({ ...input, conversationId: this.persistentId }),
12515
+ recordAudit: (input) => eventLogger.recordAudit({ ...input, conversationId: this.persistentId })
12516
+ };
12517
+ this.auditRecoveryDirectory = config.auditRecoveryDirectory ?? sinks?.auditSink.directory;
11884
12518
  this.tools = new ToolRegistry();
11885
12519
  if (config.skills) {
11886
12520
  this.skillRegistry = new SkillRegistry(config.skills);
11887
12521
  this.skillRuntime = new SkillRuntime(
11888
12522
  this.skillRegistry,
11889
12523
  this.tools,
11890
- async (source, input, page) => {
11891
- try {
11892
- await this.eventLogger.recordAudit({
11893
- level: "info",
11894
- event: "skill.resource_read",
11895
- sessionId: this.sessionId,
11896
- runId: source.runId,
11897
- fields: {
11898
- skillName: source.name,
11899
- contentDigest: source.contentDigest,
11900
- invocationId: source.invocationId,
11901
- pathDigest: skillDigest(input.path),
11902
- resourceDigest: skillDigest(page.text),
11903
- outputBytes: Buffer.byteLength(page.text),
11904
- outputTruncated: page.truncated,
11905
- offset: input.offset,
11906
- startLine: input.startLine,
11907
- endLine: input.endLine
11908
- }
11909
- });
11910
- } catch {
11911
- throw new KapibalaError("Skill resource audit unavailable", {
11912
- code: "AUDIT_UNAVAILABLE",
11913
- retryPolicy: "after_user_action",
11914
- safeMessage: "\u6280\u80FD\u8D44\u6E90\u8BFB\u53D6\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
11915
- });
11916
- }
11917
- }
12524
+ createSkillResourceAuditor(this.eventLogger, this.sessionId)
11918
12525
  );
11919
12526
  }
11920
12527
  this.hooks = new HookRegistry();
@@ -11927,19 +12534,6 @@ var AgentSession = class {
11927
12534
  this.mode = config.mode ?? "Approval";
11928
12535
  this.permissionRules = config.permissionRules ?? [];
11929
12536
  this.approvalChannel = config.approvalChannel;
11930
- this.userInstructionsPath = config.userInstructionsPath;
11931
- const sinks = config.eventLogger ? void 0 : createDefaultLogSinks(config.loggingDirectory);
11932
- const eventLogger = config.eventLogger ?? new StructuredLogger({
11933
- operationSink: sinks.operationSink,
11934
- auditSink: sinks.auditSink,
11935
- debug: config.logger !== void 0,
11936
- onDiagnostic: config.onDiagnostic ?? config.logger
11937
- });
11938
- this.eventLogger = {
11939
- record: (input) => eventLogger.record({ ...input, conversationId: this.persistentId }),
11940
- recordAudit: (input) => eventLogger.recordAudit({ ...input, conversationId: this.persistentId })
11941
- };
11942
- this.auditRecoveryDirectory = config.auditRecoveryDirectory ?? sinks?.auditSink.directory;
11943
12537
  this.rootTaskId = config.rootTaskId;
11944
12538
  this.depth = config.depth ?? 0;
11945
12539
  this.agentId = config.agentId;
@@ -12544,71 +13138,21 @@ var AgentSession = class {
12544
13138
  runId,
12545
13139
  modelId: profile.id
12546
13140
  }) : this.context.project(history);
12547
- for (const message of projected) {
12548
- const original = sourceView.find((source) => source.id === message.id);
12549
- const privateBag = /* @__PURE__ */ new Map();
12550
- const blockBag = /* @__PURE__ */ new Map();
12551
- for (const block of original?.content ?? []) {
12552
- const serialized = stableSerialize(block);
12553
- if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin)
12554
- privateBag.set(serialized, (privateBag.get(serialized) ?? 0) + 1);
12555
- blockBag.set(serialized, (blockBag.get(serialized) ?? 0) + 1);
12556
- }
12557
- const take = (bag, serialized) => {
12558
- const count = bag.get(serialized) ?? 0;
12559
- if (!count) return false;
12560
- if (count === 1) bag.delete(serialized);
12561
- else bag.set(serialized, count - 1);
12562
- return true;
12563
- };
12564
- for (const block of message.content) {
12565
- if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin) {
12566
- if (!original || !take(privateBag, stableSerialize(block)))
12567
- throw new ModelError("Hook changed signed or private request provenance", {
12568
- stage: "request",
12569
- retryable: false
12570
- });
12571
- }
12572
- if (block.type !== "text" && block.type !== "tool_use") continue;
12573
- if (block.protocolMeta && !take(blockBag, stableSerialize(block)))
12574
- block.protocolMeta = void 0;
12575
- }
12576
- }
12577
- const frozen = Boolean(
12578
- previousRequest && history.some(
12579
- (message) => message.interactionId === runId && message.content.some(
12580
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.signature && block.origin?.runId === runId)
12581
- )
12582
- )
12583
- );
13141
+ verifyPrivateBlockIntegrity(projected, sourceView);
12584
13142
  const candidate = {
12585
13143
  ...request,
12586
13144
  messages: projected,
12587
13145
  context: { runId, modelId: profile.id },
12588
13146
  signal: options?.signal
12589
13147
  };
12590
- if (frozen) {
12591
- const generated = nativeBinding ? projectProtocolHistory(
12592
- this.context.project(history).filter(
12593
- (message) => message.interactionId === runId && message.role !== "user"
12594
- ),
12595
- nativeBinding,
12596
- candidate.context
12597
- ) : [];
12598
- for (const original of generated) {
12599
- const matches = candidate.messages.filter((message) => message.id === original.id);
12600
- if (matches.length !== 1 || stableSerialize(matches[0]) !== stableSerialize(original))
12601
- throw new ModelError("Signed continuation prefix or tool chain changed", {
12602
- stage: "request",
12603
- retryable: false
12604
- });
12605
- }
12606
- }
12607
- if (frozen && previousRequest && (stableSerialize(candidate.messages.slice(0, previousRequest.messages.length)) !== stableSerialize(previousRequest.messages) || candidate.systemPrompt !== previousRequest.systemPrompt || stableSerialize(candidate.tools) !== stableSerialize(previousRequest.tools)))
12608
- throw new ModelError("Signed continuation prefix changed", {
12609
- stage: "request",
12610
- retryable: false
12611
- });
13148
+ const frozen = assertSignedContinuationStable({
13149
+ candidate,
13150
+ previousRequest,
13151
+ history,
13152
+ runId,
13153
+ nativeBinding,
13154
+ projectHistory: (projectedHistory) => this.context.project(projectedHistory)
13155
+ });
12612
13156
  const context = this.context;
12613
13157
  const skills = this.skillRuntime;
12614
13158
  const preparation = context.prepare(history, candidate, profile, runId, reason, !frozen);
@@ -12657,6 +13201,7 @@ var AgentSession = class {
12657
13201
  let persistedHistoryLength = this.history.length;
12658
13202
  let loopCompleted = false;
12659
13203
  let loopFailed = false;
13204
+ let consumerInterrupted = false;
12660
13205
  try {
12661
13206
  for await (const event of loop.run(this.history)) {
12662
13207
  await this.logContextEvent(event, runId);
@@ -12731,7 +13276,12 @@ var AgentSession = class {
12731
13276
  if (event.type === "message_stop" || event.type === "tool_messages") {
12732
13277
  persistedHistoryLength = this.history.length;
12733
13278
  }
12734
- yield event;
13279
+ try {
13280
+ yield event;
13281
+ } catch (error) {
13282
+ consumerInterrupted = true;
13283
+ throw error;
13284
+ }
12735
13285
  }
12736
13286
  loopCompleted = true;
12737
13287
  } catch (error) {
@@ -12748,7 +13298,7 @@ var AgentSession = class {
12748
13298
  }
12749
13299
  throw error;
12750
13300
  } finally {
12751
- if (!loopCompleted && !loopFailed) {
13301
+ if (!loopCompleted && (!loopFailed || consumerInterrupted)) {
12752
13302
  await executor.rollback();
12753
13303
  if (this.store) {
12754
13304
  for (const message of this.history.slice(persistedHistoryLength)) {
@@ -12880,102 +13430,44 @@ var AgentSession = class {
12880
13430
  }
12881
13431
  /** 完整消息提交后完成技能审计;审计失败保留真实历史,停止本轮且不发布成功事件。 */
12882
13432
  async commitSkills(runId) {
12883
- if (!this.skillRuntime) return [];
12884
- return this.skillRuntime.commit(
12885
- async (source, messageId) => {
12886
- try {
12887
- await this.eventLogger.recordAudit({
12888
- level: "info",
12889
- event: "skill.loaded",
12890
- sessionId: this.sessionId,
12891
- runId,
12892
- fields: {
12893
- skillName: source.name,
12894
- skillScope: source.scope,
12895
- contentDigest: source.contentDigest,
12896
- renderedDigest: source.renderedDigest,
12897
- pathDigest: skillDigest(source.filePath),
12898
- caller: source.caller,
12899
- invocationId: source.invocationId,
12900
- messageId
12901
- }
12902
- });
12903
- } catch {
12904
- throw new KapibalaError("Skill commit audit unavailable", {
12905
- code: "AUDIT_UNAVAILABLE",
12906
- retryPolicy: "after_user_action",
12907
- safeMessage: "\u6280\u80FD\u63D0\u4EA4\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
12908
- });
12909
- }
12910
- },
12911
- this.store ? "disk" : "memory"
12912
- );
13433
+ return commitSkillEvents({
13434
+ skillRuntime: this.skillRuntime,
13435
+ eventLogger: this.eventLogger,
13436
+ sessionId: this.sessionId,
13437
+ runId,
13438
+ persistence: this.store ? "disk" : "memory"
13439
+ });
12913
13440
  }
12914
13441
  /** 显式加载仍按同一读取策略、精确缓存与宿主审批裁决;拒绝/取消/审计失败时不追加用户任务。 */
12915
- async authorizeSkill(name, runId, signal) {
12916
- const request = {
12917
- toolName: "load_skill",
12918
- capabilities: ["fs:read"],
12919
- input: { name },
13442
+ authorizeSkill(name, runId, signal) {
13443
+ return authorizeExplicitSkill({
13444
+ name,
13445
+ runId,
13446
+ signal,
12920
13447
  rootDir: this.rootDir,
12921
- sessionAllowed: true
12922
- };
12923
- const cached = this.approvalCache.get(request);
12924
- const decision = new PermissionPolicy().decide({
12925
- toolName: request.toolName,
12926
- capabilities: request.capabilities,
12927
13448
  mode: this.mode,
12928
- rules: this.permissionRules,
12929
- cachedApproval: cached === "allow",
12930
- cachedDenial: cached === "deny"
12931
- });
12932
- let permitted = decision.decision === "allow";
12933
- let approvalChoice;
12934
- if (decision.decision === "ask") {
12935
- try {
12936
- approvalChoice = await this.approvalChannel?.requestApproval(request, signal) ?? "deny_once";
12937
- } catch {
12938
- approvalChoice = "cancel";
12939
- }
12940
- permitted = approvalChoice === "allow_once" || approvalChoice === "allow_session";
12941
- if (approvalChoice === "allow_session") this.approvalCache.set(request, "allow");
12942
- if (approvalChoice === "deny_session") this.approvalCache.set(request, "deny");
12943
- }
12944
- await this.eventLogger.recordAudit({
12945
- level: "info",
12946
- event: "skill.decided",
12947
- sessionId: this.sessionId,
12948
- runId,
12949
- fields: {
12950
- skillName: name,
12951
- caller: "user",
12952
- decision: permitted && !signal?.aborted ? "allow" : "deny",
12953
- decisionSource: decision.decision === "ask" ? this.approvalChannel ? "human" : "non_interactive" : decision.source,
12954
- approvalChoice
12955
- }
13449
+ permissionRules: this.permissionRules,
13450
+ approvalChannel: this.approvalChannel,
13451
+ approvalCache: this.approvalCache,
13452
+ eventLogger: this.eventLogger,
13453
+ sessionId: this.sessionId
12956
13454
  });
12957
- if (!permitted) throw skillFailure("SKILL_INVOCATION_FORBIDDEN", "\u5BBF\u4E3B\u6743\u9650\u7981\u6B62\u663E\u5F0F\u6280\u80FD\u8BFB\u53D6");
12958
- signal?.throwIfAborted();
12959
13455
  }
12960
13456
  /**
12961
13457
  * 计算最近一次内部模型请求的上下文占用;无 usage 时显式标注预算估算。
12962
13458
  * run 累计 promptTokens 会重复计算每一步都重发的历史,不能用于窗口占用率。
12963
13459
  */
12964
13460
  createContextUsage(usedTokens, estimatedUsage = false) {
12965
- const contextWindow = resolveContextWindow(
12966
- (this.lastPrimaryProfile ?? this.getActiveProfile()).contextWindow
12967
- );
12968
- return {
13461
+ return computeContextUsage(
13462
+ this.lastPrimaryProfile ?? this.getActiveProfile(),
12969
13463
  usedTokens,
12970
- limitTokens: contextWindow.tokens,
12971
- percent: usedTokens === void 0 ? void 0 : usedTokens / contextWindow.tokens * 100,
12972
- estimatedLimit: contextWindow.estimated,
12973
- ...estimatedUsage ? { estimatedUsage: true } : {}
12974
- };
13464
+ estimatedUsage
13465
+ );
12975
13466
  }
12976
13467
  async persistAssistant(event) {
12977
- if (this.store) await this.store.append(event.message);
12978
- if (!event.usage) this.usageKnown = false;
13468
+ await persistAssistantMessage(event, this.store, () => {
13469
+ this.usageKnown = false;
13470
+ });
12979
13471
  }
12980
13472
  /** 创建上下文协调器并注入当前路由;摘要不经过主任务 Loop,插件结果不默认为可剪裁。 */
12981
13473
  createContextManager() {
@@ -12994,17 +13486,8 @@ var AgentSession = class {
12994
13486
  }
12995
13487
  /** 摘要实际消耗按 attempt 身份独立累加;缺失消耗保留未知,不计入主任务指标。 */
12996
13488
  accountSummaryUsage(attemptId, usage) {
12997
- if (this.summaryAttempts.has(attemptId)) return;
12998
- this.summaryAttempts.add(attemptId);
12999
- if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
13000
- (n) => Number.isSafeInteger(n) && n >= 0
13001
- )) {
13002
- this.summaryUsageKnown = false;
13003
- return;
13004
- }
13005
- this.summaryUsage.promptTokens += usage.promptTokens;
13006
- this.summaryUsage.completionTokens += usage.completionTokens;
13007
- this.summaryUsage.totalTokens += usage.totalTokens;
13489
+ const outcome = recordSummaryUsage(attemptId, usage, this.summaryAttempts, this.summaryUsage);
13490
+ if (outcome === "unknown") this.summaryUsageKnown = false;
13008
13491
  }
13009
13492
  /**
13010
13493
  * 空闲时手动摘要旧完整前缀,不创建用户任务、不执行工具,不突破当前与最近成功交互。
@@ -13077,61 +13560,21 @@ var AgentSession = class {
13077
13560
  }
13078
13561
  /** 以模型 attempt 身份幂等累加真实消耗;非法或缺失 usage 保持未知。 */
13079
13562
  accountUsage(attemptId, usage) {
13080
- if (this.accountedAttempts.has(attemptId)) return;
13081
- if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
13082
- (n) => Number.isSafeInteger(n) && n >= 0
13083
- )) {
13084
- this.usageKnown = false;
13085
- return;
13086
- }
13087
- this.accountedAttempts.add(attemptId);
13088
- this.usageStats.promptTokens += usage.promptTokens;
13089
- this.usageStats.completionTokens += usage.completionTokens;
13090
- this.usageStats.totalTokens += usage.totalTokens;
13563
+ const outcome = recordUsage(attemptId, usage, this.accountedAttempts, this.usageStats);
13564
+ if (outcome === "unknown") this.usageKnown = false;
13091
13565
  }
13092
13566
  async persistToolMessages(messages) {
13093
- if (!this.store) return;
13094
- for (const message of messages) await this.store.append(message);
13567
+ await persistToolMessages(messages, this.store);
13095
13568
  }
13096
13569
  /** 中止时为缓冲 assistant 的每个 tool_use 生成 OUTCOME_UNKNOWN 结果并入历史,保持事务闭合。 */
13097
13570
  closeInterruptedToolCalls(assistantEvent, error) {
13098
- const message = {
13099
- id: createMessageId(),
13100
- role: "tool",
13101
- content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
13102
- type: "tool_result",
13103
- toolUseId: toolUse.id,
13104
- content: JSON.stringify({
13105
- code: "OUTCOME_UNKNOWN",
13106
- retryPolicy: "after_user_action",
13107
- message: "Tool execution was interrupted; outcome is unknown"
13108
- }),
13109
- isError: true,
13110
- errorCode: "OUTCOME_UNKNOWN",
13111
- retryPolicy: "after_user_action"
13112
- })),
13113
- timestamp: Date.now()
13114
- };
13571
+ const message = buildInterruptedToolMessage(assistantEvent);
13115
13572
  this.history.push(message);
13116
13573
  return [message];
13117
13574
  }
13118
13575
  /** 收集历史中紧跟该 assistant 的连续 tool 消息;仅当覆盖全部 tool_use 时视为已闭合。 */
13119
13576
  findClosedToolMessages(assistantEvent) {
13120
- const assistantIndex = this.history.lastIndexOf(assistantEvent.message);
13121
- if (assistantIndex < 0) return [];
13122
- const expected = new Set(
13123
- assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => toolUse.id)
13124
- );
13125
- const messages = [];
13126
- for (let index2 = assistantIndex + 1; index2 < this.history.length; index2++) {
13127
- const message = this.history[index2];
13128
- if (message.role !== "tool") break;
13129
- messages.push(message);
13130
- for (const block of message.content) {
13131
- if (block.type === "tool_result") expected.delete(block.toolUseId);
13132
- }
13133
- }
13134
- return expected.size === 0 ? messages : [];
13577
+ return findClosedToolMessages(this.history, assistantEvent);
13135
13578
  }
13136
13579
  };
13137
13580
  // Annotate the CommonJS export names for ESM import in node:
@@ -13209,6 +13652,7 @@ var AgentSession = class {
13209
13652
  createLogId,
13210
13653
  createMessageId,
13211
13654
  createRunCommandTool,
13655
+ createSessionError,
13212
13656
  createSpawnAgentTool,
13213
13657
  createWaitAgentTool,
13214
13658
  defineTool,
@@ -13264,7 +13708,6 @@ var AgentSession = class {
13264
13708
  sanitizeModelErrorMessage,
13265
13709
  sanitizeSessionTitle,
13266
13710
  scanSkillRoot,
13267
- sharedWorkspaceLeaseCoordinator,
13268
13711
  skillDigest,
13269
13712
  skillFailure,
13270
13713
  skillPathKey,