@kiturone/kapibala 0.0.10 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -532,15 +532,50 @@ import path from "path";
532
532
 
533
533
  // src/context/jsonl-lines.ts
534
534
  import fs from "fs";
535
- function* readJsonlLines(file) {
535
+ function assertHistoryReadBudget(bytes, options) {
536
+ const limit = options.maxReadBytes;
537
+ if (limit === void 0) return;
538
+ if (!Number.isSafeInteger(limit) || limit <= 0)
539
+ throw new KapibalaError("Invalid history read budget", { code: "CONFIG_INVALID" });
540
+ if (bytes > limit)
541
+ throw new KapibalaError("History exceeds the configured read budget", {
542
+ code: "HISTORY_READ_LIMIT",
543
+ retryPolicy: "after_user_action",
544
+ safeMessage: "\u5386\u53F2\u6587\u4EF6\u8D85\u8FC7\u8BFB\u53D6\u9884\u7B97\uFF0C\u539F\u6587\u4EF6\u4FDD\u6301\u4E0D\u53D8"
545
+ });
546
+ }
547
+ function readHistoryBytes(file, options = {}) {
548
+ const fd = fs.openSync(file, "r");
549
+ try {
550
+ assertHistoryReadBudget(fs.fstatSync(fd).size, options);
551
+ const chunks = [];
552
+ const buffer = Buffer.allocUnsafe(64 * 1024);
553
+ let total = 0;
554
+ while (true) {
555
+ const length = fs.readSync(fd, buffer, 0, buffer.length, null);
556
+ if (!length) break;
557
+ total += length;
558
+ assertHistoryReadBudget(total, options);
559
+ chunks.push(Buffer.from(buffer.subarray(0, length)));
560
+ }
561
+ return Buffer.concat(chunks, total);
562
+ } finally {
563
+ fs.closeSync(fd);
564
+ }
565
+ }
566
+ function* readJsonlLines(file, options = {}) {
536
567
  const fd = fs.openSync(file, "r");
537
568
  const buffer = Buffer.allocUnsafe(64 * 1024);
538
569
  let pending = Buffer.alloc(0);
539
570
  let offset = 0;
571
+ let total = 0;
540
572
  try {
573
+ assertHistoryReadBudget(fs.fstatSync(fd).size, options);
541
574
  while (true) {
542
575
  const length = fs.readSync(fd, buffer, 0, buffer.length, null);
543
576
  if (!length) break;
577
+ total += length;
578
+ assertHistoryReadBudget(total, options);
544
579
  pending = Buffer.concat([pending, buffer.subarray(0, length)]);
545
580
  let start = 0;
546
581
  let end = pending.indexOf(10, start);
@@ -574,7 +609,7 @@ var SESSION_SCHEMA_VERSION_V2 = 2;
574
609
  var SESSION_SCHEMA_VERSION = SESSION_SCHEMA_VERSION_V2;
575
610
  var MAIN_BRANCH_ID = "main";
576
611
  function isGlobalControlRecordType(type) {
577
- return type === "header" || type === "branch_created" || type === "branch_selected" || type === "title" || type === "archive_state" || type === "usage" || type === "legacy_import";
612
+ return type === "header" || type === "branch_created" || type === "branch_selected" || type === "title" || type === "archive_state" || type === "pin_state" || type === "usage" || type === "legacy_import";
578
613
  }
579
614
  var types = /* @__PURE__ */ new Set([
580
615
  "header",
@@ -593,7 +628,8 @@ var types = /* @__PURE__ */ new Set([
593
628
  "branch_created",
594
629
  "branch_selected",
595
630
  "branch_summary",
596
- "archive_state"
631
+ "archive_state",
632
+ "pin_state"
597
633
  ]);
598
634
  function supportsSessionState(store) {
599
635
  return "appendRecord" in store && typeof store.appendRecord === "function" && "loadState" in store && typeof store.loadState === "function";
@@ -676,6 +712,13 @@ function assertClosedToolTransaction(records, targetRecordId) {
676
712
  }
677
713
  }
678
714
  var SessionStore = class _SessionStore {
715
+ constructor(filePath, schemaVersion = SESSION_SCHEMA_VERSION_V2, readOptions = {}) {
716
+ this.readOptions = readOptions;
717
+ assertHistoryReadBudget(0, readOptions);
718
+ this.filePath = path.resolve(filePath);
719
+ this.schemaVersion = schemaVersion;
720
+ }
721
+ readOptions;
679
722
  filePath;
680
723
  queue = Promise.resolve();
681
724
  records = [];
@@ -685,10 +728,6 @@ var SessionStore = class _SessionStore {
685
728
  schemaVersion = SESSION_SCHEMA_VERSION_V2;
686
729
  activeBranchId = "main";
687
730
  branchLeaves = /* @__PURE__ */ new Map();
688
- constructor(filePath, schemaVersion = SESSION_SCHEMA_VERSION_V2) {
689
- this.filePath = path.resolve(filePath);
690
- this.schemaVersion = schemaVersion;
691
- }
692
731
  /** 获取当前会话文件使用的 Schema 版本(1 或 2)。 */
693
732
  getSchemaVersion() {
694
733
  return this.schemaVersion;
@@ -711,8 +750,8 @@ var SessionStore = class _SessionStore {
711
750
  this.onDiagnostic = onDiagnostic;
712
751
  }
713
752
  /** 以独占创建方式安装新头部(默认生成 Schema v2 格式),已有文件明确失败,绝不覆盖其它会话。 */
714
- static async create(filePath, header) {
715
- const store = new _SessionStore(filePath, SESSION_SCHEMA_VERSION_V2);
753
+ static async create(filePath, header, options = {}) {
754
+ const store = new _SessionStore(filePath, SESSION_SCHEMA_VERSION_V2, { ...options });
716
755
  fs2.mkdirSync(path.dirname(store.filePath), { recursive: true });
717
756
  const record = store.makeRecord("header", {
718
757
  ...header,
@@ -733,8 +772,8 @@ var SessionStore = class _SessionStore {
733
772
  return store;
734
773
  }
735
774
  /** 打开并验证已有会话;尾部残片先保存到隔离文件,中部损坏与未知版本拒绝恢复。 */
736
- static async open(filePath) {
737
- const store = new _SessionStore(filePath);
775
+ static async open(filePath, options = {}) {
776
+ const store = new _SessionStore(filePath, SESSION_SCHEMA_VERSION_V2, { ...options });
738
777
  store.records = store.readFile(true);
739
778
  store.refreshStamp();
740
779
  return store;
@@ -828,6 +867,24 @@ var SessionStore = class _SessionStore {
828
867
  }
829
868
  return false;
830
869
  }
870
+ /**
871
+ * 追加会话置顶控制记录。
872
+ *
873
+ * 约束:
874
+ * - 置顶状态记录为全局控制记录(parentId 为 null,不推进分支上下文叶子);
875
+ * - payload 结构为 { pinned: boolean },最后一次写入生效;
876
+ * - 置顶只影响列表排序,不改变正文活动时间,因此不会让会话挤进「最近」;
877
+ * - v1 历史会话禁止变更置顶状态。
878
+ */
879
+ async setPinnedState(pinned) {
880
+ if (this.isReadOnly()) {
881
+ throw new KapibalaError("Cannot modify pin state of legacy v1 session", {
882
+ code: "LEGACY_SESSION_READ_ONLY",
883
+ safeMessage: "\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684\u7F6E\u9876\u72B6\u6001"
884
+ });
885
+ }
886
+ return this.appendRecord("pin_state", { pinned });
887
+ }
831
888
  /**
832
889
  * 获取当前会话中所有已知分支的元数据与状态列表。
833
890
  */
@@ -1119,14 +1176,15 @@ ${summaryText}` }]
1119
1176
  }
1120
1177
  /** 校验每个完整记录,只有最后非空行的 JSON 残片可隔离,语义损坏绝不作为残片吞掉。 */
1121
1178
  readFile(recover) {
1122
- const iterator = readJsonlLines(this.filePath);
1179
+ const iterator = readJsonlLines(this.filePath, this.readOptions);
1123
1180
  let lastTerminated = true;
1124
1181
  let blankSeen = false;
1125
1182
  const records = [];
1126
1183
  const identities = /* @__PURE__ */ new Set();
1127
1184
  const messages = /* @__PURE__ */ new Set();
1128
- this.branchLeaves.clear();
1129
- this.activeBranchId = MAIN_BRANCH_ID;
1185
+ const branchLeaves = /* @__PURE__ */ new Map();
1186
+ let activeBranchId = MAIN_BRANCH_ID;
1187
+ let schemaVersion = this.schemaVersion;
1130
1188
  let detectedSchemaVersion = null;
1131
1189
  let i = -1;
1132
1190
  for (const line of iterator) {
@@ -1141,7 +1199,7 @@ ${summaryText}` }]
1141
1199
  try {
1142
1200
  record = JSON.parse(line.text);
1143
1201
  } catch {
1144
- if (!recover || this.schemaVersion === SESSION_SCHEMA_VERSION_V1 || !records.length || Array.from(iterator).some((next) => next.text.trim()))
1202
+ if (!recover || schemaVersion === SESSION_SCHEMA_VERSION_V1 || !records.length || Array.from(iterator).some((next) => next.text.trim()))
1145
1203
  throw new Error(`Session corrupt at line ${i + 1}`);
1146
1204
  fs2.writeFileSync(
1147
1205
  `${this.filePath}.corrupt-${createMessageId()}`,
@@ -1165,7 +1223,7 @@ ${summaryText}` }]
1165
1223
  throw new Error("Unsupported session schema version");
1166
1224
  }
1167
1225
  detectedSchemaVersion = record.schemaVersion;
1168
- this.schemaVersion = detectedSchemaVersion;
1226
+ schemaVersion = detectedSchemaVersion;
1169
1227
  } else if (record.schemaVersion !== detectedSchemaVersion) {
1170
1228
  throw new Error(`Session corrupt schema version mismatch at line ${i + 1}`);
1171
1229
  }
@@ -1182,11 +1240,11 @@ ${summaryText}` }]
1182
1240
  } else if (record.type === "header") {
1183
1241
  throw new Error("Session corrupt: repeated header");
1184
1242
  }
1185
- if (this.schemaVersion === SESSION_SCHEMA_VERSION_V1) {
1243
+ if (schemaVersion === SESSION_SCHEMA_VERSION_V1) {
1186
1244
  if (record.parentId !== (records.at(-1)?.recordId ?? null)) {
1187
1245
  throw new Error(`Session corrupt parent at line ${i + 1}`);
1188
1246
  }
1189
- this.branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1247
+ branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1190
1248
  } else {
1191
1249
  if (typeof record.seq !== "number" || record.seq !== records.length) {
1192
1250
  throw new Error(`Session corrupt seq at line ${i + 1}`);
@@ -1200,37 +1258,37 @@ ${summaryText}` }]
1200
1258
  throw new Error(`Session corrupt control record parent at line ${i + 1}`);
1201
1259
  }
1202
1260
  if (record.type === "header") {
1203
- this.branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1204
- this.activeBranchId = MAIN_BRANCH_ID;
1261
+ branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1262
+ activeBranchId = MAIN_BRANCH_ID;
1205
1263
  } else if (record.type === "branch_created") {
1206
1264
  const bId = record.payload.branchId;
1207
1265
  const baseId = record.payload.baseRecordId;
1208
1266
  if (typeof bId !== "string" || !bId) {
1209
1267
  throw new Error(`Session corrupt branch_created branchId at line ${i + 1}`);
1210
1268
  }
1211
- if (this.branchLeaves.has(bId)) {
1269
+ if (branchLeaves.has(bId)) {
1212
1270
  throw new Error(`Duplicate branch ID: ${bId}`);
1213
1271
  }
1214
1272
  if (typeof baseId !== "string" || !identities.has(baseId)) {
1215
1273
  throw new Error(`Session corrupt branch_created baseRecordId at line ${i + 1}`);
1216
1274
  }
1217
- this.branchLeaves.set(bId, baseId);
1275
+ branchLeaves.set(bId, baseId);
1218
1276
  } else if (record.type === "branch_selected") {
1219
1277
  const bId = record.payload.branchId;
1220
- if (typeof bId !== "string" || !this.branchLeaves.has(bId)) {
1278
+ if (typeof bId !== "string" || !branchLeaves.has(bId)) {
1221
1279
  throw new Error(`Selected branch not found: ${bId}`);
1222
1280
  }
1223
- this.activeBranchId = bId;
1281
+ activeBranchId = bId;
1224
1282
  }
1225
1283
  } else {
1226
1284
  const bId = record.branchId;
1227
- if (typeof bId !== "string" || !this.branchLeaves.has(bId)) {
1285
+ if (typeof bId !== "string" || !branchLeaves.has(bId)) {
1228
1286
  throw new Error(`Session corrupt branchId at line ${i + 1}`);
1229
1287
  }
1230
1288
  if (typeof record.parentId !== "string" || !identities.has(record.parentId)) {
1231
1289
  throw new Error(`Session corrupt parent at line ${i + 1}`);
1232
1290
  }
1233
- this.branchLeaves.set(bId, record.recordId);
1291
+ branchLeaves.set(bId, record.recordId);
1234
1292
  }
1235
1293
  }
1236
1294
  identities.add(record.recordId);
@@ -1249,7 +1307,7 @@ ${summaryText}` }]
1249
1307
  if (!records.length) {
1250
1308
  throw new Error("Session corrupt header");
1251
1309
  }
1252
- if (recover && !lastTerminated && records.length === i + 1 && this.schemaVersion !== SESSION_SCHEMA_VERSION_V1) {
1310
+ if (recover && !lastTerminated && records.length === i + 1 && schemaVersion !== SESSION_SCHEMA_VERSION_V1) {
1253
1311
  const fd = fs2.openSync(this.filePath, "a");
1254
1312
  try {
1255
1313
  fs2.writeFileSync(fd, "\n");
@@ -1258,6 +1316,9 @@ ${summaryText}` }]
1258
1316
  fs2.closeSync(fd);
1259
1317
  }
1260
1318
  }
1319
+ this.branchLeaves = branchLeaves;
1320
+ this.activeBranchId = activeBranchId;
1321
+ this.schemaVersion = schemaVersion;
1261
1322
  return records;
1262
1323
  }
1263
1324
  };
@@ -1545,18 +1606,21 @@ function resolveWorkspaceContext(input) {
1545
1606
  function normalizedProjectRoot(root) {
1546
1607
  return process.platform === "win32" ? root.replaceAll("\\", "/").toLowerCase() : root;
1547
1608
  }
1548
- function resolveSessionProject(cwd) {
1609
+ function resolveSessionProject(cwd, mode = "git") {
1549
1610
  const realCwd = toNativePhysicalPath(cwd);
1550
1611
  let root = realCwd;
1551
1612
  let gitBranch;
1552
1613
  let gitWorktree = false;
1553
1614
  try {
1554
- root = toNativePhysicalPath(
1615
+ const gitRoot = toNativePhysicalPath(
1555
1616
  execFileSync("git", ["-C", realCwd, "rev-parse", "--show-toplevel"], {
1556
1617
  encoding: "utf8",
1557
1618
  stdio: ["ignore", "pipe", "ignore"]
1558
1619
  }).trim()
1559
1620
  );
1621
+ if (mode === "git") root = gitRoot;
1622
+ if (normalizedProjectRoot(gitRoot) !== normalizedProjectRoot(root))
1623
+ return { projectRoot: root, cwd: realCwd, projectKey: computeProjectKey(root) };
1560
1624
  gitWorktree = true;
1561
1625
  gitBranch = execFileSync("git", ["-C", realCwd, "symbolic-ref", "--short", "-q", "HEAD"], {
1562
1626
  encoding: "utf8",
@@ -1564,11 +1628,10 @@ function resolveSessionProject(cwd) {
1564
1628
  }).trim() || void 0;
1565
1629
  } catch {
1566
1630
  }
1567
- const normalized = normalizedProjectRoot(root);
1568
1631
  return {
1569
1632
  projectRoot: root,
1570
1633
  cwd: realCwd,
1571
- projectKey: createHash4("sha256").update(normalized).digest("hex"),
1634
+ projectKey: computeProjectKey(root),
1572
1635
  gitBranch,
1573
1636
  gitWorktree
1574
1637
  };
@@ -1586,7 +1649,10 @@ var SessionManager = class {
1586
1649
  directory;
1587
1650
  handles = /* @__PURE__ */ new Map();
1588
1651
  diagnostic;
1652
+ readOptions;
1589
1653
  constructor(options = {}) {
1654
+ this.readOptions = { maxReadBytes: options.maxReadBytes };
1655
+ assertHistoryReadBudget(0, this.readOptions);
1590
1656
  const rootCandidate = options.workspaceRoot ?? options.projectRoot ?? options.cwd;
1591
1657
  if (!rootCandidate) {
1592
1658
  throw new KapibalaError("Workspace root is required for SessionManager", {
@@ -1594,7 +1660,7 @@ var SessionManager = class {
1594
1660
  safeMessage: "\u5FC5\u987B\u663E\u5F0F\u6307\u5B9A\u5DE5\u4F5C\u533A\u6839\u8DEF\u5F84\u6216\u9879\u76EE\u76EE\u5F55"
1595
1661
  });
1596
1662
  }
1597
- this.project = resolveSessionProject(rootCandidate);
1663
+ this.project = resolveSessionProject(rootCandidate, options.projectRootMode);
1598
1664
  this.directory = path4.join(
1599
1665
  options.homeDirectory ?? os.homedir(),
1600
1666
  ".kapibala",
@@ -1610,12 +1676,16 @@ var SessionManager = class {
1610
1676
  const file = path4.join(this.directory, `${Date.now()}_${conversationId}.jsonl`);
1611
1677
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1612
1678
  try {
1613
- const store = await SessionStore.create(file, {
1614
- conversationId,
1615
- projectRoot: this.project.projectRoot,
1616
- initialCwd: this.project.cwd,
1617
- gitBranch: this.project.gitBranch
1618
- });
1679
+ const store = await SessionStore.create(
1680
+ file,
1681
+ {
1682
+ conversationId,
1683
+ projectRoot: this.project.projectRoot,
1684
+ initialCwd: this.project.cwd,
1685
+ gitBranch: this.project.gitBranch
1686
+ },
1687
+ this.readOptions
1688
+ );
1619
1689
  return await this.attach(store, lock);
1620
1690
  } catch (error) {
1621
1691
  await lock.release();
@@ -1624,19 +1694,25 @@ var SessionManager = class {
1624
1694
  }
1625
1695
  /**
1626
1696
  * 按正文活动排序分页;有效 sidecar 只读元数据,不读取完整 JSONL。默认排除已归档会话。
1627
- * pageSize 由调用方显式给出时必须在 1–100 之间;非法页参数直接失败,不静默修正。
1697
+ * pageSize 由调用方显式给出时必须在 1–100 之间;filter 在分页前筛选元数据,调用方须自行处理敏感信息。
1698
+ * 置顶项整体前置、组内仍按活动时间排序;排序只作用于本方法的返回值,不影响 continueRecent。
1628
1699
  */
1629
1700
  async list(page = 1, options) {
1630
1701
  const pageSize = options?.pageSize ?? 20;
1631
1702
  if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
1632
1703
  throw createSessionError("HISTORY_PAGE_INVALID", "History page must be a positive integer");
1633
1704
  const all = await this.allMetadata();
1634
- const filtered = options?.includeArchived ? all : all.filter((m) => !m.archived);
1705
+ const visible = options?.includeArchived ? all : all.filter((m) => !m.archived);
1706
+ const filtered = options?.filter ? visible.filter(options.filter) : visible;
1707
+ const ordered = [
1708
+ ...filtered.filter((item) => item.pinned),
1709
+ ...filtered.filter((item) => !item.pinned)
1710
+ ];
1635
1711
  return {
1636
- items: filtered.slice((page - 1) * pageSize, page * pageSize),
1637
- total: filtered.length,
1712
+ items: ordered.slice((page - 1) * pageSize, page * pageSize),
1713
+ total: ordered.length,
1638
1714
  page,
1639
- pages: Math.max(1, Math.ceil(filtered.length / pageSize))
1715
+ pages: Math.max(1, Math.ceil(ordered.length / pageSize))
1640
1716
  };
1641
1717
  }
1642
1718
  /** 解析完整 ID 或唯一前缀对应的会话文件与 ID。 */
@@ -1655,60 +1731,109 @@ var SessionManager = class {
1655
1731
  return matches[0];
1656
1732
  }
1657
1733
  /**
1658
- * 逻辑归档指定会话。
1734
+ * 归档、恢复、重命名与置顶都向正文追加一条全局控制记录,共用同一套前置约束。
1659
1735
  *
1660
1736
  * 约束:
1661
1737
  * - 必须取得会话独占锁;
1662
- * - 若会话当前正处于活动打开状态,拒绝归档并抛出 SESSION_IN_USE;
1663
- * - 向会话正文追加 archive_state { archived: true } 并刷新 sidecar 缓存。
1738
+ * - 若会话当前正处于活动打开状态,拒绝并抛出 SESSION_IN_USE(调用方先关闭句柄);
1739
+ * - v1 只读历史不能变更,明确以 LEGACY_SESSION_READ_ONLY 失败而不是静默忽略;
1740
+ * - 写入后按正文重建 sidecar,状态只以正文记录为准。
1664
1741
  */
1665
- async archiveSession(id) {
1742
+ async appendControlRecord(id, subject, append) {
1666
1743
  const { fileName, conversationId } = this.resolveSession(id);
1667
1744
  if (this.handles.has(conversationId)) {
1668
- throw new KapibalaError("Session is currently active or in use, cannot archive", {
1745
+ throw new KapibalaError("Session is currently active or in use", {
1669
1746
  code: "SESSION_IN_USE",
1670
- safeMessage: "\u4F1A\u8BDD\u6B63\u5728\u4F7F\u7528\u4E2D\uFF0C\u65E0\u6CD5\u5F52\u6863"
1747
+ safeMessage: `\u4F1A\u8BDD\u6B63\u5728\u4F7F\u7528\u4E2D\uFF0C\u65E0\u6CD5${subject}`
1671
1748
  });
1672
1749
  }
1673
1750
  const targetFile = path4.join(this.directory, fileName);
1674
1751
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1675
1752
  try {
1676
- const store = await SessionStore.open(targetFile);
1753
+ const store = await SessionStore.open(targetFile, this.readOptions);
1677
1754
  if (store.isReadOnly()) {
1678
- throw new KapibalaError("Cannot archive legacy v1 session", {
1755
+ throw new KapibalaError("Cannot modify legacy v1 session state", {
1679
1756
  code: "LEGACY_SESSION_READ_ONLY",
1680
- safeMessage: "\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684\u5F52\u6863\u72B6\u6001"
1757
+ safeMessage: `\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684${subject}\u72B6\u6001`
1681
1758
  });
1682
1759
  }
1683
- await store.setArchiveState(true);
1684
- const records = await store.readRecords();
1685
- const meta = this.buildMetadata(targetFile, records);
1686
- meta.archived = true;
1687
- this.writeMetadata(targetFile, meta);
1760
+ await append(store);
1761
+ this.writeMetadata(targetFile, this.buildMetadata(targetFile, await store.readRecords()));
1688
1762
  } finally {
1689
1763
  await lock.release();
1690
1764
  }
1691
1765
  }
1692
1766
  /**
1693
- * 恢复已逻辑归档的会话。
1767
+ * 逻辑归档指定会话:向正文追加 archive_state { archived: true } 并刷新 sidecar 缓存。
1768
+ */
1769
+ async archiveSession(id) {
1770
+ await this.appendControlRecord(id, "\u5F52\u6863", (store) => store.setArchiveState(true));
1771
+ }
1772
+ /**
1773
+ * 恢复已逻辑归档的会话:追加 archive_state { archived: false },正文保持不动。
1694
1774
  */
1695
1775
  async restoreSession(id) {
1776
+ await this.appendControlRecord(id, "\u5F52\u6863", (store) => store.setArchiveState(false));
1777
+ }
1778
+ /**
1779
+ * 单独重命名指定会话,不保留句柄:追加 title 记录后立即释放写锁。
1780
+ *
1781
+ * 与 `rename` 的分工:本方法面向「没有打开该会话」的宿主(桌面侧栏、设置页),
1782
+ * 因此不接受活动句柄;已打开的会话由宿主经自己的 Store 追加,避免同进程锁冲突。
1783
+ */
1784
+ async renameSession(id, title) {
1785
+ const value = title.trim();
1786
+ if (!value) throw new Error("Session title cannot be empty");
1787
+ await this.appendControlRecord(
1788
+ id,
1789
+ "\u91CD\u547D\u540D",
1790
+ (store) => store.appendRecord("title", { title: sanitizeSessionTitle(value) })
1791
+ );
1792
+ }
1793
+ /**
1794
+ * 切换会话置顶:追加 pin_state { pinned } 控制记录,只在列表排序时前置。
1795
+ *
1796
+ * 置顶不是「最近活动」,因此不写 run_started、也不改 lastActivityAt;
1797
+ * 关闭置顶后会话回到按活动时间排序的原有位置。
1798
+ */
1799
+ async pinSession(id, pinned) {
1800
+ await this.appendControlRecord(id, "\u7F6E\u9876", (store) => store.setPinnedState(pinned));
1801
+ }
1802
+ /**
1803
+ * 物理删除指定会话的正文与 sidecar 缓存。
1804
+ *
1805
+ * 约束与边界:
1806
+ * - 删除不可逆,本方法不提供回收站或软删除,调用方必须先取得用户确认;
1807
+ * - 本 Manager 已持有句柄的会话拒绝(SESSION_IN_USE),避免删掉正在追加的文件;
1808
+ * - 其它进程持锁时以 SESSION_LOCKED 失败,不抢占他人写入权,也不做「先等一会儿」的重试;
1809
+ * - 只删该会话自己的 `.jsonl` 与 `.meta.json`;桶目录内其它文件(含 `*.tmp-*` 残片)不动;
1810
+ * - 删除前复核首条头部身份与桶归属:文件名匹配但正文身份不符的残留文件删除会放大错误;
1811
+ * - 缓存先删、正文后删:缓存本可重建,若正文删除失败该会话会在下次浏览时自动重建缓存。
1812
+ */
1813
+ async deleteSession(id) {
1696
1814
  const { fileName, conversationId } = this.resolveSession(id);
1815
+ if (this.handles.has(conversationId)) {
1816
+ throw new KapibalaError("Session is currently active or in use, cannot delete", {
1817
+ code: "SESSION_IN_USE",
1818
+ safeMessage: "\u4F1A\u8BDD\u6B63\u5728\u4F7F\u7528\u4E2D\uFF0C\u65E0\u6CD5\u5220\u9664"
1819
+ });
1820
+ }
1697
1821
  const targetFile = path4.join(this.directory, fileName);
1698
1822
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1699
1823
  try {
1700
- const store = await SessionStore.open(targetFile);
1701
- if (store.isReadOnly()) {
1702
- throw new KapibalaError("Cannot restore legacy v1 session", {
1703
- code: "LEGACY_SESSION_READ_ONLY",
1704
- safeMessage: "\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684\u5F52\u6863\u72B6\u6001"
1705
- });
1824
+ const headerLines = this.readDiscoveryRecords(targetFile);
1825
+ let header;
1826
+ try {
1827
+ header = headerLines.next().value.payload;
1828
+ } finally {
1829
+ headerLines.return(void 0);
1706
1830
  }
1707
- await store.setArchiveState(false);
1708
- const records = await store.readRecords();
1709
- const meta = this.buildMetadata(targetFile, records);
1710
- meta.archived = false;
1711
- this.writeMetadata(targetFile, meta);
1831
+ if (header.conversationId !== conversationId || normalizedProjectRoot(header.projectRoot) !== normalizedProjectRoot(this.project.projectRoot))
1832
+ throw new Error("Foreign session identity");
1833
+ const metadata = this.metadataPath(targetFile);
1834
+ if (fs5.existsSync(metadata)) fs5.unlinkSync(metadata);
1835
+ fs5.unlinkSync(targetFile);
1836
+ syncSessionDirectory(this.directory);
1712
1837
  } finally {
1713
1838
  await lock.release();
1714
1839
  }
@@ -1720,6 +1845,11 @@ var SessionManager = class {
1720
1845
  const file = path4.join(this.directory, fileName);
1721
1846
  try {
1722
1847
  let meta = this.readMetadata(file);
1848
+ if (this.readOptions.maxReadBytes !== void 0 && fs5.statSync(file).size > this.readOptions.maxReadBytes) {
1849
+ meta ??= this.readOversizedMetadata(file);
1850
+ all.push({ ...meta, unavailableReason: "HISTORY_READ_LIMIT" });
1851
+ continue;
1852
+ }
1723
1853
  if (!meta) {
1724
1854
  const records = this.readDiscoveryRecords(file);
1725
1855
  meta = this.buildMetadata(file, records);
@@ -1730,7 +1860,17 @@ var SessionManager = class {
1730
1860
  }
1731
1861
  }
1732
1862
  all.push(meta);
1733
- } catch {
1863
+ } catch (error) {
1864
+ if (error instanceof KapibalaError && error.code === "HISTORY_READ_LIMIT") {
1865
+ try {
1866
+ all.push({
1867
+ ...this.readOversizedMetadata(file),
1868
+ unavailableReason: "HISTORY_READ_LIMIT"
1869
+ });
1870
+ continue;
1871
+ } catch {
1872
+ }
1873
+ }
1734
1874
  this.report(`Cannot discover session: ${fileName}`);
1735
1875
  }
1736
1876
  }
@@ -1780,7 +1920,10 @@ var SessionManager = class {
1780
1920
  }
1781
1921
  if (header.conversationId !== meta.conversationId || normalizedProjectRoot(header.projectRoot) !== normalizedProjectRoot(this.project.projectRoot))
1782
1922
  throw new Error("Foreign session identity");
1783
- const store = await SessionStore.open(path4.join(this.directory, meta.fileName));
1923
+ const store = await SessionStore.open(
1924
+ path4.join(this.directory, meta.fileName),
1925
+ this.readOptions
1926
+ );
1784
1927
  if ((await store.loadState()).header.conversationId !== meta.conversationId)
1785
1928
  throw new Error("Session identity mismatch");
1786
1929
  await store.load();
@@ -1792,7 +1935,9 @@ var SessionManager = class {
1792
1935
  }
1793
1936
  /** 继续最近有正文的会话;没有历史才新建,显式打开失败绝不回退为空会话。 */
1794
1937
  async continueRecent(options) {
1795
- const latest = (await this.allMetadata()).find((m) => m.messageCount > 0);
1938
+ const latest = (await this.allMetadata()).find(
1939
+ (m) => m.messageCount > 0 || m.unavailableReason
1940
+ );
1796
1941
  if (latest)
1797
1942
  return this.open(latest.conversationId, {
1798
1943
  autoUpgradeLegacy: options?.autoUpgradeLegacy ?? true
@@ -1800,11 +1945,21 @@ var SessionManager = class {
1800
1945
  this.report("No previous session with content; created a new session");
1801
1946
  return this.create();
1802
1947
  }
1803
- /** 标题作为独立耐久记录;标题或浏览不改变正文活动排序。 */
1948
+ /**
1949
+ * 重命名已打开或待打开的会话;标题作为独立耐久记录,标题或浏览都不改变正文活动排序。
1950
+ *
1951
+ * 未持有句柄时临时打开并在追加后释放:只改标题不该把会话一直挂在写锁上,
1952
+ * 否则同进程后续的归档、删除与置顶都会被自己占住的锁挡掉。
1953
+ */
1804
1954
  async rename(id, title) {
1805
1955
  if (!title.trim()) throw new Error("Session title cannot be empty");
1956
+ const alreadyOpen = this.handles.has(this.resolveSession(id).conversationId);
1806
1957
  const handle = await this.open(id, { autoUpgradeLegacy: true });
1807
- await handle.store.appendRecord("title", { title: sanitizeSessionTitle(title) });
1958
+ try {
1959
+ await handle.store.appendRecord("title", { title: sanitizeSessionTitle(title) });
1960
+ } finally {
1961
+ if (!alreadyOpen) await handle.release();
1962
+ }
1808
1963
  }
1809
1964
  /** 等候所有存储队列后释放句柄;宿主必须先结束 AgentSession 的工具/摘要清理。 */
1810
1965
  async close() {
@@ -1824,7 +1979,7 @@ var SessionManager = class {
1824
1979
  for (const source of sources) {
1825
1980
  if (!fs5.existsSync(source)) continue;
1826
1981
  const before = fs5.statSync(source);
1827
- const bytes = fs5.readFileSync(source);
1982
+ const bytes = readHistoryBytes(source, this.readOptions);
1828
1983
  const identity = createHash4("sha256").update(fs5.realpathSync.native(source)).update("\0").update(bytes).digest("hex");
1829
1984
  const importLock = await SessionLock.acquire(
1830
1985
  path4.join(this.directory, `import-${identity}.lock`)
@@ -1850,19 +2005,23 @@ var SessionManager = class {
1850
2005
  const id = createMessageId();
1851
2006
  const target = path4.join(this.directory, `${Date.now()}_${id}.jsonl`);
1852
2007
  temp = `${target}.tmp-${createMessageId()}`;
1853
- const store = await SessionStore.create(temp, {
1854
- conversationId: id,
1855
- projectRoot: this.project.projectRoot,
1856
- initialCwd: this.project.cwd,
1857
- gitBranch: this.project.gitBranch,
1858
- importIdentity: identity
1859
- });
2008
+ const store = await SessionStore.create(
2009
+ temp,
2010
+ {
2011
+ conversationId: id,
2012
+ projectRoot: this.project.projectRoot,
2013
+ initialCwd: this.project.cwd,
2014
+ gitBranch: this.project.gitBranch,
2015
+ importIdentity: identity
2016
+ },
2017
+ this.readOptions
2018
+ );
1860
2019
  await store.appendRecord("legacy_import", { source, identity });
1861
2020
  await store.appendRecord("title", { title: "\u65E7\u5386\u53F2\u5BFC\u5165" });
1862
2021
  for (const message of identifyHistory(raw, identity)) await store.append(message);
1863
2022
  await store.load();
1864
2023
  const after = fs5.statSync(source);
1865
- if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || !fs5.readFileSync(source).equals(bytes)) {
2024
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || !readHistoryBytes(source, this.readOptions).equals(bytes)) {
1866
2025
  this.report("Legacy history is changing; import postponed");
1867
2026
  continue;
1868
2027
  }
@@ -1925,7 +2084,7 @@ var SessionManager = class {
1925
2084
  }
1926
2085
  const nativeSource = toNativePhysicalPath(sourcePath);
1927
2086
  const beforeStat = fs5.statSync(nativeSource);
1928
- const bytes = fs5.readFileSync(nativeSource);
2087
+ const bytes = readHistoryBytes(nativeSource, this.readOptions);
1929
2088
  const upgradeIdentity = createHash4("sha256").update(nativeSource).update("\0").update(bytes).digest("hex");
1930
2089
  const existingMeta = (await this.allMetadata()).find(
1931
2090
  (m) => m.upgradeIdentity === upgradeIdentity
@@ -1944,7 +2103,7 @@ var SessionManager = class {
1944
2103
  if (doubleCheck) {
1945
2104
  return this.open(doubleCheck.conversationId);
1946
2105
  }
1947
- const sourceStore = await SessionStore.open(nativeSource);
2106
+ const sourceStore = await SessionStore.open(nativeSource, this.readOptions);
1948
2107
  if (sourceStore.getSchemaVersion() !== SESSION_SCHEMA_VERSION_V1) {
1949
2108
  const state = await sourceStore.loadState();
1950
2109
  return this.open(state.header.conversationId);
@@ -2011,7 +2170,7 @@ var SessionManager = class {
2011
2170
  fs5.closeSync(fd);
2012
2171
  }
2013
2172
  const afterStat = fs5.statSync(nativeSource);
2014
- if (beforeStat.size !== afterStat.size || beforeStat.mtimeMs !== afterStat.mtimeMs || !fs5.readFileSync(nativeSource).equals(bytes)) {
2173
+ if (beforeStat.size !== afterStat.size || beforeStat.mtimeMs !== afterStat.mtimeMs || !readHistoryBytes(nativeSource, this.readOptions).equals(bytes)) {
2015
2174
  throw new Error("Source legacy session changed during upgrade; retry aborted");
2016
2175
  }
2017
2176
  fs5.renameSync(tempPath, targetPath);
@@ -2174,7 +2333,7 @@ var SessionManager = class {
2174
2333
  store.setAppendObserver(
2175
2334
  (record) => {
2176
2335
  const previous = JSON.parse(
2177
- fs5.readFileSync(this.metadataPath(store.filePath), "utf8")
2336
+ readHistoryBytes(this.metadataPath(store.filePath), this.readOptions).toString("utf8")
2178
2337
  );
2179
2338
  const currentPrev = record.prevRecordId ?? record.parentId;
2180
2339
  if (previous.conversationId !== header.conversationId || currentPrev !== null && currentPrev !== (previous.logRevision ?? previous.revision))
@@ -2219,10 +2378,28 @@ var SessionManager = class {
2219
2378
  this.handles.set(header.conversationId, handle);
2220
2379
  return handle;
2221
2380
  }
2381
+ /** 超限列表只解析有界头部并核对桶身份,不读取正文、不生成可误用的耐久缓存。 */
2382
+ readOversizedMetadata(file) {
2383
+ const fd = fs5.openSync(file, "r");
2384
+ try {
2385
+ const buffer = Buffer.alloc(Math.min(this.readOptions.maxReadBytes, 64 * 1024));
2386
+ const length = fs5.readSync(fd, buffer, 0, buffer.length, 0);
2387
+ const end = buffer.subarray(0, length).indexOf(10);
2388
+ if (end < 0) throw new Error("Session header exceeds discovery budget");
2389
+ const header = JSON.parse(buffer.subarray(0, end).toString("utf8"));
2390
+ if (header.type !== "header" || ![1, 2].includes(header.schemaVersion))
2391
+ throw new Error("Session corrupt header");
2392
+ return { ...this.buildMetadata(file, [header]), title: "\u5386\u53F2\u8D85\u8FC7\u8BFB\u53D6\u9884\u7B97" };
2393
+ } finally {
2394
+ fs5.closeSync(fd);
2395
+ }
2396
+ }
2222
2397
  /** 缓存通过文件名、身份、统计与正文 stat 校验;失败交由正文重建。 */
2223
2398
  readMetadata(file) {
2224
2399
  try {
2225
- const meta = JSON.parse(fs5.readFileSync(this.metadataPath(file), "utf8"));
2400
+ const meta = JSON.parse(
2401
+ readHistoryBytes(this.metadataPath(file), this.readOptions).toString("utf8")
2402
+ );
2226
2403
  const stat = fs5.statSync(file);
2227
2404
  if ((meta.schemaVersion === 1 || meta.schemaVersion === 2) && meta.fileName === path4.basename(file) && typeof meta.conversationId === "string" && path4.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)
2228
2405
  return meta;
@@ -2232,7 +2409,7 @@ var SessionManager = class {
2232
2409
  }
2233
2410
  /** 浏览仅解析完整记录;严格打开由 SessionStore 验证,浏览不能改写残片。 */
2234
2411
  *readDiscoveryRecords(file) {
2235
- const iterator = readJsonlLines(file);
2412
+ const iterator = readJsonlLines(file, this.readOptions);
2236
2413
  let first = true;
2237
2414
  let blankSeen = false;
2238
2415
  for (const line of iterator) {
@@ -2287,7 +2464,8 @@ var SessionManager = class {
2287
2464
  contentRevision: first.value.recordId,
2288
2465
  activeBranchId: MAIN_BRANCH_ID,
2289
2466
  activeLeafId: first.value.recordId,
2290
- archived: false
2467
+ archived: false,
2468
+ pinned: false
2291
2469
  };
2292
2470
  const branchLeaves = /* @__PURE__ */ new Map();
2293
2471
  branchLeaves.set(MAIN_BRANCH_ID, first.value.recordId);
@@ -2340,6 +2518,8 @@ var SessionManager = class {
2340
2518
  if (bId) meta.activeBranchId = bId;
2341
2519
  } else if (record.type === "archive_state") {
2342
2520
  meta.archived = Boolean(record.payload.archived);
2521
+ } else if (record.type === "pin_state") {
2522
+ meta.pinned = Boolean(record.payload.pinned);
2343
2523
  }
2344
2524
  if (branchLeaves && !isGlobalControlRecordType(record.type)) {
2345
2525
  const bId = record.branchId ?? meta.activeBranchId ?? MAIN_BRANCH_ID;
@@ -2846,6 +3026,7 @@ var CrossProjectSessionCatalog = class {
2846
3026
  let title = "\u65B0\u4F1A\u8BDD";
2847
3027
  let activeBranchId = MAIN_BRANCH_ID;
2848
3028
  let archived = false;
3029
+ let pinned = false;
2849
3030
  let lastRecordId = headerRecord.recordId;
2850
3031
  for (const line of iterator) {
2851
3032
  if (!line.text.trim()) continue;
@@ -2866,6 +3047,8 @@ var CrossProjectSessionCatalog = class {
2866
3047
  title = String(rec.payload.title);
2867
3048
  } else if (rec.type === "archive_state") {
2868
3049
  archived = Boolean(rec.payload.archived);
3050
+ } else if (rec.type === "pin_state") {
3051
+ pinned = Boolean(rec.payload.pinned);
2869
3052
  } else if (rec.type === "branch_selected") {
2870
3053
  activeBranchId = String(rec.payload.branchId);
2871
3054
  }
@@ -2884,6 +3067,7 @@ var CrossProjectSessionCatalog = class {
2884
3067
  title,
2885
3068
  activeBranchId,
2886
3069
  archived,
3070
+ pinned,
2887
3071
  size: stat.size,
2888
3072
  mtimeMs: stat.mtimeMs,
2889
3073
  revision: lastRecordId,
@@ -6021,7 +6205,8 @@ function stopProcessTree(child, nativeGroup) {
6021
6205
  try {
6022
6206
  process.kill(-child.pid, "SIGTERM");
6023
6207
  return true;
6024
- } catch {
6208
+ } catch (error) {
6209
+ if (error.code === "ESRCH") return true;
6025
6210
  try {
6026
6211
  child.kill("SIGTERM");
6027
6212
  } catch {
@@ -6042,7 +6227,8 @@ function forceProcessTree(child, nativeGroup) {
6042
6227
  try {
6043
6228
  process.kill(-child.pid, "SIGKILL");
6044
6229
  return true;
6045
- } catch {
6230
+ } catch (error) {
6231
+ if (error.code === "ESRCH") return true;
6046
6232
  try {
6047
6233
  child.kill("SIGKILL");
6048
6234
  } catch {
@@ -6063,13 +6249,31 @@ async function confirmProcessTreeStopped(child, terminationSucceeded, nativeGrou
6063
6249
  if (process.platform === "win32") return true;
6064
6250
  const deadline = Date.now() + 1e3;
6065
6251
  while (Date.now() < deadline) {
6252
+ let exists;
6066
6253
  try {
6067
6254
  process.kill(-child.pid, 0);
6255
+ exists = true;
6068
6256
  } catch (error) {
6069
- if (error.code === "ESRCH") return true;
6070
- return false;
6257
+ const code = error.code;
6258
+ if (code === "ESRCH") return true;
6259
+ if (code !== "EPERM") return false;
6260
+ exists = true;
6071
6261
  }
6072
- await new Promise((resolve) => setTimeout(resolve, 50));
6262
+ if (groupHasLiveProcess(child.pid)) {
6263
+ await new Promise((resolve) => setTimeout(resolve, 50));
6264
+ continue;
6265
+ }
6266
+ return true;
6267
+ }
6268
+ return false;
6269
+ }
6270
+ function groupHasLiveProcess(pgid) {
6271
+ const result = spawnSync2("ps", ["-axo", "pgid=", "stat="], { timeout: 2e3, encoding: "utf8" });
6272
+ if (result.status !== 0 || result.error) return true;
6273
+ for (const row of result.stdout.split("\n")) {
6274
+ const columns = row.trim().split(/\s+/);
6275
+ if (columns.length < 2 || Number(columns[0]) !== pgid) continue;
6276
+ if (!columns[1].startsWith("Z")) return true;
6073
6277
  }
6074
6278
  return false;
6075
6279
  }
@@ -7437,133 +7641,462 @@ var HookRegistry = class {
7437
7641
  };
7438
7642
 
7439
7643
  // src/runtime/executor/index.ts
7440
- import { createHash as createHash12 } from "crypto";
7441
7644
  import path17 from "path";
7442
- var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
7443
- var ToolExecutor = class {
7444
- hardDenial;
7445
- onToolResult;
7446
- tools;
7447
- hooks;
7448
- workspaceRoot;
7449
- executionCwd;
7450
- sandbox;
7451
- leaseCoordinator;
7452
- rootDir;
7453
- signal;
7454
- logger;
7455
- toolTimeoutMs;
7456
- eventLogger;
7457
- sessionId;
7458
- runId;
7459
- branchId;
7460
- rootTaskId;
7461
- depth;
7462
- agentId;
7463
- committedActions = [];
7464
- rollbackActions = [];
7465
- mode;
7466
- permissionRules;
7467
- approvalChannel;
7468
- approvalCache;
7469
- policy = new PermissionPolicy();
7470
- neverFailures = /* @__PURE__ */ new Set();
7471
- cancellation = new AbortController();
7472
- constructor(options) {
7473
- this.hardDenial = options.hardDenial;
7474
- this.onToolResult = options.onToolResult;
7475
- this.tools = options.tools;
7476
- this.hooks = options.hooks;
7477
- this.workspaceRoot = options.workspaceRoot ?? options.rootDir;
7478
- this.executionCwd = options.executionCwd ?? options.rootDir;
7479
- this.sandbox = options.sandbox;
7480
- this.leaseCoordinator = options.leaseCoordinator;
7481
- this.rootDir = options.rootDir;
7482
- this.signal = options.signal ? AbortSignal.any([options.signal, this.cancellation.signal]) : this.cancellation.signal;
7483
- this.logger = options.logger;
7484
- this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
7485
- this.eventLogger = options.eventLogger;
7486
- this.sessionId = options.sessionId;
7487
- this.runId = options.runId;
7488
- this.branchId = options.branchId;
7489
- this.rootTaskId = options.rootTaskId;
7490
- this.depth = options.depth;
7491
- this.agentId = options.agentId;
7492
- this.mode = options.mode ?? "Approval";
7493
- this.permissionRules = options.permissionRules ?? [];
7494
- this.approvalChannel = options.approvalChannel;
7495
- this.approvalCache = options.approvalCache ?? new SessionApprovalCache();
7496
- }
7497
- /**
7498
- * 父工具事务提交成功后调用,按注册顺序执行所有提交动作。
7499
- * 动作执行成功后逐项清理对应回滚动作,若某项失败保留剩余回滚动作。
7500
- */
7501
- async commit() {
7502
- while (this.committedActions.length > 0) {
7503
- const action = this.committedActions.shift();
7504
- await action();
7505
- this.rollbackActions.shift();
7506
- }
7507
- this.rollbackActions.length = 0;
7508
- }
7509
- /**
7510
- * 父工具事务提交失败或取消时调用,按逆序执行所有回滚清理动作。
7511
- */
7512
- async rollback() {
7513
- const actions = [...this.rollbackActions].reverse();
7514
- this.committedActions.length = 0;
7515
- this.rollbackActions.length = 0;
7516
- for (const action of actions) {
7645
+
7646
+ // src/runtime/executor/approval.ts
7647
+ import { createHash as createHash12 } from "crypto";
7648
+ async function resolveToolApproval(deps) {
7649
+ const {
7650
+ call,
7651
+ finalInput,
7652
+ tool,
7653
+ rootDir,
7654
+ executionCwd,
7655
+ mode,
7656
+ permissionRules,
7657
+ approvalChannel,
7658
+ approvalCache,
7659
+ hardDenial,
7660
+ neverFailures,
7661
+ policy,
7662
+ signal,
7663
+ audit,
7664
+ failure
7665
+ } = deps;
7666
+ let shell = deps.shell;
7667
+ const executionIdentity = deps.executionIdentity;
7668
+ let auditFields = {};
7669
+ for (let attempt = 0; attempt < 2; attempt++) {
7670
+ const request = {
7671
+ toolName: call.name,
7672
+ capabilities: tool.metadata?.permissions ?? [],
7673
+ input: finalInput,
7674
+ rootDir,
7675
+ ...shell ? { shell } : {},
7676
+ ...executionIdentity ? { executionIdentity } : {},
7677
+ // 解释器内建查询与内容已绑定摘要的工作区直引脚本可确认不受外部脚本变更影响,
7678
+ // 允许会话级批准;其余 shell 命令仅允许单次批准。
7679
+ // 非 shell 工具(含深度绑定 executionIdentity 的外部工具)允许会话级批准。
7680
+ sessionAllowed: !shell || /^(?:pwd|get-location)$/i.test(shell.command.trim()) || shell.scriptDigest !== void 0
7681
+ };
7682
+ const repeatKey = createHash12("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex");
7683
+ if (neverFailures.has(repeatKey)) {
7517
7684
  try {
7518
- await action();
7685
+ await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
7686
+ await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
7519
7687
  } catch {
7688
+ return {
7689
+ result: failure(
7690
+ "Audit unavailable; tool was not started",
7691
+ "AUDIT_UNAVAILABLE",
7692
+ "after_user_action",
7693
+ {
7694
+ version: 1,
7695
+ dispatchOutcome: "not_sent",
7696
+ hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7697
+ }
7698
+ )
7699
+ };
7520
7700
  }
7701
+ return {
7702
+ result: failure(
7703
+ "Identical failed tool call cannot be repeated in this run",
7704
+ "REPEAT_BLOCKED"
7705
+ )
7706
+ };
7521
7707
  }
7522
- }
7523
- /** 中止本轮正在等待或执行的工具;调用方仍须等待 runAll 完成资源回收及结果审计。 */
7524
- cancel() {
7525
- this.cancellation.abort();
7526
- }
7527
- /**
7528
- * 串行执行全部调用并返回与入参顺序一致的结果;某个结果要求用户介入后,其余调用直接拒绝。
7529
- * onResult 在每个最终结果(含拒绝/失败)产生后提供独立快照,供在途预算估算,不代表历史已提交;回调不得抛错。
7530
- */
7531
- async runAll(calls, onProgress, onResult) {
7532
- const results = [];
7533
- let requiresUserAction = false;
7534
- for (const call of calls) {
7535
- const result = await this.executeOne(call, onProgress, requiresUserAction);
7536
- results.push(result);
7537
- onResult?.(structuredClone(result));
7538
- if (result.retryPolicy === "after_user_action") requiresUserAction = true;
7539
- }
7540
- return results;
7541
- }
7542
- /**
7543
- * 在最终参数上裁决并审计一次调用,所有拒绝和异常均闭合为工具结果。
7544
- * 为调用记录本次绝对工具根目录,供跨目录恢复后的文件事实解析;不使用历史目录授权。
7545
- */
7546
- async executeOne(call, onProgress, forcedDeny = false) {
7547
- call.executionRoot = path17.resolve(this.workspaceRoot);
7548
- const startedAt = Date.now();
7549
- const operationId = createLogId();
7550
- const failure = (message, errorCode, retryPolicy = "never", execution) => ({
7551
- type: "tool_result",
7552
- toolUseId: call.id,
7553
- content: JSON.stringify({ code: errorCode, retryPolicy, message }),
7554
- isError: true,
7555
- errorCode,
7556
- retryPolicy,
7557
- durationMs: Math.max(0, Date.now() - startedAt),
7558
- execution: execution ?? {
7559
- version: 1,
7560
- dispatchOutcome: "not_sent"
7561
- }
7708
+ const hardDenied = hardDenial?.(tool, finalInput);
7709
+ const cached = approvalCache.get(request);
7710
+ const decision = policy.decide({
7711
+ hardDenied: hardDenied !== void 0,
7712
+ mode: typeof mode === "function" ? mode() : mode,
7713
+ toolName: call.name,
7714
+ capabilities: request.capabilities,
7715
+ dangerous: tool.metadata?.dangerous,
7716
+ cachedApproval: cached === "allow",
7717
+ cachedDenial: cached === "deny",
7718
+ rules: permissionRules,
7719
+ ...shell ? { shell } : {}
7562
7720
  });
7563
- const audit = async (event, fields = {}) => {
7564
- if (!this.eventLogger || !this.sessionId) return;
7565
- await this.eventLogger.recordAudit({
7566
- level: "info",
7721
+ let permitted = decision.decision === "allow";
7722
+ let source = decision.source;
7723
+ let approvalChoice;
7724
+ if (decision.decision === "ask") {
7725
+ let choice = "deny_once";
7726
+ try {
7727
+ choice = await approvalChannel?.requestApproval(request, signal) ?? "deny_once";
7728
+ } catch {
7729
+ choice = "cancel";
7730
+ }
7731
+ approvalChoice = choice;
7732
+ if (choice === "allow_session" && request.sessionAllowed) approvalCache.set(request, "allow");
7733
+ if (choice === "deny_session") approvalCache.set(request, "deny");
7734
+ permitted = choice === "allow_once" || choice === "allow_session" && request.sessionAllowed;
7735
+ source = approvalChannel ? "human" : "non_interactive";
7736
+ }
7737
+ auditFields = shell ? {
7738
+ shell: shell.interpreter,
7739
+ cwdDigest: createHash12("sha256").update(shell.cwd).digest("hex"),
7740
+ executableDigest: shell.executableDigest,
7741
+ executionFingerprint: shell.executionFingerprint,
7742
+ ...commandAuditSummary(shell.command)
7743
+ } : executionIdentity ? {
7744
+ serverIdentity: executionIdentity.serverIdentity,
7745
+ connectionGeneration: executionIdentity.connectionGeneration,
7746
+ catalogEpoch: executionIdentity.catalogEpoch,
7747
+ toolDigest: executionIdentity.toolDigest
7748
+ } : {};
7749
+ try {
7750
+ await audit("tool.decided", {
7751
+ decision: permitted ? "allow" : "deny",
7752
+ decisionSource: source,
7753
+ ...approvalChoice ? { approvalChoice } : {},
7754
+ ...hardDenied?.fields ?? {},
7755
+ ...auditFields
7756
+ });
7757
+ } catch {
7758
+ return {
7759
+ result: failure(
7760
+ "Audit unavailable; tool was not started",
7761
+ "AUDIT_UNAVAILABLE",
7762
+ "after_user_action",
7763
+ {
7764
+ version: 1,
7765
+ dispatchOutcome: "not_sent",
7766
+ hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7767
+ }
7768
+ )
7769
+ };
7770
+ }
7771
+ if (!permitted || signal?.aborted) {
7772
+ const code = signal?.aborted ? "ABORTED" : hardDenied?.code ?? "PERMISSION_DENIED";
7773
+ try {
7774
+ await audit("tool.finished", { status: "denied", errorCode: code });
7775
+ } catch {
7776
+ return {
7777
+ result: failure("Audit result unavailable", "AUDIT_UNAVAILABLE", "after_user_action", {
7778
+ version: 1,
7779
+ dispatchOutcome: "not_sent",
7780
+ hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7781
+ })
7782
+ };
7783
+ }
7784
+ return {
7785
+ result: failure(
7786
+ signal?.aborted ? "Operation cancelled" : hardDenied?.message ?? "Tool permission denied",
7787
+ code,
7788
+ signal?.aborted ? "after_user_action" : hardDenied?.retryPolicy ?? "after_user_action"
7789
+ )
7790
+ };
7791
+ }
7792
+ if (shell) {
7793
+ try {
7794
+ const next = tool.approvalScope?.(finalInput, rootDir, executionCwd);
7795
+ if (next?.executionFingerprint !== shell.executionFingerprint) {
7796
+ if (attempt === 0 && next) {
7797
+ try {
7798
+ await audit("tool.target_changed", { status: "redeciding" });
7799
+ } catch {
7800
+ return {
7801
+ result: failure(
7802
+ "Audit unavailable; tool was not started",
7803
+ "AUDIT_UNAVAILABLE",
7804
+ "after_user_action",
7805
+ {
7806
+ version: 1,
7807
+ dispatchOutcome: "not_sent",
7808
+ hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7809
+ }
7810
+ )
7811
+ };
7812
+ }
7813
+ shell = next;
7814
+ continue;
7815
+ }
7816
+ await audit("tool.finished", { status: "target_changed", errorCode: "TARGET_CHANGED" });
7817
+ return {
7818
+ result: failure(
7819
+ "Execution target changed after approval",
7820
+ "TARGET_CHANGED",
7821
+ "after_user_action"
7822
+ )
7823
+ };
7824
+ }
7825
+ } catch {
7826
+ return {
7827
+ result: failure(
7828
+ "Execution target changed after approval",
7829
+ "TARGET_CHANGED",
7830
+ "after_user_action"
7831
+ )
7832
+ };
7833
+ }
7834
+ }
7835
+ if (executionIdentity) {
7836
+ try {
7837
+ const nextIdentity = tool.executionIdentity?.(finalInput, rootDir);
7838
+ if (!nextIdentity || nextIdentity.serverIdentity !== executionIdentity.serverIdentity || nextIdentity.connectionGeneration !== executionIdentity.connectionGeneration || nextIdentity.catalogEpoch !== executionIdentity.catalogEpoch || nextIdentity.toolDigest !== executionIdentity.toolDigest) {
7839
+ await audit("tool.finished", {
7840
+ status: "identity_changed",
7841
+ errorCode: "MCP_TOOL_CHANGED"
7842
+ });
7843
+ return {
7844
+ result: failure(
7845
+ "Tool execution identity changed after approval",
7846
+ "MCP_TOOL_CHANGED",
7847
+ "after_user_action",
7848
+ {
7849
+ version: 1,
7850
+ dispatchOutcome: "not_sent",
7851
+ hostError: { phase: "validation", code: "MCP_TOOL_CHANGED" }
7852
+ }
7853
+ )
7854
+ };
7855
+ }
7856
+ } catch {
7857
+ return {
7858
+ result: failure(
7859
+ "Tool execution identity check failed after approval",
7860
+ "MCP_TOOL_CHANGED",
7861
+ "after_user_action",
7862
+ {
7863
+ version: 1,
7864
+ dispatchOutcome: "not_sent"
7865
+ }
7866
+ )
7867
+ };
7868
+ }
7869
+ }
7870
+ break;
7871
+ }
7872
+ return { shell, auditFields };
7873
+ }
7874
+
7875
+ // src/runtime/executor/result.ts
7876
+ import { createHash as createHash13 } from "crypto";
7877
+ async function closeToolResult(deps) {
7878
+ const { call, tool, finalInput, hookCtx, hooks, neverFailures, audit, onToolResult, startedAt } = deps;
7879
+ const resultBlock = {
7880
+ type: "tool_result",
7881
+ toolUseId: call.id,
7882
+ content: deps.output.content,
7883
+ isError: deps.output.isError,
7884
+ ...deps.output.errorCode ? { errorCode: deps.output.errorCode, retryPolicy: deps.output.retryPolicy } : {},
7885
+ execution: deps.output.execution
7886
+ };
7887
+ if (resultBlock.isError && resultBlock.errorCode) {
7888
+ resultBlock.content = JSON.stringify({
7889
+ code: resultBlock.errorCode,
7890
+ retryPolicy: resultBlock.retryPolicy,
7891
+ message: deps.output.content
7892
+ });
7893
+ }
7894
+ try {
7895
+ for (const hook of hooks.get("tool:after")) {
7896
+ await hook(
7897
+ hookCtx,
7898
+ { id: call.id, name: call.name, input: finalInput },
7899
+ { output: deps.output.content, isError: deps.output.isError }
7900
+ );
7901
+ }
7902
+ } catch {
7903
+ resultBlock.content = JSON.stringify({
7904
+ code: "HOOK_ERROR",
7905
+ retryPolicy: "after_user_action",
7906
+ message: "Tool finished but result processing failed"
7907
+ });
7908
+ resultBlock.isError = true;
7909
+ resultBlock.errorCode = "HOOK_ERROR";
7910
+ resultBlock.retryPolicy = "after_user_action";
7911
+ resultBlock.execution = {
7912
+ version: 1,
7913
+ dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "failed",
7914
+ hostError: {
7915
+ phase: "hook",
7916
+ code: "HOOK_ERROR"
7917
+ }
7918
+ };
7919
+ }
7920
+ resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
7921
+ if (resultBlock.isError && resultBlock.retryPolicy === "never") {
7922
+ neverFailures.add(
7923
+ createHash13("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex")
7924
+ );
7925
+ }
7926
+ try {
7927
+ await audit("tool.finished", {
7928
+ status: resultBlock.isError ? "failed" : "completed",
7929
+ ...resultBlock.errorCode ? { errorCode: resultBlock.errorCode } : {},
7930
+ durationMs: resultBlock.durationMs
7931
+ });
7932
+ } catch {
7933
+ resultBlock.isError = true;
7934
+ resultBlock.errorCode = "OUTCOME_UNKNOWN";
7935
+ resultBlock.retryPolicy = "after_user_action";
7936
+ resultBlock.content = JSON.stringify({
7937
+ code: "OUTCOME_UNKNOWN",
7938
+ retryPolicy: "after_user_action",
7939
+ message: "Tool outcome is unknown because the audit result could not be written"
7940
+ });
7941
+ resultBlock.execution = {
7942
+ version: 1,
7943
+ dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "succeeded",
7944
+ hostError: {
7945
+ phase: "audit",
7946
+ code: "AUDIT_UNAVAILABLE"
7947
+ }
7948
+ };
7949
+ return resultBlock;
7950
+ }
7951
+ await onToolResult?.(tool, finalInput, resultBlock);
7952
+ return resultBlock;
7953
+ }
7954
+
7955
+ // src/runtime/executor/timeout.ts
7956
+ function withToolTimeout(promise, toolName, timeoutMs, executionController) {
7957
+ let timer;
7958
+ const timeout = new Promise((_, reject) => {
7959
+ timer = setTimeout(() => {
7960
+ const error = new KapibalaError(`Tool '${toolName}' timed out after ${timeoutMs}ms`, {
7961
+ code: "OUTCOME_UNKNOWN",
7962
+ retryPolicy: "after_user_action",
7963
+ safeMessage: "Tool timed out; completion of side effects is unknown"
7964
+ });
7965
+ reject(error);
7966
+ executionController.abort(error);
7967
+ }, timeoutMs);
7968
+ });
7969
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
7970
+ }
7971
+
7972
+ // src/runtime/executor/types.ts
7973
+ var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
7974
+
7975
+ // src/runtime/executor/index.ts
7976
+ var ToolExecutor = class {
7977
+ hardDenial;
7978
+ onToolResult;
7979
+ tools;
7980
+ hooks;
7981
+ workspaceRoot;
7982
+ executionCwd;
7983
+ sandbox;
7984
+ leaseCoordinator;
7985
+ rootDir;
7986
+ signal;
7987
+ logger;
7988
+ toolTimeoutMs;
7989
+ eventLogger;
7990
+ sessionId;
7991
+ runId;
7992
+ branchId;
7993
+ rootTaskId;
7994
+ depth;
7995
+ agentId;
7996
+ committedActions = [];
7997
+ rollbackActions = [];
7998
+ mode;
7999
+ permissionRules;
8000
+ approvalChannel;
8001
+ approvalCache;
8002
+ policy = new PermissionPolicy();
8003
+ neverFailures = /* @__PURE__ */ new Set();
8004
+ cancellation = new AbortController();
8005
+ constructor(options) {
8006
+ this.hardDenial = options.hardDenial;
8007
+ this.onToolResult = options.onToolResult;
8008
+ this.tools = options.tools;
8009
+ this.hooks = options.hooks;
8010
+ this.workspaceRoot = options.workspaceRoot ?? options.rootDir;
8011
+ this.executionCwd = options.executionCwd ?? options.rootDir;
8012
+ this.sandbox = options.sandbox;
8013
+ this.leaseCoordinator = options.leaseCoordinator;
8014
+ this.rootDir = options.rootDir;
8015
+ this.signal = options.signal ? AbortSignal.any([options.signal, this.cancellation.signal]) : this.cancellation.signal;
8016
+ this.logger = options.logger;
8017
+ this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
8018
+ this.eventLogger = options.eventLogger;
8019
+ this.sessionId = options.sessionId;
8020
+ this.runId = options.runId;
8021
+ this.branchId = options.branchId;
8022
+ this.rootTaskId = options.rootTaskId;
8023
+ this.depth = options.depth;
8024
+ this.agentId = options.agentId;
8025
+ this.mode = options.mode ?? "Approval";
8026
+ this.permissionRules = options.permissionRules ?? [];
8027
+ this.approvalChannel = options.approvalChannel;
8028
+ this.approvalCache = options.approvalCache ?? new SessionApprovalCache();
8029
+ }
8030
+ /**
8031
+ * 父工具事务提交成功后调用,按注册顺序执行所有提交动作。
8032
+ * 动作执行成功后逐项清理对应回滚动作,若某项失败保留剩余回滚动作。
8033
+ */
8034
+ async commit() {
8035
+ while (this.committedActions.length > 0) {
8036
+ const action = this.committedActions.shift();
8037
+ await action();
8038
+ this.rollbackActions.shift();
8039
+ }
8040
+ this.rollbackActions.length = 0;
8041
+ }
8042
+ /**
8043
+ * 父工具事务提交失败或取消时调用,按逆序执行所有回滚清理动作。
8044
+ */
8045
+ async rollback() {
8046
+ const actions = [...this.rollbackActions].reverse();
8047
+ this.committedActions.length = 0;
8048
+ this.rollbackActions.length = 0;
8049
+ for (const action of actions) {
8050
+ try {
8051
+ await action();
8052
+ } catch {
8053
+ }
8054
+ }
8055
+ }
8056
+ /** 中止本轮正在等待或执行的工具;调用方仍须等待 runAll 完成资源回收及结果审计。 */
8057
+ cancel() {
8058
+ this.cancellation.abort();
8059
+ }
8060
+ /**
8061
+ * 串行执行全部调用并返回与入参顺序一致的结果;某个结果要求用户介入后,其余调用直接拒绝。
8062
+ * onResult 在每个最终结果(含拒绝/失败)产生后提供独立快照,供在途预算估算,不代表历史已提交;回调不得抛错。
8063
+ */
8064
+ async runAll(calls, onProgress, onResult) {
8065
+ const results = [];
8066
+ let requiresUserAction = false;
8067
+ for (const call of calls) {
8068
+ const result = await this.executeOne(call, onProgress, requiresUserAction);
8069
+ results.push(result);
8070
+ onResult?.(structuredClone(result));
8071
+ if (result.retryPolicy === "after_user_action") requiresUserAction = true;
8072
+ }
8073
+ return results;
8074
+ }
8075
+ /**
8076
+ * 在最终参数上裁决并审计一次调用,所有拒绝和异常均闭合为工具结果。
8077
+ * 为调用记录本次绝对工具根目录,供跨目录恢复后的文件事实解析;不使用历史目录授权。
8078
+ */
8079
+ async executeOne(call, onProgress, forcedDeny = false) {
8080
+ call.executionRoot = path17.resolve(this.workspaceRoot);
8081
+ const startedAt = Date.now();
8082
+ const operationId = createLogId();
8083
+ const failure = (message, errorCode, retryPolicy = "never", execution) => ({
8084
+ type: "tool_result",
8085
+ toolUseId: call.id,
8086
+ content: JSON.stringify({ code: errorCode, retryPolicy, message }),
8087
+ isError: true,
8088
+ errorCode,
8089
+ retryPolicy,
8090
+ durationMs: Math.max(0, Date.now() - startedAt),
8091
+ execution: execution ?? {
8092
+ version: 1,
8093
+ dispatchOutcome: "not_sent"
8094
+ }
8095
+ });
8096
+ const audit = async (event, fields = {}) => {
8097
+ if (!this.eventLogger || !this.sessionId) return;
8098
+ await this.eventLogger.recordAudit({
8099
+ level: "info",
7567
8100
  event,
7568
8101
  sessionId: this.sessionId,
7569
8102
  runId: this.runId,
@@ -7755,191 +8288,28 @@ var ToolExecutor = class {
7755
8288
  }
7756
8289
  return failure("Invalid tool execution identity", "INVALID_TARGET");
7757
8290
  }
7758
- let auditFields = {};
7759
- for (let attempt = 0; attempt < 2; attempt++) {
7760
- const request = {
7761
- toolName: call.name,
7762
- capabilities: tool.metadata?.permissions ?? [],
7763
- input: finalInput,
7764
- rootDir: this.rootDir,
7765
- ...shell ? { shell } : {},
7766
- ...executionIdentity ? { executionIdentity } : {},
7767
- // 解释器内建查询与内容已绑定摘要的工作区直引脚本可确认不受外部脚本变更影响,
7768
- // 允许会话级批准;其余 shell 命令仅允许单次批准。
7769
- // 非 shell 工具(含深度绑定 executionIdentity 的外部工具)允许会话级批准。
7770
- sessionAllowed: !shell || /^(?:pwd|get-location)$/i.test(shell.command.trim()) || shell.scriptDigest !== void 0
7771
- };
7772
- const repeatKey = createHash12("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex");
7773
- if (this.neverFailures.has(repeatKey)) {
7774
- try {
7775
- await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
7776
- await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
7777
- } catch {
7778
- return failure(
7779
- "Audit unavailable; tool was not started",
7780
- "AUDIT_UNAVAILABLE",
7781
- "after_user_action",
7782
- {
7783
- version: 1,
7784
- dispatchOutcome: "not_sent",
7785
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7786
- }
7787
- );
7788
- }
7789
- return failure(
7790
- "Identical failed tool call cannot be repeated in this run",
7791
- "REPEAT_BLOCKED"
7792
- );
7793
- }
7794
- const hardDenied = this.hardDenial?.(tool, finalInput);
7795
- const cached = this.approvalCache.get(request);
7796
- const decision = this.policy.decide({
7797
- hardDenied: hardDenied !== void 0,
7798
- mode: typeof this.mode === "function" ? this.mode() : this.mode,
7799
- toolName: call.name,
7800
- capabilities: request.capabilities,
7801
- dangerous: tool.metadata?.dangerous,
7802
- cachedApproval: cached === "allow",
7803
- cachedDenial: cached === "deny",
7804
- rules: this.permissionRules,
7805
- ...shell ? { shell } : {}
7806
- });
7807
- let permitted = decision.decision === "allow";
7808
- let source = decision.source;
7809
- let approvalChoice;
7810
- if (decision.decision === "ask") {
7811
- let choice = "deny_once";
7812
- try {
7813
- choice = await this.approvalChannel?.requestApproval(request, this.signal) ?? "deny_once";
7814
- } catch {
7815
- choice = "cancel";
7816
- }
7817
- approvalChoice = choice;
7818
- if (choice === "allow_session" && request.sessionAllowed)
7819
- this.approvalCache.set(request, "allow");
7820
- if (choice === "deny_session") this.approvalCache.set(request, "deny");
7821
- permitted = choice === "allow_once" || choice === "allow_session" && request.sessionAllowed;
7822
- source = this.approvalChannel ? "human" : "non_interactive";
7823
- }
7824
- auditFields = shell ? {
7825
- shell: shell.interpreter,
7826
- cwdDigest: createHash12("sha256").update(shell.cwd).digest("hex"),
7827
- executableDigest: shell.executableDigest,
7828
- executionFingerprint: shell.executionFingerprint,
7829
- ...commandAuditSummary(shell.command)
7830
- } : executionIdentity ? {
7831
- serverIdentity: executionIdentity.serverIdentity,
7832
- connectionGeneration: executionIdentity.connectionGeneration,
7833
- catalogEpoch: executionIdentity.catalogEpoch,
7834
- toolDigest: executionIdentity.toolDigest
7835
- } : {};
7836
- try {
7837
- await audit("tool.decided", {
7838
- decision: permitted ? "allow" : "deny",
7839
- decisionSource: source,
7840
- ...approvalChoice ? { approvalChoice } : {},
7841
- ...hardDenied?.fields ?? {},
7842
- ...auditFields
7843
- });
7844
- } catch {
7845
- return failure(
7846
- "Audit unavailable; tool was not started",
7847
- "AUDIT_UNAVAILABLE",
7848
- "after_user_action",
7849
- {
7850
- version: 1,
7851
- dispatchOutcome: "not_sent",
7852
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7853
- }
7854
- );
7855
- }
7856
- if (!permitted || this.signal?.aborted) {
7857
- const code = this.signal?.aborted ? "ABORTED" : hardDenied?.code ?? "PERMISSION_DENIED";
7858
- try {
7859
- await audit("tool.finished", { status: "denied", errorCode: code });
7860
- } catch {
7861
- return failure("Audit result unavailable", "AUDIT_UNAVAILABLE", "after_user_action", {
7862
- version: 1,
7863
- dispatchOutcome: "not_sent",
7864
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7865
- });
7866
- }
7867
- return failure(
7868
- this.signal?.aborted ? "Operation cancelled" : hardDenied?.message ?? "Tool permission denied",
7869
- code,
7870
- this.signal?.aborted ? "after_user_action" : hardDenied?.retryPolicy ?? "after_user_action"
7871
- );
7872
- }
7873
- if (shell) {
7874
- try {
7875
- const next = tool.approvalScope?.(finalInput, this.rootDir, this.executionCwd);
7876
- if (next?.executionFingerprint !== shell.executionFingerprint) {
7877
- if (attempt === 0 && next) {
7878
- try {
7879
- await audit("tool.target_changed", { status: "redeciding" });
7880
- } catch {
7881
- return failure(
7882
- "Audit unavailable; tool was not started",
7883
- "AUDIT_UNAVAILABLE",
7884
- "after_user_action",
7885
- {
7886
- version: 1,
7887
- dispatchOutcome: "not_sent",
7888
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7889
- }
7890
- );
7891
- }
7892
- shell = next;
7893
- continue;
7894
- }
7895
- await audit("tool.finished", { status: "target_changed", errorCode: "TARGET_CHANGED" });
7896
- return failure(
7897
- "Execution target changed after approval",
7898
- "TARGET_CHANGED",
7899
- "after_user_action"
7900
- );
7901
- }
7902
- } catch {
7903
- return failure(
7904
- "Execution target changed after approval",
7905
- "TARGET_CHANGED",
7906
- "after_user_action"
7907
- );
7908
- }
7909
- }
7910
- if (executionIdentity) {
7911
- try {
7912
- const nextIdentity = tool.executionIdentity?.(finalInput, this.rootDir);
7913
- if (!nextIdentity || nextIdentity.serverIdentity !== executionIdentity.serverIdentity || nextIdentity.connectionGeneration !== executionIdentity.connectionGeneration || nextIdentity.catalogEpoch !== executionIdentity.catalogEpoch || nextIdentity.toolDigest !== executionIdentity.toolDigest) {
7914
- await audit("tool.finished", {
7915
- status: "identity_changed",
7916
- errorCode: "MCP_TOOL_CHANGED"
7917
- });
7918
- return failure(
7919
- "Tool execution identity changed after approval",
7920
- "MCP_TOOL_CHANGED",
7921
- "after_user_action",
7922
- {
7923
- version: 1,
7924
- dispatchOutcome: "not_sent",
7925
- hostError: { phase: "validation", code: "MCP_TOOL_CHANGED" }
7926
- }
7927
- );
7928
- }
7929
- } catch {
7930
- return failure(
7931
- "Tool execution identity check failed after approval",
7932
- "MCP_TOOL_CHANGED",
7933
- "after_user_action",
7934
- {
7935
- version: 1,
7936
- dispatchOutcome: "not_sent"
7937
- }
7938
- );
7939
- }
7940
- }
7941
- break;
7942
- }
8291
+ const approval = await resolveToolApproval({
8292
+ call,
8293
+ finalInput,
8294
+ tool,
8295
+ shell,
8296
+ executionIdentity,
8297
+ rootDir: this.rootDir,
8298
+ executionCwd: this.executionCwd,
8299
+ mode: this.mode,
8300
+ permissionRules: this.permissionRules,
8301
+ approvalChannel: this.approvalChannel,
8302
+ approvalCache: this.approvalCache,
8303
+ hardDenial: this.hardDenial,
8304
+ neverFailures: this.neverFailures,
8305
+ policy: this.policy,
8306
+ signal: this.signal,
8307
+ audit,
8308
+ failure
8309
+ });
8310
+ if (approval.result) return approval.result;
8311
+ shell = approval.shell;
8312
+ const auditFields = approval.auditFields ?? {};
7943
8313
  try {
7944
8314
  await audit("tool.started", { status: "started", ...auditFields });
7945
8315
  } catch {
@@ -7999,9 +8369,10 @@ var ToolExecutor = class {
7999
8369
  { signal: executionController.signal }
8000
8370
  );
8001
8371
  }
8002
- const rawResult = tool.metadata?.managesTimeout ? await tool.execute(finalInput, toolCtx) : await this.withTimeout(
8372
+ const rawResult = tool.metadata?.managesTimeout ? await tool.execute(finalInput, toolCtx) : await withToolTimeout(
8003
8373
  tool.execute(finalInput, toolCtx),
8004
8374
  call.name,
8375
+ this.toolTimeoutMs,
8005
8376
  executionController
8006
8377
  );
8007
8378
  if (typeof tool.toExecutionResult === "function") {
@@ -8062,105 +8433,18 @@ var ToolExecutor = class {
8062
8433
  dispatchOutcome: outcome
8063
8434
  };
8064
8435
  }
8065
- const resultBlock = {
8066
- type: "tool_result",
8067
- toolUseId: call.id,
8068
- content: outputStr,
8069
- isError,
8070
- ...errorCode ? { errorCode, retryPolicy } : {},
8071
- execution
8072
- };
8073
- if (resultBlock.isError && resultBlock.errorCode) {
8074
- resultBlock.content = JSON.stringify({
8075
- code: resultBlock.errorCode,
8076
- retryPolicy: resultBlock.retryPolicy,
8077
- message: outputStr
8078
- });
8079
- }
8080
- try {
8081
- for (const hook of this.hooks.get("tool:after")) {
8082
- await hook(
8083
- hookCtx,
8084
- { id: call.id, name: call.name, input: finalInput },
8085
- { output: outputStr, isError }
8086
- );
8087
- }
8088
- } catch {
8089
- resultBlock.content = JSON.stringify({
8090
- code: "HOOK_ERROR",
8091
- retryPolicy: "after_user_action",
8092
- message: "Tool finished but result processing failed"
8093
- });
8094
- resultBlock.isError = true;
8095
- resultBlock.errorCode = "HOOK_ERROR";
8096
- resultBlock.retryPolicy = "after_user_action";
8097
- resultBlock.execution = {
8098
- version: 1,
8099
- dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "failed",
8100
- hostError: {
8101
- phase: "hook",
8102
- code: "HOOK_ERROR"
8103
- }
8104
- };
8105
- }
8106
- resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
8107
- if (resultBlock.isError && resultBlock.retryPolicy === "never") {
8108
- this.neverFailures.add(
8109
- createHash12("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex")
8110
- );
8111
- }
8112
- try {
8113
- await audit("tool.finished", {
8114
- status: resultBlock.isError ? "failed" : "completed",
8115
- ...resultBlock.errorCode ? { errorCode: resultBlock.errorCode } : {},
8116
- durationMs: resultBlock.durationMs
8117
- });
8118
- } catch {
8119
- resultBlock.isError = true;
8120
- resultBlock.errorCode = "OUTCOME_UNKNOWN";
8121
- resultBlock.retryPolicy = "after_user_action";
8122
- resultBlock.content = JSON.stringify({
8123
- code: "OUTCOME_UNKNOWN",
8124
- retryPolicy: "after_user_action",
8125
- message: "Tool outcome is unknown because the audit result could not be written"
8126
- });
8127
- resultBlock.execution = {
8128
- version: 1,
8129
- dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "succeeded",
8130
- hostError: {
8131
- phase: "audit",
8132
- code: "AUDIT_UNAVAILABLE"
8133
- }
8134
- };
8135
- return resultBlock;
8136
- }
8137
- await this.onToolResult?.(tool, finalInput, resultBlock);
8138
- return resultBlock;
8139
- }
8140
- /**
8141
- * 给工具执行加超时护栏:Promise.race 在超时后reject,循环拿到 isError 结果继续运转,
8142
- * 不会因某个异步工具(如未来接入的网络/exec 工具)挂起而永久卡死。
8143
- *
8144
- * 局限:同步 CPU 密集型实现会阻塞事件循环,timer 同样无法触发;
8145
- * 因此内置工具仍需避免执行用户可控的同步高复杂度计算。
8146
- */
8147
- withTimeout(promise, toolName, executionController) {
8148
- let timer;
8149
- const timeout = new Promise((_, reject) => {
8150
- timer = setTimeout(() => {
8151
- const error = new KapibalaError(
8152
- `Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`,
8153
- {
8154
- code: "OUTCOME_UNKNOWN",
8155
- retryPolicy: "after_user_action",
8156
- safeMessage: "Tool timed out; completion of side effects is unknown"
8157
- }
8158
- );
8159
- reject(error);
8160
- executionController.abort(error);
8161
- }, this.toolTimeoutMs);
8436
+ return closeToolResult({
8437
+ call,
8438
+ tool,
8439
+ finalInput,
8440
+ hookCtx,
8441
+ hooks: this.hooks,
8442
+ neverFailures: this.neverFailures,
8443
+ audit,
8444
+ onToolResult: this.onToolResult,
8445
+ startedAt,
8446
+ output: { content: outputStr, isError, errorCode, retryPolicy, execution }
8162
8447
  });
8163
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
8164
8448
  }
8165
8449
  /**
8166
8450
  * 构建当前会话在派生时刻的父权限策略快照。
@@ -8759,7 +9043,7 @@ var OpenAICompatibleProvider = class {
8759
9043
  };
8760
9044
 
8761
9045
  // src/models/anthropic/index.ts
8762
- import { createHash as createHash13 } from "crypto";
9046
+ import { createHash as createHash14 } from "crypto";
8763
9047
  function invalidResponse2() {
8764
9048
  throw new ModelError("Invalid Anthropic response state", {
8765
9049
  code: "MODEL_INVALID_RESPONSE",
@@ -8774,7 +9058,7 @@ function wireToolId(id) {
8774
9058
  stage: "request",
8775
9059
  retryable: false
8776
9060
  });
8777
- return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${createHash13("sha256").update(id).digest("hex").slice(0, 48)}`;
9061
+ return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${createHash14("sha256").update(id).digest("hex").slice(0, 48)}`;
8778
9062
  }
8779
9063
  var AnthropicProvider = class {
8780
9064
  name = "anthropic";
@@ -9140,7 +9424,7 @@ var AnthropicProvider = class {
9140
9424
  };
9141
9425
 
9142
9426
  // src/models/openai-responses/index.ts
9143
- import { createHash as createHash14 } from "crypto";
9427
+ import { createHash as createHash15 } from "crypto";
9144
9428
  function invalid(message = "Invalid Responses output Item") {
9145
9429
  throw new ModelError(message, {
9146
9430
  code: "MODEL_INVALID_RESPONSE",
@@ -9161,7 +9445,7 @@ function wireCallId(id) {
9161
9445
  stage: "request",
9162
9446
  retryable: false
9163
9447
  });
9164
- return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${createHash14("sha256").update(id).digest("hex").slice(0, 48)}`;
9448
+ return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${createHash15("sha256").update(id).digest("hex").slice(0, 48)}`;
9165
9449
  }
9166
9450
  function validateItem(item, final) {
9167
9451
  if (!item || typeof item !== "object" || !nonempty(item.id)) invalid();
@@ -9858,13 +10142,99 @@ Follow these key behavioral rules:
9858
10142
  }
9859
10143
  return lines.join("\n");
9860
10144
  }
9861
- getL3Environment() {
9862
- const platform = os6.platform();
9863
- return `# Environment Context:
9864
- - Current Working Directory (Workspace Root): ${this.rootDir}
9865
- - Operating System: ${platform}`;
10145
+ getL3Environment() {
10146
+ const platform = os6.platform();
10147
+ return `# Environment Context:
10148
+ - Current Working Directory (Workspace Root): ${this.rootDir}
10149
+ - Operating System: ${platform}`;
10150
+ }
10151
+ };
10152
+
10153
+ // src/runtime/loop/hook-integrity.ts
10154
+ function assertHookToolDefinitionsIntact(request, snapshot) {
10155
+ if (!request.tools) return;
10156
+ for (const t of request.tools) {
10157
+ const original = snapshot.get(t.name);
10158
+ if (!original) {
10159
+ throw new ModelError(`Hook injected tool '${t.name}' not present in snapshot`, {
10160
+ stage: "request",
10161
+ retryable: false
10162
+ });
10163
+ }
10164
+ if (t.description !== original.description || JSON.stringify(t.parameters) !== JSON.stringify(original.parameters)) {
10165
+ throw new ModelError(
10166
+ `Hook mutated tool definition for '${t.name}' (description or parameters drifted from snapshot)`,
10167
+ {
10168
+ stage: "request",
10169
+ retryable: false
10170
+ }
10171
+ );
10172
+ }
10173
+ }
10174
+ }
10175
+ function snapshotAssistantIntegrity(message) {
10176
+ return {
10177
+ identity: stableSerialize({ ...message, content: void 0 }),
10178
+ originalTools: stableSerialize(
10179
+ message.content.filter((block) => block.type === "tool_use" || block.type === "tool_result")
10180
+ ),
10181
+ originalContent: stableSerialize(message.content),
10182
+ privateOutput: message.content.some(
10183
+ (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10184
+ ),
10185
+ hasToolCalls: message.content.some((block) => block.type === "tool_use")
10186
+ };
10187
+ }
10188
+ function assertAssistantMessageUntampered(message, snapshot) {
10189
+ if (snapshot.identity !== stableSerialize({ ...message, content: void 0 }))
10190
+ throw new ModelError("Hook changed assistant message identity", {
10191
+ stage: "request",
10192
+ retryable: false
10193
+ });
10194
+ if (snapshot.originalTools !== stableSerialize(
10195
+ message.content.filter((block) => block.type === "tool_use" || block.type === "tool_result")
10196
+ ))
10197
+ throw new ModelError("Hook changed validated tool model output", {
10198
+ stage: "request",
10199
+ retryable: false
10200
+ });
10201
+ if (snapshot.originalContent !== stableSerialize(message.content)) {
10202
+ if (snapshot.privateOutput || message.content.some(
10203
+ (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10204
+ ))
10205
+ throw new ModelError("Hook changed signed or private model output", {
10206
+ stage: "request",
10207
+ retryable: false
10208
+ });
10209
+ if (snapshot.hasToolCalls)
10210
+ throw new ModelError("Hook changed validated tool model output", {
10211
+ stage: "request",
10212
+ retryable: false
10213
+ });
10214
+ for (const block of message.content)
10215
+ if (block.type === "text" || block.type === "tool_use") block.protocolMeta = void 0;
10216
+ }
10217
+ }
10218
+
10219
+ // src/runtime/loop/tool-call-validation.ts
10220
+ function assertToolCallsExecutable(history, toolCalls) {
10221
+ const knownCalls = new Set(
10222
+ history.flatMap(
10223
+ (message) => message.content.filter((block) => block.type === "tool_use").map((block) => block.id)
10224
+ )
10225
+ );
10226
+ for (const call of toolCalls) {
10227
+ if (!call.name?.trim() || !call.input || typeof call.input !== "object" || Array.isArray(call.input))
10228
+ throw new ModelError("Invalid tool call arguments or name; no tools executed", {
10229
+ code: "MODEL_INVALID_RESPONSE",
10230
+ stage: "stream",
10231
+ retryable: false
10232
+ });
10233
+ if (!call.id?.trim() || knownCalls.has(call.id))
10234
+ throw new ModelError("Duplicate tool call ID; no tools executed");
10235
+ knownCalls.add(call.id);
9866
10236
  }
9867
- };
10237
+ }
9868
10238
 
9869
10239
  // src/runtime/loop/index.ts
9870
10240
  var AgentLoop = class {
@@ -9948,26 +10318,7 @@ var AgentLoop = class {
9948
10318
  request = modified;
9949
10319
  }
9950
10320
  }
9951
- if (request.tools) {
9952
- for (const t of request.tools) {
9953
- const original = toolSnapshot.get(t.name);
9954
- if (!original) {
9955
- throw new ModelError(`Hook injected tool '${t.name}' not present in snapshot`, {
9956
- stage: "request",
9957
- retryable: false
9958
- });
9959
- }
9960
- if (t.description !== original.description || JSON.stringify(t.parameters) !== JSON.stringify(original.parameters)) {
9961
- throw new ModelError(
9962
- `Hook mutated tool definition for '${t.name}' (description or parameters drifted from snapshot)`,
9963
- {
9964
- stage: "request",
9965
- retryable: false
9966
- }
9967
- );
9968
- }
9969
- }
9970
- }
10321
+ assertHookToolDefinitionsIntact(request, toolSnapshot);
9971
10322
  if (this.prepareRequest) request = yield* this.prepareRequest(request, history);
9972
10323
  yield {
9973
10324
  type: "step_log",
@@ -10118,22 +10469,7 @@ var AgentLoop = class {
10118
10469
  stage: "stream",
10119
10470
  retryable: false
10120
10471
  });
10121
- const knownCalls = new Set(
10122
- history.flatMap(
10123
- (message) => message.content.filter((block) => block.type === "tool_use").map((block) => block.id)
10124
- )
10125
- );
10126
- for (const call of toolCalls) {
10127
- if (!call.name?.trim() || !call.input || typeof call.input !== "object" || Array.isArray(call.input))
10128
- throw new ModelError("Invalid tool call arguments or name; no tools executed", {
10129
- code: "MODEL_INVALID_RESPONSE",
10130
- stage: "stream",
10131
- retryable: false
10132
- });
10133
- if (!call.id?.trim() || knownCalls.has(call.id))
10134
- throw new ModelError("Duplicate tool call ID; no tools executed");
10135
- knownCalls.add(call.id);
10136
- }
10472
+ assertToolCallsExecutable(history, toolCalls);
10137
10473
  break;
10138
10474
  } catch (error) {
10139
10475
  if (!(error instanceof ContextOverflowError) || outputSeen || accumulatedText || accumulatedThinking || toolCalls.length || overflowRetried || !this.prepareRequest || this.signal?.aborted)
@@ -10246,47 +10582,9 @@ var AgentLoop = class {
10246
10582
  timestamp: Date.now()
10247
10583
  };
10248
10584
  validateProtocolContent(assistantMessage.content);
10249
- const originalContent = stableSerialize(assistantMessage.content);
10250
- const identity = stableSerialize({ ...assistantMessage, content: void 0 });
10251
- const originalTools = stableSerialize(
10252
- assistantMessage.content.filter(
10253
- (block) => block.type === "tool_use" || block.type === "tool_result"
10254
- )
10255
- );
10256
- const privateOutput = assistantMessage.content.some(
10257
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10258
- );
10585
+ const integrity = snapshotAssistantIntegrity(assistantMessage);
10259
10586
  await this.hooks.emit("model:after", hookCtx, { message: assistantMessage });
10260
- if (identity !== stableSerialize({ ...assistantMessage, content: void 0 }))
10261
- throw new ModelError("Hook changed assistant message identity", {
10262
- stage: "request",
10263
- retryable: false
10264
- });
10265
- if (originalTools !== stableSerialize(
10266
- assistantMessage.content.filter(
10267
- (block) => block.type === "tool_use" || block.type === "tool_result"
10268
- )
10269
- ))
10270
- throw new ModelError("Hook changed validated tool model output", {
10271
- stage: "request",
10272
- retryable: false
10273
- });
10274
- if (originalContent !== stableSerialize(assistantMessage.content)) {
10275
- if (privateOutput || assistantMessage.content.some(
10276
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10277
- ))
10278
- throw new ModelError("Hook changed signed or private model output", {
10279
- stage: "request",
10280
- retryable: false
10281
- });
10282
- if (toolCalls.length)
10283
- throw new ModelError("Hook changed validated tool model output", {
10284
- stage: "request",
10285
- retryable: false
10286
- });
10287
- for (const block of assistantMessage.content)
10288
- if (block.type === "text" || block.type === "tool_use") block.protocolMeta = void 0;
10289
- }
10587
+ assertAssistantMessageUntampered(assistantMessage, integrity);
10290
10588
  validateProtocolContent(assistantMessage.content);
10291
10589
  if (toolCalls.length === 0) {
10292
10590
  history.push(assistantMessage);
@@ -11055,7 +11353,6 @@ var AgentRuntime = class {
11055
11353
  task.status = "failed";
11056
11354
  }
11057
11355
  task.error = runError.message;
11058
- await this.cascadeCancelChildren(task.agentId, "Parent task failed");
11059
11356
  } finally {
11060
11357
  this.runnerContexts.delete(task.agentId);
11061
11358
  this.clearTaskTimer(task.agentId);
@@ -11064,6 +11361,9 @@ var AgentRuntime = class {
11064
11361
  task.status = "failed";
11065
11362
  task.error = runError?.message ?? "Unknown execution failure";
11066
11363
  }
11364
+ if (task.status === "failed") {
11365
+ await this.cascadeCancelChildren(task.agentId, task.error ?? "Parent task failed");
11366
+ }
11067
11367
  task.finishedAt = Date.now();
11068
11368
  this.releaseTaskSlotOnce(task);
11069
11369
  this.outbox.enqueueFromTask(task);
@@ -11670,6 +11970,274 @@ function cancellableGenerator(create, external) {
11670
11970
  };
11671
11971
  }
11672
11972
 
11973
+ // src/context/session/persistence.ts
11974
+ async function persistAssistantMessage(event, store, markUnknownUsage) {
11975
+ if (store) await store.append(event.message);
11976
+ if (!event.usage) markUnknownUsage();
11977
+ }
11978
+ async function persistToolMessages(messages, store) {
11979
+ if (!store) return;
11980
+ for (const message of messages) await store.append(message);
11981
+ }
11982
+ function buildInterruptedToolMessage(assistantEvent) {
11983
+ return {
11984
+ id: createMessageId(),
11985
+ role: "tool",
11986
+ content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
11987
+ type: "tool_result",
11988
+ toolUseId: toolUse.id,
11989
+ content: JSON.stringify({
11990
+ code: "OUTCOME_UNKNOWN",
11991
+ retryPolicy: "after_user_action",
11992
+ message: "Tool execution was interrupted; outcome is unknown"
11993
+ }),
11994
+ isError: true,
11995
+ errorCode: "OUTCOME_UNKNOWN",
11996
+ retryPolicy: "after_user_action"
11997
+ })),
11998
+ timestamp: Date.now()
11999
+ };
12000
+ }
12001
+ function findClosedToolMessages(history, assistantEvent) {
12002
+ const assistantIndex = history.lastIndexOf(assistantEvent.message);
12003
+ if (assistantIndex < 0) return [];
12004
+ const expected = new Set(
12005
+ assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => toolUse.id)
12006
+ );
12007
+ const messages = [];
12008
+ for (let index2 = assistantIndex + 1; index2 < history.length; index2++) {
12009
+ const message = history[index2];
12010
+ if (message.role !== "tool") break;
12011
+ messages.push(message);
12012
+ for (const block of message.content) {
12013
+ if (block.type === "tool_result") expected.delete(block.toolUseId);
12014
+ }
12015
+ }
12016
+ return expected.size === 0 ? messages : [];
12017
+ }
12018
+
12019
+ // src/context/session/request-prepare.ts
12020
+ function verifyPrivateBlockIntegrity(projected, sourceView) {
12021
+ for (const message of projected) {
12022
+ const original = sourceView.find((source) => source.id === message.id);
12023
+ const privateBag = /* @__PURE__ */ new Map();
12024
+ const blockBag = /* @__PURE__ */ new Map();
12025
+ for (const block of original?.content ?? []) {
12026
+ const serialized = stableSerialize(block);
12027
+ if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin)
12028
+ privateBag.set(serialized, (privateBag.get(serialized) ?? 0) + 1);
12029
+ blockBag.set(serialized, (blockBag.get(serialized) ?? 0) + 1);
12030
+ }
12031
+ const take = (bag, serialized) => {
12032
+ const count = bag.get(serialized) ?? 0;
12033
+ if (!count) return false;
12034
+ if (count === 1) bag.delete(serialized);
12035
+ else bag.set(serialized, count - 1);
12036
+ return true;
12037
+ };
12038
+ for (const block of message.content) {
12039
+ if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin) {
12040
+ if (!original || !take(privateBag, stableSerialize(block)))
12041
+ throw new ModelError("Hook changed signed or private request provenance", {
12042
+ stage: "request",
12043
+ retryable: false
12044
+ });
12045
+ }
12046
+ if (block.type !== "text" && block.type !== "tool_use") continue;
12047
+ if (block.protocolMeta && !take(blockBag, stableSerialize(block)))
12048
+ block.protocolMeta = void 0;
12049
+ }
12050
+ }
12051
+ }
12052
+ function assertSignedContinuationStable(check) {
12053
+ const { candidate, previousRequest, history, runId, nativeBinding, projectHistory } = check;
12054
+ const frozen = Boolean(
12055
+ previousRequest && history.some(
12056
+ (message) => message.interactionId === runId && message.content.some(
12057
+ (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.signature && block.origin?.runId === runId)
12058
+ )
12059
+ )
12060
+ );
12061
+ if (frozen) {
12062
+ const generated = nativeBinding ? projectProtocolHistory(
12063
+ projectHistory(history).filter(
12064
+ (message) => message.interactionId === runId && message.role !== "user"
12065
+ ),
12066
+ nativeBinding,
12067
+ candidate.context
12068
+ ) : [];
12069
+ for (const original of generated) {
12070
+ const matches = candidate.messages.filter((message) => message.id === original.id);
12071
+ if (matches.length !== 1 || stableSerialize(matches[0]) !== stableSerialize(original))
12072
+ throw new ModelError("Signed continuation prefix or tool chain changed", {
12073
+ stage: "request",
12074
+ retryable: false
12075
+ });
12076
+ }
12077
+ }
12078
+ if (frozen && previousRequest && (stableSerialize(candidate.messages.slice(0, previousRequest.messages.length)) !== stableSerialize(previousRequest.messages) || candidate.systemPrompt !== previousRequest.systemPrompt || stableSerialize(candidate.tools) !== stableSerialize(previousRequest.tools)))
12079
+ throw new ModelError("Signed continuation prefix changed", {
12080
+ stage: "request",
12081
+ retryable: false
12082
+ });
12083
+ return frozen;
12084
+ }
12085
+
12086
+ // src/context/session/skills.ts
12087
+ function createSkillResourceAuditor(eventLogger, sessionId) {
12088
+ return async (source, input, page) => {
12089
+ try {
12090
+ await eventLogger.recordAudit({
12091
+ level: "info",
12092
+ event: "skill.resource_read",
12093
+ sessionId,
12094
+ runId: source.runId,
12095
+ fields: {
12096
+ skillName: source.name,
12097
+ contentDigest: source.contentDigest,
12098
+ invocationId: source.invocationId,
12099
+ pathDigest: skillDigest(input.path),
12100
+ resourceDigest: skillDigest(page.text),
12101
+ outputBytes: Buffer.byteLength(page.text),
12102
+ outputTruncated: page.truncated,
12103
+ offset: input.offset,
12104
+ startLine: input.startLine,
12105
+ endLine: input.endLine
12106
+ }
12107
+ });
12108
+ } catch {
12109
+ throw new KapibalaError("Skill resource audit unavailable", {
12110
+ code: "AUDIT_UNAVAILABLE",
12111
+ retryPolicy: "after_user_action",
12112
+ safeMessage: "\u6280\u80FD\u8D44\u6E90\u8BFB\u53D6\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
12113
+ });
12114
+ }
12115
+ };
12116
+ }
12117
+ async function commitSkillEvents(deps) {
12118
+ const { skillRuntime, eventLogger, sessionId, runId, persistence } = deps;
12119
+ if (!skillRuntime) return [];
12120
+ return skillRuntime.commit(async (source, messageId) => {
12121
+ try {
12122
+ await eventLogger.recordAudit({
12123
+ level: "info",
12124
+ event: "skill.loaded",
12125
+ sessionId,
12126
+ runId,
12127
+ fields: {
12128
+ skillName: source.name,
12129
+ skillScope: source.scope,
12130
+ contentDigest: source.contentDigest,
12131
+ renderedDigest: source.renderedDigest,
12132
+ pathDigest: skillDigest(source.filePath),
12133
+ caller: source.caller,
12134
+ invocationId: source.invocationId,
12135
+ messageId
12136
+ }
12137
+ });
12138
+ } catch {
12139
+ throw new KapibalaError("Skill commit audit unavailable", {
12140
+ code: "AUDIT_UNAVAILABLE",
12141
+ retryPolicy: "after_user_action",
12142
+ safeMessage: "\u6280\u80FD\u63D0\u4EA4\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
12143
+ });
12144
+ }
12145
+ }, persistence);
12146
+ }
12147
+ async function authorizeExplicitSkill(deps) {
12148
+ const {
12149
+ name,
12150
+ runId,
12151
+ signal,
12152
+ rootDir,
12153
+ mode,
12154
+ permissionRules,
12155
+ approvalChannel,
12156
+ approvalCache,
12157
+ eventLogger,
12158
+ sessionId
12159
+ } = deps;
12160
+ const request = {
12161
+ toolName: "load_skill",
12162
+ capabilities: ["fs:read"],
12163
+ input: { name },
12164
+ rootDir,
12165
+ sessionAllowed: true
12166
+ };
12167
+ const cached = approvalCache.get(request);
12168
+ const decision = new PermissionPolicy().decide({
12169
+ toolName: request.toolName,
12170
+ capabilities: request.capabilities,
12171
+ mode,
12172
+ rules: permissionRules,
12173
+ cachedApproval: cached === "allow",
12174
+ cachedDenial: cached === "deny"
12175
+ });
12176
+ let permitted = decision.decision === "allow";
12177
+ let approvalChoice;
12178
+ if (decision.decision === "ask") {
12179
+ try {
12180
+ approvalChoice = await approvalChannel?.requestApproval(request, signal) ?? "deny_once";
12181
+ } catch {
12182
+ approvalChoice = "cancel";
12183
+ }
12184
+ permitted = approvalChoice === "allow_once" || approvalChoice === "allow_session";
12185
+ if (approvalChoice === "allow_session") approvalCache.set(request, "allow");
12186
+ if (approvalChoice === "deny_session") approvalCache.set(request, "deny");
12187
+ }
12188
+ await eventLogger.recordAudit({
12189
+ level: "info",
12190
+ event: "skill.decided",
12191
+ sessionId,
12192
+ runId,
12193
+ fields: {
12194
+ skillName: name,
12195
+ caller: "user",
12196
+ decision: permitted && !signal?.aborted ? "allow" : "deny",
12197
+ decisionSource: decision.decision === "ask" ? approvalChannel ? "human" : "non_interactive" : decision.source,
12198
+ approvalChoice
12199
+ }
12200
+ });
12201
+ if (!permitted) throw skillFailure("SKILL_INVOCATION_FORBIDDEN", "\u5BBF\u4E3B\u6743\u9650\u7981\u6B62\u663E\u5F0F\u6280\u80FD\u8BFB\u53D6");
12202
+ signal?.throwIfAborted();
12203
+ }
12204
+
12205
+ // src/context/session/usage.ts
12206
+ function recordUsage(attemptId, usage, accountedAttempts, usageStats) {
12207
+ if (accountedAttempts.has(attemptId)) return "duplicate";
12208
+ if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
12209
+ (n) => Number.isSafeInteger(n) && n >= 0
12210
+ ))
12211
+ return "unknown";
12212
+ accountedAttempts.add(attemptId);
12213
+ usageStats.promptTokens += usage.promptTokens;
12214
+ usageStats.completionTokens += usage.completionTokens;
12215
+ usageStats.totalTokens += usage.totalTokens;
12216
+ return "recorded";
12217
+ }
12218
+ function recordSummaryUsage(attemptId, usage, summaryAttempts, summaryUsage) {
12219
+ if (summaryAttempts.has(attemptId)) return "duplicate";
12220
+ if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
12221
+ (n) => Number.isSafeInteger(n) && n >= 0
12222
+ ))
12223
+ return "unknown";
12224
+ summaryAttempts.add(attemptId);
12225
+ summaryUsage.promptTokens += usage.promptTokens;
12226
+ summaryUsage.completionTokens += usage.completionTokens;
12227
+ summaryUsage.totalTokens += usage.totalTokens;
12228
+ return "recorded";
12229
+ }
12230
+ function computeContextUsage(profile, usedTokens, estimatedUsage = false) {
12231
+ const contextWindow = resolveContextWindow(profile.contextWindow);
12232
+ return {
12233
+ usedTokens,
12234
+ limitTokens: contextWindow.tokens,
12235
+ percent: usedTokens === void 0 ? void 0 : usedTokens / contextWindow.tokens * 100,
12236
+ estimatedLimit: contextWindow.estimated,
12237
+ ...estimatedUsage ? { estimatedUsage: true } : {}
12238
+ };
12239
+ }
12240
+
11673
12241
  // src/context/session/index.ts
11674
12242
  var AgentSession = class {
11675
12243
  skillRegistry;
@@ -11755,40 +12323,26 @@ var AgentSession = class {
11755
12323
  this.projectKey = resolvedWorkspace.projectKey;
11756
12324
  this.sandbox = config.sandbox ?? new PathSandbox({ workspaceRoot: this.workspaceRoot });
11757
12325
  this.leaseCoordinator = config.leaseCoordinator ?? new WorkspaceLeaseCoordinator();
12326
+ this.userInstructionsPath = config.userInstructionsPath;
12327
+ const sinks = config.eventLogger ? void 0 : createDefaultLogSinks(config.loggingDirectory);
12328
+ const eventLogger = config.eventLogger ?? new StructuredLogger({
12329
+ operationSink: sinks.operationSink,
12330
+ auditSink: sinks.auditSink,
12331
+ debug: config.logger !== void 0,
12332
+ onDiagnostic: config.onDiagnostic ?? config.logger
12333
+ });
12334
+ this.eventLogger = {
12335
+ record: (input) => eventLogger.record({ ...input, conversationId: this.persistentId }),
12336
+ recordAudit: (input) => eventLogger.recordAudit({ ...input, conversationId: this.persistentId })
12337
+ };
12338
+ this.auditRecoveryDirectory = config.auditRecoveryDirectory ?? sinks?.auditSink.directory;
11758
12339
  this.tools = new ToolRegistry();
11759
12340
  if (config.skills) {
11760
12341
  this.skillRegistry = new SkillRegistry(config.skills);
11761
12342
  this.skillRuntime = new SkillRuntime(
11762
12343
  this.skillRegistry,
11763
12344
  this.tools,
11764
- async (source, input, page) => {
11765
- try {
11766
- await this.eventLogger.recordAudit({
11767
- level: "info",
11768
- event: "skill.resource_read",
11769
- sessionId: this.sessionId,
11770
- runId: source.runId,
11771
- fields: {
11772
- skillName: source.name,
11773
- contentDigest: source.contentDigest,
11774
- invocationId: source.invocationId,
11775
- pathDigest: skillDigest(input.path),
11776
- resourceDigest: skillDigest(page.text),
11777
- outputBytes: Buffer.byteLength(page.text),
11778
- outputTruncated: page.truncated,
11779
- offset: input.offset,
11780
- startLine: input.startLine,
11781
- endLine: input.endLine
11782
- }
11783
- });
11784
- } catch {
11785
- throw new KapibalaError("Skill resource audit unavailable", {
11786
- code: "AUDIT_UNAVAILABLE",
11787
- retryPolicy: "after_user_action",
11788
- safeMessage: "\u6280\u80FD\u8D44\u6E90\u8BFB\u53D6\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
11789
- });
11790
- }
11791
- }
12345
+ createSkillResourceAuditor(this.eventLogger, this.sessionId)
11792
12346
  );
11793
12347
  }
11794
12348
  this.hooks = new HookRegistry();
@@ -11801,19 +12355,6 @@ var AgentSession = class {
11801
12355
  this.mode = config.mode ?? "Approval";
11802
12356
  this.permissionRules = config.permissionRules ?? [];
11803
12357
  this.approvalChannel = config.approvalChannel;
11804
- this.userInstructionsPath = config.userInstructionsPath;
11805
- const sinks = config.eventLogger ? void 0 : createDefaultLogSinks(config.loggingDirectory);
11806
- const eventLogger = config.eventLogger ?? new StructuredLogger({
11807
- operationSink: sinks.operationSink,
11808
- auditSink: sinks.auditSink,
11809
- debug: config.logger !== void 0,
11810
- onDiagnostic: config.onDiagnostic ?? config.logger
11811
- });
11812
- this.eventLogger = {
11813
- record: (input) => eventLogger.record({ ...input, conversationId: this.persistentId }),
11814
- recordAudit: (input) => eventLogger.recordAudit({ ...input, conversationId: this.persistentId })
11815
- };
11816
- this.auditRecoveryDirectory = config.auditRecoveryDirectory ?? sinks?.auditSink.directory;
11817
12358
  this.rootTaskId = config.rootTaskId;
11818
12359
  this.depth = config.depth ?? 0;
11819
12360
  this.agentId = config.agentId;
@@ -12418,71 +12959,21 @@ var AgentSession = class {
12418
12959
  runId,
12419
12960
  modelId: profile.id
12420
12961
  }) : this.context.project(history);
12421
- for (const message of projected) {
12422
- const original = sourceView.find((source) => source.id === message.id);
12423
- const privateBag = /* @__PURE__ */ new Map();
12424
- const blockBag = /* @__PURE__ */ new Map();
12425
- for (const block of original?.content ?? []) {
12426
- const serialized = stableSerialize(block);
12427
- if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin)
12428
- privateBag.set(serialized, (privateBag.get(serialized) ?? 0) + 1);
12429
- blockBag.set(serialized, (blockBag.get(serialized) ?? 0) + 1);
12430
- }
12431
- const take = (bag, serialized) => {
12432
- const count = bag.get(serialized) ?? 0;
12433
- if (!count) return false;
12434
- if (count === 1) bag.delete(serialized);
12435
- else bag.set(serialized, count - 1);
12436
- return true;
12437
- };
12438
- for (const block of message.content) {
12439
- if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin) {
12440
- if (!original || !take(privateBag, stableSerialize(block)))
12441
- throw new ModelError("Hook changed signed or private request provenance", {
12442
- stage: "request",
12443
- retryable: false
12444
- });
12445
- }
12446
- if (block.type !== "text" && block.type !== "tool_use") continue;
12447
- if (block.protocolMeta && !take(blockBag, stableSerialize(block)))
12448
- block.protocolMeta = void 0;
12449
- }
12450
- }
12451
- const frozen = Boolean(
12452
- previousRequest && history.some(
12453
- (message) => message.interactionId === runId && message.content.some(
12454
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.signature && block.origin?.runId === runId)
12455
- )
12456
- )
12457
- );
12962
+ verifyPrivateBlockIntegrity(projected, sourceView);
12458
12963
  const candidate = {
12459
12964
  ...request,
12460
12965
  messages: projected,
12461
12966
  context: { runId, modelId: profile.id },
12462
12967
  signal: options?.signal
12463
12968
  };
12464
- if (frozen) {
12465
- const generated = nativeBinding ? projectProtocolHistory(
12466
- this.context.project(history).filter(
12467
- (message) => message.interactionId === runId && message.role !== "user"
12468
- ),
12469
- nativeBinding,
12470
- candidate.context
12471
- ) : [];
12472
- for (const original of generated) {
12473
- const matches = candidate.messages.filter((message) => message.id === original.id);
12474
- if (matches.length !== 1 || stableSerialize(matches[0]) !== stableSerialize(original))
12475
- throw new ModelError("Signed continuation prefix or tool chain changed", {
12476
- stage: "request",
12477
- retryable: false
12478
- });
12479
- }
12480
- }
12481
- if (frozen && previousRequest && (stableSerialize(candidate.messages.slice(0, previousRequest.messages.length)) !== stableSerialize(previousRequest.messages) || candidate.systemPrompt !== previousRequest.systemPrompt || stableSerialize(candidate.tools) !== stableSerialize(previousRequest.tools)))
12482
- throw new ModelError("Signed continuation prefix changed", {
12483
- stage: "request",
12484
- retryable: false
12485
- });
12969
+ const frozen = assertSignedContinuationStable({
12970
+ candidate,
12971
+ previousRequest,
12972
+ history,
12973
+ runId,
12974
+ nativeBinding,
12975
+ projectHistory: (projectedHistory) => this.context.project(projectedHistory)
12976
+ });
12486
12977
  const context = this.context;
12487
12978
  const skills = this.skillRuntime;
12488
12979
  const preparation = context.prepare(history, candidate, profile, runId, reason, !frozen);
@@ -12531,6 +13022,7 @@ var AgentSession = class {
12531
13022
  let persistedHistoryLength = this.history.length;
12532
13023
  let loopCompleted = false;
12533
13024
  let loopFailed = false;
13025
+ let consumerInterrupted = false;
12534
13026
  try {
12535
13027
  for await (const event of loop.run(this.history)) {
12536
13028
  await this.logContextEvent(event, runId);
@@ -12605,7 +13097,12 @@ var AgentSession = class {
12605
13097
  if (event.type === "message_stop" || event.type === "tool_messages") {
12606
13098
  persistedHistoryLength = this.history.length;
12607
13099
  }
12608
- yield event;
13100
+ try {
13101
+ yield event;
13102
+ } catch (error) {
13103
+ consumerInterrupted = true;
13104
+ throw error;
13105
+ }
12609
13106
  }
12610
13107
  loopCompleted = true;
12611
13108
  } catch (error) {
@@ -12622,7 +13119,7 @@ var AgentSession = class {
12622
13119
  }
12623
13120
  throw error;
12624
13121
  } finally {
12625
- if (!loopCompleted && !loopFailed) {
13122
+ if (!loopCompleted && (!loopFailed || consumerInterrupted)) {
12626
13123
  await executor.rollback();
12627
13124
  if (this.store) {
12628
13125
  for (const message of this.history.slice(persistedHistoryLength)) {
@@ -12754,102 +13251,44 @@ var AgentSession = class {
12754
13251
  }
12755
13252
  /** 完整消息提交后完成技能审计;审计失败保留真实历史,停止本轮且不发布成功事件。 */
12756
13253
  async commitSkills(runId) {
12757
- if (!this.skillRuntime) return [];
12758
- return this.skillRuntime.commit(
12759
- async (source, messageId) => {
12760
- try {
12761
- await this.eventLogger.recordAudit({
12762
- level: "info",
12763
- event: "skill.loaded",
12764
- sessionId: this.sessionId,
12765
- runId,
12766
- fields: {
12767
- skillName: source.name,
12768
- skillScope: source.scope,
12769
- contentDigest: source.contentDigest,
12770
- renderedDigest: source.renderedDigest,
12771
- pathDigest: skillDigest(source.filePath),
12772
- caller: source.caller,
12773
- invocationId: source.invocationId,
12774
- messageId
12775
- }
12776
- });
12777
- } catch {
12778
- throw new KapibalaError("Skill commit audit unavailable", {
12779
- code: "AUDIT_UNAVAILABLE",
12780
- retryPolicy: "after_user_action",
12781
- safeMessage: "\u6280\u80FD\u63D0\u4EA4\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
12782
- });
12783
- }
12784
- },
12785
- this.store ? "disk" : "memory"
12786
- );
13254
+ return commitSkillEvents({
13255
+ skillRuntime: this.skillRuntime,
13256
+ eventLogger: this.eventLogger,
13257
+ sessionId: this.sessionId,
13258
+ runId,
13259
+ persistence: this.store ? "disk" : "memory"
13260
+ });
12787
13261
  }
12788
13262
  /** 显式加载仍按同一读取策略、精确缓存与宿主审批裁决;拒绝/取消/审计失败时不追加用户任务。 */
12789
- async authorizeSkill(name, runId, signal) {
12790
- const request = {
12791
- toolName: "load_skill",
12792
- capabilities: ["fs:read"],
12793
- input: { name },
13263
+ authorizeSkill(name, runId, signal) {
13264
+ return authorizeExplicitSkill({
13265
+ name,
13266
+ runId,
13267
+ signal,
12794
13268
  rootDir: this.rootDir,
12795
- sessionAllowed: true
12796
- };
12797
- const cached = this.approvalCache.get(request);
12798
- const decision = new PermissionPolicy().decide({
12799
- toolName: request.toolName,
12800
- capabilities: request.capabilities,
12801
13269
  mode: this.mode,
12802
- rules: this.permissionRules,
12803
- cachedApproval: cached === "allow",
12804
- cachedDenial: cached === "deny"
12805
- });
12806
- let permitted = decision.decision === "allow";
12807
- let approvalChoice;
12808
- if (decision.decision === "ask") {
12809
- try {
12810
- approvalChoice = await this.approvalChannel?.requestApproval(request, signal) ?? "deny_once";
12811
- } catch {
12812
- approvalChoice = "cancel";
12813
- }
12814
- permitted = approvalChoice === "allow_once" || approvalChoice === "allow_session";
12815
- if (approvalChoice === "allow_session") this.approvalCache.set(request, "allow");
12816
- if (approvalChoice === "deny_session") this.approvalCache.set(request, "deny");
12817
- }
12818
- await this.eventLogger.recordAudit({
12819
- level: "info",
12820
- event: "skill.decided",
12821
- sessionId: this.sessionId,
12822
- runId,
12823
- fields: {
12824
- skillName: name,
12825
- caller: "user",
12826
- decision: permitted && !signal?.aborted ? "allow" : "deny",
12827
- decisionSource: decision.decision === "ask" ? this.approvalChannel ? "human" : "non_interactive" : decision.source,
12828
- approvalChoice
12829
- }
13270
+ permissionRules: this.permissionRules,
13271
+ approvalChannel: this.approvalChannel,
13272
+ approvalCache: this.approvalCache,
13273
+ eventLogger: this.eventLogger,
13274
+ sessionId: this.sessionId
12830
13275
  });
12831
- if (!permitted) throw skillFailure("SKILL_INVOCATION_FORBIDDEN", "\u5BBF\u4E3B\u6743\u9650\u7981\u6B62\u663E\u5F0F\u6280\u80FD\u8BFB\u53D6");
12832
- signal?.throwIfAborted();
12833
13276
  }
12834
13277
  /**
12835
13278
  * 计算最近一次内部模型请求的上下文占用;无 usage 时显式标注预算估算。
12836
13279
  * run 累计 promptTokens 会重复计算每一步都重发的历史,不能用于窗口占用率。
12837
13280
  */
12838
13281
  createContextUsage(usedTokens, estimatedUsage = false) {
12839
- const contextWindow = resolveContextWindow(
12840
- (this.lastPrimaryProfile ?? this.getActiveProfile()).contextWindow
12841
- );
12842
- return {
13282
+ return computeContextUsage(
13283
+ this.lastPrimaryProfile ?? this.getActiveProfile(),
12843
13284
  usedTokens,
12844
- limitTokens: contextWindow.tokens,
12845
- percent: usedTokens === void 0 ? void 0 : usedTokens / contextWindow.tokens * 100,
12846
- estimatedLimit: contextWindow.estimated,
12847
- ...estimatedUsage ? { estimatedUsage: true } : {}
12848
- };
13285
+ estimatedUsage
13286
+ );
12849
13287
  }
12850
13288
  async persistAssistant(event) {
12851
- if (this.store) await this.store.append(event.message);
12852
- if (!event.usage) this.usageKnown = false;
13289
+ await persistAssistantMessage(event, this.store, () => {
13290
+ this.usageKnown = false;
13291
+ });
12853
13292
  }
12854
13293
  /** 创建上下文协调器并注入当前路由;摘要不经过主任务 Loop,插件结果不默认为可剪裁。 */
12855
13294
  createContextManager() {
@@ -12868,17 +13307,8 @@ var AgentSession = class {
12868
13307
  }
12869
13308
  /** 摘要实际消耗按 attempt 身份独立累加;缺失消耗保留未知,不计入主任务指标。 */
12870
13309
  accountSummaryUsage(attemptId, usage) {
12871
- if (this.summaryAttempts.has(attemptId)) return;
12872
- this.summaryAttempts.add(attemptId);
12873
- if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
12874
- (n) => Number.isSafeInteger(n) && n >= 0
12875
- )) {
12876
- this.summaryUsageKnown = false;
12877
- return;
12878
- }
12879
- this.summaryUsage.promptTokens += usage.promptTokens;
12880
- this.summaryUsage.completionTokens += usage.completionTokens;
12881
- this.summaryUsage.totalTokens += usage.totalTokens;
13310
+ const outcome = recordSummaryUsage(attemptId, usage, this.summaryAttempts, this.summaryUsage);
13311
+ if (outcome === "unknown") this.summaryUsageKnown = false;
12882
13312
  }
12883
13313
  /**
12884
13314
  * 空闲时手动摘要旧完整前缀,不创建用户任务、不执行工具,不突破当前与最近成功交互。
@@ -12951,61 +13381,21 @@ var AgentSession = class {
12951
13381
  }
12952
13382
  /** 以模型 attempt 身份幂等累加真实消耗;非法或缺失 usage 保持未知。 */
12953
13383
  accountUsage(attemptId, usage) {
12954
- if (this.accountedAttempts.has(attemptId)) return;
12955
- if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
12956
- (n) => Number.isSafeInteger(n) && n >= 0
12957
- )) {
12958
- this.usageKnown = false;
12959
- return;
12960
- }
12961
- this.accountedAttempts.add(attemptId);
12962
- this.usageStats.promptTokens += usage.promptTokens;
12963
- this.usageStats.completionTokens += usage.completionTokens;
12964
- this.usageStats.totalTokens += usage.totalTokens;
13384
+ const outcome = recordUsage(attemptId, usage, this.accountedAttempts, this.usageStats);
13385
+ if (outcome === "unknown") this.usageKnown = false;
12965
13386
  }
12966
13387
  async persistToolMessages(messages) {
12967
- if (!this.store) return;
12968
- for (const message of messages) await this.store.append(message);
13388
+ await persistToolMessages(messages, this.store);
12969
13389
  }
12970
13390
  /** 中止时为缓冲 assistant 的每个 tool_use 生成 OUTCOME_UNKNOWN 结果并入历史,保持事务闭合。 */
12971
13391
  closeInterruptedToolCalls(assistantEvent, error) {
12972
- const message = {
12973
- id: createMessageId(),
12974
- role: "tool",
12975
- content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
12976
- type: "tool_result",
12977
- toolUseId: toolUse.id,
12978
- content: JSON.stringify({
12979
- code: "OUTCOME_UNKNOWN",
12980
- retryPolicy: "after_user_action",
12981
- message: "Tool execution was interrupted; outcome is unknown"
12982
- }),
12983
- isError: true,
12984
- errorCode: "OUTCOME_UNKNOWN",
12985
- retryPolicy: "after_user_action"
12986
- })),
12987
- timestamp: Date.now()
12988
- };
13392
+ const message = buildInterruptedToolMessage(assistantEvent);
12989
13393
  this.history.push(message);
12990
13394
  return [message];
12991
13395
  }
12992
13396
  /** 收集历史中紧跟该 assistant 的连续 tool 消息;仅当覆盖全部 tool_use 时视为已闭合。 */
12993
13397
  findClosedToolMessages(assistantEvent) {
12994
- const assistantIndex = this.history.lastIndexOf(assistantEvent.message);
12995
- if (assistantIndex < 0) return [];
12996
- const expected = new Set(
12997
- assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => toolUse.id)
12998
- );
12999
- const messages = [];
13000
- for (let index2 = assistantIndex + 1; index2 < this.history.length; index2++) {
13001
- const message = this.history[index2];
13002
- if (message.role !== "tool") break;
13003
- messages.push(message);
13004
- for (const block of message.content) {
13005
- if (block.type === "tool_result") expected.delete(block.toolUseId);
13006
- }
13007
- }
13008
- return expected.size === 0 ? messages : [];
13398
+ return findClosedToolMessages(this.history, assistantEvent);
13009
13399
  }
13010
13400
  };
13011
13401
  export {