@kiturone/kapibala 0.0.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -129,6 +129,21 @@ var KapibalaError = class extends Error {
129
129
  this.dispatchOutcome = options?.dispatchOutcome;
130
130
  }
131
131
  };
132
+ var SESSION_SAFE_MESSAGES = {
133
+ SESSION_LOCKED: "\u4F1A\u8BDD\u5DF2\u88AB\u5176\u4ED6\u6301\u6709\u8005\u5360\u7528",
134
+ SESSION_RECOVERY_IN_PROGRESS: "\u4F1A\u8BDD\u6B63\u5728\u6062\u590D",
135
+ SESSION_LOCK_OWNER_CHANGED: "\u4F1A\u8BDD\u9501\u6301\u6709\u8005\u5DF2\u53D8\u5316",
136
+ SESSION_NOT_FOUND: "\u672A\u627E\u5230\u6307\u5B9A\u4F1A\u8BDD",
137
+ SESSION_ID_AMBIGUOUS: "\u4F1A\u8BDD\u524D\u7F00\u4E0D\u552F\u4E00",
138
+ HISTORY_PAGE_INVALID: "\u5386\u53F2\u9875\u53C2\u6570\u65E0\u6548"
139
+ };
140
+ function createSessionError(code, message) {
141
+ return new KapibalaError(message, {
142
+ code,
143
+ safeMessage: SESSION_SAFE_MESSAGES[code],
144
+ retryPolicy: "after_user_action"
145
+ });
146
+ }
132
147
  var PreDispatchError = class extends KapibalaError {
133
148
  constructor(message, options) {
134
149
  super(message, {
@@ -517,15 +532,50 @@ import path from "path";
517
532
 
518
533
  // src/context/jsonl-lines.ts
519
534
  import fs from "fs";
520
- 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 = {}) {
521
567
  const fd = fs.openSync(file, "r");
522
568
  const buffer = Buffer.allocUnsafe(64 * 1024);
523
569
  let pending = Buffer.alloc(0);
524
570
  let offset = 0;
571
+ let total = 0;
525
572
  try {
573
+ assertHistoryReadBudget(fs.fstatSync(fd).size, options);
526
574
  while (true) {
527
575
  const length = fs.readSync(fd, buffer, 0, buffer.length, null);
528
576
  if (!length) break;
577
+ total += length;
578
+ assertHistoryReadBudget(total, options);
529
579
  pending = Buffer.concat([pending, buffer.subarray(0, length)]);
530
580
  let start = 0;
531
581
  let end = pending.indexOf(10, start);
@@ -559,7 +609,7 @@ var SESSION_SCHEMA_VERSION_V2 = 2;
559
609
  var SESSION_SCHEMA_VERSION = SESSION_SCHEMA_VERSION_V2;
560
610
  var MAIN_BRANCH_ID = "main";
561
611
  function isGlobalControlRecordType(type) {
562
- 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";
563
613
  }
564
614
  var types = /* @__PURE__ */ new Set([
565
615
  "header",
@@ -578,7 +628,8 @@ var types = /* @__PURE__ */ new Set([
578
628
  "branch_created",
579
629
  "branch_selected",
580
630
  "branch_summary",
581
- "archive_state"
631
+ "archive_state",
632
+ "pin_state"
582
633
  ]);
583
634
  function supportsSessionState(store) {
584
635
  return "appendRecord" in store && typeof store.appendRecord === "function" && "loadState" in store && typeof store.loadState === "function";
@@ -661,6 +712,13 @@ function assertClosedToolTransaction(records, targetRecordId) {
661
712
  }
662
713
  }
663
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;
664
722
  filePath;
665
723
  queue = Promise.resolve();
666
724
  records = [];
@@ -670,10 +728,6 @@ var SessionStore = class _SessionStore {
670
728
  schemaVersion = SESSION_SCHEMA_VERSION_V2;
671
729
  activeBranchId = "main";
672
730
  branchLeaves = /* @__PURE__ */ new Map();
673
- constructor(filePath, schemaVersion = SESSION_SCHEMA_VERSION_V2) {
674
- this.filePath = path.resolve(filePath);
675
- this.schemaVersion = schemaVersion;
676
- }
677
731
  /** 获取当前会话文件使用的 Schema 版本(1 或 2)。 */
678
732
  getSchemaVersion() {
679
733
  return this.schemaVersion;
@@ -696,8 +750,8 @@ var SessionStore = class _SessionStore {
696
750
  this.onDiagnostic = onDiagnostic;
697
751
  }
698
752
  /** 以独占创建方式安装新头部(默认生成 Schema v2 格式),已有文件明确失败,绝不覆盖其它会话。 */
699
- static async create(filePath, header) {
700
- 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 });
701
755
  fs2.mkdirSync(path.dirname(store.filePath), { recursive: true });
702
756
  const record = store.makeRecord("header", {
703
757
  ...header,
@@ -718,8 +772,8 @@ var SessionStore = class _SessionStore {
718
772
  return store;
719
773
  }
720
774
  /** 打开并验证已有会话;尾部残片先保存到隔离文件,中部损坏与未知版本拒绝恢复。 */
721
- static async open(filePath) {
722
- const store = new _SessionStore(filePath);
775
+ static async open(filePath, options = {}) {
776
+ const store = new _SessionStore(filePath, SESSION_SCHEMA_VERSION_V2, { ...options });
723
777
  store.records = store.readFile(true);
724
778
  store.refreshStamp();
725
779
  return store;
@@ -813,6 +867,24 @@ var SessionStore = class _SessionStore {
813
867
  }
814
868
  return false;
815
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
+ }
816
888
  /**
817
889
  * 获取当前会话中所有已知分支的元数据与状态列表。
818
890
  */
@@ -1104,14 +1176,15 @@ ${summaryText}` }]
1104
1176
  }
1105
1177
  /** 校验每个完整记录,只有最后非空行的 JSON 残片可隔离,语义损坏绝不作为残片吞掉。 */
1106
1178
  readFile(recover) {
1107
- const iterator = readJsonlLines(this.filePath);
1179
+ const iterator = readJsonlLines(this.filePath, this.readOptions);
1108
1180
  let lastTerminated = true;
1109
1181
  let blankSeen = false;
1110
1182
  const records = [];
1111
1183
  const identities = /* @__PURE__ */ new Set();
1112
1184
  const messages = /* @__PURE__ */ new Set();
1113
- this.branchLeaves.clear();
1114
- this.activeBranchId = MAIN_BRANCH_ID;
1185
+ const branchLeaves = /* @__PURE__ */ new Map();
1186
+ let activeBranchId = MAIN_BRANCH_ID;
1187
+ let schemaVersion = this.schemaVersion;
1115
1188
  let detectedSchemaVersion = null;
1116
1189
  let i = -1;
1117
1190
  for (const line of iterator) {
@@ -1126,7 +1199,7 @@ ${summaryText}` }]
1126
1199
  try {
1127
1200
  record = JSON.parse(line.text);
1128
1201
  } catch {
1129
- 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()))
1130
1203
  throw new Error(`Session corrupt at line ${i + 1}`);
1131
1204
  fs2.writeFileSync(
1132
1205
  `${this.filePath}.corrupt-${createMessageId()}`,
@@ -1150,7 +1223,7 @@ ${summaryText}` }]
1150
1223
  throw new Error("Unsupported session schema version");
1151
1224
  }
1152
1225
  detectedSchemaVersion = record.schemaVersion;
1153
- this.schemaVersion = detectedSchemaVersion;
1226
+ schemaVersion = detectedSchemaVersion;
1154
1227
  } else if (record.schemaVersion !== detectedSchemaVersion) {
1155
1228
  throw new Error(`Session corrupt schema version mismatch at line ${i + 1}`);
1156
1229
  }
@@ -1167,11 +1240,11 @@ ${summaryText}` }]
1167
1240
  } else if (record.type === "header") {
1168
1241
  throw new Error("Session corrupt: repeated header");
1169
1242
  }
1170
- if (this.schemaVersion === SESSION_SCHEMA_VERSION_V1) {
1243
+ if (schemaVersion === SESSION_SCHEMA_VERSION_V1) {
1171
1244
  if (record.parentId !== (records.at(-1)?.recordId ?? null)) {
1172
1245
  throw new Error(`Session corrupt parent at line ${i + 1}`);
1173
1246
  }
1174
- this.branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1247
+ branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1175
1248
  } else {
1176
1249
  if (typeof record.seq !== "number" || record.seq !== records.length) {
1177
1250
  throw new Error(`Session corrupt seq at line ${i + 1}`);
@@ -1185,37 +1258,37 @@ ${summaryText}` }]
1185
1258
  throw new Error(`Session corrupt control record parent at line ${i + 1}`);
1186
1259
  }
1187
1260
  if (record.type === "header") {
1188
- this.branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1189
- this.activeBranchId = MAIN_BRANCH_ID;
1261
+ branchLeaves.set(MAIN_BRANCH_ID, record.recordId);
1262
+ activeBranchId = MAIN_BRANCH_ID;
1190
1263
  } else if (record.type === "branch_created") {
1191
1264
  const bId = record.payload.branchId;
1192
1265
  const baseId = record.payload.baseRecordId;
1193
1266
  if (typeof bId !== "string" || !bId) {
1194
1267
  throw new Error(`Session corrupt branch_created branchId at line ${i + 1}`);
1195
1268
  }
1196
- if (this.branchLeaves.has(bId)) {
1269
+ if (branchLeaves.has(bId)) {
1197
1270
  throw new Error(`Duplicate branch ID: ${bId}`);
1198
1271
  }
1199
1272
  if (typeof baseId !== "string" || !identities.has(baseId)) {
1200
1273
  throw new Error(`Session corrupt branch_created baseRecordId at line ${i + 1}`);
1201
1274
  }
1202
- this.branchLeaves.set(bId, baseId);
1275
+ branchLeaves.set(bId, baseId);
1203
1276
  } else if (record.type === "branch_selected") {
1204
1277
  const bId = record.payload.branchId;
1205
- if (typeof bId !== "string" || !this.branchLeaves.has(bId)) {
1278
+ if (typeof bId !== "string" || !branchLeaves.has(bId)) {
1206
1279
  throw new Error(`Selected branch not found: ${bId}`);
1207
1280
  }
1208
- this.activeBranchId = bId;
1281
+ activeBranchId = bId;
1209
1282
  }
1210
1283
  } else {
1211
1284
  const bId = record.branchId;
1212
- if (typeof bId !== "string" || !this.branchLeaves.has(bId)) {
1285
+ if (typeof bId !== "string" || !branchLeaves.has(bId)) {
1213
1286
  throw new Error(`Session corrupt branchId at line ${i + 1}`);
1214
1287
  }
1215
1288
  if (typeof record.parentId !== "string" || !identities.has(record.parentId)) {
1216
1289
  throw new Error(`Session corrupt parent at line ${i + 1}`);
1217
1290
  }
1218
- this.branchLeaves.set(bId, record.recordId);
1291
+ branchLeaves.set(bId, record.recordId);
1219
1292
  }
1220
1293
  }
1221
1294
  identities.add(record.recordId);
@@ -1234,7 +1307,7 @@ ${summaryText}` }]
1234
1307
  if (!records.length) {
1235
1308
  throw new Error("Session corrupt header");
1236
1309
  }
1237
- 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) {
1238
1311
  const fd = fs2.openSync(this.filePath, "a");
1239
1312
  try {
1240
1313
  fs2.writeFileSync(fd, "\n");
@@ -1243,6 +1316,9 @@ ${summaryText}` }]
1243
1316
  fs2.closeSync(fd);
1244
1317
  }
1245
1318
  }
1319
+ this.branchLeaves = branchLeaves;
1320
+ this.activeBranchId = activeBranchId;
1321
+ this.schemaVersion = schemaVersion;
1246
1322
  return records;
1247
1323
  }
1248
1324
  };
@@ -1274,19 +1350,23 @@ var SessionLock = class _SessionLock {
1274
1350
  if (error.code !== "EEXIST") throw error;
1275
1351
  const stale = _SessionLock.readOwner(lockPath);
1276
1352
  if (!stale || !_SessionLock.isDead(stale.pid))
1277
- throw new Error(
1353
+ throw createSessionError(
1354
+ "SESSION_LOCKED",
1278
1355
  `Session locked by a live or unknown owner${stale ? ` (pid=${stale.pid})` : ""}`
1279
1356
  );
1280
1357
  try {
1281
1358
  fs3.mkdirSync(recovery);
1282
1359
  } catch {
1283
- throw new Error("Session locked: recovery in progress");
1360
+ throw createSessionError(
1361
+ "SESSION_RECOVERY_IN_PROGRESS",
1362
+ "Session locked: recovery in progress"
1363
+ );
1284
1364
  }
1285
1365
  try {
1286
1366
  fs3.writeFileSync(path2.join(recovery, "owner.json"), JSON.stringify(owner), { flag: "wx" });
1287
1367
  const current = _SessionLock.readOwner(lockPath);
1288
1368
  if (current?.nonce !== stale.nonce || current.pid !== stale.pid || !_SessionLock.isDead(current.pid)) {
1289
- throw new Error("Session locked: owner changed");
1369
+ throw createSessionError("SESSION_LOCK_OWNER_CHANGED", "Session locked: owner changed");
1290
1370
  }
1291
1371
  const quarantine = `${lockPath}.stale-${randomUUID2()}`;
1292
1372
  fs3.renameSync(lockPath, quarantine);
@@ -1306,7 +1386,8 @@ var SessionLock = class _SessionLock {
1306
1386
  }
1307
1387
  if (installError.code === "EEXIST") {
1308
1388
  const winner = _SessionLock.readOwner(lockPath);
1309
- throw new Error(
1389
+ throw createSessionError(
1390
+ "SESSION_LOCKED",
1310
1391
  `Session locked by a live or unknown owner${winner ? ` (pid=${winner.pid})` : ""}`
1311
1392
  );
1312
1393
  }
@@ -1340,7 +1421,7 @@ var SessionLock = class _SessionLock {
1340
1421
  if (this.released) return;
1341
1422
  const current = _SessionLock.readOwner(this.lockPath);
1342
1423
  if (current?.nonce !== this.owner.nonce || current.pid !== this.owner.pid)
1343
- throw new Error("Session lock owner changed");
1424
+ throw createSessionError("SESSION_LOCK_OWNER_CHANGED", "Session lock owner changed");
1344
1425
  fs3.unlinkSync(path2.join(this.lockPath, "owner.json"));
1345
1426
  fs3.rmdirSync(this.lockPath);
1346
1427
  this.released = true;
@@ -1371,7 +1452,11 @@ var SessionLock = class _SessionLock {
1371
1452
  /** 无残留或残留进程已死亡时放行;确认存活的回收进行中则明确失败。 */
1372
1453
  static ensureNoLiveRecovery(recovery) {
1373
1454
  _SessionLock.clearAbandonedRecovery(recovery);
1374
- if (fs3.existsSync(recovery)) throw new Error("Session locked: recovery in progress");
1455
+ if (fs3.existsSync(recovery))
1456
+ throw createSessionError(
1457
+ "SESSION_RECOVERY_IN_PROGRESS",
1458
+ "Session locked: recovery in progress"
1459
+ );
1375
1460
  }
1376
1461
  /** 仅 ESRCH 表示确定死亡;PID 复用或 EPERM 均保持原锁。 */
1377
1462
  static isDead(pid) {
@@ -1521,18 +1606,21 @@ function resolveWorkspaceContext(input) {
1521
1606
  function normalizedProjectRoot(root) {
1522
1607
  return process.platform === "win32" ? root.replaceAll("\\", "/").toLowerCase() : root;
1523
1608
  }
1524
- function resolveSessionProject(cwd) {
1609
+ function resolveSessionProject(cwd, mode = "git") {
1525
1610
  const realCwd = toNativePhysicalPath(cwd);
1526
1611
  let root = realCwd;
1527
1612
  let gitBranch;
1528
1613
  let gitWorktree = false;
1529
1614
  try {
1530
- root = toNativePhysicalPath(
1615
+ const gitRoot = toNativePhysicalPath(
1531
1616
  execFileSync("git", ["-C", realCwd, "rev-parse", "--show-toplevel"], {
1532
1617
  encoding: "utf8",
1533
1618
  stdio: ["ignore", "pipe", "ignore"]
1534
1619
  }).trim()
1535
1620
  );
1621
+ if (mode === "git") root = gitRoot;
1622
+ if (normalizedProjectRoot(gitRoot) !== normalizedProjectRoot(root))
1623
+ return { projectRoot: root, cwd: realCwd, projectKey: computeProjectKey(root) };
1536
1624
  gitWorktree = true;
1537
1625
  gitBranch = execFileSync("git", ["-C", realCwd, "symbolic-ref", "--short", "-q", "HEAD"], {
1538
1626
  encoding: "utf8",
@@ -1540,11 +1628,10 @@ function resolveSessionProject(cwd) {
1540
1628
  }).trim() || void 0;
1541
1629
  } catch {
1542
1630
  }
1543
- const normalized = normalizedProjectRoot(root);
1544
1631
  return {
1545
1632
  projectRoot: root,
1546
1633
  cwd: realCwd,
1547
- projectKey: createHash4("sha256").update(normalized).digest("hex"),
1634
+ projectKey: computeProjectKey(root),
1548
1635
  gitBranch,
1549
1636
  gitWorktree
1550
1637
  };
@@ -1562,7 +1649,10 @@ var SessionManager = class {
1562
1649
  directory;
1563
1650
  handles = /* @__PURE__ */ new Map();
1564
1651
  diagnostic;
1652
+ readOptions;
1565
1653
  constructor(options = {}) {
1654
+ this.readOptions = { maxReadBytes: options.maxReadBytes };
1655
+ assertHistoryReadBudget(0, this.readOptions);
1566
1656
  const rootCandidate = options.workspaceRoot ?? options.projectRoot ?? options.cwd;
1567
1657
  if (!rootCandidate) {
1568
1658
  throw new KapibalaError("Workspace root is required for SessionManager", {
@@ -1570,7 +1660,7 @@ var SessionManager = class {
1570
1660
  safeMessage: "\u5FC5\u987B\u663E\u5F0F\u6307\u5B9A\u5DE5\u4F5C\u533A\u6839\u8DEF\u5F84\u6216\u9879\u76EE\u76EE\u5F55"
1571
1661
  });
1572
1662
  }
1573
- this.project = resolveSessionProject(rootCandidate);
1663
+ this.project = resolveSessionProject(rootCandidate, options.projectRootMode);
1574
1664
  this.directory = path4.join(
1575
1665
  options.homeDirectory ?? os.homedir(),
1576
1666
  ".kapibala",
@@ -1586,12 +1676,16 @@ var SessionManager = class {
1586
1676
  const file = path4.join(this.directory, `${Date.now()}_${conversationId}.jsonl`);
1587
1677
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1588
1678
  try {
1589
- const store = await SessionStore.create(file, {
1590
- conversationId,
1591
- projectRoot: this.project.projectRoot,
1592
- initialCwd: this.project.cwd,
1593
- gitBranch: this.project.gitBranch
1594
- });
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
+ );
1595
1689
  return await this.attach(store, lock);
1596
1690
  } catch (error) {
1597
1691
  await lock.release();
@@ -1600,89 +1694,146 @@ var SessionManager = class {
1600
1694
  }
1601
1695
  /**
1602
1696
  * 按正文活动排序分页;有效 sidecar 只读元数据,不读取完整 JSONL。默认排除已归档会话。
1603
- * pageSize 由调用方显式给出时必须在 1–100 之间;非法页参数直接失败,不静默修正。
1697
+ * pageSize 由调用方显式给出时必须在 1–100 之间;filter 在分页前筛选元数据,调用方须自行处理敏感信息。
1698
+ * 置顶项整体前置、组内仍按活动时间排序;排序只作用于本方法的返回值,不影响 continueRecent。
1604
1699
  */
1605
1700
  async list(page = 1, options) {
1606
1701
  const pageSize = options?.pageSize ?? 20;
1607
1702
  if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
1608
- throw new Error("History page must be a positive integer");
1703
+ throw createSessionError("HISTORY_PAGE_INVALID", "History page must be a positive integer");
1609
1704
  const all = await this.allMetadata();
1610
- 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
+ ];
1611
1711
  return {
1612
- items: filtered.slice((page - 1) * pageSize, page * pageSize),
1613
- total: filtered.length,
1712
+ items: ordered.slice((page - 1) * pageSize, page * pageSize),
1713
+ total: ordered.length,
1614
1714
  page,
1615
- pages: Math.max(1, Math.ceil(filtered.length / pageSize))
1715
+ pages: Math.max(1, Math.ceil(ordered.length / pageSize))
1616
1716
  };
1617
1717
  }
1618
1718
  /** 解析完整 ID 或唯一前缀对应的会话文件与 ID。 */
1619
1719
  resolveSession(id) {
1620
- if (!id || !/^[a-zA-Z0-9-]+$/.test(id)) throw new Error("Session not found");
1720
+ if (!id || !/^[a-zA-Z0-9-]+$/.test(id))
1721
+ throw createSessionError("SESSION_NOT_FOUND", "Session not found");
1621
1722
  const all = fs5.readdirSync(this.directory).filter((name) => /^\d+_[a-zA-Z0-9-]+\.jsonl$/.test(name)).map((fileName) => ({
1622
1723
  fileName,
1623
1724
  conversationId: fileName.slice(fileName.indexOf("_") + 1, -6)
1624
1725
  }));
1625
1726
  const exact = all.find((m) => m.conversationId === id || m.fileName === id);
1626
1727
  const matches = exact ? [exact] : all.filter((m) => m.conversationId.startsWith(id));
1627
- if (!matches.length) throw new Error(`Session not found: ${id}`);
1628
- if (matches.length !== 1) throw new Error(`Ambiguous session prefix: ${id}`);
1728
+ if (!matches.length) throw createSessionError("SESSION_NOT_FOUND", `Session not found: ${id}`);
1729
+ if (matches.length !== 1)
1730
+ throw createSessionError("SESSION_ID_AMBIGUOUS", `Ambiguous session prefix: ${id}`);
1629
1731
  return matches[0];
1630
1732
  }
1631
1733
  /**
1632
- * 逻辑归档指定会话。
1734
+ * 归档、恢复、重命名与置顶都向正文追加一条全局控制记录,共用同一套前置约束。
1633
1735
  *
1634
1736
  * 约束:
1635
1737
  * - 必须取得会话独占锁;
1636
- * - 若会话当前正处于活动打开状态,拒绝归档并抛出 SESSION_IN_USE;
1637
- * - 向会话正文追加 archive_state { archived: true } 并刷新 sidecar 缓存。
1738
+ * - 若会话当前正处于活动打开状态,拒绝并抛出 SESSION_IN_USE(调用方先关闭句柄);
1739
+ * - v1 只读历史不能变更,明确以 LEGACY_SESSION_READ_ONLY 失败而不是静默忽略;
1740
+ * - 写入后按正文重建 sidecar,状态只以正文记录为准。
1638
1741
  */
1639
- async archiveSession(id) {
1742
+ async appendControlRecord(id, subject, append) {
1640
1743
  const { fileName, conversationId } = this.resolveSession(id);
1641
1744
  if (this.handles.has(conversationId)) {
1642
- throw new KapibalaError("Session is currently active or in use, cannot archive", {
1745
+ throw new KapibalaError("Session is currently active or in use", {
1643
1746
  code: "SESSION_IN_USE",
1644
- 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}`
1645
1748
  });
1646
1749
  }
1647
1750
  const targetFile = path4.join(this.directory, fileName);
1648
1751
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1649
1752
  try {
1650
- const store = await SessionStore.open(targetFile);
1753
+ const store = await SessionStore.open(targetFile, this.readOptions);
1651
1754
  if (store.isReadOnly()) {
1652
- throw new KapibalaError("Cannot archive legacy v1 session", {
1755
+ throw new KapibalaError("Cannot modify legacy v1 session state", {
1653
1756
  code: "LEGACY_SESSION_READ_ONLY",
1654
- 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`
1655
1758
  });
1656
1759
  }
1657
- await store.setArchiveState(true);
1658
- const records = await store.readRecords();
1659
- const meta = this.buildMetadata(targetFile, records);
1660
- meta.archived = true;
1661
- this.writeMetadata(targetFile, meta);
1760
+ await append(store);
1761
+ this.writeMetadata(targetFile, this.buildMetadata(targetFile, await store.readRecords()));
1662
1762
  } finally {
1663
1763
  await lock.release();
1664
1764
  }
1665
1765
  }
1666
1766
  /**
1667
- * 恢复已逻辑归档的会话。
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 },正文保持不动。
1668
1774
  */
1669
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) {
1670
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
+ }
1671
1821
  const targetFile = path4.join(this.directory, fileName);
1672
1822
  const lock = await SessionLock.acquire(this.lockPath(conversationId));
1673
1823
  try {
1674
- const store = await SessionStore.open(targetFile);
1675
- if (store.isReadOnly()) {
1676
- throw new KapibalaError("Cannot restore legacy v1 session", {
1677
- code: "LEGACY_SESSION_READ_ONLY",
1678
- safeMessage: "\u65E0\u6CD5\u53D8\u66F4\u53EA\u8BFB\u5386\u53F2\u4F1A\u8BDD\u7684\u5F52\u6863\u72B6\u6001"
1679
- });
1824
+ const headerLines = this.readDiscoveryRecords(targetFile);
1825
+ let header;
1826
+ try {
1827
+ header = headerLines.next().value.payload;
1828
+ } finally {
1829
+ headerLines.return(void 0);
1680
1830
  }
1681
- await store.setArchiveState(false);
1682
- const records = await store.readRecords();
1683
- const meta = this.buildMetadata(targetFile, records);
1684
- meta.archived = false;
1685
- 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);
1686
1837
  } finally {
1687
1838
  await lock.release();
1688
1839
  }
@@ -1694,6 +1845,11 @@ var SessionManager = class {
1694
1845
  const file = path4.join(this.directory, fileName);
1695
1846
  try {
1696
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
+ }
1697
1853
  if (!meta) {
1698
1854
  const records = this.readDiscoveryRecords(file);
1699
1855
  meta = this.buildMetadata(file, records);
@@ -1704,7 +1860,17 @@ var SessionManager = class {
1704
1860
  }
1705
1861
  }
1706
1862
  all.push(meta);
1707
- } 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
+ }
1708
1874
  this.report(`Cannot discover session: ${fileName}`);
1709
1875
  }
1710
1876
  }
@@ -1754,7 +1920,10 @@ var SessionManager = class {
1754
1920
  }
1755
1921
  if (header.conversationId !== meta.conversationId || normalizedProjectRoot(header.projectRoot) !== normalizedProjectRoot(this.project.projectRoot))
1756
1922
  throw new Error("Foreign session identity");
1757
- 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
+ );
1758
1927
  if ((await store.loadState()).header.conversationId !== meta.conversationId)
1759
1928
  throw new Error("Session identity mismatch");
1760
1929
  await store.load();
@@ -1766,7 +1935,9 @@ var SessionManager = class {
1766
1935
  }
1767
1936
  /** 继续最近有正文的会话;没有历史才新建,显式打开失败绝不回退为空会话。 */
1768
1937
  async continueRecent(options) {
1769
- 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
+ );
1770
1941
  if (latest)
1771
1942
  return this.open(latest.conversationId, {
1772
1943
  autoUpgradeLegacy: options?.autoUpgradeLegacy ?? true
@@ -1774,11 +1945,21 @@ var SessionManager = class {
1774
1945
  this.report("No previous session with content; created a new session");
1775
1946
  return this.create();
1776
1947
  }
1777
- /** 标题作为独立耐久记录;标题或浏览不改变正文活动排序。 */
1948
+ /**
1949
+ * 重命名已打开或待打开的会话;标题作为独立耐久记录,标题或浏览都不改变正文活动排序。
1950
+ *
1951
+ * 未持有句柄时临时打开并在追加后释放:只改标题不该把会话一直挂在写锁上,
1952
+ * 否则同进程后续的归档、删除与置顶都会被自己占住的锁挡掉。
1953
+ */
1778
1954
  async rename(id, title) {
1779
1955
  if (!title.trim()) throw new Error("Session title cannot be empty");
1956
+ const alreadyOpen = this.handles.has(this.resolveSession(id).conversationId);
1780
1957
  const handle = await this.open(id, { autoUpgradeLegacy: true });
1781
- 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
+ }
1782
1963
  }
1783
1964
  /** 等候所有存储队列后释放句柄;宿主必须先结束 AgentSession 的工具/摘要清理。 */
1784
1965
  async close() {
@@ -1798,7 +1979,7 @@ var SessionManager = class {
1798
1979
  for (const source of sources) {
1799
1980
  if (!fs5.existsSync(source)) continue;
1800
1981
  const before = fs5.statSync(source);
1801
- const bytes = fs5.readFileSync(source);
1982
+ const bytes = readHistoryBytes(source, this.readOptions);
1802
1983
  const identity = createHash4("sha256").update(fs5.realpathSync.native(source)).update("\0").update(bytes).digest("hex");
1803
1984
  const importLock = await SessionLock.acquire(
1804
1985
  path4.join(this.directory, `import-${identity}.lock`)
@@ -1824,19 +2005,23 @@ var SessionManager = class {
1824
2005
  const id = createMessageId();
1825
2006
  const target = path4.join(this.directory, `${Date.now()}_${id}.jsonl`);
1826
2007
  temp = `${target}.tmp-${createMessageId()}`;
1827
- const store = await SessionStore.create(temp, {
1828
- conversationId: id,
1829
- projectRoot: this.project.projectRoot,
1830
- initialCwd: this.project.cwd,
1831
- gitBranch: this.project.gitBranch,
1832
- importIdentity: identity
1833
- });
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
+ );
1834
2019
  await store.appendRecord("legacy_import", { source, identity });
1835
2020
  await store.appendRecord("title", { title: "\u65E7\u5386\u53F2\u5BFC\u5165" });
1836
2021
  for (const message of identifyHistory(raw, identity)) await store.append(message);
1837
2022
  await store.load();
1838
2023
  const after = fs5.statSync(source);
1839
- 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)) {
1840
2025
  this.report("Legacy history is changing; import postponed");
1841
2026
  continue;
1842
2027
  }
@@ -1884,17 +2069,22 @@ var SessionManager = class {
1884
2069
  }));
1885
2070
  const exact = all.find((m) => m.conversationId === idOrFileName);
1886
2071
  const matches = exact ? [exact] : all.filter((m) => m.conversationId.startsWith(idOrFileName));
1887
- if (!matches.length) throw new Error(`Session not found: ${idOrFileName}`);
1888
- if (matches.length !== 1) throw new Error(`Ambiguous session prefix: ${idOrFileName}`);
2072
+ if (!matches.length)
2073
+ throw createSessionError("SESSION_NOT_FOUND", `Session not found: ${idOrFileName}`);
2074
+ if (matches.length !== 1)
2075
+ throw createSessionError(
2076
+ "SESSION_ID_AMBIGUOUS",
2077
+ `Ambiguous session prefix: ${idOrFileName}`
2078
+ );
1889
2079
  sourceFileName = matches[0].fileName;
1890
2080
  }
1891
2081
  const sourcePath = path4.join(this.directory, sourceFileName);
1892
2082
  if (!fs5.existsSync(sourcePath)) {
1893
- throw new Error(`Session not found: ${idOrFileName}`);
2083
+ throw createSessionError("SESSION_NOT_FOUND", `Session not found: ${idOrFileName}`);
1894
2084
  }
1895
2085
  const nativeSource = toNativePhysicalPath(sourcePath);
1896
2086
  const beforeStat = fs5.statSync(nativeSource);
1897
- const bytes = fs5.readFileSync(nativeSource);
2087
+ const bytes = readHistoryBytes(nativeSource, this.readOptions);
1898
2088
  const upgradeIdentity = createHash4("sha256").update(nativeSource).update("\0").update(bytes).digest("hex");
1899
2089
  const existingMeta = (await this.allMetadata()).find(
1900
2090
  (m) => m.upgradeIdentity === upgradeIdentity
@@ -1913,7 +2103,7 @@ var SessionManager = class {
1913
2103
  if (doubleCheck) {
1914
2104
  return this.open(doubleCheck.conversationId);
1915
2105
  }
1916
- const sourceStore = await SessionStore.open(nativeSource);
2106
+ const sourceStore = await SessionStore.open(nativeSource, this.readOptions);
1917
2107
  if (sourceStore.getSchemaVersion() !== SESSION_SCHEMA_VERSION_V1) {
1918
2108
  const state = await sourceStore.loadState();
1919
2109
  return this.open(state.header.conversationId);
@@ -1980,7 +2170,7 @@ var SessionManager = class {
1980
2170
  fs5.closeSync(fd);
1981
2171
  }
1982
2172
  const afterStat = fs5.statSync(nativeSource);
1983
- 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)) {
1984
2174
  throw new Error("Source legacy session changed during upgrade; retry aborted");
1985
2175
  }
1986
2176
  fs5.renameSync(tempPath, targetPath);
@@ -2143,7 +2333,7 @@ var SessionManager = class {
2143
2333
  store.setAppendObserver(
2144
2334
  (record) => {
2145
2335
  const previous = JSON.parse(
2146
- fs5.readFileSync(this.metadataPath(store.filePath), "utf8")
2336
+ readHistoryBytes(this.metadataPath(store.filePath), this.readOptions).toString("utf8")
2147
2337
  );
2148
2338
  const currentPrev = record.prevRecordId ?? record.parentId;
2149
2339
  if (previous.conversationId !== header.conversationId || currentPrev !== null && currentPrev !== (previous.logRevision ?? previous.revision))
@@ -2188,10 +2378,28 @@ var SessionManager = class {
2188
2378
  this.handles.set(header.conversationId, handle);
2189
2379
  return handle;
2190
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
+ }
2191
2397
  /** 缓存通过文件名、身份、统计与正文 stat 校验;失败交由正文重建。 */
2192
2398
  readMetadata(file) {
2193
2399
  try {
2194
- 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
+ );
2195
2403
  const stat = fs5.statSync(file);
2196
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)
2197
2405
  return meta;
@@ -2201,7 +2409,7 @@ var SessionManager = class {
2201
2409
  }
2202
2410
  /** 浏览仅解析完整记录;严格打开由 SessionStore 验证,浏览不能改写残片。 */
2203
2411
  *readDiscoveryRecords(file) {
2204
- const iterator = readJsonlLines(file);
2412
+ const iterator = readJsonlLines(file, this.readOptions);
2205
2413
  let first = true;
2206
2414
  let blankSeen = false;
2207
2415
  for (const line of iterator) {
@@ -2256,7 +2464,8 @@ var SessionManager = class {
2256
2464
  contentRevision: first.value.recordId,
2257
2465
  activeBranchId: MAIN_BRANCH_ID,
2258
2466
  activeLeafId: first.value.recordId,
2259
- archived: false
2467
+ archived: false,
2468
+ pinned: false
2260
2469
  };
2261
2470
  const branchLeaves = /* @__PURE__ */ new Map();
2262
2471
  branchLeaves.set(MAIN_BRANCH_ID, first.value.recordId);
@@ -2309,6 +2518,8 @@ var SessionManager = class {
2309
2518
  if (bId) meta.activeBranchId = bId;
2310
2519
  } else if (record.type === "archive_state") {
2311
2520
  meta.archived = Boolean(record.payload.archived);
2521
+ } else if (record.type === "pin_state") {
2522
+ meta.pinned = Boolean(record.payload.pinned);
2312
2523
  }
2313
2524
  if (branchLeaves && !isGlobalControlRecordType(record.type)) {
2314
2525
  const bId = record.branchId ?? meta.activeBranchId ?? MAIN_BRANCH_ID;
@@ -2815,6 +3026,7 @@ var CrossProjectSessionCatalog = class {
2815
3026
  let title = "\u65B0\u4F1A\u8BDD";
2816
3027
  let activeBranchId = MAIN_BRANCH_ID;
2817
3028
  let archived = false;
3029
+ let pinned = false;
2818
3030
  let lastRecordId = headerRecord.recordId;
2819
3031
  for (const line of iterator) {
2820
3032
  if (!line.text.trim()) continue;
@@ -2835,6 +3047,8 @@ var CrossProjectSessionCatalog = class {
2835
3047
  title = String(rec.payload.title);
2836
3048
  } else if (rec.type === "archive_state") {
2837
3049
  archived = Boolean(rec.payload.archived);
3050
+ } else if (rec.type === "pin_state") {
3051
+ pinned = Boolean(rec.payload.pinned);
2838
3052
  } else if (rec.type === "branch_selected") {
2839
3053
  activeBranchId = String(rec.payload.branchId);
2840
3054
  }
@@ -2853,6 +3067,7 @@ var CrossProjectSessionCatalog = class {
2853
3067
  title,
2854
3068
  activeBranchId,
2855
3069
  archived,
3070
+ pinned,
2856
3071
  size: stat.size,
2857
3072
  mtimeMs: stat.mtimeMs,
2858
3073
  revision: lastRecordId,
@@ -3259,7 +3474,6 @@ var WorkspaceLeaseCoordinator = class {
3259
3474
  }
3260
3475
  }
3261
3476
  };
3262
- var sharedWorkspaceLeaseCoordinator = new WorkspaceLeaseCoordinator();
3263
3477
 
3264
3478
  // src/context/manager.ts
3265
3479
  import { createHash as createHash7 } from "crypto";
@@ -5991,7 +6205,8 @@ function stopProcessTree(child, nativeGroup) {
5991
6205
  try {
5992
6206
  process.kill(-child.pid, "SIGTERM");
5993
6207
  return true;
5994
- } catch {
6208
+ } catch (error) {
6209
+ if (error.code === "ESRCH") return true;
5995
6210
  try {
5996
6211
  child.kill("SIGTERM");
5997
6212
  } catch {
@@ -6012,7 +6227,8 @@ function forceProcessTree(child, nativeGroup) {
6012
6227
  try {
6013
6228
  process.kill(-child.pid, "SIGKILL");
6014
6229
  return true;
6015
- } catch {
6230
+ } catch (error) {
6231
+ if (error.code === "ESRCH") return true;
6016
6232
  try {
6017
6233
  child.kill("SIGKILL");
6018
6234
  } catch {
@@ -6033,13 +6249,31 @@ async function confirmProcessTreeStopped(child, terminationSucceeded, nativeGrou
6033
6249
  if (process.platform === "win32") return true;
6034
6250
  const deadline = Date.now() + 1e3;
6035
6251
  while (Date.now() < deadline) {
6252
+ let exists;
6036
6253
  try {
6037
6254
  process.kill(-child.pid, 0);
6255
+ exists = true;
6038
6256
  } catch (error) {
6039
- if (error.code === "ESRCH") return true;
6040
- return false;
6257
+ const code = error.code;
6258
+ if (code === "ESRCH") return true;
6259
+ if (code !== "EPERM") return false;
6260
+ exists = true;
6261
+ }
6262
+ if (groupHasLiveProcess(child.pid)) {
6263
+ await new Promise((resolve) => setTimeout(resolve, 50));
6264
+ continue;
6041
6265
  }
6042
- await new Promise((resolve) => setTimeout(resolve, 50));
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;
6043
6277
  }
6044
6278
  return false;
6045
6279
  }
@@ -7407,90 +7641,419 @@ var HookRegistry = class {
7407
7641
  };
7408
7642
 
7409
7643
  // src/runtime/executor/index.ts
7410
- import { createHash as createHash12 } from "crypto";
7411
7644
  import path17 from "path";
7412
- var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
7413
- var ToolExecutor = class {
7414
- hardDenial;
7415
- onToolResult;
7416
- tools;
7417
- hooks;
7418
- workspaceRoot;
7419
- executionCwd;
7420
- sandbox;
7421
- leaseCoordinator;
7422
- rootDir;
7423
- signal;
7424
- logger;
7425
- toolTimeoutMs;
7426
- eventLogger;
7427
- sessionId;
7428
- runId;
7429
- branchId;
7430
- rootTaskId;
7431
- depth;
7432
- agentId;
7433
- committedActions = [];
7434
- rollbackActions = [];
7435
- mode;
7436
- permissionRules;
7437
- approvalChannel;
7438
- approvalCache;
7439
- policy = new PermissionPolicy();
7440
- neverFailures = /* @__PURE__ */ new Set();
7441
- cancellation = new AbortController();
7442
- constructor(options) {
7443
- this.hardDenial = options.hardDenial;
7444
- this.onToolResult = options.onToolResult;
7445
- this.tools = options.tools;
7446
- this.hooks = options.hooks;
7447
- this.workspaceRoot = options.workspaceRoot ?? options.rootDir;
7448
- this.executionCwd = options.executionCwd ?? options.rootDir;
7449
- this.sandbox = options.sandbox;
7450
- this.leaseCoordinator = options.leaseCoordinator;
7451
- this.rootDir = options.rootDir;
7452
- this.signal = options.signal ? AbortSignal.any([options.signal, this.cancellation.signal]) : this.cancellation.signal;
7453
- this.logger = options.logger;
7454
- this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
7455
- this.eventLogger = options.eventLogger;
7456
- this.sessionId = options.sessionId;
7457
- this.runId = options.runId;
7458
- this.branchId = options.branchId;
7459
- this.rootTaskId = options.rootTaskId;
7460
- this.depth = options.depth;
7461
- this.agentId = options.agentId;
7462
- this.mode = options.mode ?? "Approval";
7463
- this.permissionRules = options.permissionRules ?? [];
7464
- this.approvalChannel = options.approvalChannel;
7465
- this.approvalCache = options.approvalCache ?? new SessionApprovalCache();
7466
- }
7467
- /**
7468
- * 父工具事务提交成功后调用,按注册顺序执行所有提交动作。
7469
- * 动作执行成功后逐项清理对应回滚动作,若某项失败保留剩余回滚动作。
7470
- */
7471
- async commit() {
7472
- while (this.committedActions.length > 0) {
7473
- const action = this.committedActions.shift();
7474
- await action();
7475
- this.rollbackActions.shift();
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)) {
7684
+ try {
7685
+ await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
7686
+ await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
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
+ };
7700
+ }
7701
+ return {
7702
+ result: failure(
7703
+ "Identical failed tool call cannot be repeated in this run",
7704
+ "REPEAT_BLOCKED"
7705
+ )
7706
+ };
7476
7707
  }
7477
- this.rollbackActions.length = 0;
7478
- }
7479
- /**
7480
- * 父工具事务提交失败或取消时调用,按逆序执行所有回滚清理动作。
7481
- */
7482
- async rollback() {
7483
- const actions = [...this.rollbackActions].reverse();
7484
- this.committedActions.length = 0;
7485
- this.rollbackActions.length = 0;
7486
- for (const action of actions) {
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 } : {}
7720
+ });
7721
+ let permitted = decision.decision === "allow";
7722
+ let source = decision.source;
7723
+ let approvalChoice;
7724
+ if (decision.decision === "ask") {
7725
+ let choice = "deny_once";
7487
7726
  try {
7488
- await action();
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 });
7489
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
+ };
7490
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
+ };
7491
7791
  }
7492
- }
7493
- /** 中止本轮正在等待或执行的工具;调用方仍须等待 runAll 完成资源回收及结果审计。 */
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 完成资源回收及结果审计。 */
7494
8057
  cancel() {
7495
8058
  this.cancellation.abort();
7496
8059
  }
@@ -7725,191 +8288,28 @@ var ToolExecutor = class {
7725
8288
  }
7726
8289
  return failure("Invalid tool execution identity", "INVALID_TARGET");
7727
8290
  }
7728
- let auditFields = {};
7729
- for (let attempt = 0; attempt < 2; attempt++) {
7730
- const request = {
7731
- toolName: call.name,
7732
- capabilities: tool.metadata?.permissions ?? [],
7733
- input: finalInput,
7734
- rootDir: this.rootDir,
7735
- ...shell ? { shell } : {},
7736
- ...executionIdentity ? { executionIdentity } : {},
7737
- // 解释器内建查询与内容已绑定摘要的工作区直引脚本可确认不受外部脚本变更影响,
7738
- // 允许会话级批准;其余 shell 命令仅允许单次批准。
7739
- // 非 shell 工具(含深度绑定 executionIdentity 的外部工具)允许会话级批准。
7740
- sessionAllowed: !shell || /^(?:pwd|get-location)$/i.test(shell.command.trim()) || shell.scriptDigest !== void 0
7741
- };
7742
- const repeatKey = createHash12("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex");
7743
- if (this.neverFailures.has(repeatKey)) {
7744
- try {
7745
- await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
7746
- await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
7747
- } catch {
7748
- return failure(
7749
- "Audit unavailable; tool was not started",
7750
- "AUDIT_UNAVAILABLE",
7751
- "after_user_action",
7752
- {
7753
- version: 1,
7754
- dispatchOutcome: "not_sent",
7755
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7756
- }
7757
- );
7758
- }
7759
- return failure(
7760
- "Identical failed tool call cannot be repeated in this run",
7761
- "REPEAT_BLOCKED"
7762
- );
7763
- }
7764
- const hardDenied = this.hardDenial?.(tool, finalInput);
7765
- const cached = this.approvalCache.get(request);
7766
- const decision = this.policy.decide({
7767
- hardDenied: hardDenied !== void 0,
7768
- mode: typeof this.mode === "function" ? this.mode() : this.mode,
7769
- toolName: call.name,
7770
- capabilities: request.capabilities,
7771
- dangerous: tool.metadata?.dangerous,
7772
- cachedApproval: cached === "allow",
7773
- cachedDenial: cached === "deny",
7774
- rules: this.permissionRules,
7775
- ...shell ? { shell } : {}
7776
- });
7777
- let permitted = decision.decision === "allow";
7778
- let source = decision.source;
7779
- let approvalChoice;
7780
- if (decision.decision === "ask") {
7781
- let choice = "deny_once";
7782
- try {
7783
- choice = await this.approvalChannel?.requestApproval(request, this.signal) ?? "deny_once";
7784
- } catch {
7785
- choice = "cancel";
7786
- }
7787
- approvalChoice = choice;
7788
- if (choice === "allow_session" && request.sessionAllowed)
7789
- this.approvalCache.set(request, "allow");
7790
- if (choice === "deny_session") this.approvalCache.set(request, "deny");
7791
- permitted = choice === "allow_once" || choice === "allow_session" && request.sessionAllowed;
7792
- source = this.approvalChannel ? "human" : "non_interactive";
7793
- }
7794
- auditFields = shell ? {
7795
- shell: shell.interpreter,
7796
- cwdDigest: createHash12("sha256").update(shell.cwd).digest("hex"),
7797
- executableDigest: shell.executableDigest,
7798
- executionFingerprint: shell.executionFingerprint,
7799
- ...commandAuditSummary(shell.command)
7800
- } : executionIdentity ? {
7801
- serverIdentity: executionIdentity.serverIdentity,
7802
- connectionGeneration: executionIdentity.connectionGeneration,
7803
- catalogEpoch: executionIdentity.catalogEpoch,
7804
- toolDigest: executionIdentity.toolDigest
7805
- } : {};
7806
- try {
7807
- await audit("tool.decided", {
7808
- decision: permitted ? "allow" : "deny",
7809
- decisionSource: source,
7810
- ...approvalChoice ? { approvalChoice } : {},
7811
- ...hardDenied?.fields ?? {},
7812
- ...auditFields
7813
- });
7814
- } catch {
7815
- return failure(
7816
- "Audit unavailable; tool was not started",
7817
- "AUDIT_UNAVAILABLE",
7818
- "after_user_action",
7819
- {
7820
- version: 1,
7821
- dispatchOutcome: "not_sent",
7822
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7823
- }
7824
- );
7825
- }
7826
- if (!permitted || this.signal?.aborted) {
7827
- const code = this.signal?.aborted ? "ABORTED" : hardDenied?.code ?? "PERMISSION_DENIED";
7828
- try {
7829
- await audit("tool.finished", { status: "denied", errorCode: code });
7830
- } catch {
7831
- return failure("Audit result unavailable", "AUDIT_UNAVAILABLE", "after_user_action", {
7832
- version: 1,
7833
- dispatchOutcome: "not_sent",
7834
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7835
- });
7836
- }
7837
- return failure(
7838
- this.signal?.aborted ? "Operation cancelled" : hardDenied?.message ?? "Tool permission denied",
7839
- code,
7840
- this.signal?.aborted ? "after_user_action" : hardDenied?.retryPolicy ?? "after_user_action"
7841
- );
7842
- }
7843
- if (shell) {
7844
- try {
7845
- const next = tool.approvalScope?.(finalInput, this.rootDir, this.executionCwd);
7846
- if (next?.executionFingerprint !== shell.executionFingerprint) {
7847
- if (attempt === 0 && next) {
7848
- try {
7849
- await audit("tool.target_changed", { status: "redeciding" });
7850
- } catch {
7851
- return failure(
7852
- "Audit unavailable; tool was not started",
7853
- "AUDIT_UNAVAILABLE",
7854
- "after_user_action",
7855
- {
7856
- version: 1,
7857
- dispatchOutcome: "not_sent",
7858
- hostError: { phase: "audit", code: "AUDIT_UNAVAILABLE" }
7859
- }
7860
- );
7861
- }
7862
- shell = next;
7863
- continue;
7864
- }
7865
- await audit("tool.finished", { status: "target_changed", errorCode: "TARGET_CHANGED" });
7866
- return failure(
7867
- "Execution target changed after approval",
7868
- "TARGET_CHANGED",
7869
- "after_user_action"
7870
- );
7871
- }
7872
- } catch {
7873
- return failure(
7874
- "Execution target changed after approval",
7875
- "TARGET_CHANGED",
7876
- "after_user_action"
7877
- );
7878
- }
7879
- }
7880
- if (executionIdentity) {
7881
- try {
7882
- const nextIdentity = tool.executionIdentity?.(finalInput, this.rootDir);
7883
- if (!nextIdentity || nextIdentity.serverIdentity !== executionIdentity.serverIdentity || nextIdentity.connectionGeneration !== executionIdentity.connectionGeneration || nextIdentity.catalogEpoch !== executionIdentity.catalogEpoch || nextIdentity.toolDigest !== executionIdentity.toolDigest) {
7884
- await audit("tool.finished", {
7885
- status: "identity_changed",
7886
- errorCode: "MCP_TOOL_CHANGED"
7887
- });
7888
- return failure(
7889
- "Tool execution identity changed after approval",
7890
- "MCP_TOOL_CHANGED",
7891
- "after_user_action",
7892
- {
7893
- version: 1,
7894
- dispatchOutcome: "not_sent",
7895
- hostError: { phase: "validation", code: "MCP_TOOL_CHANGED" }
7896
- }
7897
- );
7898
- }
7899
- } catch {
7900
- return failure(
7901
- "Tool execution identity check failed after approval",
7902
- "MCP_TOOL_CHANGED",
7903
- "after_user_action",
7904
- {
7905
- version: 1,
7906
- dispatchOutcome: "not_sent"
7907
- }
7908
- );
7909
- }
7910
- }
7911
- break;
7912
- }
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 ?? {};
7913
8313
  try {
7914
8314
  await audit("tool.started", { status: "started", ...auditFields });
7915
8315
  } catch {
@@ -7938,6 +8338,7 @@ var ToolExecutor = class {
7938
8338
  this.signal?.addEventListener("abort", abortFromSession, { once: true });
7939
8339
  }
7940
8340
  const toolCtx = {
8341
+ leaseCoordinator: this.leaseCoordinator,
7941
8342
  workspaceRoot: this.workspaceRoot,
7942
8343
  executionCwd: this.executionCwd,
7943
8344
  sandbox: this.sandbox,
@@ -7968,9 +8369,10 @@ var ToolExecutor = class {
7968
8369
  { signal: executionController.signal }
7969
8370
  );
7970
8371
  }
7971
- 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(
7972
8373
  tool.execute(finalInput, toolCtx),
7973
8374
  call.name,
8375
+ this.toolTimeoutMs,
7974
8376
  executionController
7975
8377
  );
7976
8378
  if (typeof tool.toExecutionResult === "function") {
@@ -8031,105 +8433,18 @@ var ToolExecutor = class {
8031
8433
  dispatchOutcome: outcome
8032
8434
  };
8033
8435
  }
8034
- const resultBlock = {
8035
- type: "tool_result",
8036
- toolUseId: call.id,
8037
- content: outputStr,
8038
- isError,
8039
- ...errorCode ? { errorCode, retryPolicy } : {},
8040
- execution
8041
- };
8042
- if (resultBlock.isError && resultBlock.errorCode) {
8043
- resultBlock.content = JSON.stringify({
8044
- code: resultBlock.errorCode,
8045
- retryPolicy: resultBlock.retryPolicy,
8046
- message: outputStr
8047
- });
8048
- }
8049
- try {
8050
- for (const hook of this.hooks.get("tool:after")) {
8051
- await hook(
8052
- hookCtx,
8053
- { id: call.id, name: call.name, input: finalInput },
8054
- { output: outputStr, isError }
8055
- );
8056
- }
8057
- } catch {
8058
- resultBlock.content = JSON.stringify({
8059
- code: "HOOK_ERROR",
8060
- retryPolicy: "after_user_action",
8061
- message: "Tool finished but result processing failed"
8062
- });
8063
- resultBlock.isError = true;
8064
- resultBlock.errorCode = "HOOK_ERROR";
8065
- resultBlock.retryPolicy = "after_user_action";
8066
- resultBlock.execution = {
8067
- version: 1,
8068
- dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "failed",
8069
- hostError: {
8070
- phase: "hook",
8071
- code: "HOOK_ERROR"
8072
- }
8073
- };
8074
- }
8075
- resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
8076
- if (resultBlock.isError && resultBlock.retryPolicy === "never") {
8077
- this.neverFailures.add(
8078
- createHash12("sha256").update(stableJsonStringify([call.name, finalInput])).digest("hex")
8079
- );
8080
- }
8081
- try {
8082
- await audit("tool.finished", {
8083
- status: resultBlock.isError ? "failed" : "completed",
8084
- ...resultBlock.errorCode ? { errorCode: resultBlock.errorCode } : {},
8085
- durationMs: resultBlock.durationMs
8086
- });
8087
- } catch {
8088
- resultBlock.isError = true;
8089
- resultBlock.errorCode = "OUTCOME_UNKNOWN";
8090
- resultBlock.retryPolicy = "after_user_action";
8091
- resultBlock.content = JSON.stringify({
8092
- code: "OUTCOME_UNKNOWN",
8093
- retryPolicy: "after_user_action",
8094
- message: "Tool outcome is unknown because the audit result could not be written"
8095
- });
8096
- resultBlock.execution = {
8097
- version: 1,
8098
- dispatchOutcome: resultBlock.execution?.dispatchOutcome ?? "succeeded",
8099
- hostError: {
8100
- phase: "audit",
8101
- code: "AUDIT_UNAVAILABLE"
8102
- }
8103
- };
8104
- return resultBlock;
8105
- }
8106
- await this.onToolResult?.(tool, finalInput, resultBlock);
8107
- return resultBlock;
8108
- }
8109
- /**
8110
- * 给工具执行加超时护栏:Promise.race 在超时后reject,循环拿到 isError 结果继续运转,
8111
- * 不会因某个异步工具(如未来接入的网络/exec 工具)挂起而永久卡死。
8112
- *
8113
- * 局限:同步 CPU 密集型实现会阻塞事件循环,timer 同样无法触发;
8114
- * 因此内置工具仍需避免执行用户可控的同步高复杂度计算。
8115
- */
8116
- withTimeout(promise, toolName, executionController) {
8117
- let timer;
8118
- const timeout = new Promise((_, reject) => {
8119
- timer = setTimeout(() => {
8120
- const error = new KapibalaError(
8121
- `Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`,
8122
- {
8123
- code: "OUTCOME_UNKNOWN",
8124
- retryPolicy: "after_user_action",
8125
- safeMessage: "Tool timed out; completion of side effects is unknown"
8126
- }
8127
- );
8128
- reject(error);
8129
- executionController.abort(error);
8130
- }, 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 }
8131
8447
  });
8132
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
8133
8448
  }
8134
8449
  /**
8135
8450
  * 构建当前会话在派生时刻的父权限策略快照。
@@ -8728,7 +9043,7 @@ var OpenAICompatibleProvider = class {
8728
9043
  };
8729
9044
 
8730
9045
  // src/models/anthropic/index.ts
8731
- import { createHash as createHash13 } from "crypto";
9046
+ import { createHash as createHash14 } from "crypto";
8732
9047
  function invalidResponse2() {
8733
9048
  throw new ModelError("Invalid Anthropic response state", {
8734
9049
  code: "MODEL_INVALID_RESPONSE",
@@ -8743,7 +9058,7 @@ function wireToolId(id) {
8743
9058
  stage: "request",
8744
9059
  retryable: false
8745
9060
  });
8746
- 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)}`;
8747
9062
  }
8748
9063
  var AnthropicProvider = class {
8749
9064
  name = "anthropic";
@@ -9109,7 +9424,7 @@ var AnthropicProvider = class {
9109
9424
  };
9110
9425
 
9111
9426
  // src/models/openai-responses/index.ts
9112
- import { createHash as createHash14 } from "crypto";
9427
+ import { createHash as createHash15 } from "crypto";
9113
9428
  function invalid(message = "Invalid Responses output Item") {
9114
9429
  throw new ModelError(message, {
9115
9430
  code: "MODEL_INVALID_RESPONSE",
@@ -9130,7 +9445,7 @@ function wireCallId(id) {
9130
9445
  stage: "request",
9131
9446
  retryable: false
9132
9447
  });
9133
- 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)}`;
9134
9449
  }
9135
9450
  function validateItem(item, final) {
9136
9451
  if (!item || typeof item !== "object" || !nonempty(item.id)) invalid();
@@ -9827,13 +10142,99 @@ Follow these key behavioral rules:
9827
10142
  }
9828
10143
  return lines.join("\n");
9829
10144
  }
9830
- getL3Environment() {
9831
- const platform = os6.platform();
9832
- return `# Environment Context:
9833
- - Current Working Directory (Workspace Root): ${this.rootDir}
9834
- - 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);
9835
10236
  }
9836
- };
10237
+ }
9837
10238
 
9838
10239
  // src/runtime/loop/index.ts
9839
10240
  var AgentLoop = class {
@@ -9917,26 +10318,7 @@ var AgentLoop = class {
9917
10318
  request = modified;
9918
10319
  }
9919
10320
  }
9920
- if (request.tools) {
9921
- for (const t of request.tools) {
9922
- const original = toolSnapshot.get(t.name);
9923
- if (!original) {
9924
- throw new ModelError(`Hook injected tool '${t.name}' not present in snapshot`, {
9925
- stage: "request",
9926
- retryable: false
9927
- });
9928
- }
9929
- if (t.description !== original.description || JSON.stringify(t.parameters) !== JSON.stringify(original.parameters)) {
9930
- throw new ModelError(
9931
- `Hook mutated tool definition for '${t.name}' (description or parameters drifted from snapshot)`,
9932
- {
9933
- stage: "request",
9934
- retryable: false
9935
- }
9936
- );
9937
- }
9938
- }
9939
- }
10321
+ assertHookToolDefinitionsIntact(request, toolSnapshot);
9940
10322
  if (this.prepareRequest) request = yield* this.prepareRequest(request, history);
9941
10323
  yield {
9942
10324
  type: "step_log",
@@ -10087,22 +10469,7 @@ var AgentLoop = class {
10087
10469
  stage: "stream",
10088
10470
  retryable: false
10089
10471
  });
10090
- const knownCalls = new Set(
10091
- history.flatMap(
10092
- (message) => message.content.filter((block) => block.type === "tool_use").map((block) => block.id)
10093
- )
10094
- );
10095
- for (const call of toolCalls) {
10096
- if (!call.name?.trim() || !call.input || typeof call.input !== "object" || Array.isArray(call.input))
10097
- throw new ModelError("Invalid tool call arguments or name; no tools executed", {
10098
- code: "MODEL_INVALID_RESPONSE",
10099
- stage: "stream",
10100
- retryable: false
10101
- });
10102
- if (!call.id?.trim() || knownCalls.has(call.id))
10103
- throw new ModelError("Duplicate tool call ID; no tools executed");
10104
- knownCalls.add(call.id);
10105
- }
10472
+ assertToolCallsExecutable(history, toolCalls);
10106
10473
  break;
10107
10474
  } catch (error) {
10108
10475
  if (!(error instanceof ContextOverflowError) || outputSeen || accumulatedText || accumulatedThinking || toolCalls.length || overflowRetried || !this.prepareRequest || this.signal?.aborted)
@@ -10215,47 +10582,9 @@ var AgentLoop = class {
10215
10582
  timestamp: Date.now()
10216
10583
  };
10217
10584
  validateProtocolContent(assistantMessage.content);
10218
- const originalContent = stableSerialize(assistantMessage.content);
10219
- const identity = stableSerialize({ ...assistantMessage, content: void 0 });
10220
- const originalTools = stableSerialize(
10221
- assistantMessage.content.filter(
10222
- (block) => block.type === "tool_use" || block.type === "tool_result"
10223
- )
10224
- );
10225
- const privateOutput = assistantMessage.content.some(
10226
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10227
- );
10585
+ const integrity = snapshotAssistantIntegrity(assistantMessage);
10228
10586
  await this.hooks.emit("model:after", hookCtx, { message: assistantMessage });
10229
- if (identity !== stableSerialize({ ...assistantMessage, content: void 0 }))
10230
- throw new ModelError("Hook changed assistant message identity", {
10231
- stage: "request",
10232
- retryable: false
10233
- });
10234
- if (originalTools !== stableSerialize(
10235
- assistantMessage.content.filter(
10236
- (block) => block.type === "tool_use" || block.type === "tool_result"
10237
- )
10238
- ))
10239
- throw new ModelError("Hook changed validated tool model output", {
10240
- stage: "request",
10241
- retryable: false
10242
- });
10243
- if (originalContent !== stableSerialize(assistantMessage.content)) {
10244
- if (privateOutput || assistantMessage.content.some(
10245
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.origin || block.signature)
10246
- ))
10247
- throw new ModelError("Hook changed signed or private model output", {
10248
- stage: "request",
10249
- retryable: false
10250
- });
10251
- if (toolCalls.length)
10252
- throw new ModelError("Hook changed validated tool model output", {
10253
- stage: "request",
10254
- retryable: false
10255
- });
10256
- for (const block of assistantMessage.content)
10257
- if (block.type === "text" || block.type === "tool_use") block.protocolMeta = void 0;
10258
- }
10587
+ assertAssistantMessageUntampered(assistantMessage, integrity);
10259
10588
  validateProtocolContent(assistantMessage.content);
10260
10589
  if (toolCalls.length === 0) {
10261
10590
  history.push(assistantMessage);
@@ -10660,6 +10989,7 @@ var SessionInbox = class _SessionInbox {
10660
10989
 
10661
10990
  // src/runtime/agent/runtime.ts
10662
10991
  var AgentRuntime = class {
10992
+ runnerContexts = /* @__PURE__ */ new Map();
10663
10993
  tasks = /* @__PURE__ */ new Map();
10664
10994
  controllers = /* @__PURE__ */ new Map();
10665
10995
  timers = /* @__PURE__ */ new Map();
@@ -10743,6 +11073,13 @@ var AgentRuntime = class {
10743
11073
  * - 登记为 preparing 状态并返回 accepted 回执,使父工具事务能立刻闭合。
10744
11074
  */
10745
11075
  async prepareSpawn(context, params) {
11076
+ const coordinator = context.leaseCoordinator;
11077
+ if (!coordinator || typeof coordinator !== "object" || typeof coordinator.isLocked !== "function" || typeof coordinator.getActiveLease !== "function" || typeof coordinator.tryAcquire !== "function" || typeof coordinator.acquire !== "function") {
11078
+ throw new KapibalaError("Spawn requires the parent workspace lease coordinator", {
11079
+ code: "CONFIG_INVALID",
11080
+ safeMessage: "\u5FC5\u987B\u663E\u5F0F\u4F20\u5165\u7236\u4F1A\u8BDD\u5199\u79DF\u7EA6\u534F\u8C03\u5668"
11081
+ });
11082
+ }
10746
11083
  if (this.isClosed) {
10747
11084
  throw new KapibalaError("AgentRuntime is closed", {
10748
11085
  code: "CONFIG_INVALID",
@@ -10885,6 +11222,7 @@ var AgentRuntime = class {
10885
11222
  timeoutMs
10886
11223
  })
10887
11224
  };
11225
+ this.runnerContexts.set(agentId, Object.freeze({ leaseCoordinator: context.leaseCoordinator }));
10888
11226
  this.tasks.set(agentId, taskRecord);
10889
11227
  this.totalActiveCount++;
10890
11228
  this.treeActiveCounts.set(rootTaskId, treeActive + 1);
@@ -10937,6 +11275,7 @@ var AgentRuntime = class {
10937
11275
  const task = this.tasks.get(agentId);
10938
11276
  if (!task) return;
10939
11277
  if (task.status === "preparing") {
11278
+ this.runnerContexts.delete(agentId);
10940
11279
  task.status = "cancelled";
10941
11280
  task.error = "Parent transaction aborted before commit";
10942
11281
  task.finishedAt = Date.now();
@@ -10962,7 +11301,10 @@ var AgentRuntime = class {
10962
11301
  this.timers.set(task.agentId, timer);
10963
11302
  let runError;
10964
11303
  try {
10965
- const result = await this.runner(task, controller.signal);
11304
+ const context = this.runnerContexts.get(task.agentId);
11305
+ if (!context)
11306
+ throw new KapibalaError("Runner context is unavailable", { code: "CONFIG_INVALID" });
11307
+ const result = await this.runner(task, controller.signal, context);
10966
11308
  if (task.status === "cancelling") {
10967
11309
  task.status = "cancelled";
10968
11310
  return;
@@ -11011,14 +11353,17 @@ var AgentRuntime = class {
11011
11353
  task.status = "failed";
11012
11354
  }
11013
11355
  task.error = runError.message;
11014
- await this.cascadeCancelChildren(task.agentId, "Parent task failed");
11015
11356
  } finally {
11357
+ this.runnerContexts.delete(task.agentId);
11016
11358
  this.clearTaskTimer(task.agentId);
11017
11359
  this.controllers.delete(task.agentId);
11018
11360
  if (!isTerminalTaskStatus(task.status)) {
11019
11361
  task.status = "failed";
11020
11362
  task.error = runError?.message ?? "Unknown execution failure";
11021
11363
  }
11364
+ if (task.status === "failed") {
11365
+ await this.cascadeCancelChildren(task.agentId, task.error ?? "Parent task failed");
11366
+ }
11022
11367
  task.finishedAt = Date.now();
11023
11368
  this.releaseTaskSlotOnce(task);
11024
11369
  this.outbox.enqueueFromTask(task);
@@ -11088,6 +11433,7 @@ var AgentRuntime = class {
11088
11433
  controller?.abort(new Error(reason));
11089
11434
  await this.cascadeCancelChildren(agentId, reason);
11090
11435
  const inFlight = this.runningPromises.get(agentId);
11436
+ if (!inFlight) this.runnerContexts.delete(agentId);
11091
11437
  if (inFlight) {
11092
11438
  const GRACE_PERIOD_MS = 5e3;
11093
11439
  let timer;
@@ -11196,6 +11542,7 @@ var AgentRuntime = class {
11196
11542
  }
11197
11543
  this.timers.clear();
11198
11544
  this.controllers.clear();
11545
+ this.runnerContexts.clear();
11199
11546
  this.stateListeners.clear();
11200
11547
  }
11201
11548
  /**
@@ -11336,9 +11683,15 @@ function createSpawnAgentTool(runtime) {
11336
11683
  required: ["description", "prompt", "role"]
11337
11684
  },
11338
11685
  async execute(input, ctx) {
11686
+ if (!ctx.leaseCoordinator)
11687
+ throw new KapibalaError("Missing parent lease coordinator", {
11688
+ code: "CONFIG_INVALID",
11689
+ safeMessage: "\u7F3A\u5C11\u7236\u4F1A\u8BDD\u5199\u79DF\u7EA6\u534F\u8C03\u5668"
11690
+ });
11339
11691
  const workspaceRoot = ctx.workspaceRoot ?? ctx.rootDir;
11340
11692
  const executionCwd = ctx.executionCwd ?? workspaceRoot;
11341
11693
  const spawnContext = {
11694
+ leaseCoordinator: ctx.leaseCoordinator,
11342
11695
  rootTaskId: ctx.rootTaskId,
11343
11696
  sessionId: ctx.sessionId ?? "session-default",
11344
11697
  branchId: ctx.branchId ?? "main",
@@ -11617,6 +11970,274 @@ function cancellableGenerator(create, external) {
11617
11970
  };
11618
11971
  }
11619
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
+
11620
12241
  // src/context/session/index.ts
11621
12242
  var AgentSession = class {
11622
12243
  skillRegistry;
@@ -11701,41 +12322,27 @@ var AgentSession = class {
11701
12322
  this.cwd = resolvedWorkspace.executionCwd;
11702
12323
  this.projectKey = resolvedWorkspace.projectKey;
11703
12324
  this.sandbox = config.sandbox ?? new PathSandbox({ workspaceRoot: this.workspaceRoot });
11704
- this.leaseCoordinator = config.leaseCoordinator ?? sharedWorkspaceLeaseCoordinator;
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;
11705
12339
  this.tools = new ToolRegistry();
11706
12340
  if (config.skills) {
11707
12341
  this.skillRegistry = new SkillRegistry(config.skills);
11708
12342
  this.skillRuntime = new SkillRuntime(
11709
12343
  this.skillRegistry,
11710
12344
  this.tools,
11711
- async (source, input, page) => {
11712
- try {
11713
- await this.eventLogger.recordAudit({
11714
- level: "info",
11715
- event: "skill.resource_read",
11716
- sessionId: this.sessionId,
11717
- runId: source.runId,
11718
- fields: {
11719
- skillName: source.name,
11720
- contentDigest: source.contentDigest,
11721
- invocationId: source.invocationId,
11722
- pathDigest: skillDigest(input.path),
11723
- resourceDigest: skillDigest(page.text),
11724
- outputBytes: Buffer.byteLength(page.text),
11725
- outputTruncated: page.truncated,
11726
- offset: input.offset,
11727
- startLine: input.startLine,
11728
- endLine: input.endLine
11729
- }
11730
- });
11731
- } catch {
11732
- throw new KapibalaError("Skill resource audit unavailable", {
11733
- code: "AUDIT_UNAVAILABLE",
11734
- retryPolicy: "after_user_action",
11735
- safeMessage: "\u6280\u80FD\u8D44\u6E90\u8BFB\u53D6\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
11736
- });
11737
- }
11738
- }
12345
+ createSkillResourceAuditor(this.eventLogger, this.sessionId)
11739
12346
  );
11740
12347
  }
11741
12348
  this.hooks = new HookRegistry();
@@ -11748,19 +12355,6 @@ var AgentSession = class {
11748
12355
  this.mode = config.mode ?? "Approval";
11749
12356
  this.permissionRules = config.permissionRules ?? [];
11750
12357
  this.approvalChannel = config.approvalChannel;
11751
- this.userInstructionsPath = config.userInstructionsPath;
11752
- const sinks = config.eventLogger ? void 0 : createDefaultLogSinks(config.loggingDirectory);
11753
- const eventLogger = config.eventLogger ?? new StructuredLogger({
11754
- operationSink: sinks.operationSink,
11755
- auditSink: sinks.auditSink,
11756
- debug: config.logger !== void 0,
11757
- onDiagnostic: config.onDiagnostic ?? config.logger
11758
- });
11759
- this.eventLogger = {
11760
- record: (input) => eventLogger.record({ ...input, conversationId: this.persistentId }),
11761
- recordAudit: (input) => eventLogger.recordAudit({ ...input, conversationId: this.persistentId })
11762
- };
11763
- this.auditRecoveryDirectory = config.auditRecoveryDirectory ?? sinks?.auditSink.directory;
11764
12358
  this.rootTaskId = config.rootTaskId;
11765
12359
  this.depth = config.depth ?? 0;
11766
12360
  this.agentId = config.agentId;
@@ -12365,71 +12959,21 @@ var AgentSession = class {
12365
12959
  runId,
12366
12960
  modelId: profile.id
12367
12961
  }) : this.context.project(history);
12368
- for (const message of projected) {
12369
- const original = sourceView.find((source) => source.id === message.id);
12370
- const privateBag = /* @__PURE__ */ new Map();
12371
- const blockBag = /* @__PURE__ */ new Map();
12372
- for (const block of original?.content ?? []) {
12373
- const serialized = stableSerialize(block);
12374
- if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin)
12375
- privateBag.set(serialized, (privateBag.get(serialized) ?? 0) + 1);
12376
- blockBag.set(serialized, (blockBag.get(serialized) ?? 0) + 1);
12377
- }
12378
- const take = (bag, serialized) => {
12379
- const count = bag.get(serialized) ?? 0;
12380
- if (!count) return false;
12381
- if (count === 1) bag.delete(serialized);
12382
- else bag.set(serialized, count - 1);
12383
- return true;
12384
- };
12385
- for (const block of message.content) {
12386
- if (block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && block.origin) {
12387
- if (!original || !take(privateBag, stableSerialize(block)))
12388
- throw new ModelError("Hook changed signed or private request provenance", {
12389
- stage: "request",
12390
- retryable: false
12391
- });
12392
- }
12393
- if (block.type !== "text" && block.type !== "tool_use") continue;
12394
- if (block.protocolMeta && !take(blockBag, stableSerialize(block)))
12395
- block.protocolMeta = void 0;
12396
- }
12397
- }
12398
- const frozen = Boolean(
12399
- previousRequest && history.some(
12400
- (message) => message.interactionId === runId && message.content.some(
12401
- (block) => block.type === "provider_state" || block.type === "redacted_thinking" || block.type === "thinking" && Boolean(block.signature && block.origin?.runId === runId)
12402
- )
12403
- )
12404
- );
12962
+ verifyPrivateBlockIntegrity(projected, sourceView);
12405
12963
  const candidate = {
12406
12964
  ...request,
12407
12965
  messages: projected,
12408
12966
  context: { runId, modelId: profile.id },
12409
12967
  signal: options?.signal
12410
12968
  };
12411
- if (frozen) {
12412
- const generated = nativeBinding ? projectProtocolHistory(
12413
- this.context.project(history).filter(
12414
- (message) => message.interactionId === runId && message.role !== "user"
12415
- ),
12416
- nativeBinding,
12417
- candidate.context
12418
- ) : [];
12419
- for (const original of generated) {
12420
- const matches = candidate.messages.filter((message) => message.id === original.id);
12421
- if (matches.length !== 1 || stableSerialize(matches[0]) !== stableSerialize(original))
12422
- throw new ModelError("Signed continuation prefix or tool chain changed", {
12423
- stage: "request",
12424
- retryable: false
12425
- });
12426
- }
12427
- }
12428
- if (frozen && previousRequest && (stableSerialize(candidate.messages.slice(0, previousRequest.messages.length)) !== stableSerialize(previousRequest.messages) || candidate.systemPrompt !== previousRequest.systemPrompt || stableSerialize(candidate.tools) !== stableSerialize(previousRequest.tools)))
12429
- throw new ModelError("Signed continuation prefix changed", {
12430
- stage: "request",
12431
- retryable: false
12432
- });
12969
+ const frozen = assertSignedContinuationStable({
12970
+ candidate,
12971
+ previousRequest,
12972
+ history,
12973
+ runId,
12974
+ nativeBinding,
12975
+ projectHistory: (projectedHistory) => this.context.project(projectedHistory)
12976
+ });
12433
12977
  const context = this.context;
12434
12978
  const skills = this.skillRuntime;
12435
12979
  const preparation = context.prepare(history, candidate, profile, runId, reason, !frozen);
@@ -12478,6 +13022,7 @@ var AgentSession = class {
12478
13022
  let persistedHistoryLength = this.history.length;
12479
13023
  let loopCompleted = false;
12480
13024
  let loopFailed = false;
13025
+ let consumerInterrupted = false;
12481
13026
  try {
12482
13027
  for await (const event of loop.run(this.history)) {
12483
13028
  await this.logContextEvent(event, runId);
@@ -12552,7 +13097,12 @@ var AgentSession = class {
12552
13097
  if (event.type === "message_stop" || event.type === "tool_messages") {
12553
13098
  persistedHistoryLength = this.history.length;
12554
13099
  }
12555
- yield event;
13100
+ try {
13101
+ yield event;
13102
+ } catch (error) {
13103
+ consumerInterrupted = true;
13104
+ throw error;
13105
+ }
12556
13106
  }
12557
13107
  loopCompleted = true;
12558
13108
  } catch (error) {
@@ -12569,7 +13119,7 @@ var AgentSession = class {
12569
13119
  }
12570
13120
  throw error;
12571
13121
  } finally {
12572
- if (!loopCompleted && !loopFailed) {
13122
+ if (!loopCompleted && (!loopFailed || consumerInterrupted)) {
12573
13123
  await executor.rollback();
12574
13124
  if (this.store) {
12575
13125
  for (const message of this.history.slice(persistedHistoryLength)) {
@@ -12701,102 +13251,44 @@ var AgentSession = class {
12701
13251
  }
12702
13252
  /** 完整消息提交后完成技能审计;审计失败保留真实历史,停止本轮且不发布成功事件。 */
12703
13253
  async commitSkills(runId) {
12704
- if (!this.skillRuntime) return [];
12705
- return this.skillRuntime.commit(
12706
- async (source, messageId) => {
12707
- try {
12708
- await this.eventLogger.recordAudit({
12709
- level: "info",
12710
- event: "skill.loaded",
12711
- sessionId: this.sessionId,
12712
- runId,
12713
- fields: {
12714
- skillName: source.name,
12715
- skillScope: source.scope,
12716
- contentDigest: source.contentDigest,
12717
- renderedDigest: source.renderedDigest,
12718
- pathDigest: skillDigest(source.filePath),
12719
- caller: source.caller,
12720
- invocationId: source.invocationId,
12721
- messageId
12722
- }
12723
- });
12724
- } catch {
12725
- throw new KapibalaError("Skill commit audit unavailable", {
12726
- code: "AUDIT_UNAVAILABLE",
12727
- retryPolicy: "after_user_action",
12728
- safeMessage: "\u6280\u80FD\u63D0\u4EA4\u5BA1\u8BA1\u4E0D\u53EF\u7528\uFF0C\u672C\u8F6E\u505C\u6B62"
12729
- });
12730
- }
12731
- },
12732
- this.store ? "disk" : "memory"
12733
- );
13254
+ return commitSkillEvents({
13255
+ skillRuntime: this.skillRuntime,
13256
+ eventLogger: this.eventLogger,
13257
+ sessionId: this.sessionId,
13258
+ runId,
13259
+ persistence: this.store ? "disk" : "memory"
13260
+ });
12734
13261
  }
12735
13262
  /** 显式加载仍按同一读取策略、精确缓存与宿主审批裁决;拒绝/取消/审计失败时不追加用户任务。 */
12736
- async authorizeSkill(name, runId, signal) {
12737
- const request = {
12738
- toolName: "load_skill",
12739
- capabilities: ["fs:read"],
12740
- input: { name },
13263
+ authorizeSkill(name, runId, signal) {
13264
+ return authorizeExplicitSkill({
13265
+ name,
13266
+ runId,
13267
+ signal,
12741
13268
  rootDir: this.rootDir,
12742
- sessionAllowed: true
12743
- };
12744
- const cached = this.approvalCache.get(request);
12745
- const decision = new PermissionPolicy().decide({
12746
- toolName: request.toolName,
12747
- capabilities: request.capabilities,
12748
13269
  mode: this.mode,
12749
- rules: this.permissionRules,
12750
- cachedApproval: cached === "allow",
12751
- cachedDenial: cached === "deny"
12752
- });
12753
- let permitted = decision.decision === "allow";
12754
- let approvalChoice;
12755
- if (decision.decision === "ask") {
12756
- try {
12757
- approvalChoice = await this.approvalChannel?.requestApproval(request, signal) ?? "deny_once";
12758
- } catch {
12759
- approvalChoice = "cancel";
12760
- }
12761
- permitted = approvalChoice === "allow_once" || approvalChoice === "allow_session";
12762
- if (approvalChoice === "allow_session") this.approvalCache.set(request, "allow");
12763
- if (approvalChoice === "deny_session") this.approvalCache.set(request, "deny");
12764
- }
12765
- await this.eventLogger.recordAudit({
12766
- level: "info",
12767
- event: "skill.decided",
12768
- sessionId: this.sessionId,
12769
- runId,
12770
- fields: {
12771
- skillName: name,
12772
- caller: "user",
12773
- decision: permitted && !signal?.aborted ? "allow" : "deny",
12774
- decisionSource: decision.decision === "ask" ? this.approvalChannel ? "human" : "non_interactive" : decision.source,
12775
- approvalChoice
12776
- }
13270
+ permissionRules: this.permissionRules,
13271
+ approvalChannel: this.approvalChannel,
13272
+ approvalCache: this.approvalCache,
13273
+ eventLogger: this.eventLogger,
13274
+ sessionId: this.sessionId
12777
13275
  });
12778
- if (!permitted) throw skillFailure("SKILL_INVOCATION_FORBIDDEN", "\u5BBF\u4E3B\u6743\u9650\u7981\u6B62\u663E\u5F0F\u6280\u80FD\u8BFB\u53D6");
12779
- signal?.throwIfAborted();
12780
13276
  }
12781
13277
  /**
12782
13278
  * 计算最近一次内部模型请求的上下文占用;无 usage 时显式标注预算估算。
12783
13279
  * run 累计 promptTokens 会重复计算每一步都重发的历史,不能用于窗口占用率。
12784
13280
  */
12785
13281
  createContextUsage(usedTokens, estimatedUsage = false) {
12786
- const contextWindow = resolveContextWindow(
12787
- (this.lastPrimaryProfile ?? this.getActiveProfile()).contextWindow
12788
- );
12789
- return {
13282
+ return computeContextUsage(
13283
+ this.lastPrimaryProfile ?? this.getActiveProfile(),
12790
13284
  usedTokens,
12791
- limitTokens: contextWindow.tokens,
12792
- percent: usedTokens === void 0 ? void 0 : usedTokens / contextWindow.tokens * 100,
12793
- estimatedLimit: contextWindow.estimated,
12794
- ...estimatedUsage ? { estimatedUsage: true } : {}
12795
- };
13285
+ estimatedUsage
13286
+ );
12796
13287
  }
12797
13288
  async persistAssistant(event) {
12798
- if (this.store) await this.store.append(event.message);
12799
- if (!event.usage) this.usageKnown = false;
13289
+ await persistAssistantMessage(event, this.store, () => {
13290
+ this.usageKnown = false;
13291
+ });
12800
13292
  }
12801
13293
  /** 创建上下文协调器并注入当前路由;摘要不经过主任务 Loop,插件结果不默认为可剪裁。 */
12802
13294
  createContextManager() {
@@ -12815,17 +13307,8 @@ var AgentSession = class {
12815
13307
  }
12816
13308
  /** 摘要实际消耗按 attempt 身份独立累加;缺失消耗保留未知,不计入主任务指标。 */
12817
13309
  accountSummaryUsage(attemptId, usage) {
12818
- if (this.summaryAttempts.has(attemptId)) return;
12819
- this.summaryAttempts.add(attemptId);
12820
- if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
12821
- (n) => Number.isSafeInteger(n) && n >= 0
12822
- )) {
12823
- this.summaryUsageKnown = false;
12824
- return;
12825
- }
12826
- this.summaryUsage.promptTokens += usage.promptTokens;
12827
- this.summaryUsage.completionTokens += usage.completionTokens;
12828
- this.summaryUsage.totalTokens += usage.totalTokens;
13310
+ const outcome = recordSummaryUsage(attemptId, usage, this.summaryAttempts, this.summaryUsage);
13311
+ if (outcome === "unknown") this.summaryUsageKnown = false;
12829
13312
  }
12830
13313
  /**
12831
13314
  * 空闲时手动摘要旧完整前缀,不创建用户任务、不执行工具,不突破当前与最近成功交互。
@@ -12898,61 +13381,21 @@ var AgentSession = class {
12898
13381
  }
12899
13382
  /** 以模型 attempt 身份幂等累加真实消耗;非法或缺失 usage 保持未知。 */
12900
13383
  accountUsage(attemptId, usage) {
12901
- if (this.accountedAttempts.has(attemptId)) return;
12902
- if (!usage || ![usage.promptTokens, usage.completionTokens, usage.totalTokens].every(
12903
- (n) => Number.isSafeInteger(n) && n >= 0
12904
- )) {
12905
- this.usageKnown = false;
12906
- return;
12907
- }
12908
- this.accountedAttempts.add(attemptId);
12909
- this.usageStats.promptTokens += usage.promptTokens;
12910
- this.usageStats.completionTokens += usage.completionTokens;
12911
- this.usageStats.totalTokens += usage.totalTokens;
13384
+ const outcome = recordUsage(attemptId, usage, this.accountedAttempts, this.usageStats);
13385
+ if (outcome === "unknown") this.usageKnown = false;
12912
13386
  }
12913
13387
  async persistToolMessages(messages) {
12914
- if (!this.store) return;
12915
- for (const message of messages) await this.store.append(message);
13388
+ await persistToolMessages(messages, this.store);
12916
13389
  }
12917
13390
  /** 中止时为缓冲 assistant 的每个 tool_use 生成 OUTCOME_UNKNOWN 结果并入历史,保持事务闭合。 */
12918
13391
  closeInterruptedToolCalls(assistantEvent, error) {
12919
- const message = {
12920
- id: createMessageId(),
12921
- role: "tool",
12922
- content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
12923
- type: "tool_result",
12924
- toolUseId: toolUse.id,
12925
- content: JSON.stringify({
12926
- code: "OUTCOME_UNKNOWN",
12927
- retryPolicy: "after_user_action",
12928
- message: "Tool execution was interrupted; outcome is unknown"
12929
- }),
12930
- isError: true,
12931
- errorCode: "OUTCOME_UNKNOWN",
12932
- retryPolicy: "after_user_action"
12933
- })),
12934
- timestamp: Date.now()
12935
- };
13392
+ const message = buildInterruptedToolMessage(assistantEvent);
12936
13393
  this.history.push(message);
12937
13394
  return [message];
12938
13395
  }
12939
13396
  /** 收集历史中紧跟该 assistant 的连续 tool 消息;仅当覆盖全部 tool_use 时视为已闭合。 */
12940
13397
  findClosedToolMessages(assistantEvent) {
12941
- const assistantIndex = this.history.lastIndexOf(assistantEvent.message);
12942
- if (assistantIndex < 0) return [];
12943
- const expected = new Set(
12944
- assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => toolUse.id)
12945
- );
12946
- const messages = [];
12947
- for (let index2 = assistantIndex + 1; index2 < this.history.length; index2++) {
12948
- const message = this.history[index2];
12949
- if (message.role !== "tool") break;
12950
- messages.push(message);
12951
- for (const block of message.content) {
12952
- if (block.type === "tool_result") expected.delete(block.toolUseId);
12953
- }
12954
- }
12955
- return expected.size === 0 ? messages : [];
13398
+ return findClosedToolMessages(this.history, assistantEvent);
12956
13399
  }
12957
13400
  };
12958
13401
  export {
@@ -13029,6 +13472,7 @@ export {
13029
13472
  createLogId,
13030
13473
  createMessageId,
13031
13474
  createRunCommandTool,
13475
+ createSessionError,
13032
13476
  createSpawnAgentTool,
13033
13477
  createWaitAgentTool,
13034
13478
  defineTool,
@@ -13084,7 +13528,6 @@ export {
13084
13528
  sanitizeModelErrorMessage,
13085
13529
  sanitizeSessionTitle,
13086
13530
  scanSkillRoot,
13087
- sharedWorkspaceLeaseCoordinator,
13088
13531
  skillDigest,
13089
13532
  skillFailure,
13090
13533
  skillPathKey,