@kiturone/kapibala 0.0.10 → 0.1.1

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
@@ -711,15 +711,50 @@ var import_node_path = __toESM(require("path"), 1);
711
711
 
712
712
  // src/context/jsonl-lines.ts
713
713
  var import_node_fs = __toESM(require("fs"), 1);
714
- 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 = {}) {
715
746
  const fd = import_node_fs.default.openSync(file, "r");
716
747
  const buffer = Buffer.allocUnsafe(64 * 1024);
717
748
  let pending = Buffer.alloc(0);
718
749
  let offset = 0;
750
+ let total = 0;
719
751
  try {
752
+ assertHistoryReadBudget(import_node_fs.default.fstatSync(fd).size, options);
720
753
  while (true) {
721
754
  const length = import_node_fs.default.readSync(fd, buffer, 0, buffer.length, null);
722
755
  if (!length) break;
756
+ total += length;
757
+ assertHistoryReadBudget(total, options);
723
758
  pending = Buffer.concat([pending, buffer.subarray(0, length)]);
724
759
  let start = 0;
725
760
  let end = pending.indexOf(10, start);
@@ -753,7 +788,7 @@ var SESSION_SCHEMA_VERSION_V2 = 2;
753
788
  var SESSION_SCHEMA_VERSION = SESSION_SCHEMA_VERSION_V2;
754
789
  var MAIN_BRANCH_ID = "main";
755
790
  function isGlobalControlRecordType(type) {
756
- 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";
757
792
  }
758
793
  var types = /* @__PURE__ */ new Set([
759
794
  "header",
@@ -772,7 +807,8 @@ var types = /* @__PURE__ */ new Set([
772
807
  "branch_created",
773
808
  "branch_selected",
774
809
  "branch_summary",
775
- "archive_state"
810
+ "archive_state",
811
+ "pin_state"
776
812
  ]);
777
813
  function supportsSessionState(store) {
778
814
  return "appendRecord" in store && typeof store.appendRecord === "function" && "loadState" in store && typeof store.loadState === "function";
@@ -855,6 +891,13 @@ function assertClosedToolTransaction(records, targetRecordId) {
855
891
  }
856
892
  }
857
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;
858
901
  filePath;
859
902
  queue = Promise.resolve();
860
903
  records = [];
@@ -864,10 +907,6 @@ var SessionStore = class _SessionStore {
864
907
  schemaVersion = SESSION_SCHEMA_VERSION_V2;
865
908
  activeBranchId = "main";
866
909
  branchLeaves = /* @__PURE__ */ new Map();
867
- constructor(filePath, schemaVersion = SESSION_SCHEMA_VERSION_V2) {
868
- this.filePath = import_node_path.default.resolve(filePath);
869
- this.schemaVersion = schemaVersion;
870
- }
871
910
  /** 获取当前会话文件使用的 Schema 版本(1 或 2)。 */
872
911
  getSchemaVersion() {
873
912
  return this.schemaVersion;
@@ -890,8 +929,8 @@ var SessionStore = class _SessionStore {
890
929
  this.onDiagnostic = onDiagnostic;
891
930
  }
892
931
  /** 以独占创建方式安装新头部(默认生成 Schema v2 格式),已有文件明确失败,绝不覆盖其它会话。 */
893
- static async create(filePath, header) {
894
- 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 });
895
934
  import_node_fs2.default.mkdirSync(import_node_path.default.dirname(store.filePath), { recursive: true });
896
935
  const record = store.makeRecord("header", {
897
936
  ...header,
@@ -912,8 +951,8 @@ var SessionStore = class _SessionStore {
912
951
  return store;
913
952
  }
914
953
  /** 打开并验证已有会话;尾部残片先保存到隔离文件,中部损坏与未知版本拒绝恢复。 */
915
- static async open(filePath) {
916
- const store = new _SessionStore(filePath);
954
+ static async open(filePath, options = {}) {
955
+ const store = new _SessionStore(filePath, SESSION_SCHEMA_VERSION_V2, { ...options });
917
956
  store.records = store.readFile(true);
918
957
  store.refreshStamp();
919
958
  return store;
@@ -1007,6 +1046,24 @@ var SessionStore = class _SessionStore {
1007
1046
  }
1008
1047
  return false;
1009
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
+ }
1010
1067
  /**
1011
1068
  * 获取当前会话中所有已知分支的元数据与状态列表。
1012
1069
  */
@@ -1298,14 +1355,15 @@ ${summaryText}` }]
1298
1355
  }
1299
1356
  /** 校验每个完整记录,只有最后非空行的 JSON 残片可隔离,语义损坏绝不作为残片吞掉。 */
1300
1357
  readFile(recover) {
1301
- const iterator = readJsonlLines(this.filePath);
1358
+ const iterator = readJsonlLines(this.filePath, this.readOptions);
1302
1359
  let lastTerminated = true;
1303
1360
  let blankSeen = false;
1304
1361
  const records = [];
1305
1362
  const identities = /* @__PURE__ */ new Set();
1306
1363
  const messages = /* @__PURE__ */ new Set();
1307
- this.branchLeaves.clear();
1308
- this.activeBranchId = MAIN_BRANCH_ID;
1364
+ const branchLeaves = /* @__PURE__ */ new Map();
1365
+ let activeBranchId = MAIN_BRANCH_ID;
1366
+ let schemaVersion = this.schemaVersion;
1309
1367
  let detectedSchemaVersion = null;
1310
1368
  let i = -1;
1311
1369
  for (const line of iterator) {
@@ -1320,7 +1378,7 @@ ${summaryText}` }]
1320
1378
  try {
1321
1379
  record = JSON.parse(line.text);
1322
1380
  } catch {
1323
- 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()))
1324
1382
  throw new Error(`Session corrupt at line ${i + 1}`);
1325
1383
  import_node_fs2.default.writeFileSync(
1326
1384
  `${this.filePath}.corrupt-${createMessageId()}`,
@@ -1344,7 +1402,7 @@ ${summaryText}` }]
1344
1402
  throw new Error("Unsupported session schema version");
1345
1403
  }
1346
1404
  detectedSchemaVersion = record.schemaVersion;
1347
- this.schemaVersion = detectedSchemaVersion;
1405
+ schemaVersion = detectedSchemaVersion;
1348
1406
  } else if (record.schemaVersion !== detectedSchemaVersion) {
1349
1407
  throw new Error(`Session corrupt schema version mismatch at line ${i + 1}`);
1350
1408
  }
@@ -1361,11 +1419,11 @@ ${summaryText}` }]
1361
1419
  } else if (record.type === "header") {
1362
1420
  throw new Error("Session corrupt: repeated header");
1363
1421
  }
1364
- if (this.schemaVersion === SESSION_SCHEMA_VERSION_V1) {
1422
+ if (schemaVersion === SESSION_SCHEMA_VERSION_V1) {
1365
1423
  if (record.parentId !== (records.at(-1)?.recordId ?? null)) {
1366
1424
  throw new Error(`Session corrupt parent at line ${i + 1}`);
1367
1425
  }
1368
- this.branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1426
+ branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1369
1427
  } else {
1370
1428
  if (typeof record.seq !== "number" || record.seq !== records.length) {
1371
1429
  throw new Error(`Session corrupt seq at line ${i + 1}`);
@@ -1379,37 +1437,37 @@ ${summaryText}` }]
1379
1437
  throw new Error(`Session corrupt control record parent at line ${i + 1}`);
1380
1438
  }
1381
1439
  if (record.type === "header") {
1382
- this.branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1383
- this.activeBranchId = MAIN_BRANCH_ID;
1440
+ branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1441
+ activeBranchId = MAIN_BRANCH_ID;
1384
1442
  } else if (record.type === "branch_created") {
1385
1443
  const bId = record.payload.branchId;
1386
1444
  const baseId = record.payload.baseRecordId;
1387
1445
  if (typeof bId !== "string" || !bId) {
1388
1446
  throw new Error(`Session corrupt branch_created branchId at line ${i + 1}`);
1389
1447
  }
1390
- if (this.branchLeaves.has(bId)) {
1448
+ if (branchLeaves.has(bId)) {
1391
1449
  throw new Error(`Duplicate branch ID: ${bId}`);
1392
1450
  }
1393
1451
  if (typeof baseId !== "string" || !identities.has(baseId)) {
1394
1452
  throw new Error(`Session corrupt branch_created baseRecordId at line ${i + 1}`);
1395
1453
  }
1396
- this.branchLeaves.set(bId, baseId);
1454
+ branchLeaves.set(bId, baseId);
1397
1455
  } else if (record.type === "branch_selected") {
1398
1456
  const bId = record.payload.branchId;
1399
- if (typeof bId !== "string" || !this.branchLeaves.has(bId)) {
1457
+ if (typeof bId !== "string" || !branchLeaves.has(bId)) {
1400
1458
  throw new Error(`Selected branch not found: ${bId}`);
1401
1459
  }
1402
- this.activeBranchId = bId;
1460
+ activeBranchId = bId;
1403
1461
  }
1404
1462
  } else {
1405
1463
  const bId = record.branchId;
1406
- if (typeof bId !== "string" || !this.branchLeaves.has(bId)) {
1464
+ if (typeof bId !== "string" || !branchLeaves.has(bId)) {
1407
1465
  throw new Error(`Session corrupt branchId at line ${i + 1}`);
1408
1466
  }
1409
1467
  if (typeof record.parentId !== "string" || !identities.has(record.parentId)) {
1410
1468
  throw new Error(`Session corrupt parent at line ${i + 1}`);
1411
1469
  }
1412
- this.branchLeaves.set(bId, record.recordId);
1470
+ branchLeaves.set(bId, record.recordId);
1413
1471
  }
1414
1472
  }
1415
1473
  identities.add(record.recordId);
@@ -1428,7 +1486,7 @@ ${summaryText}` }]
1428
1486
  if (!records.length) {
1429
1487
  throw new Error("Session corrupt header");
1430
1488
  }
1431
- 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) {
1432
1490
  const fd = import_node_fs2.default.openSync(this.filePath, "a");
1433
1491
  try {
1434
1492
  import_node_fs2.default.writeFileSync(fd, "\n");
@@ -1437,6 +1495,9 @@ ${summaryText}` }]
1437
1495
  import_node_fs2.default.closeSync(fd);
1438
1496
  }
1439
1497
  }
1498
+ this.branchLeaves = branchLeaves;
1499
+ this.activeBranchId = activeBranchId;
1500
+ this.schemaVersion = schemaVersion;
1440
1501
  return records;
1441
1502
  }
1442
1503
  };
@@ -1724,18 +1785,21 @@ function resolveWorkspaceContext(input) {
1724
1785
  function normalizedProjectRoot(root) {
1725
1786
  return process.platform === "win32" ? root.replaceAll("\\", "/").toLowerCase() : root;
1726
1787
  }
1727
- function resolveSessionProject(cwd) {
1788
+ function resolveSessionProject(cwd, mode = "git") {
1728
1789
  const realCwd = toNativePhysicalPath(cwd);
1729
1790
  let root = realCwd;
1730
1791
  let gitBranch;
1731
1792
  let gitWorktree = false;
1732
1793
  try {
1733
- root = toNativePhysicalPath(
1794
+ const gitRoot = toNativePhysicalPath(
1734
1795
  (0, import_node_child_process.execFileSync)("git", ["-C", realCwd, "rev-parse", "--show-toplevel"], {
1735
1796
  encoding: "utf8",
1736
1797
  stdio: ["ignore", "pipe", "ignore"]
1737
1798
  }).trim()
1738
1799
  );
1800
+ if (mode === "git") root = gitRoot;
1801
+ if (normalizedProjectRoot(gitRoot) !== normalizedProjectRoot(root))
1802
+ return { projectRoot: root, cwd: realCwd, projectKey: computeProjectKey(root) };
1739
1803
  gitWorktree = true;
1740
1804
  gitBranch = (0, import_node_child_process.execFileSync)("git", ["-C", realCwd, "symbolic-ref", "--short", "-q", "HEAD"], {
1741
1805
  encoding: "utf8",
@@ -1743,11 +1807,10 @@ function resolveSessionProject(cwd) {
1743
1807
  }).trim() || void 0;
1744
1808
  } catch {
1745
1809
  }
1746
- const normalized = normalizedProjectRoot(root);
1747
1810
  return {
1748
1811
  projectRoot: root,
1749
1812
  cwd: realCwd,
1750
- projectKey: (0, import_node_crypto6.createHash)("sha256").update(normalized).digest("hex"),
1813
+ projectKey: computeProjectKey(root),
1751
1814
  gitBranch,
1752
1815
  gitWorktree
1753
1816
  };
@@ -1765,7 +1828,10 @@ var SessionManager = class {
1765
1828
  directory;
1766
1829
  handles = /* @__PURE__ */ new Map();
1767
1830
  diagnostic;
1831
+ readOptions;
1768
1832
  constructor(options = {}) {
1833
+ this.readOptions = { maxReadBytes: options.maxReadBytes };
1834
+ assertHistoryReadBudget(0, this.readOptions);
1769
1835
  const rootCandidate = options.workspaceRoot ?? options.projectRoot ?? options.cwd;
1770
1836
  if (!rootCandidate) {
1771
1837
  throw new KapibalaError("Workspace root is required for SessionManager", {
@@ -1773,7 +1839,7 @@ var SessionManager = class {
1773
1839
  safeMessage: "\u5FC5\u987B\u663E\u5F0F\u6307\u5B9A\u5DE5\u4F5C\u533A\u6839\u8DEF\u5F84\u6216\u9879\u76EE\u76EE\u5F55"
1774
1840
  });
1775
1841
  }
1776
- this.project = resolveSessionProject(rootCandidate);
1842
+ this.project = resolveSessionProject(rootCandidate, options.projectRootMode);
1777
1843
  this.directory = import_node_path4.default.join(
1778
1844
  options.homeDirectory ?? import_node_os.default.homedir(),
1779
1845
  ".kapibala",
@@ -1789,12 +1855,16 @@ var SessionManager = class {
1789
1855
  const file = import_node_path4.default.join(this.directory, `${Date.now()}_${conversationId}.jsonl`);
1790
1856
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1791
1857
  try {
1792
- const store = await SessionStore.create(file, {
1793
- conversationId,
1794
- projectRoot: this.project.projectRoot,
1795
- initialCwd: this.project.cwd,
1796
- gitBranch: this.project.gitBranch
1797
- });
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
+ );
1798
1868
  return await this.attach(store, lock);
1799
1869
  } catch (error) {
1800
1870
  await lock.release();
@@ -1803,19 +1873,25 @@ var SessionManager = class {
1803
1873
  }
1804
1874
  /**
1805
1875
  * 按正文活动排序分页;有效 sidecar 只读元数据,不读取完整 JSONL。默认排除已归档会话。
1806
- * pageSize 由调用方显式给出时必须在 1–100 之间;非法页参数直接失败,不静默修正。
1876
+ * pageSize 由调用方显式给出时必须在 1–100 之间;filter 在分页前筛选元数据,调用方须自行处理敏感信息。
1877
+ * 置顶项整体前置、组内仍按活动时间排序;排序只作用于本方法的返回值,不影响 continueRecent。
1807
1878
  */
1808
1879
  async list(page = 1, options) {
1809
1880
  const pageSize = options?.pageSize ?? 20;
1810
1881
  if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
1811
1882
  throw createSessionError("HISTORY_PAGE_INVALID", "History page must be a positive integer");
1812
1883
  const all = await this.allMetadata();
1813
- 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
+ ];
1814
1890
  return {
1815
- items: filtered.slice((page - 1) * pageSize, page * pageSize),
1816
- total: filtered.length,
1891
+ items: ordered.slice((page - 1) * pageSize, page * pageSize),
1892
+ total: ordered.length,
1817
1893
  page,
1818
- pages: Math.max(1, Math.ceil(filtered.length / pageSize))
1894
+ pages: Math.max(1, Math.ceil(ordered.length / pageSize))
1819
1895
  };
1820
1896
  }
1821
1897
  /** 解析完整 ID 或唯一前缀对应的会话文件与 ID。 */
@@ -1834,60 +1910,109 @@ var SessionManager = class {
1834
1910
  return matches[0];
1835
1911
  }
1836
1912
  /**
1837
- * 逻辑归档指定会话。
1913
+ * 归档、恢复、重命名与置顶都向正文追加一条全局控制记录,共用同一套前置约束。
1838
1914
  *
1839
1915
  * 约束:
1840
1916
  * - 必须取得会话独占锁;
1841
- * - 若会话当前正处于活动打开状态,拒绝归档并抛出 SESSION_IN_USE;
1842
- * - 向会话正文追加 archive_state { archived: true } 并刷新 sidecar 缓存。
1917
+ * - 若会话当前正处于活动打开状态,拒绝并抛出 SESSION_IN_USE(调用方先关闭句柄);
1918
+ * - v1 只读历史不能变更,明确以 LEGACY_SESSION_READ_ONLY 失败而不是静默忽略;
1919
+ * - 写入后按正文重建 sidecar,状态只以正文记录为准。
1843
1920
  */
1844
- async archiveSession(id) {
1921
+ async appendControlRecord(id, subject, append) {
1845
1922
  const { fileName, conversationId } = this.resolveSession(id);
1846
1923
  if (this.handles.has(conversationId)) {
1847
- throw new KapibalaError("Session is currently active or in use, cannot archive", {
1924
+ throw new KapibalaError("Session is currently active or in use", {
1848
1925
  code: "SESSION_IN_USE",
1849
- 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}`
1850
1927
  });
1851
1928
  }
1852
1929
  const targetFile = import_node_path4.default.join(this.directory, fileName);
1853
1930
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1854
1931
  try {
1855
- const store = await SessionStore.open(targetFile);
1932
+ const store = await SessionStore.open(targetFile, this.readOptions);
1856
1933
  if (store.isReadOnly()) {
1857
- throw new KapibalaError("Cannot archive legacy v1 session", {
1934
+ throw new KapibalaError("Cannot modify legacy v1 session state", {
1858
1935
  code: "LEGACY_SESSION_READ_ONLY",
1859
- 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`
1860
1937
  });
1861
1938
  }
1862
- await store.setArchiveState(true);
1863
- const records = await store.readRecords();
1864
- const meta = this.buildMetadata(targetFile, records);
1865
- meta.archived = true;
1866
- this.writeMetadata(targetFile, meta);
1939
+ await append(store);
1940
+ this.writeMetadata(targetFile, this.buildMetadata(targetFile, await store.readRecords()));
1867
1941
  } finally {
1868
1942
  await lock.release();
1869
1943
  }
1870
1944
  }
1871
1945
  /**
1872
- * 恢复已逻辑归档的会话。
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 },正文保持不动。
1873
1953
  */
1874
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) {
1875
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
+ }
1876
2000
  const targetFile = import_node_path4.default.join(this.directory, fileName);
1877
2001
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1878
2002
  try {
1879
- const store = await SessionStore.open(targetFile);
1880
- if (store.isReadOnly()) {
1881
- throw new KapibalaError("Cannot restore legacy v1 session", {
1882
- code: "LEGACY_SESSION_READ_ONLY",
1883
- safeMessage: "\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684\u5F52\u6863\u72B6\u6001"
1884
- });
2003
+ const headerLines = this.readDiscoveryRecords(targetFile);
2004
+ let header;
2005
+ try {
2006
+ header = headerLines.next().value.payload;
2007
+ } finally {
2008
+ headerLines.return(void 0);
1885
2009
  }
1886
- await store.setArchiveState(false);
1887
- const records = await store.readRecords();
1888
- const meta = this.buildMetadata(targetFile, records);
1889
- meta.archived = false;
1890
- 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);
1891
2016
  } finally {
1892
2017
  await lock.release();
1893
2018
  }
@@ -1899,6 +2024,11 @@ var SessionManager = class {
1899
2024
  const file = import_node_path4.default.join(this.directory, fileName);
1900
2025
  try {
1901
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
+ }
1902
2032
  if (!meta) {
1903
2033
  const records = this.readDiscoveryRecords(file);
1904
2034
  meta = this.buildMetadata(file, records);
@@ -1909,7 +2039,17 @@ var SessionManager = class {
1909
2039
  }
1910
2040
  }
1911
2041
  all.push(meta);
1912
- } 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
+ }
1913
2053
  this.report(`Cannot discover session: ${fileName}`);
1914
2054
  }
1915
2055
  }
@@ -1959,7 +2099,10 @@ var SessionManager = class {
1959
2099
  }
1960
2100
  if (header.conversationId !== meta.conversationId || normalizedProjectRoot(header.projectRoot) !== normalizedProjectRoot(this.project.projectRoot))
1961
2101
  throw new Error("Foreign session identity");
1962
- 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
+ );
1963
2106
  if ((await store.loadState()).header.conversationId !== meta.conversationId)
1964
2107
  throw new Error("Session identity mismatch");
1965
2108
  await store.load();
@@ -1971,7 +2114,9 @@ var SessionManager = class {
1971
2114
  }
1972
2115
  /** 继续最近有正文的会话;没有历史才新建,显式打开失败绝不回退为空会话。 */
1973
2116
  async continueRecent(options) {
1974
- 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
+ );
1975
2120
  if (latest)
1976
2121
  return this.open(latest.conversationId, {
1977
2122
  autoUpgradeLegacy: options?.autoUpgradeLegacy ?? true
@@ -1979,11 +2124,21 @@ var SessionManager = class {
1979
2124
  this.report("No previous session with content; created a new session");
1980
2125
  return this.create();
1981
2126
  }
1982
- /** 标题作为独立耐久记录;标题或浏览不改变正文活动排序。 */
2127
+ /**
2128
+ * 重命名已打开或待打开的会话;标题作为独立耐久记录,标题或浏览都不改变正文活动排序。
2129
+ *
2130
+ * 未持有句柄时临时打开并在追加后释放:只改标题不该把会话一直挂在写锁上,
2131
+ * 否则同进程后续的归档、删除与置顶都会被自己占住的锁挡掉。
2132
+ */
1983
2133
  async rename(id, title) {
1984
2134
  if (!title.trim()) throw new Error("Session title cannot be empty");
2135
+ const alreadyOpen = this.handles.has(this.resolveSession(id).conversationId);
1985
2136
  const handle = await this.open(id, { autoUpgradeLegacy: true });
1986
- 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
+ }
1987
2142
  }
1988
2143
  /** 等候所有存储队列后释放句柄;宿主必须先结束 AgentSession 的工具/摘要清理。 */
1989
2144
  async close() {
@@ -2003,7 +2158,7 @@ var SessionManager = class {
2003
2158
  for (const source of sources) {
2004
2159
  if (!import_node_fs5.default.existsSync(source)) continue;
2005
2160
  const before = import_node_fs5.default.statSync(source);
2006
- const bytes = import_node_fs5.default.readFileSync(source);
2161
+ const bytes = readHistoryBytes(source, this.readOptions);
2007
2162
  const identity = (0, import_node_crypto6.createHash)("sha256").update(import_node_fs5.default.realpathSync.native(source)).update("\0").update(bytes).digest("hex");
2008
2163
  const importLock = await SessionLock.acquire(
2009
2164
  import_node_path4.default.join(this.directory, `import-${identity}.lock`)
@@ -2029,19 +2184,23 @@ var SessionManager = class {
2029
2184
  const id = createMessageId();
2030
2185
  const target = import_node_path4.default.join(this.directory, `${Date.now()}_${id}.jsonl`);
2031
2186
  temp = `${target}.tmp-${createMessageId()}`;
2032
- const store = await SessionStore.create(temp, {
2033
- conversationId: id,
2034
- projectRoot: this.project.projectRoot,
2035
- initialCwd: this.project.cwd,
2036
- gitBranch: this.project.gitBranch,
2037
- importIdentity: identity
2038
- });
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
+ );
2039
2198
  await store.appendRecord("legacy_import", { source, identity });
2040
2199
  await store.appendRecord("title", { title: "\u65E7\u5386\u53F2\u5BFC\u5165" });
2041
2200
  for (const message of identifyHistory(raw, identity)) await store.append(message);
2042
2201
  await store.load();
2043
2202
  const after = import_node_fs5.default.statSync(source);
2044
- 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)) {
2045
2204
  this.report("Legacy history is changing; import postponed");
2046
2205
  continue;
2047
2206
  }
@@ -2104,7 +2263,7 @@ var SessionManager = class {
2104
2263
  }
2105
2264
  const nativeSource = toNativePhysicalPath(sourcePath);
2106
2265
  const beforeStat = import_node_fs5.default.statSync(nativeSource);
2107
- const bytes = import_node_fs5.default.readFileSync(nativeSource);
2266
+ const bytes = readHistoryBytes(nativeSource, this.readOptions);
2108
2267
  const upgradeIdentity = (0, import_node_crypto6.createHash)("sha256").update(nativeSource).update("\0").update(bytes).digest("hex");
2109
2268
  const existingMeta = (await this.allMetadata()).find(
2110
2269
  (m) => m.upgradeIdentity === upgradeIdentity
@@ -2123,7 +2282,7 @@ var SessionManager = class {
2123
2282
  if (doubleCheck) {
2124
2283
  return this.open(doubleCheck.conversationId);
2125
2284
  }
2126
- const sourceStore = await SessionStore.open(nativeSource);
2285
+ const sourceStore = await SessionStore.open(nativeSource, this.readOptions);
2127
2286
  if (sourceStore.getSchemaVersion() !== SESSION_SCHEMA_VERSION_V1) {
2128
2287
  const state = await sourceStore.loadState();
2129
2288
  return this.open(state.header.conversationId);
@@ -2190,7 +2349,7 @@ var SessionManager = class {
2190
2349
  import_node_fs5.default.closeSync(fd);
2191
2350
  }
2192
2351
  const afterStat = import_node_fs5.default.statSync(nativeSource);
2193
- 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)) {
2194
2353
  throw new Error("Source legacy session changed during upgrade; retry aborted");
2195
2354
  }
2196
2355
  import_node_fs5.default.renameSync(tempPath, targetPath);
@@ -2353,7 +2512,7 @@ var SessionManager = class {
2353
2512
  store.setAppendObserver(
2354
2513
  (record) => {
2355
2514
  const previous = JSON.parse(
2356
- import_node_fs5.default.readFileSync(this.metadataPath(store.filePath), "utf8")
2515
+ readHistoryBytes(this.metadataPath(store.filePath), this.readOptions).toString("utf8")
2357
2516
  );
2358
2517
  const currentPrev = record.prevRecordId ?? record.parentId;
2359
2518
  if (previous.conversationId !== header.conversationId || currentPrev !== null && currentPrev !== (previous.logRevision ?? previous.revision))
@@ -2398,10 +2557,28 @@ var SessionManager = class {
2398
2557
  this.handles.set(header.conversationId, handle);
2399
2558
  return handle;
2400
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
+ }
2401
2576
  /** 缓存通过文件名、身份、统计与正文 stat 校验;失败交由正文重建。 */
2402
2577
  readMetadata(file) {
2403
2578
  try {
2404
- 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
+ );
2405
2582
  const stat = import_node_fs5.default.statSync(file);
2406
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)
2407
2584
  return meta;
@@ -2411,7 +2588,7 @@ var SessionManager = class {
2411
2588
  }
2412
2589
  /** 浏览仅解析完整记录;严格打开由 SessionStore 验证,浏览不能改写残片。 */
2413
2590
  *readDiscoveryRecords(file) {
2414
- const iterator = readJsonlLines(file);
2591
+ const iterator = readJsonlLines(file, this.readOptions);
2415
2592
  let first = true;
2416
2593
  let blankSeen = false;
2417
2594
  for (const line of iterator) {
@@ -2466,7 +2643,8 @@ var SessionManager = class {
2466
2643
  contentRevision: first.value.recordId,
2467
2644
  activeBranchId: MAIN_BRANCH_ID,
2468
2645
  activeLeafId: first.value.recordId,
2469
- archived: false
2646
+ archived: false,
2647
+ pinned: false
2470
2648
  };
2471
2649
  const branchLeaves = /* @__PURE__ */ new Map();
2472
2650
  branchLeaves.set(MAIN_BRANCH_ID, first.value.recordId);
@@ -2519,6 +2697,8 @@ var SessionManager = class {
2519
2697
  if (bId) meta.activeBranchId = bId;
2520
2698
  } else if (record.type === "archive_state") {
2521
2699
  meta.archived = Boolean(record.payload.archived);
2700
+ } else if (record.type === "pin_state") {
2701
+ meta.pinned = Boolean(record.payload.pinned);
2522
2702
  }
2523
2703
  if (branchLeaves && !isGlobalControlRecordType(record.type)) {
2524
2704
  const bId = record.branchId ?? meta.activeBranchId ?? MAIN_BRANCH_ID;
@@ -3025,6 +3205,7 @@ var CrossProjectSessionCatalog = class {
3025
3205
  let title = "\u65B0\u4F1A\u8BDD";
3026
3206
  let activeBranchId = MAIN_BRANCH_ID;
3027
3207
  let archived = false;
3208
+ let pinned = false;
3028
3209
  let lastRecordId = headerRecord.recordId;
3029
3210
  for (const line of iterator) {
3030
3211
  if (!line.text.trim()) continue;
@@ -3045,6 +3226,8 @@ var CrossProjectSessionCatalog = class {
3045
3226
  title = String(rec.payload.title);
3046
3227
  } else if (rec.type === "archive_state") {
3047
3228
  archived = Boolean(rec.payload.archived);
3229
+ } else if (rec.type === "pin_state") {
3230
+ pinned = Boolean(rec.payload.pinned);
3048
3231
  } else if (rec.type === "branch_selected") {
3049
3232
  activeBranchId = String(rec.payload.branchId);
3050
3233
  }
@@ -3063,6 +3246,7 @@ var CrossProjectSessionCatalog = class {
3063
3246
  title,
3064
3247
  activeBranchId,
3065
3248
  archived,
3249
+ pinned,
3066
3250
  size: stat.size,
3067
3251
  mtimeMs: stat.mtimeMs,
3068
3252
  revision: lastRecordId,
@@ -6200,7 +6384,8 @@ function stopProcessTree(child, nativeGroup) {
6200
6384
  try {
6201
6385
  process.kill(-child.pid, "SIGTERM");
6202
6386
  return true;
6203
- } catch {
6387
+ } catch (error) {
6388
+ if (error.code === "ESRCH") return true;
6204
6389
  try {
6205
6390
  child.kill("SIGTERM");
6206
6391
  } catch {
@@ -6221,7 +6406,8 @@ function forceProcessTree(child, nativeGroup) {
6221
6406
  try {
6222
6407
  process.kill(-child.pid, "SIGKILL");
6223
6408
  return true;
6224
- } catch {
6409
+ } catch (error) {
6410
+ if (error.code === "ESRCH") return true;
6225
6411
  try {
6226
6412
  child.kill("SIGKILL");
6227
6413
  } catch {
@@ -6242,13 +6428,31 @@ async function confirmProcessTreeStopped(child, terminationSucceeded, nativeGrou
6242
6428
  if (process.platform === "win32") return true;
6243
6429
  const deadline = Date.now() + 1e3;
6244
6430
  while (Date.now() < deadline) {
6431
+ let exists;
6245
6432
  try {
6246
6433
  process.kill(-child.pid, 0);
6434
+ exists = true;
6247
6435
  } catch (error) {
6248
- if (error.code === "ESRCH") return true;
6249
- return false;
6436
+ const code = error.code;
6437
+ if (code === "ESRCH") return true;
6438
+ if (code !== "EPERM") return false;
6439
+ exists = true;
6250
6440
  }
6251
- await new Promise((resolve) => setTimeout(resolve, 50));
6441
+ if (groupHasLiveProcess(child.pid)) {
6442
+ await new Promise((resolve) => setTimeout(resolve, 50));
6443
+ continue;
6444
+ }
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;
6252
6456
  }
6253
6457
  return false;
6254
6458
  }
@@ -7616,133 +7820,462 @@ var HookRegistry = class {
7616
7820
  };
7617
7821
 
7618
7822
  // src/runtime/executor/index.ts
7619
- var import_node_crypto16 = require("crypto");
7620
7823
  var import_node_path17 = __toESM(require("path"), 1);
7621
- var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
7622
- var ToolExecutor = class {
7623
- hardDenial;
7624
- onToolResult;
7625
- tools;
7626
- hooks;
7627
- workspaceRoot;
7628
- executionCwd;
7629
- sandbox;
7630
- leaseCoordinator;
7631
- rootDir;
7632
- signal;
7633
- logger;
7634
- toolTimeoutMs;
7635
- eventLogger;
7636
- sessionId;
7637
- runId;
7638
- branchId;
7639
- rootTaskId;
7640
- depth;
7641
- agentId;
7642
- committedActions = [];
7643
- rollbackActions = [];
7644
- mode;
7645
- permissionRules;
7646
- approvalChannel;
7647
- approvalCache;
7648
- policy = new PermissionPolicy();
7649
- neverFailures = /* @__PURE__ */ new Set();
7650
- cancellation = new AbortController();
7651
- constructor(options) {
7652
- this.hardDenial = options.hardDenial;
7653
- this.onToolResult = options.onToolResult;
7654
- this.tools = options.tools;
7655
- this.hooks = options.hooks;
7656
- this.workspaceRoot = options.workspaceRoot ?? options.rootDir;
7657
- this.executionCwd = options.executionCwd ?? options.rootDir;
7658
- this.sandbox = options.sandbox;
7659
- this.leaseCoordinator = options.leaseCoordinator;
7660
- this.rootDir = options.rootDir;
7661
- this.signal = options.signal ? AbortSignal.any([options.signal, this.cancellation.signal]) : this.cancellation.signal;
7662
- this.logger = options.logger;
7663
- this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
7664
- this.eventLogger = options.eventLogger;
7665
- this.sessionId = options.sessionId;
7666
- this.runId = options.runId;
7667
- this.branchId = options.branchId;
7668
- this.rootTaskId = options.rootTaskId;
7669
- this.depth = options.depth;
7670
- this.agentId = options.agentId;
7671
- this.mode = options.mode ?? "Approval";
7672
- this.permissionRules = options.permissionRules ?? [];
7673
- this.approvalChannel = options.approvalChannel;
7674
- this.approvalCache = options.approvalCache ?? new SessionApprovalCache();
7675
- }
7676
- /**
7677
- * 父工具事务提交成功后调用,按注册顺序执行所有提交动作。
7678
- * 动作执行成功后逐项清理对应回滚动作,若某项失败保留剩余回滚动作。
7679
- */
7680
- async commit() {
7681
- while (this.committedActions.length > 0) {
7682
- const action = this.committedActions.shift();
7683
- await action();
7684
- this.rollbackActions.shift();
7685
- }
7686
- this.rollbackActions.length = 0;
7687
- }
7688
- /**
7689
- * 父工具事务提交失败或取消时调用,按逆序执行所有回滚清理动作。
7690
- */
7691
- async rollback() {
7692
- const actions = [...this.rollbackActions].reverse();
7693
- this.committedActions.length = 0;
7694
- this.rollbackActions.length = 0;
7695
- for (const action of actions) {
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)) {
7696
7863
  try {
7697
- await action();
7864
+ await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
7865
+ await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
7698
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
+ };
7699
7879
  }
7880
+ return {
7881
+ result: failure(
7882
+ "Identical failed tool call cannot be repeated in this run",
7883
+ "REPEAT_BLOCKED"
7884
+ )
7885
+ };
7700
7886
  }
7701
- }
7702
- /** 中止本轮正在等待或执行的工具;调用方仍须等待 runAll 完成资源回收及结果审计。 */
7703
- cancel() {
7704
- this.cancellation.abort();
7705
- }
7706
- /**
7707
- * 串行执行全部调用并返回与入参顺序一致的结果;某个结果要求用户介入后,其余调用直接拒绝。
7708
- * onResult 在每个最终结果(含拒绝/失败)产生后提供独立快照,供在途预算估算,不代表历史已提交;回调不得抛错。
7709
- */
7710
- async runAll(calls, onProgress, onResult) {
7711
- const results = [];
7712
- let requiresUserAction = false;
7713
- for (const call of calls) {
7714
- const result = await this.executeOne(call, onProgress, requiresUserAction);
7715
- results.push(result);
7716
- onResult?.(structuredClone(result));
7717
- if (result.retryPolicy === "after_user_action") requiresUserAction = true;
7718
- }
7719
- return results;
7720
- }
7721
- /**
7722
- * 在最终参数上裁决并审计一次调用,所有拒绝和异常均闭合为工具结果。
7723
- * 为调用记录本次绝对工具根目录,供跨目录恢复后的文件事实解析;不使用历史目录授权。
7724
- */
7725
- async executeOne(call, onProgress, forcedDeny = false) {
7726
- call.executionRoot = import_node_path17.default.resolve(this.workspaceRoot);
7727
- const startedAt = Date.now();
7728
- const operationId = createLogId();
7729
- const failure = (message, errorCode, retryPolicy = "never", execution) => ({
7730
- type: "tool_result",
7731
- toolUseId: call.id,
7732
- content: JSON.stringify({ code: errorCode, retryPolicy, message }),
7733
- isError: true,
7734
- errorCode,
7735
- retryPolicy,
7736
- durationMs: Math.max(0, Date.now() - startedAt),
7737
- execution: execution ?? {
7738
- version: 1,
7739
- dispatchOutcome: "not_sent"
7740
- }
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 } : {}
7741
7899
  });
7742
- const audit = async (event, fields = {}) => {
7743
- if (!this.eventLogger || !this.sessionId) return;
7744
- await this.eventLogger.recordAudit({
7745
- level: "info",
7900
+ let permitted = decision.decision === "allow";
7901
+ let source = decision.source;
7902
+ let approvalChoice;
7903
+ if (decision.decision === "ask") {
7904
+ let choice = "deny_once";
7905
+ try {
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 });
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
+ };
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
+ };
7970
+ }
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 完成资源回收及结果审计。 */
8236
+ cancel() {
8237
+ this.cancellation.abort();
8238
+ }
8239
+ /**
8240
+ * 串行执行全部调用并返回与入参顺序一致的结果;某个结果要求用户介入后,其余调用直接拒绝。
8241
+ * onResult 在每个最终结果(含拒绝/失败)产生后提供独立快照,供在途预算估算,不代表历史已提交;回调不得抛错。
8242
+ */
8243
+ async runAll(calls, onProgress, onResult) {
8244
+ const results = [];
8245
+ let requiresUserAction = false;
8246
+ for (const call of calls) {
8247
+ const result = await this.executeOne(call, onProgress, requiresUserAction);
8248
+ results.push(result);
8249
+ onResult?.(structuredClone(result));
8250
+ if (result.retryPolicy === "after_user_action") requiresUserAction = true;
8251
+ }
8252
+ return results;
8253
+ }
8254
+ /**
8255
+ * 在最终参数上裁决并审计一次调用,所有拒绝和异常均闭合为工具结果。
8256
+ * 为调用记录本次绝对工具根目录,供跨目录恢复后的文件事实解析;不使用历史目录授权。
8257
+ */
8258
+ async executeOne(call, onProgress, forcedDeny = false) {
8259
+ call.executionRoot = import_node_path17.default.resolve(this.workspaceRoot);
8260
+ const startedAt = Date.now();
8261
+ const operationId = createLogId();
8262
+ const failure = (message, errorCode, retryPolicy = "never", execution) => ({
8263
+ type: "tool_result",
8264
+ toolUseId: call.id,
8265
+ content: JSON.stringify({ code: errorCode, retryPolicy, message }),
8266
+ isError: true,
8267
+ errorCode,
8268
+ retryPolicy,
8269
+ durationMs: Math.max(0, Date.now() - startedAt),
8270
+ execution: execution ?? {
8271
+ version: 1,
8272
+ dispatchOutcome: "not_sent"
8273
+ }
8274
+ });
8275
+ const audit = async (event, fields = {}) => {
8276
+ if (!this.eventLogger || !this.sessionId) return;
8277
+ await this.eventLogger.recordAudit({
8278
+ level: "info",
7746
8279
  event,
7747
8280
  sessionId: this.sessionId,
7748
8281
  runId: this.runId,
@@ -7934,191 +8467,28 @@ var ToolExecutor = class {
7934
8467
  }
7935
8468
  return failure("Invalid tool execution identity", "INVALID_TARGET");
7936
8469
  }
7937
- let auditFields = {};
7938
- for (let attempt = 0; attempt < 2; attempt++) {
7939
- const request = {
7940
- toolName: call.name,
7941
- capabilities: tool.metadata?.permissions ?? [],
7942
- input: finalInput,
7943
- rootDir: this.rootDir,
7944
- ...shell ? { shell } : {},
7945
- ...executionIdentity ? { executionIdentity } : {},
7946
- // 解释器内建查询与内容已绑定摘要的工作区直引脚本可确认不受外部脚本变更影响,
7947
- // 允许会话级批准;其余 shell 命令仅允许单次批准。
7948
- // 非 shell 工具(含深度绑定 executionIdentity 的外部工具)允许会话级批准。
7949
- sessionAllowed: !shell || /^(?:pwd|get-location)$/i.test(shell.command.trim()) || shell.scriptDigest !== void 0
7950
- };
7951
- const repeatKey = (0, import_node_crypto16.createHash)("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex");
7952
- if (this.neverFailures.has(repeatKey)) {
7953
- try {
7954
- await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
7955
- await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
7956
- } catch {
7957
- return failure(
7958
- "Audit unavailable; tool was not started",
7959
- "AUDIT_UNAVAILABLE",
7960
- "after_user_action",
7961
- {
7962
- version: 1,
7963
- dispatchOutcome: "not_sent",
7964
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7965
- }
7966
- );
7967
- }
7968
- return failure(
7969
- "Identical failed tool call cannot be repeated in this run",
7970
- "REPEAT_BLOCKED"
7971
- );
7972
- }
7973
- const hardDenied = this.hardDenial?.(tool, finalInput);
7974
- const cached = this.approvalCache.get(request);
7975
- const decision = this.policy.decide({
7976
- hardDenied: hardDenied !== void 0,
7977
- mode: typeof this.mode === "function" ? this.mode() : this.mode,
7978
- toolName: call.name,
7979
- capabilities: request.capabilities,
7980
- dangerous: tool.metadata?.dangerous,
7981
- cachedApproval: cached === "allow",
7982
- cachedDenial: cached === "deny",
7983
- rules: this.permissionRules,
7984
- ...shell ? { shell } : {}
7985
- });
7986
- let permitted = decision.decision === "allow";
7987
- let source = decision.source;
7988
- let approvalChoice;
7989
- if (decision.decision === "ask") {
7990
- let choice = "deny_once";
7991
- try {
7992
- choice = await this.approvalChannel?.requestApproval(request, this.signal) ?? "deny_once";
7993
- } catch {
7994
- choice = "cancel";
7995
- }
7996
- approvalChoice = choice;
7997
- if (choice === "allow_session" && request.sessionAllowed)
7998
- this.approvalCache.set(request, "allow");
7999
- if (choice === "deny_session") this.approvalCache.set(request, "deny");
8000
- permitted = choice === "allow_once" || choice === "allow_session" && request.sessionAllowed;
8001
- source = this.approvalChannel ? "human" : "non_interactive";
8002
- }
8003
- auditFields = shell ? {
8004
- shell: shell.interpreter,
8005
- cwdDigest: (0, import_node_crypto16.createHash)("sha256").update(shell.cwd).digest("hex"),
8006
- executableDigest: shell.executableDigest,
8007
- executionFingerprint: shell.executionFingerprint,
8008
- ...commandAuditSummary(shell.command)
8009
- } : executionIdentity ? {
8010
- serverIdentity: executionIdentity.serverIdentity,
8011
- connectionGeneration: executionIdentity.connectionGeneration,
8012
- catalogEpoch: executionIdentity.catalogEpoch,
8013
- toolDigest: executionIdentity.toolDigest
8014
- } : {};
8015
- try {
8016
- await audit("tool.decided", {
8017
- decision: permitted ? "allow" : "deny",
8018
- decisionSource: source,
8019
- ...approvalChoice ? { approvalChoice } : {},
8020
- ...hardDenied?.fields ?? {},
8021
- ...auditFields
8022
- });
8023
- } catch {
8024
- return failure(
8025
- "Audit unavailable; tool was not started",
8026
- "AUDIT_UNAVAILABLE",
8027
- "after_user_action",
8028
- {
8029
- version: 1,
8030
- dispatchOutcome: "not_sent",
8031
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
8032
- }
8033
- );
8034
- }
8035
- if (!permitted || this.signal?.aborted) {
8036
- const code = this.signal?.aborted ? "ABORTED" : hardDenied?.code ?? "PERMISSION_DENIED";
8037
- try {
8038
- await audit("tool.finished", { status: "denied", errorCode: code });
8039
- } catch {
8040
- return failure("Audit result unavailable", "AUDIT_UNAVAILABLE", "after_user_action", {
8041
- version: 1,
8042
- dispatchOutcome: "not_sent",
8043
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
8044
- });
8045
- }
8046
- return failure(
8047
- this.signal?.aborted ? "Operation cancelled" : hardDenied?.message ?? "Tool permission denied",
8048
- code,
8049
- this.signal?.aborted ? "after_user_action" : hardDenied?.retryPolicy ?? "after_user_action"
8050
- );
8051
- }
8052
- if (shell) {
8053
- try {
8054
- const next = tool.approvalScope?.(finalInput, this.rootDir, this.executionCwd);
8055
- if (next?.executionFingerprint !== shell.executionFingerprint) {
8056
- if (attempt === 0 && next) {
8057
- try {
8058
- await audit("tool.target_changed", { status: "redeciding" });
8059
- } catch {
8060
- return failure(
8061
- "Audit unavailable; tool was not started",
8062
- "AUDIT_UNAVAILABLE",
8063
- "after_user_action",
8064
- {
8065
- version: 1,
8066
- dispatchOutcome: "not_sent",
8067
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
8068
- }
8069
- );
8070
- }
8071
- shell = next;
8072
- continue;
8073
- }
8074
- await audit("tool.finished", { status: "target_changed", errorCode: "TARGET_CHANGED" });
8075
- return failure(
8076
- "Execution target changed after approval",
8077
- "TARGET_CHANGED",
8078
- "after_user_action"
8079
- );
8080
- }
8081
- } catch {
8082
- return failure(
8083
- "Execution target changed after approval",
8084
- "TARGET_CHANGED",
8085
- "after_user_action"
8086
- );
8087
- }
8088
- }
8089
- if (executionIdentity) {
8090
- try {
8091
- const nextIdentity = tool.executionIdentity?.(finalInput, this.rootDir);
8092
- if (!nextIdentity || nextIdentity.serverIdentity !== executionIdentity.serverIdentity || nextIdentity.connectionGeneration !== executionIdentity.connectionGeneration || nextIdentity.catalogEpoch !== executionIdentity.catalogEpoch || nextIdentity.toolDigest !== executionIdentity.toolDigest) {
8093
- await audit("tool.finished", {
8094
- status: "identity_changed",
8095
- errorCode: "MCP_TOOL_CHANGED"
8096
- });
8097
- return failure(
8098
- "Tool execution identity changed after approval",
8099
- "MCP_TOOL_CHANGED",
8100
- "after_user_action",
8101
- {
8102
- version: 1,
8103
- dispatchOutcome: "not_sent",
8104
- hostError: { phase: "validation", code: "MCP_TOOL_CHANGED" }
8105
- }
8106
- );
8107
- }
8108
- } catch {
8109
- return failure(
8110
- "Tool execution identity check failed after approval",
8111
- "MCP_TOOL_CHANGED",
8112
- "after_user_action",
8113
- {
8114
- version: 1,
8115
- dispatchOutcome: "not_sent"
8116
- }
8117
- );
8118
- }
8119
- }
8120
- break;
8121
- }
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 ?? {};
8122
8492
  try {
8123
8493
  await audit("tool.started", { status: "started", ...auditFields });
8124
8494
  } catch {
@@ -8178,9 +8548,10 @@ var ToolExecutor = class {
8178
8548
  { signal: executionController.signal }
8179
8549
  );
8180
8550
  }
8181
- 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(
8182
8552
  tool.execute(finalInput, toolCtx),
8183
8553
  call.name,
8554
+ this.toolTimeoutMs,
8184
8555
  executionController
8185
8556
  );
8186
8557
  if (typeof tool.toExecutionResult === "function") {
@@ -8241,105 +8612,18 @@ var ToolExecutor = class {
8241
8612
  dispatchOutcome: outcome
8242
8613
  };
8243
8614
  }
8244
- const resultBlock = {
8245
- type: "tool_result",
8246
- toolUseId: call.id,
8247
- content: outputStr,
8248
- isError,
8249
- ...errorCode ? { errorCode, retryPolicy } : {},
8250
- execution
8251
- };
8252
- if (resultBlock.isError && resultBlock.errorCode) {
8253
- resultBlock.content = JSON.stringify({
8254
- code: resultBlock.errorCode,
8255
- retryPolicy: resultBlock.retryPolicy,
8256
- message: outputStr
8257
- });
8258
- }
8259
- try {
8260
- for (const hook of this.hooks.get("tool:after")) {
8261
- await hook(
8262
- hookCtx,
8263
- { id: call.id, name: call.name, input: finalInput },
8264
- { output: outputStr, isError }
8265
- );
8266
- }
8267
- } catch {
8268
- resultBlock.content = JSON.stringify({
8269
- code: "HOOK_ERROR",
8270
- retryPolicy: "after_user_action",
8271
- message: "Tool finished but result processing failed"
8272
- });
8273
- resultBlock.isError = true;
8274
- resultBlock.errorCode = "HOOK_ERROR";
8275
- resultBlock.retryPolicy = "after_user_action";
8276
- resultBlock.execution = {
8277
- version: 1,
8278
- dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "failed",
8279
- hostError: {
8280
- phase: "hook",
8281
- code: "HOOK_ERROR"
8282
- }
8283
- };
8284
- }
8285
- resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
8286
- if (resultBlock.isError && resultBlock.retryPolicy === "never") {
8287
- this.neverFailures.add(
8288
- (0, import_node_crypto16.createHash)("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex")
8289
- );
8290
- }
8291
- try {
8292
- await audit("tool.finished", {
8293
- status: resultBlock.isError ? "failed" : "completed",
8294
- ...resultBlock.errorCode ? { errorCode: resultBlock.errorCode } : {},
8295
- durationMs: resultBlock.durationMs
8296
- });
8297
- } catch {
8298
- resultBlock.isError = true;
8299
- resultBlock.errorCode = "OUTCOME_UNKNOWN";
8300
- resultBlock.retryPolicy = "after_user_action";
8301
- resultBlock.content = JSON.stringify({
8302
- code: "OUTCOME_UNKNOWN",
8303
- retryPolicy: "after_user_action",
8304
- message: "Tool outcome is unknown because the audit result could not be written"
8305
- });
8306
- resultBlock.execution = {
8307
- version: 1,
8308
- dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "succeeded",
8309
- hostError: {
8310
- phase: "audit",
8311
- code: "AUDIT_UNAVAILABLE"
8312
- }
8313
- };
8314
- return resultBlock;
8315
- }
8316
- await this.onToolResult?.(tool, finalInput, resultBlock);
8317
- return resultBlock;
8318
- }
8319
- /**
8320
- * 给工具执行加超时护栏:Promise.race 在超时后reject,循环拿到 isError 结果继续运转,
8321
- * 不会因某个异步工具(如未来接入的网络/exec 工具)挂起而永久卡死。
8322
- *
8323
- * 局限:同步 CPU 密集型实现会阻塞事件循环,timer 同样无法触发;
8324
- * 因此内置工具仍需避免执行用户可控的同步高复杂度计算。
8325
- */
8326
- withTimeout(promise, toolName, executionController) {
8327
- let timer;
8328
- const timeout = new Promise((_, reject) => {
8329
- timer = setTimeout(() => {
8330
- const error = new KapibalaError(
8331
- `Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`,
8332
- {
8333
- code: "OUTCOME_UNKNOWN",
8334
- retryPolicy: "after_user_action",
8335
- safeMessage: "Tool timed out; completion of side effects is unknown"
8336
- }
8337
- );
8338
- reject(error);
8339
- executionController.abort(error);
8340
- }, 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 }
8341
8626
  });
8342
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
8343
8627
  }
8344
8628
  /**
8345
8629
  * 构建当前会话在派生时刻的父权限策略快照。
@@ -8938,7 +9222,7 @@ var OpenAICompatibleProvider = class {
8938
9222
  };
8939
9223
 
8940
9224
  // src/models/anthropic/index.ts
8941
- var import_node_crypto17 = require("crypto");
9225
+ var import_node_crypto18 = require("crypto");
8942
9226
  function invalidResponse2() {
8943
9227
  throw new ModelError("Invalid Anthropic response state", {
8944
9228
  code: "MODEL_INVALID_RESPONSE",
@@ -8953,7 +9237,7 @@ function wireToolId(id) {
8953
9237
  stage: "request",
8954
9238
  retryable: false
8955
9239
  });
8956
- 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)}`;
8957
9241
  }
8958
9242
  var AnthropicProvider = class {
8959
9243
  name = "anthropic";
@@ -9319,7 +9603,7 @@ var AnthropicProvider = class {
9319
9603
  };
9320
9604
 
9321
9605
  // src/models/openai-responses/index.ts
9322
- var import_node_crypto18 = require("crypto");
9606
+ var import_node_crypto19 = require("crypto");
9323
9607
  function invalid(message = "Invalid Responses output Item") {
9324
9608
  throw new ModelError(message, {
9325
9609
  code: "MODEL_INVALID_RESPONSE",
@@ -9340,7 +9624,7 @@ function wireCallId(id) {
9340
9624
  stage: "request",
9341
9625
  retryable: false
9342
9626
  });
9343
- 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)}`;
9344
9628
  }
9345
9629
  function validateItem(item, final) {
9346
9630
  if (!item || typeof item !== "object" || !nonempty(item.id)) invalid();
@@ -10037,13 +10321,99 @@ Follow these key behavioral rules:
10037
10321
  }
10038
10322
  return lines.join("\n");
10039
10323
  }
10040
- getL3Environment() {
10041
- const platform = import_node_os6.default.platform();
10042
- return `# Environment Context:
10043
- - Current Working Directory (Workspace Root): ${this.rootDir}
10044
- - 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);
10045
10415
  }
10046
- };
10416
+ }
10047
10417
 
10048
10418
  // src/runtime/loop/index.ts
10049
10419
  var AgentLoop = class {
@@ -10127,26 +10497,7 @@ var AgentLoop = class {
10127
10497
  request = modified;
10128
10498
  }
10129
10499
  }
10130
- if (request.tools) {
10131
- for (const t of request.tools) {
10132
- const original = toolSnapshot.get(t.name);
10133
- if (!original) {
10134
- throw new ModelError(`Hook injected tool '${t.name}' not present in snapshot`, {
10135
- stage: "request",
10136
- retryable: false
10137
- });
10138
- }
10139
- if (t.description !== original.description || JSON.stringify(t.parameters) !== JSON.stringify(original.parameters)) {
10140
- throw new ModelError(
10141
- `Hook mutated tool definition for '${t.name}' (description or parameters drifted from snapshot)`,
10142
- {
10143
- stage: "request",
10144
- retryable: false
10145
- }
10146
- );
10147
- }
10148
- }
10149
- }
10500
+ assertHookToolDefinitionsIntact(request, toolSnapshot);
10150
10501
  if (this.prepareRequest) request = yield* this.prepareRequest(request, history);
10151
10502
  yield {
10152
10503
  type: "step_log",
@@ -10297,22 +10648,7 @@ var AgentLoop = class {
10297
10648
  stage: "stream",
10298
10649
  retryable: false
10299
10650
  });
10300
- const knownCalls = new Set(
10301
- history.flatMap(
10302
- (message) => message.content.filter((block) => block.type === "tool_use").map((block) => block.id)
10303
- )
10304
- );
10305
- for (const call of toolCalls) {
10306
- if (!call.name?.trim() || !call.input || typeof call.input !== "object" || Array.isArray(call.input))
10307
- throw new ModelError("Invalid tool call arguments or name; no tools executed", {
10308
- code: "MODEL_INVALID_RESPONSE",
10309
- stage: "stream",
10310
- retryable: false
10311
- });
10312
- if (!call.id?.trim() || knownCalls.has(call.id))
10313
- throw new ModelError("Duplicate tool call ID; no tools executed");
10314
- knownCalls.add(call.id);
10315
- }
10651
+ assertToolCallsExecutable(history, toolCalls);
10316
10652
  break;
10317
10653
  } catch (error) {
10318
10654
  if (!(error instanceof ContextOverflowError) || outputSeen || accumulatedText || accumulatedThinking || toolCalls.length || overflowRetried || !this.prepareRequest || this.signal?.aborted)
@@ -10425,47 +10761,9 @@ var AgentLoop = class {
10425
10761
  timestamp: Date.now()
10426
10762
  };
10427
10763
  validateProtocolContent(assistantMessage.content);
10428
- const originalContent = stableSerialize(assistantMessage.content);
10429
- const identity = stableSerialize({ ...assistantMessage, content: void 0 });
10430
- const originalTools = stableSerialize(
10431
- assistantMessage.content.filter(
10432
- (block) => block.type === "tool_use" || block.type === "tool_result"
10433
- )
10434
- );
10435
- const privateOutput = assistantMessage.content.some(
10436
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10437
- );
10764
+ const integrity = snapshotAssistantIntegrity(assistantMessage);
10438
10765
  await this.hooks.emit("model:after", hookCtx, { message: assistantMessage });
10439
- if (identity !== stableSerialize({ ...assistantMessage, content: void 0 }))
10440
- throw new ModelError("Hook changed assistant message identity", {
10441
- stage: "request",
10442
- retryable: false
10443
- });
10444
- if (originalTools !== stableSerialize(
10445
- assistantMessage.content.filter(
10446
- (block) => block.type === "tool_use" || block.type === "tool_result"
10447
- )
10448
- ))
10449
- throw new ModelError("Hook changed validated tool model output", {
10450
- stage: "request",
10451
- retryable: false
10452
- });
10453
- if (originalContent !== stableSerialize(assistantMessage.content)) {
10454
- if (privateOutput || assistantMessage.content.some(
10455
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10456
- ))
10457
- throw new ModelError("Hook changed signed or private model output", {
10458
- stage: "request",
10459
- retryable: false
10460
- });
10461
- if (toolCalls.length)
10462
- throw new ModelError("Hook changed validated tool model output", {
10463
- stage: "request",
10464
- retryable: false
10465
- });
10466
- for (const block of assistantMessage.content)
10467
- if (block.type === "text" || block.type === "tool_use") block.protocolMeta = void 0;
10468
- }
10766
+ assertAssistantMessageUntampered(assistantMessage, integrity);
10469
10767
  validateProtocolContent(assistantMessage.content);
10470
10768
  if (toolCalls.length === 0) {
10471
10769
  history.push(assistantMessage);
@@ -11234,7 +11532,6 @@ var AgentRuntime = class {
11234
11532
  task.status = "failed";
11235
11533
  }
11236
11534
  task.error = runError.message;
11237
- await this.cascadeCancelChildren(task.agentId, "Parent task failed");
11238
11535
  } finally {
11239
11536
  this.runnerContexts.delete(task.agentId);
11240
11537
  this.clearTaskTimer(task.agentId);
@@ -11243,6 +11540,9 @@ var AgentRuntime = class {
11243
11540
  task.status = "failed";
11244
11541
  task.error = runError?.message ?? "Unknown execution failure";
11245
11542
  }
11543
+ if (task.status === "failed") {
11544
+ await this.cascadeCancelChildren(task.agentId, task.error ?? "Parent task failed");
11545
+ }
11246
11546
  task.finishedAt = Date.now();
11247
11547
  this.releaseTaskSlotOnce(task);
11248
11548
  this.outbox.enqueueFromTask(task);
@@ -11849,6 +12149,274 @@ function cancellableGenerator(create, external) {
11849
12149
  };
11850
12150
  }
11851
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
+
11852
12420
  // src/context/session/index.ts
11853
12421
  var AgentSession = class {
11854
12422
  skillRegistry;
@@ -11934,40 +12502,26 @@ var AgentSession = class {
11934
12502
  this.projectKey = resolvedWorkspace.projectKey;
11935
12503
  this.sandbox = config.sandbox ?? new PathSandbox({ workspaceRoot: this.workspaceRoot });
11936
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;
11937
12518
  this.tools = new ToolRegistry();
11938
12519
  if (config.skills) {
11939
12520
  this.skillRegistry = new SkillRegistry(config.skills);
11940
12521
  this.skillRuntime = new SkillRuntime(
11941
12522
  this.skillRegistry,
11942
12523
  this.tools,
11943
- async (source, input, page) => {
11944
- try {
11945
- await this.eventLogger.recordAudit({
11946
- level: "info",
11947
- event: "skill.resource_read",
11948
- sessionId: this.sessionId,
11949
- runId: source.runId,
11950
- fields: {
11951
- skillName: source.name,
11952
- contentDigest: source.contentDigest,
11953
- invocationId: source.invocationId,
11954
- pathDigest: skillDigest(input.path),
11955
- resourceDigest: skillDigest(page.text),
11956
- outputBytes: Buffer.byteLength(page.text),
11957
- outputTruncated: page.truncated,
11958
- offset: input.offset,
11959
- startLine: input.startLine,
11960
- endLine: input.endLine
11961
- }
11962
- });
11963
- } catch {
11964
- throw new KapibalaError("Skill resource audit unavailable", {
11965
- code: "AUDIT_UNAVAILABLE",
11966
- retryPolicy: "after_user_action",
11967
- safeMessage: "\u6280\u80FD\u8D44\u6E90\u8BFB\u53D6\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
11968
- });
11969
- }
11970
- }
12524
+ createSkillResourceAuditor(this.eventLogger, this.sessionId)
11971
12525
  );
11972
12526
  }
11973
12527
  this.hooks = new HookRegistry();
@@ -11980,19 +12534,6 @@ var AgentSession = class {
11980
12534
  this.mode = config.mode ?? "Approval";
11981
12535
  this.permissionRules = config.permissionRules ?? [];
11982
12536
  this.approvalChannel = config.approvalChannel;
11983
- this.userInstructionsPath = config.userInstructionsPath;
11984
- const sinks = config.eventLogger ? void 0 : createDefaultLogSinks(config.loggingDirectory);
11985
- const eventLogger = config.eventLogger ?? new StructuredLogger({
11986
- operationSink: sinks.operationSink,
11987
- auditSink: sinks.auditSink,
11988
- debug: config.logger !== void 0,
11989
- onDiagnostic: config.onDiagnostic ?? config.logger
11990
- });
11991
- this.eventLogger = {
11992
- record: (input) => eventLogger.record({ ...input, conversationId: this.persistentId }),
11993
- recordAudit: (input) => eventLogger.recordAudit({ ...input, conversationId: this.persistentId })
11994
- };
11995
- this.auditRecoveryDirectory = config.auditRecoveryDirectory ?? sinks?.auditSink.directory;
11996
12537
  this.rootTaskId = config.rootTaskId;
11997
12538
  this.depth = config.depth ?? 0;
11998
12539
  this.agentId = config.agentId;
@@ -12597,71 +13138,21 @@ var AgentSession = class {
12597
13138
  runId,
12598
13139
  modelId: profile.id
12599
13140
  }) : this.context.project(history);
12600
- for (const message of projected) {
12601
- const original = sourceView.find((source) => source.id === message.id);
12602
- const privateBag = /* @__PURE__ */ new Map();
12603
- const blockBag = /* @__PURE__ */ new Map();
12604
- for (const block of original?.content ?? []) {
12605
- const serialized = stableSerialize(block);
12606
- if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin)
12607
- privateBag.set(serialized, (privateBag.get(serialized) ?? 0) + 1);
12608
- blockBag.set(serialized, (blockBag.get(serialized) ?? 0) + 1);
12609
- }
12610
- const take = (bag, serialized) => {
12611
- const count = bag.get(serialized) ?? 0;
12612
- if (!count) return false;
12613
- if (count === 1) bag.delete(serialized);
12614
- else bag.set(serialized, count - 1);
12615
- return true;
12616
- };
12617
- for (const block of message.content) {
12618
- if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin) {
12619
- if (!original || !take(privateBag, stableSerialize(block)))
12620
- throw new ModelError("Hook changed signed or private request provenance", {
12621
- stage: "request",
12622
- retryable: false
12623
- });
12624
- }
12625
- if (block.type !== "text" && block.type !== "tool_use") continue;
12626
- if (block.protocolMeta && !take(blockBag, stableSerialize(block)))
12627
- block.protocolMeta = void 0;
12628
- }
12629
- }
12630
- const frozen = Boolean(
12631
- previousRequest && history.some(
12632
- (message) => message.interactionId === runId && message.content.some(
12633
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.signature && block.origin?.runId === runId)
12634
- )
12635
- )
12636
- );
13141
+ verifyPrivateBlockIntegrity(projected, sourceView);
12637
13142
  const candidate = {
12638
13143
  ...request,
12639
13144
  messages: projected,
12640
13145
  context: { runId, modelId: profile.id },
12641
13146
  signal: options?.signal
12642
13147
  };
12643
- if (frozen) {
12644
- const generated = nativeBinding ? projectProtocolHistory(
12645
- this.context.project(history).filter(
12646
- (message) => message.interactionId === runId && message.role !== "user"
12647
- ),
12648
- nativeBinding,
12649
- candidate.context
12650
- ) : [];
12651
- for (const original of generated) {
12652
- const matches = candidate.messages.filter((message) => message.id === original.id);
12653
- if (matches.length !== 1 || stableSerialize(matches[0]) !== stableSerialize(original))
12654
- throw new ModelError("Signed continuation prefix or tool chain changed", {
12655
- stage: "request",
12656
- retryable: false
12657
- });
12658
- }
12659
- }
12660
- if (frozen && previousRequest && (stableSerialize(candidate.messages.slice(0, previousRequest.messages.length)) !== stableSerialize(previousRequest.messages) || candidate.systemPrompt !== previousRequest.systemPrompt || stableSerialize(candidate.tools) !== stableSerialize(previousRequest.tools)))
12661
- throw new ModelError("Signed continuation prefix changed", {
12662
- stage: "request",
12663
- retryable: false
12664
- });
13148
+ const frozen = assertSignedContinuationStable({
13149
+ candidate,
13150
+ previousRequest,
13151
+ history,
13152
+ runId,
13153
+ nativeBinding,
13154
+ projectHistory: (projectedHistory) => this.context.project(projectedHistory)
13155
+ });
12665
13156
  const context = this.context;
12666
13157
  const skills = this.skillRuntime;
12667
13158
  const preparation = context.prepare(history, candidate, profile, runId, reason, !frozen);
@@ -12710,6 +13201,7 @@ var AgentSession = class {
12710
13201
  let persistedHistoryLength = this.history.length;
12711
13202
  let loopCompleted = false;
12712
13203
  let loopFailed = false;
13204
+ let consumerInterrupted = false;
12713
13205
  try {
12714
13206
  for await (const event of loop.run(this.history)) {
12715
13207
  await this.logContextEvent(event, runId);
@@ -12784,7 +13276,12 @@ var AgentSession = class {
12784
13276
  if (event.type === "message_stop" || event.type === "tool_messages") {
12785
13277
  persistedHistoryLength = this.history.length;
12786
13278
  }
12787
- yield event;
13279
+ try {
13280
+ yield event;
13281
+ } catch (error) {
13282
+ consumerInterrupted = true;
13283
+ throw error;
13284
+ }
12788
13285
  }
12789
13286
  loopCompleted = true;
12790
13287
  } catch (error) {
@@ -12801,7 +13298,7 @@ var AgentSession = class {
12801
13298
  }
12802
13299
  throw error;
12803
13300
  } finally {
12804
- if (!loopCompleted && !loopFailed) {
13301
+ if (!loopCompleted && (!loopFailed || consumerInterrupted)) {
12805
13302
  await executor.rollback();
12806
13303
  if (this.store) {
12807
13304
  for (const message of this.history.slice(persistedHistoryLength)) {
@@ -12933,102 +13430,44 @@ var AgentSession = class {
12933
13430
  }
12934
13431
  /** 完整消息提交后完成技能审计;审计失败保留真实历史,停止本轮且不发布成功事件。 */
12935
13432
  async commitSkills(runId) {
12936
- if (!this.skillRuntime) return [];
12937
- return this.skillRuntime.commit(
12938
- async (source, messageId) => {
12939
- try {
12940
- await this.eventLogger.recordAudit({
12941
- level: "info",
12942
- event: "skill.loaded",
12943
- sessionId: this.sessionId,
12944
- runId,
12945
- fields: {
12946
- skillName: source.name,
12947
- skillScope: source.scope,
12948
- contentDigest: source.contentDigest,
12949
- renderedDigest: source.renderedDigest,
12950
- pathDigest: skillDigest(source.filePath),
12951
- caller: source.caller,
12952
- invocationId: source.invocationId,
12953
- messageId
12954
- }
12955
- });
12956
- } catch {
12957
- throw new KapibalaError("Skill commit audit unavailable", {
12958
- code: "AUDIT_UNAVAILABLE",
12959
- retryPolicy: "after_user_action",
12960
- safeMessage: "\u6280\u80FD\u63D0\u4EA4\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
12961
- });
12962
- }
12963
- },
12964
- this.store ? "disk" : "memory"
12965
- );
13433
+ return commitSkillEvents({
13434
+ skillRuntime: this.skillRuntime,
13435
+ eventLogger: this.eventLogger,
13436
+ sessionId: this.sessionId,
13437
+ runId,
13438
+ persistence: this.store ? "disk" : "memory"
13439
+ });
12966
13440
  }
12967
13441
  /** 显式加载仍按同一读取策略、精确缓存与宿主审批裁决;拒绝/取消/审计失败时不追加用户任务。 */
12968
- async authorizeSkill(name, runId, signal) {
12969
- const request = {
12970
- toolName: "load_skill",
12971
- capabilities: ["fs:read"],
12972
- input: { name },
13442
+ authorizeSkill(name, runId, signal) {
13443
+ return authorizeExplicitSkill({
13444
+ name,
13445
+ runId,
13446
+ signal,
12973
13447
  rootDir: this.rootDir,
12974
- sessionAllowed: true
12975
- };
12976
- const cached = this.approvalCache.get(request);
12977
- const decision = new PermissionPolicy().decide({
12978
- toolName: request.toolName,
12979
- capabilities: request.capabilities,
12980
13448
  mode: this.mode,
12981
- rules: this.permissionRules,
12982
- cachedApproval: cached === "allow",
12983
- cachedDenial: cached === "deny"
12984
- });
12985
- let permitted = decision.decision === "allow";
12986
- let approvalChoice;
12987
- if (decision.decision === "ask") {
12988
- try {
12989
- approvalChoice = await this.approvalChannel?.requestApproval(request, signal) ?? "deny_once";
12990
- } catch {
12991
- approvalChoice = "cancel";
12992
- }
12993
- permitted = approvalChoice === "allow_once" || approvalChoice === "allow_session";
12994
- if (approvalChoice === "allow_session") this.approvalCache.set(request, "allow");
12995
- if (approvalChoice === "deny_session") this.approvalCache.set(request, "deny");
12996
- }
12997
- await this.eventLogger.recordAudit({
12998
- level: "info",
12999
- event: "skill.decided",
13000
- sessionId: this.sessionId,
13001
- runId,
13002
- fields: {
13003
- skillName: name,
13004
- caller: "user",
13005
- decision: permitted && !signal?.aborted ? "allow" : "deny",
13006
- decisionSource: decision.decision === "ask" ? this.approvalChannel ? "human" : "non_interactive" : decision.source,
13007
- approvalChoice
13008
- }
13449
+ permissionRules: this.permissionRules,
13450
+ approvalChannel: this.approvalChannel,
13451
+ approvalCache: this.approvalCache,
13452
+ eventLogger: this.eventLogger,
13453
+ sessionId: this.sessionId
13009
13454
  });
13010
- if (!permitted) throw skillFailure("SKILL_INVOCATION_FORBIDDEN", "\u5BBF\u4E3B\u6743\u9650\u7981\u6B62\u663E\u5F0F\u6280\u80FD\u8BFB\u53D6");
13011
- signal?.throwIfAborted();
13012
13455
  }
13013
13456
  /**
13014
13457
  * 计算最近一次内部模型请求的上下文占用;无 usage 时显式标注预算估算。
13015
13458
  * run 累计 promptTokens 会重复计算每一步都重发的历史,不能用于窗口占用率。
13016
13459
  */
13017
13460
  createContextUsage(usedTokens, estimatedUsage = false) {
13018
- const contextWindow = resolveContextWindow(
13019
- (this.lastPrimaryProfile ?? this.getActiveProfile()).contextWindow
13020
- );
13021
- return {
13461
+ return computeContextUsage(
13462
+ this.lastPrimaryProfile ?? this.getActiveProfile(),
13022
13463
  usedTokens,
13023
- limitTokens: contextWindow.tokens,
13024
- percent: usedTokens === void 0 ? void 0 : usedTokens / contextWindow.tokens * 100,
13025
- estimatedLimit: contextWindow.estimated,
13026
- ...estimatedUsage ? { estimatedUsage: true } : {}
13027
- };
13464
+ estimatedUsage
13465
+ );
13028
13466
  }
13029
13467
  async persistAssistant(event) {
13030
- if (this.store) await this.store.append(event.message);
13031
- if (!event.usage) this.usageKnown = false;
13468
+ await persistAssistantMessage(event, this.store, () => {
13469
+ this.usageKnown = false;
13470
+ });
13032
13471
  }
13033
13472
  /** 创建上下文协调器并注入当前路由;摘要不经过主任务 Loop,插件结果不默认为可剪裁。 */
13034
13473
  createContextManager() {
@@ -13047,17 +13486,8 @@ var AgentSession = class {
13047
13486
  }
13048
13487
  /** 摘要实际消耗按 attempt 身份独立累加;缺失消耗保留未知,不计入主任务指标。 */
13049
13488
  accountSummaryUsage(attemptId, usage) {
13050
- if (this.summaryAttempts.has(attemptId)) return;
13051
- this.summaryAttempts.add(attemptId);
13052
- if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
13053
- (n) => Number.isSafeInteger(n) && n >= 0
13054
- )) {
13055
- this.summaryUsageKnown = false;
13056
- return;
13057
- }
13058
- this.summaryUsage.promptTokens += usage.promptTokens;
13059
- this.summaryUsage.completionTokens += usage.completionTokens;
13060
- this.summaryUsage.totalTokens += usage.totalTokens;
13489
+ const outcome = recordSummaryUsage(attemptId, usage, this.summaryAttempts, this.summaryUsage);
13490
+ if (outcome === "unknown") this.summaryUsageKnown = false;
13061
13491
  }
13062
13492
  /**
13063
13493
  * 空闲时手动摘要旧完整前缀,不创建用户任务、不执行工具,不突破当前与最近成功交互。
@@ -13130,61 +13560,21 @@ var AgentSession = class {
13130
13560
  }
13131
13561
  /** 以模型 attempt 身份幂等累加真实消耗;非法或缺失 usage 保持未知。 */
13132
13562
  accountUsage(attemptId, usage) {
13133
- if (this.accountedAttempts.has(attemptId)) return;
13134
- if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
13135
- (n) => Number.isSafeInteger(n) && n >= 0
13136
- )) {
13137
- this.usageKnown = false;
13138
- return;
13139
- }
13140
- this.accountedAttempts.add(attemptId);
13141
- this.usageStats.promptTokens += usage.promptTokens;
13142
- this.usageStats.completionTokens += usage.completionTokens;
13143
- this.usageStats.totalTokens += usage.totalTokens;
13563
+ const outcome = recordUsage(attemptId, usage, this.accountedAttempts, this.usageStats);
13564
+ if (outcome === "unknown") this.usageKnown = false;
13144
13565
  }
13145
13566
  async persistToolMessages(messages) {
13146
- if (!this.store) return;
13147
- for (const message of messages) await this.store.append(message);
13567
+ await persistToolMessages(messages, this.store);
13148
13568
  }
13149
13569
  /** 中止时为缓冲 assistant 的每个 tool_use 生成 OUTCOME_UNKNOWN 结果并入历史,保持事务闭合。 */
13150
13570
  closeInterruptedToolCalls(assistantEvent, error) {
13151
- const message = {
13152
- id: createMessageId(),
13153
- role: "tool",
13154
- content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
13155
- type: "tool_result",
13156
- toolUseId: toolUse.id,
13157
- content: JSON.stringify({
13158
- code: "OUTCOME_UNKNOWN",
13159
- retryPolicy: "after_user_action",
13160
- message: "Tool execution was interrupted; outcome is unknown"
13161
- }),
13162
- isError: true,
13163
- errorCode: "OUTCOME_UNKNOWN",
13164
- retryPolicy: "after_user_action"
13165
- })),
13166
- timestamp: Date.now()
13167
- };
13571
+ const message = buildInterruptedToolMessage(assistantEvent);
13168
13572
  this.history.push(message);
13169
13573
  return [message];
13170
13574
  }
13171
13575
  /** 收集历史中紧跟该 assistant 的连续 tool 消息;仅当覆盖全部 tool_use 时视为已闭合。 */
13172
13576
  findClosedToolMessages(assistantEvent) {
13173
- const assistantIndex = this.history.lastIndexOf(assistantEvent.message);
13174
- if (assistantIndex < 0) return [];
13175
- const expected = new Set(
13176
- assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => toolUse.id)
13177
- );
13178
- const messages = [];
13179
- for (let index2 = assistantIndex + 1; index2 < this.history.length; index2++) {
13180
- const message = this.history[index2];
13181
- if (message.role !== "tool") break;
13182
- messages.push(message);
13183
- for (const block of message.content) {
13184
- if (block.type === "tool_result") expected.delete(block.toolUseId);
13185
- }
13186
- }
13187
- return expected.size === 0 ? messages : [];
13577
+ return findClosedToolMessages(this.history, assistantEvent);
13188
13578
  }
13189
13579
  };
13190
13580
  // Annotate the CommonJS export names for ESM import in node: