@kiturone/kapibala 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -35,32 +35,50 @@ __export(index_exports, {
35
35
  AgentSession: () => AgentSession,
36
36
  DEFAULT_CONTEXT_WINDOW_TOKENS: () => DEFAULT_CONTEXT_WINDOW_TOKENS,
37
37
  FatalError: () => FatalError,
38
+ FileLogSink: () => FileLogSink,
38
39
  HookRegistry: () => HookRegistry,
39
40
  JSONLMessageStore: () => JSONLMessageStore,
40
41
  KapibalaError: () => KapibalaError,
41
42
  ModelError: () => ModelError,
42
43
  OpenAICompatibleProvider: () => OpenAICompatibleProvider,
43
44
  PathSandbox: () => PathSandbox,
45
+ PermissionPolicy: () => PermissionPolicy,
44
46
  PromptAssembler: () => PromptAssembler,
47
+ SessionApprovalCache: () => SessionApprovalCache,
45
48
  SessionBusyError: () => SessionBusyError,
46
49
  SimpleModelRouter: () => SimpleModelRouter,
50
+ StructuredLogger: () => StructuredLogger,
47
51
  ToolError: () => ToolError,
48
52
  ToolExecutor: () => ToolExecutor,
49
53
  ToolNotFound: () => ToolNotFound,
50
54
  ToolRegistry: () => ToolRegistry,
51
55
  TransportError: () => TransportError,
52
56
  builtinTools: () => builtinTools,
57
+ classifyBashOutput: () => classifyBashOutput,
58
+ cleanupCommandResults: () => cleanupCommandResults,
59
+ commandAuditSummary: () => commandAuditSummary,
60
+ createDefaultLogSinks: () => createDefaultLogSinks,
61
+ createLogId: () => createLogId,
62
+ createRunCommandTool: () => createRunCommandTool,
53
63
  defineTool: () => defineTool,
64
+ detectShell: () => detectShell,
54
65
  editFileTool: () => editFileTool,
66
+ executionFingerprint: () => executionFingerprint,
55
67
  globTool: () => globTool,
56
68
  grepTool: () => grepTool,
57
69
  isTextBlock: () => isTextBlock,
58
70
  isThinkingBlock: () => isThinkingBlock,
59
71
  isToolResultBlock: () => isToolResultBlock,
60
72
  isToolUseBlock: () => isToolUseBlock,
73
+ loadInstructions: () => loadInstructions,
74
+ projectSafeFields: () => projectSafeFields,
61
75
  readFileTool: () => readFileTool,
76
+ recoverIncompleteAudit: () => recoverIncompleteAudit,
62
77
  resolveContextWindow: () => resolveContextWindow,
78
+ stableJsonStringify: () => stableJsonStringify,
63
79
  toToolDefinition: () => toToolDefinition,
80
+ validatePermissionRules: () => validatePermissionRules,
81
+ visibleTools: () => visibleTools,
64
82
  writeFileTool: () => writeFileTool
65
83
  });
66
84
  module.exports = __toCommonJS(index_exports);
@@ -81,9 +99,15 @@ function isThinkingBlock(block) {
81
99
 
82
100
  // src/errors/index.ts
83
101
  var KapibalaError = class extends Error {
84
- constructor(message) {
102
+ code;
103
+ retryPolicy;
104
+ safeMessage;
105
+ constructor(message, options) {
85
106
  super(message);
86
107
  this.name = "KapibalaError";
108
+ this.code = options?.code ?? "INTERNAL_ERROR";
109
+ this.retryPolicy = options?.retryPolicy ?? "never";
110
+ this.safeMessage = options?.safeMessage ?? "Operation failed";
87
111
  }
88
112
  };
89
113
  var ModelError = class extends KapibalaError {
@@ -106,7 +130,11 @@ var TransportError = class extends KapibalaError {
106
130
  };
107
131
  var ToolError = class extends KapibalaError {
108
132
  constructor(message) {
109
- super(message);
133
+ super(message, {
134
+ code: "TOOL_ERROR",
135
+ retryPolicy: "never",
136
+ safeMessage: "Tool execution failed"
137
+ });
110
138
  this.name = "ToolError";
111
139
  }
112
140
  };
@@ -114,7 +142,11 @@ var ToolNotFound = class extends KapibalaError {
114
142
  toolName;
115
143
  availableTools;
116
144
  constructor(toolName, availableTools = []) {
117
- super(`Tool '${toolName}' not found. Available tools: ${availableTools.join(", ") || "none"}`);
145
+ super(`Tool '${toolName}' not found. Available tools: ${availableTools.join(", ") || "none"}`, {
146
+ code: "TOOL_NOT_FOUND",
147
+ retryPolicy: "never",
148
+ safeMessage: "Requested tool is unavailable"
149
+ });
118
150
  this.name = "ToolNotFound";
119
151
  this.toolName = toolName;
120
152
  this.availableTools = availableTools;
@@ -122,7 +154,11 @@ var ToolNotFound = class extends KapibalaError {
122
154
  };
123
155
  var AbortError = class extends KapibalaError {
124
156
  constructor(message = "Execution aborted by user") {
125
- super(message);
157
+ super(message, {
158
+ code: "ABORTED",
159
+ retryPolicy: "after_user_action",
160
+ safeMessage: "Operation cancelled"
161
+ });
126
162
  this.name = "AbortError";
127
163
  }
128
164
  };
@@ -139,12 +175,1028 @@ var SessionBusyError = class extends KapibalaError {
139
175
  }
140
176
  };
141
177
 
142
- // src/security/sandbox.ts
178
+ // src/logging/index.ts
179
+ var import_node_crypto = require("crypto");
180
+ var SAFE_FIELDS = /* @__PURE__ */ new Set([
181
+ "mode",
182
+ "toolName",
183
+ "durationMs",
184
+ "decision",
185
+ "decisionSource",
186
+ "ruleSource",
187
+ "approvalChoice",
188
+ "status",
189
+ "phase",
190
+ "errorCode",
191
+ "retryPolicy",
192
+ "reason",
193
+ "shell",
194
+ "executableDigest",
195
+ "cwdDigest",
196
+ "commandDigest",
197
+ "commandPreview",
198
+ "commandLength",
199
+ "argumentCount",
200
+ "executionFingerprint",
201
+ "exitCode",
202
+ "outputBytes",
203
+ "outputTruncated",
204
+ "outputFileTruncated",
205
+ "reclaimed",
206
+ "modelId",
207
+ "provider",
208
+ "promptTokens",
209
+ "completionTokens",
210
+ "totalTokens",
211
+ "attempt",
212
+ "count",
213
+ "pathDigest",
214
+ "source",
215
+ "sessionMode",
216
+ "processId"
217
+ ]);
218
+ var SENSITIVE_VALUE = /(?:\bBearer\s+\S+|\b(?:sk|api|key)-[A-Za-z0-9_-]{5,}|(?:api[_-]?key|token|password|secret)\s*[:=]\s*\S+)/i;
219
+ function projectSafeFields(fields) {
220
+ const safe = {};
221
+ for (const [name, value] of Object.entries(fields)) {
222
+ if (!SAFE_FIELDS.has(name)) continue;
223
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
224
+ safe[name] = value;
225
+ } else if (typeof value === "string") {
226
+ const validPreview = name !== "commandPreview" || /^(?:[a-z][a-z0-9-]* \(\d+ args\)|\[redacted\])$/.test(value);
227
+ const validReason = name !== "reason" || /^[a-z][a-z0-9_.-]{0,80}$/i.test(value);
228
+ safe[name] = SENSITIVE_VALUE.test(value) || !validPreview || !validReason || value.includes("\n") || value.includes("\r") ? "[redacted]" : value.slice(0, 512);
229
+ }
230
+ }
231
+ return safe;
232
+ }
233
+ function createLogId() {
234
+ return (0, import_node_crypto.randomUUID)();
235
+ }
236
+ function commandAuditSummary(command) {
237
+ const parts = command.trim().split(/\s+/).filter(Boolean);
238
+ const first = parts[0]?.toLowerCase();
239
+ const category = first && (/* @__PURE__ */ new Set([
240
+ "git",
241
+ "pnpm",
242
+ "npm",
243
+ "node",
244
+ "rg",
245
+ "ls",
246
+ "pwd",
247
+ "cat",
248
+ "get-childitem",
249
+ "get-content"
250
+ ])).has(first) ? first : void 0;
251
+ return {
252
+ commandDigest: (0, import_node_crypto.createHash)("sha256").update(command, "utf8").digest("hex"),
253
+ commandPreview: category ? `${category} (${Math.max(0, parts.length - 1)} args)` : "[redacted]",
254
+ commandLength: Buffer.byteLength(command, "utf8"),
255
+ argumentCount: Math.max(0, parts.length - 1)
256
+ };
257
+ }
258
+ function executionFingerprint(input) {
259
+ return (0, import_node_crypto.createHash)("sha256").update(
260
+ JSON.stringify([
261
+ input.command,
262
+ input.shell,
263
+ input.executable,
264
+ input.cwd,
265
+ input.environmentDigest,
266
+ input.timeoutMs,
267
+ input.outputLimitBytes,
268
+ input.targetDigest ?? null
269
+ ])
270
+ ).digest("hex");
271
+ }
272
+ var StructuredLogger = class {
273
+ operationSink;
274
+ auditSink;
275
+ debug;
276
+ onDiagnostic;
277
+ constructor(options) {
278
+ this.operationSink = options.operationSink;
279
+ this.auditSink = options.auditSink;
280
+ this.debug = options.debug ?? false;
281
+ this.onDiagnostic = options.onDiagnostic;
282
+ }
283
+ /** 普通日志写入失败不阻断业务,也不声称持久化成功;诊断不携带原始异常内容。 */
284
+ async record(event) {
285
+ if (!this.debug && (event.level === "debug" || event.level === "trace")) return;
286
+ try {
287
+ await this.operationSink.write(this.format(event, "operation"));
288
+ } catch {
289
+ try {
290
+ this.onDiagnostic?.("Operation log write failed; the event was not persisted");
291
+ } catch {
292
+ }
293
+ }
294
+ }
295
+ /** 审计必须成功持久化;异常交由执行器拒绝启动工具或报告已执行操作的结果未知。 */
296
+ async recordAudit(event) {
297
+ await this.auditSink.write(this.format(event, "audit"));
298
+ }
299
+ format(input, channel) {
300
+ return {
301
+ schemaVersion: 1,
302
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
303
+ level: input.level,
304
+ channel,
305
+ event: input.event,
306
+ sessionId: input.sessionId,
307
+ ...input.runId === void 0 ? {} : { runId: input.runId },
308
+ ...input.operationId === void 0 ? {} : { operationId: input.operationId },
309
+ ...input.toolUseId === void 0 ? {} : { toolUseId: input.toolUseId },
310
+ fields: projectSafeFields(input.fields ?? {})
311
+ };
312
+ }
313
+ };
314
+
315
+ // src/logging/file-sink.ts
143
316
  var import_node_fs = __toESM(require("fs"), 1);
317
+ var import_node_os = __toESM(require("os"), 1);
144
318
  var import_node_path = __toESM(require("path"), 1);
319
+ var import_node_readline = __toESM(require("readline"), 1);
320
+ var FileLogSink = class {
321
+ directory;
322
+ prefix;
323
+ maxBytes;
324
+ retentionDays;
325
+ sync;
326
+ constructor(options) {
327
+ this.directory = import_node_path.default.resolve(options.directory);
328
+ this.prefix = options.prefix;
329
+ this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024;
330
+ this.retentionDays = options.prefix === "audit" ? void 0 : options.retentionDays;
331
+ this.sync = options.prefix === "audit" || (options.sync ?? false);
332
+ }
333
+ async write(event) {
334
+ const line = `${JSON.stringify(event)}
335
+ `;
336
+ const bytes = Buffer.byteLength(line, "utf8");
337
+ const date = event.timestamp.slice(0, 10);
338
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error("Invalid log timestamp");
339
+ import_node_fs.default.mkdirSync(this.directory, { recursive: true, mode: 448 });
340
+ const filePath = this.chooseFile(date, bytes);
341
+ const descriptor = import_node_fs.default.openSync(filePath, "a", 384);
342
+ try {
343
+ import_node_fs.default.writeSync(descriptor, line, void 0, "utf8");
344
+ if (this.sync) import_node_fs.default.fsyncSync(descriptor);
345
+ } finally {
346
+ import_node_fs.default.closeSync(descriptor);
347
+ }
348
+ if (this.retentionDays !== void 0) this.cleanupExpired(date);
349
+ }
350
+ /** 返回该通道已占用的字节数,供宿主提示长期保留的审计用量。 */
351
+ getUsageBytes() {
352
+ if (!import_node_fs.default.existsSync(this.directory)) return 0;
353
+ return import_node_fs.default.readdirSync(this.directory).filter((name) => this.isOwnedFile(name)).reduce((total, name) => total + import_node_fs.default.statSync(import_node_path.default.join(this.directory, name)).size, 0);
354
+ }
355
+ chooseFile(date, bytes) {
356
+ const names = import_node_fs.default.readdirSync(this.directory).filter((name) => name.startsWith(`${this.prefix}-${date}-`) && this.isOwnedFile(name)).sort();
357
+ const lastName = names.at(-1);
358
+ if (lastName) {
359
+ const lastPath = import_node_path.default.join(this.directory, lastName);
360
+ if (import_node_fs.default.statSync(lastPath).size + bytes <= this.maxBytes) return lastPath;
361
+ }
362
+ const next = lastName ? Number(lastName.slice(-9, -6)) + 1 : 1;
363
+ return import_node_path.default.join(
364
+ this.directory,
365
+ `${this.prefix}-${date}-${String(next).padStart(3, "0")}.jsonl`
366
+ );
367
+ }
368
+ cleanupExpired(currentDate) {
369
+ const current = Date.parse(`${currentDate}T00:00:00.000Z`);
370
+ const cutoff = current - (this.retentionDays ?? 0) * 864e5;
371
+ for (const name of import_node_fs.default.readdirSync(this.directory)) {
372
+ if (!this.isOwnedFile(name)) continue;
373
+ const fileDate = name.slice(this.prefix.length + 1, this.prefix.length + 11);
374
+ if (Date.parse(`${fileDate}T00:00:00.000Z`) < cutoff) {
375
+ import_node_fs.default.unlinkSync(import_node_path.default.join(this.directory, name));
376
+ }
377
+ }
378
+ }
379
+ isOwnedFile(name) {
380
+ return new RegExp(`^${this.prefix}-\\d{4}-\\d{2}-\\d{2}-\\d{3}\\.jsonl$`).test(name);
381
+ }
382
+ };
383
+ function createDefaultLogSinks(baseDirectory = import_node_path.default.join(import_node_os.default.homedir(), ".kapibala")) {
384
+ return {
385
+ operationSink: new FileLogSink({
386
+ directory: import_node_path.default.join(baseDirectory, "logs"),
387
+ prefix: "operation",
388
+ retentionDays: 30
389
+ }),
390
+ auditSink: new FileLogSink({
391
+ directory: import_node_path.default.join(baseDirectory, "audit"),
392
+ prefix: "audit",
393
+ sync: true
394
+ })
395
+ };
396
+ }
397
+ async function recoverIncompleteAudit(directory) {
398
+ if (!import_node_fs.default.existsSync(directory)) return 0;
399
+ const files = import_node_fs.default.readdirSync(directory).filter((name) => /^audit-\d{4}-\d{2}-\d{2}-\d{3}\.jsonl$/.test(name)).sort();
400
+ const open = /* @__PURE__ */ new Map();
401
+ for (const name of files) {
402
+ const lines = import_node_readline.default.createInterface({
403
+ input: import_node_fs.default.createReadStream(import_node_path.default.join(directory, name), { encoding: "utf8" }),
404
+ crlfDelay: Number.POSITIVE_INFINITY
405
+ });
406
+ for await (const line of lines) {
407
+ if (!line) continue;
408
+ let event;
409
+ try {
410
+ event = JSON.parse(line);
411
+ } catch {
412
+ continue;
413
+ }
414
+ if (!event.operationId || typeof event.operationId !== "string") continue;
415
+ if (event.event === "tool.requested") open.set(event.operationId, event);
416
+ if (event.event === "tool.finished" || event.event === "audit.outcome_unknown") {
417
+ open.delete(event.operationId);
418
+ }
419
+ }
420
+ }
421
+ const sink = new FileLogSink({ directory, prefix: "audit" });
422
+ let recovered = 0;
423
+ for (const event of open.values()) {
424
+ const processId = event.fields?.processId;
425
+ if (typeof processId === "number" && Number.isSafeInteger(processId) && processId > 0) {
426
+ try {
427
+ process.kill(processId, 0);
428
+ continue;
429
+ } catch (error) {
430
+ if (error.code === "EPERM") continue;
431
+ }
432
+ }
433
+ await sink.write({
434
+ schemaVersion: 1,
435
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
436
+ channel: "audit",
437
+ level: "warn",
438
+ event: "audit.outcome_unknown",
439
+ sessionId: event.sessionId,
440
+ runId: event.runId,
441
+ operationId: event.operationId,
442
+ toolUseId: event.toolUseId,
443
+ fields: { status: "unknown_after_restart" }
444
+ });
445
+ recovered++;
446
+ }
447
+ return recovered;
448
+ }
449
+
450
+ // src/instructions/index.ts
451
+ var import_node_fs2 = __toESM(require("fs"), 1);
452
+ var import_node_os2 = __toESM(require("os"), 1);
453
+ var import_node_path2 = __toESM(require("path"), 1);
454
+ var MAX_FILE_BYTES = 32 * 1024;
455
+ var MAX_TOTAL_BYTES = 128 * 1024;
456
+ var MAX_LEVELS = 16;
457
+ function loadInstructions(options) {
458
+ const root = import_node_fs2.default.realpathSync(options.projectRoot);
459
+ const cwd = import_node_fs2.default.realpathSync(options.cwd);
460
+ const relative = import_node_path2.default.relative(root, cwd);
461
+ if (relative === ".." || relative.startsWith(`..${import_node_path2.default.sep}`) || import_node_path2.default.isAbsolute(relative)) {
462
+ throw new Error("Instruction cwd is outside project root");
463
+ }
464
+ const segments = relative ? relative.split(import_node_path2.default.sep) : [];
465
+ if (segments.length + 1 > MAX_LEVELS)
466
+ throw new Error(`Instruction directory chain exceeds ${MAX_LEVELS} levels`);
467
+ const userFile = options.userFile ?? import_node_path2.default.join(import_node_os2.default.homedir(), ".kapibala", "AGENTS.md");
468
+ const candidates = [userFile];
469
+ let directory = root;
470
+ candidates.push(import_node_path2.default.join(directory, "AGENTS.md"));
471
+ for (const segment of segments) {
472
+ directory = import_node_path2.default.join(directory, segment);
473
+ candidates.push(import_node_path2.default.join(directory, "AGENTS.md"));
474
+ }
475
+ const seenPaths = /* @__PURE__ */ new Set();
476
+ const uniqueCandidates = [];
477
+ for (const file of candidates) {
478
+ const resolved = import_node_path2.default.resolve(file);
479
+ const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
480
+ if (!seenPaths.has(key)) {
481
+ seenPaths.add(key);
482
+ uniqueCandidates.push(file);
483
+ }
484
+ }
485
+ const sources = [];
486
+ let totalBytes = 0;
487
+ for (const file of uniqueCandidates) {
488
+ let stat;
489
+ try {
490
+ stat = import_node_fs2.default.lstatSync(file);
491
+ } catch (error) {
492
+ if (error.code === "ENOENT") continue;
493
+ throw new Error(`Cannot inspect instructions at ${file}`, { cause: error });
494
+ }
495
+ if (stat.isSymbolicLink())
496
+ throw new Error(`Instruction symbolic link is not accepted: ${file}`);
497
+ if (!stat.isFile()) throw new Error(`Instruction source is not a regular file: ${file}`);
498
+ if (stat.size > MAX_FILE_BYTES) throw new Error(`Instruction ${file} exceeds 32 KiB`);
499
+ totalBytes += stat.size;
500
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("Instructions exceed 128 KiB total");
501
+ let bytes;
502
+ try {
503
+ bytes = import_node_fs2.default.readFileSync(file);
504
+ } catch (error) {
505
+ throw new Error(`Cannot read instructions at ${file}`, { cause: error });
506
+ }
507
+ if (bytes.byteLength > MAX_FILE_BYTES) throw new Error(`Instruction ${file} exceeds 32 KiB`);
508
+ totalBytes += bytes.byteLength - stat.size;
509
+ if (totalBytes > MAX_TOTAL_BYTES) throw new Error("Instructions exceed 128 KiB total");
510
+ let content;
511
+ try {
512
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
513
+ } catch (error) {
514
+ throw new Error(`Instruction ${file} is not valid UTF-8`, { cause: error });
515
+ }
516
+ sources.push({ path: file, content });
517
+ }
518
+ return { sources, totalBytes };
519
+ }
520
+
521
+ // src/shell/detect.ts
522
+ var import_node_child_process = require("child_process");
523
+ var import_node_crypto2 = require("crypto");
524
+ var import_node_fs3 = __toESM(require("fs"), 1);
525
+ var import_node_os3 = __toESM(require("os"), 1);
526
+ var import_node_path3 = __toESM(require("path"), 1);
527
+ function classifyBashOutput(unameOutput, platform) {
528
+ const uname = unameOutput.trim().split(/\r?\n/)[0]?.toLowerCase() ?? "";
529
+ if (platform === "win32") {
530
+ if (uname.startsWith("mingw") || uname.startsWith("msys") || uname.startsWith("cygwin")) {
531
+ return "bash";
532
+ }
533
+ return uname === "linux" ? "wsl" : void 0;
534
+ }
535
+ return uname ? "bash" : void 0;
536
+ }
537
+ function findExecutables(name, pathEnv, platform) {
538
+ const extension = platform === "win32" && !name.toLowerCase().endsWith(".exe") ? ".exe" : "";
539
+ const found = [];
540
+ const seen = /* @__PURE__ */ new Set();
541
+ for (const directory of pathEnv.split(import_node_path3.default.delimiter)) {
542
+ if (!directory) continue;
543
+ const candidate = import_node_path3.default.join(directory, name + extension);
544
+ try {
545
+ if (!import_node_fs3.default.statSync(candidate).isFile()) continue;
546
+ const real = import_node_fs3.default.realpathSync(candidate);
547
+ const key = platform === "win32" ? real.toLowerCase() : real;
548
+ if (seen.has(key)) continue;
549
+ seen.add(key);
550
+ found.push(real);
551
+ } catch {
552
+ }
553
+ }
554
+ return found;
555
+ }
556
+ function probeBash(executable, cwd, platform) {
557
+ const result = (0, import_node_child_process.spawnSync)(executable, ["--noprofile", "--norc", "-c", "uname -s; pwd"], {
558
+ cwd,
559
+ encoding: "utf8",
560
+ timeout: 15e3,
561
+ windowsHide: true
562
+ });
563
+ if (result.status !== 0 || !result.stdout?.trim()) return { ok: false };
564
+ return { ok: true, kind: classifyBashOutput(result.stdout, platform) };
565
+ }
566
+ function probePowerShell(executable, cwd) {
567
+ const result = (0, import_node_child_process.spawnSync)(executable, ["-NoProfile", "-NonInteractive", "-Command", "$PWD.Path"], {
568
+ cwd,
569
+ encoding: "utf8",
570
+ timeout: 15e3,
571
+ windowsHide: true
572
+ });
573
+ return result.status === 0 && Boolean(result.stdout?.trim());
574
+ }
575
+ function buildEnvironment(executable, kind, cwd) {
576
+ const stat = import_node_fs3.default.statSync(executable);
577
+ return {
578
+ kind,
579
+ executable,
580
+ cwd,
581
+ executableDigest: (0, import_node_crypto2.createHash)("sha256").update(JSON.stringify([executable, stat.size, stat.mtimeMs])).digest("hex")
582
+ };
583
+ }
584
+ function candidatesFor(kind, platform, pathEnv) {
585
+ if (kind === "bash" || kind === "wsl") {
586
+ return findExecutables("bash", pathEnv, platform);
587
+ }
588
+ if (platform !== "win32" && kind === "powershell") return [];
589
+ return findExecutables(kind, pathEnv, platform);
590
+ }
591
+ function tryKind(kind, platform, pathEnv, cwd) {
592
+ for (const executable of candidatesFor(kind, platform, pathEnv)) {
593
+ try {
594
+ if (kind === "bash" || kind === "wsl") {
595
+ const probe = probeBash(executable, cwd, platform);
596
+ if (probe.ok && probe.kind === kind) return buildEnvironment(executable, kind, cwd);
597
+ } else if (probePowerShell(executable, cwd)) {
598
+ return buildEnvironment(executable, kind, cwd);
599
+ }
600
+ } catch {
601
+ }
602
+ }
603
+ return void 0;
604
+ }
605
+ function detectExplicitPath(preference, cwd, platform) {
606
+ const executable = import_node_fs3.default.realpathSync(preference);
607
+ if (!import_node_fs3.default.statSync(executable).isFile()) {
608
+ throw new Error(`Requested interpreter path is not a file: ${preference}`);
609
+ }
610
+ const base = import_node_path3.default.basename(executable).toLowerCase();
611
+ if (/^pwsh(\.exe)?$/.test(base) || /^powershell(_ise)?(\.exe)?$/.test(base)) {
612
+ const kind = base.startsWith("pwsh") ? "pwsh" : "powershell";
613
+ if (platform !== "win32" && kind === "powershell") {
614
+ throw new Error(`Requested ${kind} interpreter is unavailable on this platform`);
615
+ }
616
+ if (!probePowerShell(executable, cwd)) {
617
+ throw new Error(
618
+ `Requested ${kind} interpreter is unavailable or incompatible with this working directory`
619
+ );
620
+ }
621
+ return buildEnvironment(executable, kind, cwd);
622
+ }
623
+ const probe = probeBash(executable, cwd, platform);
624
+ if (!probe.ok || !probe.kind) {
625
+ throw new Error(
626
+ `Requested interpreter is unavailable or incompatible with this working directory: ${preference}`
627
+ );
628
+ }
629
+ return buildEnvironment(executable, probe.kind, cwd);
630
+ }
631
+ function detectShell(options) {
632
+ const platform = options.platform ?? import_node_os3.default.platform();
633
+ const cwd = import_node_fs3.default.realpathSync(options.cwd);
634
+ const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
635
+ const preference = options.preference ?? "auto";
636
+ if (/[\\/]/.test(preference) || preference.toLowerCase().endsWith(".exe")) {
637
+ return detectExplicitPath(preference, cwd, platform);
638
+ }
639
+ const kinds = preference === "auto" ? platform === "win32" ? ["bash", "wsl", "pwsh", "powershell"] : ["bash"] : [preference];
640
+ for (const kind of kinds) {
641
+ const environment = tryKind(kind, platform, pathEnv, cwd);
642
+ if (environment) return environment;
643
+ }
644
+ throw new Error(
645
+ preference === "auto" ? "No usable Bash or PowerShell interpreter found" : `Requested ${preference} interpreter is unavailable or incompatible with this working directory`
646
+ );
647
+ }
648
+
649
+ // src/shell/tool.ts
650
+ var import_node_child_process2 = require("child_process");
651
+ var import_node_crypto3 = require("crypto");
652
+ var import_node_fs4 = __toESM(require("fs"), 1);
653
+ var import_node_path4 = __toESM(require("path"), 1);
654
+ var import_node_string_decoder = require("string_decoder");
655
+
656
+ // src/tools/index.ts
657
+ function defineTool(tool) {
658
+ return tool;
659
+ }
660
+ function toToolDefinition(tool) {
661
+ return {
662
+ name: tool.name,
663
+ description: tool.description,
664
+ parameters: tool.parameters
665
+ };
666
+ }
667
+
668
+ // src/shell/tool.ts
669
+ var INLINE_LIMIT = 64 * 1024;
670
+ var FILE_LIMIT = 16 * 1024 * 1024;
671
+ var DEFAULT_TIMEOUT = 12e4;
672
+ var MAX_TIMEOUT = 6e5;
673
+ var MAX_COMMAND_BYTES = 8 * 1024;
674
+ var OWNED_RESULT = /^run-[a-f0-9-]{36}\.txt$/;
675
+ var RESULT_MANIFEST = "managed-results.jsonl";
676
+ function limitedEnvironment(kind) {
677
+ const allowed = [
678
+ "PATH",
679
+ "Path",
680
+ "SystemRoot",
681
+ "ComSpec",
682
+ "TEMP",
683
+ "TMP",
684
+ "HOME",
685
+ "USERPROFILE",
686
+ "APPDATA",
687
+ "LOCALAPPDATA",
688
+ "LANG",
689
+ "LC_ALL",
690
+ "TERM"
691
+ ];
692
+ const environment = Object.fromEntries(
693
+ allowed.filter((key) => process.env[key] !== void 0).map((key) => [key, process.env[key]])
694
+ );
695
+ if (kind === "wsl") environment.WSL_UTF8 = "1";
696
+ return environment;
697
+ }
698
+ function utf8Prefix(bytes, limit) {
699
+ let end = Math.min(bytes.length, limit);
700
+ let start = end - 1;
701
+ while (start >= 0 && (bytes[start] & 192) === 128) start--;
702
+ if (start >= 0) {
703
+ const lead = bytes[start];
704
+ const length = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
705
+ if (start + length > end) end = start;
706
+ }
707
+ return bytes.subarray(0, end);
708
+ }
709
+ function effectiveCwd(input, rootDir) {
710
+ const requested = input.cwd ? import_node_path4.default.resolve(rootDir, input.cwd) : rootDir;
711
+ const real = import_node_fs4.default.realpathSync(requested);
712
+ if (!import_node_fs4.default.statSync(real).isDirectory())
713
+ throw new Error("Command working directory is not a directory");
714
+ return real;
715
+ }
716
+ function effectiveTimeout(input) {
717
+ const timeout = input.timeout_ms ?? DEFAULT_TIMEOUT;
718
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > MAX_TIMEOUT) {
719
+ throw new Error(`Command timeout must be 1-${MAX_TIMEOUT} ms`);
720
+ }
721
+ return timeout;
722
+ }
723
+ function validateCommand(input) {
724
+ if (typeof input.command !== "string" || input.command.trim().length === 0 || Buffer.byteLength(input.command, "utf8") > MAX_COMMAND_BYTES) {
725
+ throw new Error(`Command must be nonempty and at most ${MAX_COMMAND_BYTES} bytes`);
726
+ }
727
+ }
728
+ function directScriptDigest(command, kind, cwd) {
729
+ const bashLike = kind === "bash" || kind === "wsl";
730
+ if (bashLike && command.includes("\\")) return void 0;
731
+ const normalized = command.trim().replaceAll("\\", "/");
732
+ const match = bashLike ? /^(?:bash )?\.\/([A-Za-z0-9_./-]+\.sh)$/.exec(normalized) : /^\.\/([A-Za-z0-9_./-]+\.ps1)$/i.exec(normalized);
733
+ if (!match) return void 0;
734
+ try {
735
+ const script = import_node_fs4.default.realpathSync(import_node_path4.default.resolve(cwd, match[1]));
736
+ const relative = import_node_path4.default.relative(cwd, script);
737
+ if (relative.startsWith("..") || import_node_path4.default.isAbsolute(relative)) return void 0;
738
+ const stat = import_node_fs4.default.statSync(script);
739
+ if (!stat.isFile() || stat.size > 1024 * 1024) return void 0;
740
+ return (0, import_node_crypto3.createHash)("sha256").update(script).update("\0").update(import_node_fs4.default.readFileSync(script)).digest("hex");
741
+ } catch {
742
+ return void 0;
743
+ }
744
+ }
745
+ function interpreterArgs(environment, command) {
746
+ if (environment.kind === "bash" || environment.kind === "wsl") {
747
+ return ["--noprofile", "--norc", "-c", command];
748
+ }
749
+ const utf8 = "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding; ";
750
+ return ["-NoProfile", "-NonInteractive", "-Command", utf8 + command];
751
+ }
752
+ function getTaskkillPath() {
753
+ if (process.platform !== "win32") return "taskkill";
754
+ const systemRoot = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
755
+ const candidate = import_node_path4.default.join(systemRoot, "System32", "taskkill.exe");
756
+ try {
757
+ if (import_node_fs4.default.existsSync(candidate)) return candidate;
758
+ } catch {
759
+ }
760
+ return "taskkill.exe";
761
+ }
762
+ function createNativeBashGroup(environment) {
763
+ if (process.platform !== "win32" || environment.kind !== "bash") return void 0;
764
+ let resolveReady = () => {
765
+ };
766
+ let pending = "";
767
+ let accepted = false;
768
+ const group = {
769
+ environment,
770
+ token: (0, import_node_crypto3.randomUUID)(),
771
+ ready: new Promise((resolve) => {
772
+ resolveReady = resolve;
773
+ }),
774
+ accept(text) {
775
+ if (accepted) return text;
776
+ pending += text;
777
+ const newline = pending.indexOf("\n");
778
+ if (newline < 0) return "";
779
+ accepted = true;
780
+ const prefix = `${group.token}:`;
781
+ const line = pending.slice(0, newline).trimEnd();
782
+ const pid = line.startsWith(prefix) ? Number(line.slice(prefix.length)) : Number.NaN;
783
+ if (Number.isSafeInteger(pid) && pid > 0) group.pid = pid;
784
+ resolveReady();
785
+ return group.pid ? pending.slice(newline + 1) : pending;
786
+ }
787
+ };
788
+ return group;
789
+ }
790
+ function signalNativeBashGroup(group, signal) {
791
+ if (!group.pid) return false;
792
+ const result = (0, import_node_child_process2.spawnSync)(
793
+ group.environment.executable,
794
+ ["--noprofile", "--norc", "-c", `kill -${signal} -- -${group.pid}`],
795
+ { windowsHide: true, timeout: 3e3, env: limitedEnvironment("bash") }
796
+ );
797
+ return result.status === 0;
798
+ }
799
+ function nativeBashGroupStopped(group) {
800
+ if (!group.pid) return false;
801
+ const result = (0, import_node_child_process2.spawnSync)(group.environment.executable, ["--noprofile", "--norc", "-c", "ps -l"], {
802
+ windowsHide: true,
803
+ timeout: 3e3,
804
+ encoding: "utf8",
805
+ env: limitedEnvironment("bash")
806
+ });
807
+ if (result.status !== 0 || !result.stdout.includes("PGID")) return false;
808
+ return !result.stdout.split("\n").some((line) => {
809
+ const columns = line.trim().split(/\s+/);
810
+ return /^\d+$/.test(columns[0] ?? "") && Number(columns[2]) === group.pid;
811
+ });
812
+ }
813
+ function stopProcessTree(child, nativeGroup) {
814
+ if (!child.pid) return false;
815
+ if (process.platform === "win32") {
816
+ const groupStopped = !nativeGroup || signalNativeBashGroup(nativeGroup, "TERM");
817
+ const result = (0, import_node_child_process2.spawnSync)(getTaskkillPath(), ["/PID", String(child.pid), "/T", "/F"], {
818
+ windowsHide: true,
819
+ timeout: 3e3
820
+ });
821
+ return groupStopped && (result.status === 0 || Boolean(nativeGroup));
822
+ }
823
+ try {
824
+ process.kill(-child.pid, "SIGTERM");
825
+ return true;
826
+ } catch {
827
+ try {
828
+ child.kill("SIGTERM");
829
+ } catch {
830
+ }
831
+ return false;
832
+ }
833
+ }
834
+ function forceProcessTree(child, nativeGroup) {
835
+ if (!child.pid) return false;
836
+ if (process.platform === "win32") {
837
+ const groupStopped = !nativeGroup || signalNativeBashGroup(nativeGroup, "KILL");
838
+ const result = (0, import_node_child_process2.spawnSync)(getTaskkillPath(), ["/PID", String(child.pid), "/T", "/F"], {
839
+ windowsHide: true,
840
+ timeout: 3e3
841
+ });
842
+ return groupStopped && (result.status === 0 || Boolean(nativeGroup));
843
+ }
844
+ try {
845
+ process.kill(-child.pid, "SIGKILL");
846
+ return true;
847
+ } catch {
848
+ try {
849
+ child.kill("SIGKILL");
850
+ } catch {
851
+ }
852
+ return false;
853
+ }
854
+ }
855
+ async function confirmProcessTreeStopped(child, terminationSucceeded, nativeGroup) {
856
+ if (!terminationSucceeded || !child.pid) return false;
857
+ if (nativeGroup) {
858
+ const deadline2 = Date.now() + 1e3;
859
+ while (Date.now() < deadline2) {
860
+ if (nativeBashGroupStopped(nativeGroup)) return true;
861
+ await new Promise((resolve) => setTimeout(resolve, 50));
862
+ }
863
+ return false;
864
+ }
865
+ if (process.platform === "win32") return true;
866
+ const deadline = Date.now() + 1e3;
867
+ while (Date.now() < deadline) {
868
+ try {
869
+ process.kill(-child.pid, 0);
870
+ } catch (error) {
871
+ if (error.code === "ESRCH") return true;
872
+ return false;
873
+ }
874
+ await new Promise((resolve) => setTimeout(resolve, 50));
875
+ }
876
+ return false;
877
+ }
878
+ function waitForClose(promise, milliseconds) {
879
+ return new Promise((resolve) => {
880
+ const timer = setTimeout(() => resolve(false), milliseconds);
881
+ void promise.then(
882
+ () => {
883
+ clearTimeout(timer);
884
+ resolve(true);
885
+ },
886
+ () => {
887
+ clearTimeout(timer);
888
+ resolve(true);
889
+ }
890
+ );
891
+ });
892
+ }
893
+ function resultDirectory(rootDir) {
894
+ return import_node_path4.default.join(rootDir, ".kapibala", "tool-results");
895
+ }
896
+ function cleanupCommandResults(rootDir, now = Date.now()) {
897
+ const directory = resultDirectory(rootDir);
898
+ if (!import_node_fs4.default.existsSync(directory)) return;
899
+ const manifest = import_node_path4.default.join(directory, RESULT_MANIFEST);
900
+ if (!import_node_fs4.default.existsSync(manifest) || !import_node_fs4.default.lstatSync(manifest).isFile()) return;
901
+ const cutoff = now - 7 * 864e5;
902
+ const retained = [];
903
+ for (const line of import_node_fs4.default.readFileSync(manifest, "utf8").split("\n")) {
904
+ if (!line) continue;
905
+ let record;
906
+ try {
907
+ record = JSON.parse(line);
908
+ } catch {
909
+ continue;
910
+ }
911
+ if (!OWNED_RESULT.test(record.name)) continue;
912
+ const file = import_node_path4.default.join(directory, record.name);
913
+ if (!import_node_fs4.default.existsSync(file)) continue;
914
+ const stat = import_node_fs4.default.lstatSync(file);
915
+ if (!stat.isFile() || stat.dev !== record.dev || stat.ino !== record.ino) continue;
916
+ if (stat.mtimeMs < cutoff) import_node_fs4.default.unlinkSync(file);
917
+ else retained.push(line);
918
+ }
919
+ const replacement = import_node_path4.default.join(directory, `.managed-results-${(0, import_node_crypto3.randomUUID)()}.tmp`);
920
+ import_node_fs4.default.writeFileSync(replacement, retained.length ? `${retained.join("\n")}
921
+ ` : "", { mode: 384 });
922
+ import_node_fs4.default.renameSync(replacement, manifest);
923
+ }
924
+ function recordManagedResult(directory, file, descriptor) {
925
+ const stat = import_node_fs4.default.fstatSync(descriptor);
926
+ import_node_fs4.default.appendFileSync(
927
+ import_node_path4.default.join(directory, RESULT_MANIFEST),
928
+ `${JSON.stringify({ name: import_node_path4.default.basename(file), dev: stat.dev, ino: stat.ino })}
929
+ `,
930
+ { mode: 384 }
931
+ );
932
+ }
933
+ function createRunCommandTool(environment) {
934
+ return defineTool({
935
+ name: "run_command",
936
+ description: `Run a ${environment.kind === "wsl" ? "bash (WSL)" : environment.kind} command as the current OS user. The file tool PathSandbox does not isolate this process or its network access.`,
937
+ metadata: {
938
+ source: "builtin",
939
+ permissions: ["fs:read", "fs:write", "exec", "net:outbound", "env:read"],
940
+ dangerous: true,
941
+ managesTimeout: true
942
+ },
943
+ parameters: {
944
+ type: "object",
945
+ properties: {
946
+ command: {
947
+ type: "string",
948
+ description: `Command using ${environment.kind === "wsl" ? "bash" : environment.kind} syntax.`
949
+ },
950
+ cwd: { type: "string", description: "Working directory; defaults to workspace root." },
951
+ timeout_ms: { type: "integer", description: "Timeout in milliseconds, maximum 600000." }
952
+ },
953
+ required: ["command"]
954
+ },
955
+ approvalScope(input, rootDir) {
956
+ validateCommand(input);
957
+ const cwd = effectiveCwd(input, rootDir);
958
+ const timeoutMs = effectiveTimeout(input);
959
+ const scriptDigest = directScriptDigest(input.command, environment.kind, cwd);
960
+ const stat = import_node_fs4.default.statSync(environment.executable);
961
+ const executableDigest = (0, import_node_crypto3.createHash)("sha256").update(JSON.stringify([environment.executable, stat.size, stat.mtimeMs])).digest("hex");
962
+ const environmentDigest = (0, import_node_crypto3.createHash)("sha256").update(JSON.stringify(limitedEnvironment(environment.kind))).digest("hex");
963
+ return {
964
+ command: input.command,
965
+ interpreter: environment.kind,
966
+ executablePath: environment.executable,
967
+ cwd,
968
+ executableDigest,
969
+ ...scriptDigest ? { scriptDigest } : {},
970
+ executionFingerprint: executionFingerprint({
971
+ command: input.command,
972
+ shell: environment.kind,
973
+ executable: executableDigest,
974
+ cwd,
975
+ environmentDigest,
976
+ timeoutMs,
977
+ outputLimitBytes: FILE_LIMIT,
978
+ targetDigest: scriptDigest
979
+ })
980
+ };
981
+ },
982
+ async execute(input, ctx) {
983
+ const startedAt = Date.now();
984
+ validateCommand(input);
985
+ if (ctx.signal?.aborted) {
986
+ throw new KapibalaError("Command aborted before start", {
987
+ code: "ABORTED",
988
+ retryPolicy: "after_user_action",
989
+ safeMessage: "Command was not started because the run was aborted"
990
+ });
991
+ }
992
+ const cwd = effectiveCwd(input, ctx.rootDir);
993
+ const timeoutMs = effectiveTimeout(input);
994
+ cleanupCommandResults(ctx.rootDir);
995
+ const nativeGroup = createNativeBashGroup(environment);
996
+ const command = nativeGroup ? `printf '${nativeGroup.token}:%s\\n' "$$" >&2
997
+ ${input.command}` : input.command;
998
+ const child = (0, import_node_child_process2.spawn)(environment.executable, interpreterArgs(environment, command), {
999
+ cwd,
1000
+ env: limitedEnvironment(environment.kind),
1001
+ windowsHide: true,
1002
+ detached: process.platform !== "win32",
1003
+ stdio: ["ignore", "pipe", "pipe"]
1004
+ });
1005
+ const inline = [];
1006
+ let inlineBytes = 0;
1007
+ let outputBytes = 0;
1008
+ let fileBytes = 0;
1009
+ let outputFile;
1010
+ let outputFileTruncated = false;
1011
+ let outputFailure = false;
1012
+ let lastProgressAt = startedAt;
1013
+ let descriptor;
1014
+ const collect = (chunk) => {
1015
+ outputBytes += chunk.length;
1016
+ if (outputFailure) return;
1017
+ if (inlineBytes < INLINE_LIMIT) {
1018
+ const part = chunk.subarray(0, INLINE_LIMIT - inlineBytes);
1019
+ inline.push(part);
1020
+ inlineBytes += part.length;
1021
+ }
1022
+ if (outputBytes > INLINE_LIMIT && descriptor === void 0) {
1023
+ const directory = resultDirectory(ctx.rootDir);
1024
+ import_node_fs4.default.mkdirSync(directory, { recursive: true, mode: 448 });
1025
+ outputFile = import_node_path4.default.join(directory, `run-${(0, import_node_crypto3.randomUUID)()}.txt`);
1026
+ descriptor = import_node_fs4.default.openSync(outputFile, "wx", 384);
1027
+ recordManagedResult(directory, outputFile, descriptor);
1028
+ const prefix = Buffer.concat(inline);
1029
+ import_node_fs4.default.writeSync(descriptor, prefix);
1030
+ fileBytes = prefix.length;
1031
+ const remaining = chunk.subarray(
1032
+ Math.max(0, prefix.length - (outputBytes - chunk.length))
1033
+ );
1034
+ const writable = utf8Prefix(remaining, FILE_LIMIT - fileBytes);
1035
+ if (writable.length > 0) import_node_fs4.default.writeSync(descriptor, writable);
1036
+ fileBytes += writable.length;
1037
+ if (writable.length < remaining.length) outputFileTruncated = true;
1038
+ } else if (descriptor !== void 0 && !outputFileTruncated) {
1039
+ const writable = utf8Prefix(chunk, FILE_LIMIT - fileBytes);
1040
+ if (writable.length > 0) import_node_fs4.default.writeSync(descriptor, writable);
1041
+ fileBytes += writable.length;
1042
+ if (writable.length < chunk.length) outputFileTruncated = true;
1043
+ } else if (descriptor !== void 0) {
1044
+ outputFileTruncated = true;
1045
+ }
1046
+ };
1047
+ const onData = (chunk) => {
1048
+ try {
1049
+ collect(chunk);
1050
+ } catch {
1051
+ outputFailure = true;
1052
+ if (descriptor !== void 0) {
1053
+ try {
1054
+ import_node_fs4.default.closeSync(descriptor);
1055
+ } catch {
1056
+ }
1057
+ descriptor = void 0;
1058
+ }
1059
+ }
1060
+ const now = Date.now();
1061
+ if (now - lastProgressAt >= 250) {
1062
+ lastProgressAt = now;
1063
+ ctx.onProgress?.({ elapsedMs: now - startedAt, outputBytes });
1064
+ }
1065
+ };
1066
+ for (const stream of [child.stdout, child.stderr]) {
1067
+ const decoder = new import_node_string_decoder.StringDecoder("utf8");
1068
+ stream?.on("data", (chunk) => {
1069
+ let text = decoder.write(chunk);
1070
+ if (stream === child.stderr && nativeGroup) text = nativeGroup.accept(text);
1071
+ if (text) onData(Buffer.from(text, "utf8"));
1072
+ });
1073
+ stream?.once("end", () => {
1074
+ const text = decoder.end();
1075
+ if (text) onData(Buffer.from(text, "utf8"));
1076
+ });
1077
+ }
1078
+ const close = new Promise((resolve, reject) => {
1079
+ child.once("error", reject);
1080
+ child.once("close", (code) => resolve({ code }));
1081
+ });
1082
+ let closed = false;
1083
+ void close.finally(() => {
1084
+ closed = true;
1085
+ }).catch(() => {
1086
+ });
1087
+ try {
1088
+ await ctx.onProcessSpawned?.();
1089
+ } catch {
1090
+ if (nativeGroup) await waitForClose(nativeGroup.ready, 1e3);
1091
+ stopProcessTree(child, nativeGroup);
1092
+ if (!await waitForClose(close, 2e3)) {
1093
+ forceProcessTree(child, nativeGroup);
1094
+ await waitForClose(close, 3e3);
1095
+ }
1096
+ if (descriptor !== void 0) import_node_fs4.default.closeSync(descriptor);
1097
+ throw new KapibalaError("Process started but spawn audit failed", {
1098
+ code: "OUTCOME_UNKNOWN",
1099
+ retryPolicy: "after_user_action",
1100
+ safeMessage: "Command started but audit failed; outcome is unknown"
1101
+ });
1102
+ }
1103
+ let stopReason;
1104
+ let stopPromise;
1105
+ const stop = (reason) => {
1106
+ if (stopPromise) return;
1107
+ stopReason = reason;
1108
+ stopPromise = (async () => {
1109
+ if (nativeGroup) await waitForClose(nativeGroup.ready, 1e3);
1110
+ const stopped = stopProcessTree(child, nativeGroup);
1111
+ if (await waitForClose(close, 2e3)) {
1112
+ if (await confirmProcessTreeStopped(child, stopped, nativeGroup)) return true;
1113
+ }
1114
+ const forced = forceProcessTree(child, nativeGroup);
1115
+ if (!await waitForClose(close, 3e3)) return false;
1116
+ return confirmProcessTreeStopped(child, forced, nativeGroup);
1117
+ })();
1118
+ };
1119
+ const timer = setTimeout(() => stop("timeout"), timeoutMs);
1120
+ const progressTimer = setInterval(() => {
1121
+ const now = Date.now();
1122
+ if (now - lastProgressAt >= 500) {
1123
+ lastProgressAt = now;
1124
+ ctx.onProgress?.({ elapsedMs: now - startedAt, outputBytes });
1125
+ }
1126
+ }, 500);
1127
+ const onAbort = () => stop("aborted");
1128
+ if (ctx.signal?.aborted) onAbort();
1129
+ else ctx.signal?.addEventListener("abort", onAbort, { once: true });
1130
+ try {
1131
+ const outcome = await Promise.race([
1132
+ close.then((value) => ({ kind: "closed", value })),
1133
+ new Promise((resolve) => {
1134
+ const check = () => {
1135
+ if (closed) return;
1136
+ if (stopPromise)
1137
+ void stopPromise.then((confirmed) => {
1138
+ if (!confirmed) resolve({ kind: "unknown" });
1139
+ });
1140
+ else setTimeout(check, 10);
1141
+ };
1142
+ check();
1143
+ })
1144
+ ]);
1145
+ if (outcome.kind === "unknown") {
1146
+ throw new KapibalaError("Command process tree could not be confirmed stopped", {
1147
+ code: "OUTCOME_UNKNOWN",
1148
+ retryPolicy: "after_user_action",
1149
+ safeMessage: "Command outcome is unknown; inspect the system before retrying"
1150
+ });
1151
+ }
1152
+ if (stopReason) {
1153
+ if (!await stopPromise) {
1154
+ throw new KapibalaError("Command process tree could not be confirmed stopped", {
1155
+ code: "OUTCOME_UNKNOWN",
1156
+ retryPolicy: "after_user_action",
1157
+ safeMessage: "Command outcome is unknown; inspect the system before retrying"
1158
+ });
1159
+ }
1160
+ throw new KapibalaError(`Command ${stopReason}`, {
1161
+ code: stopReason === "timeout" ? "COMMAND_TIMEOUT" : "ABORTED",
1162
+ retryPolicy: "after_user_action",
1163
+ safeMessage: `Command ${stopReason}; process exit was observed`
1164
+ });
1165
+ }
1166
+ if (outputFailure) {
1167
+ throw new KapibalaError("Command output storage failed", {
1168
+ code: "OUTPUT_STORAGE_FAILED",
1169
+ retryPolicy: "after_user_action",
1170
+ safeMessage: "Command finished but its output could not be stored"
1171
+ });
1172
+ }
1173
+ const inlineBuffer = Buffer.concat(inline);
1174
+ const output = utf8Prefix(inlineBuffer, INLINE_LIMIT).toString("utf8");
1175
+ return {
1176
+ exitCode: outcome.value.code,
1177
+ output,
1178
+ outputTruncated: outputBytes > INLINE_LIMIT,
1179
+ ...outputFile ? { outputFile } : {},
1180
+ outputFileTruncated,
1181
+ outputBytes,
1182
+ shell: environment.kind
1183
+ };
1184
+ } finally {
1185
+ clearTimeout(timer);
1186
+ clearInterval(progressTimer);
1187
+ ctx.signal?.removeEventListener("abort", onAbort);
1188
+ if (descriptor !== void 0) import_node_fs4.default.closeSync(descriptor);
1189
+ }
1190
+ }
1191
+ });
1192
+ }
1193
+
1194
+ // src/security/sandbox.ts
1195
+ var import_node_fs5 = __toESM(require("fs"), 1);
1196
+ var import_node_path5 = __toESM(require("path"), 1);
145
1197
  function tryRealpath(target) {
146
1198
  try {
147
- return import_node_fs.default.realpathSync(target);
1199
+ return import_node_fs5.default.realpathSync(target);
148
1200
  } catch {
149
1201
  return void 0;
150
1202
  }
@@ -154,7 +1206,7 @@ var PathSandbox = class {
154
1206
  allowSymlinks;
155
1207
  cachedRealRootDir;
156
1208
  constructor(config) {
157
- this.rootDir = import_node_path.default.resolve(config.rootDir);
1209
+ this.rootDir = import_node_path5.default.resolve(config.rootDir);
158
1210
  this.allowSymlinks = config.allowSymlinks ?? false;
159
1211
  }
160
1212
  /**
@@ -177,16 +1229,16 @@ var PathSandbox = class {
177
1229
  if (!targetPath || typeof targetPath !== "string") {
178
1230
  throw new ToolError("Invalid path: path must be a non-empty string");
179
1231
  }
180
- const resolvedPath = import_node_path.default.isAbsolute(targetPath) ? import_node_path.default.resolve(targetPath) : import_node_path.default.resolve(this.rootDir, targetPath);
181
- const relative = import_node_path.default.relative(this.rootDir, resolvedPath);
182
- if (relative.startsWith("..") || import_node_path.default.isAbsolute(relative)) {
1232
+ const resolvedPath = import_node_path5.default.isAbsolute(targetPath) ? import_node_path5.default.resolve(targetPath) : import_node_path5.default.resolve(this.rootDir, targetPath);
1233
+ const relative = import_node_path5.default.relative(this.rootDir, resolvedPath);
1234
+ if (relative.startsWith("..") || import_node_path5.default.isAbsolute(relative)) {
183
1235
  throw new ToolError(
184
1236
  `Access denied: path '${targetPath}' escapes root directory '${this.rootDir}'`
185
1237
  );
186
1238
  }
187
1239
  const physicalPath = this.toPhysicalPath(resolvedPath);
188
- const physicalRelative = import_node_path.default.relative(this.realRootDir, physicalPath);
189
- if (physicalRelative.startsWith("..") || import_node_path.default.isAbsolute(physicalRelative)) {
1240
+ const physicalRelative = import_node_path5.default.relative(this.realRootDir, physicalPath);
1241
+ if (physicalRelative.startsWith("..") || import_node_path5.default.isAbsolute(physicalRelative)) {
190
1242
  throw new ToolError(
191
1243
  `Access denied: path '${targetPath}' resolves outside root directory '${this.rootDir}' (via symlink)`
192
1244
  );
@@ -208,7 +1260,7 @@ var PathSandbox = class {
208
1260
  while (true) {
209
1261
  let stat;
210
1262
  try {
211
- stat = import_node_fs.default.lstatSync(probe);
1263
+ stat = import_node_fs5.default.lstatSync(probe);
212
1264
  } catch {
213
1265
  stat = void 0;
214
1266
  }
@@ -219,30 +1271,146 @@ var PathSandbox = class {
219
1271
  `Access denied: cannot resolve real path for '${resolvedPath}' (broken or unresolvable symlink)`
220
1272
  );
221
1273
  }
222
- return pendingSegments.length > 0 ? import_node_path.default.join(real, ...pendingSegments) : real;
1274
+ return pendingSegments.length > 0 ? import_node_path5.default.join(real, ...pendingSegments) : real;
223
1275
  }
224
- const parent = import_node_path.default.dirname(probe);
1276
+ const parent = import_node_path5.default.dirname(probe);
225
1277
  if (parent === probe) {
226
1278
  return resolvedPath;
227
1279
  }
228
- pendingSegments.unshift(import_node_path.default.basename(probe));
1280
+ pendingSegments.unshift(import_node_path5.default.basename(probe));
229
1281
  probe = parent;
230
1282
  }
231
1283
  }
232
1284
  };
233
1285
 
234
- // src/tools/index.ts
235
- function defineTool(tool) {
236
- return tool;
1286
+ // src/security/permissions.ts
1287
+ var import_node_path6 = __toESM(require("path"), 1);
1288
+ var CAPABILITIES = /* @__PURE__ */ new Set([
1289
+ "fs:read",
1290
+ "fs:write",
1291
+ "exec",
1292
+ "net:outbound",
1293
+ "env:read",
1294
+ "agent:spawn"
1295
+ ]);
1296
+ function validatePermissionRules(rules) {
1297
+ for (const [index, rule] of rules.entries()) {
1298
+ const ruleNumber = index + 1;
1299
+ if (!["allow", "ask", "deny"].includes(rule.action)) {
1300
+ throw new Error(`Permission rule ${ruleNumber}: invalid action`);
1301
+ }
1302
+ const scopes = Number(rule.tool !== void 0) + Number(rule.capability !== void 0) + Number(rule.shell !== void 0);
1303
+ if (scopes !== 1)
1304
+ throw new Error(`Permission rule ${ruleNumber}: exactly one scope is required`);
1305
+ if (rule.capability && !CAPABILITIES.has(rule.capability)) {
1306
+ throw new Error(`Permission rule ${ruleNumber}: unknown capability`);
1307
+ }
1308
+ if (rule.tool !== void 0 && (!rule.tool.trim() || /[*?]/.test(rule.tool))) {
1309
+ throw new Error(`Permission rule ${ruleNumber}: tool scope must be an exact name`);
1310
+ }
1311
+ if (rule.shell) {
1312
+ const { command, interpreter, cwd } = rule.shell;
1313
+ if (!command?.trim() || !interpreter?.trim() || !cwd?.trim() || /[*?]/.test(command) || /[*?]/.test(interpreter) || /[*?]/.test(cwd)) {
1314
+ throw new Error(
1315
+ `Permission rule ${ruleNumber}: shell rules require an exact command, interpreter and cwd`
1316
+ );
1317
+ }
1318
+ }
1319
+ }
237
1320
  }
238
- function toToolDefinition(tool) {
239
- return {
240
- name: tool.name,
241
- description: tool.description,
242
- parameters: tool.parameters
243
- };
1321
+ function samePath(a, b) {
1322
+ const na = import_node_path6.default.resolve(a);
1323
+ const nb = import_node_path6.default.resolve(b);
1324
+ return process.platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
1325
+ }
1326
+ var PermissionPolicy = class {
1327
+ decide(request) {
1328
+ if (request.hardDenied) return { decision: "deny", source: "hard_limit" };
1329
+ if (request.mode === "Plan" && (request.dangerous || request.capabilities.length === 0 || request.capabilities.some((capability) => capability !== "fs:read"))) {
1330
+ return { decision: "deny", source: "plan" };
1331
+ }
1332
+ const matches = (rule) => {
1333
+ if (rule.tool !== void 0) return rule.tool === request.toolName;
1334
+ if (rule.capability !== void 0) return request.capabilities.includes(rule.capability);
1335
+ if (!rule.shell || !request.shell) return false;
1336
+ return rule.shell.command === request.shell.command && rule.shell.interpreter === request.shell.interpreter && samePath(rule.shell.cwd, request.shell.cwd);
1337
+ };
1338
+ const rules = request.rules ?? [];
1339
+ for (const action of ["deny", "ask"]) {
1340
+ const ruleIndex = rules.findIndex((rule) => rule.action === action && matches(rule));
1341
+ if (ruleIndex >= 0) return { decision: action, source: "explicit_rule", ruleIndex };
1342
+ if (action === "deny" && request.cachedDenial)
1343
+ return { decision: "deny", source: "session_cache" };
1344
+ }
1345
+ if (request.cachedApproval) return { decision: "allow", source: "session_cache" };
1346
+ if (request.capabilities.length === 0) return { decision: "ask", source: "mode_default" };
1347
+ const allowIndex = rules.findIndex(
1348
+ (rule) => rule.action === "allow" && matches(rule) && (request.toolName !== "run_command" || rule.shell !== void 0)
1349
+ );
1350
+ if (allowIndex >= 0)
1351
+ return { decision: "allow", source: "explicit_rule", ruleIndex: allowIndex };
1352
+ if (request.capabilities.length === 0 || request.dangerous && request.mode !== "FullAccess") {
1353
+ return { decision: "ask", source: "mode_default" };
1354
+ }
1355
+ if (request.mode === "FullAccess") return { decision: "allow", source: "mode_default" };
1356
+ if (request.capabilities.every((capability) => capability === "fs:read")) {
1357
+ return { decision: "allow", source: "mode_default" };
1358
+ }
1359
+ if (request.mode === "Auto" && request.capabilities.every(
1360
+ (capability) => capability === "fs:read" || capability === "fs:write"
1361
+ )) {
1362
+ return { decision: "allow", source: "mode_default" };
1363
+ }
1364
+ return { decision: "ask", source: "mode_default" };
1365
+ }
1366
+ };
1367
+ function visibleTools(tools, mode, rules = []) {
1368
+ const policy = new PermissionPolicy();
1369
+ return tools.filter(
1370
+ (tool) => policy.decide({
1371
+ mode,
1372
+ toolName: tool.name,
1373
+ capabilities: tool.metadata?.permissions ?? [],
1374
+ dangerous: tool.metadata?.dangerous,
1375
+ rules
1376
+ }).decision !== "deny"
1377
+ );
244
1378
  }
245
1379
 
1380
+ // src/security/approval.ts
1381
+ var import_node_crypto4 = require("crypto");
1382
+ function stableJsonStringify(value) {
1383
+ if (value === null || typeof value !== "object") {
1384
+ return JSON.stringify(value);
1385
+ }
1386
+ if (Array.isArray(value)) {
1387
+ return `[${value.map(stableJsonStringify).join(",")}]`;
1388
+ }
1389
+ const obj = value;
1390
+ const keys = Object.keys(obj).sort();
1391
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJsonStringify(obj[k])}`).join(",")}}`;
1392
+ }
1393
+ var SessionApprovalCache = class {
1394
+ choices = /* @__PURE__ */ new Map();
1395
+ key(request) {
1396
+ return (0, import_node_crypto4.createHash)("sha256").update(
1397
+ stableJsonStringify([
1398
+ request.toolName,
1399
+ [...request.capabilities].sort(),
1400
+ request.input,
1401
+ request.rootDir,
1402
+ request.shell ?? null
1403
+ ])
1404
+ ).digest("hex");
1405
+ }
1406
+ get(request) {
1407
+ return this.choices.get(this.key(request));
1408
+ }
1409
+ set(request, choice) {
1410
+ this.choices.set(this.key(request), choice);
1411
+ }
1412
+ };
1413
+
246
1414
  // src/tools/registry.ts
247
1415
  var ToolRegistry = class {
248
1416
  tools = /* @__PURE__ */ new Map();
@@ -300,8 +1468,8 @@ var ToolRegistry = class {
300
1468
  };
301
1469
 
302
1470
  // src/tools/builtin/fs.ts
303
- var import_node_fs2 = __toESM(require("fs"), 1);
304
- var import_node_path2 = __toESM(require("path"), 1);
1471
+ var import_node_fs6 = __toESM(require("fs"), 1);
1472
+ var import_node_path7 = __toESM(require("path"), 1);
305
1473
  var sandboxCache = /* @__PURE__ */ new Map();
306
1474
  function getSandbox(rootDir) {
307
1475
  let sandbox = sandboxCache.get(rootDir);
@@ -339,10 +1507,10 @@ var readFileTool = defineTool({
339
1507
  async execute(input, ctx) {
340
1508
  const sandbox = getSandbox(ctx.rootDir);
341
1509
  const safePath = sandbox.resolveSafePath(input.path);
342
- if (!import_node_fs2.default.existsSync(safePath)) {
1510
+ if (!import_node_fs6.default.existsSync(safePath)) {
343
1511
  throw new ToolError(`File not found: ${input.path}`);
344
1512
  }
345
- const stat = import_node_fs2.default.statSync(safePath);
1513
+ const stat = import_node_fs6.default.statSync(safePath);
346
1514
  if (stat.isDirectory()) {
347
1515
  throw new ToolError(`Target path is a directory, not a file: ${input.path}`);
348
1516
  }
@@ -355,11 +1523,11 @@ var readFileTool = defineTool({
355
1523
  }
356
1524
  const prefixSize = Math.min(stat.size, 8192);
357
1525
  const prefix = Buffer.alloc(prefixSize);
358
- const fileDescriptor = import_node_fs2.default.openSync(safePath, "r");
1526
+ const fileDescriptor = import_node_fs6.default.openSync(safePath, "r");
359
1527
  try {
360
- import_node_fs2.default.readSync(fileDescriptor, prefix, 0, prefixSize, 0);
1528
+ import_node_fs6.default.readSync(fileDescriptor, prefix, 0, prefixSize, 0);
361
1529
  } finally {
362
- import_node_fs2.default.closeSync(fileDescriptor);
1530
+ import_node_fs6.default.closeSync(fileDescriptor);
363
1531
  }
364
1532
  if (prefix.includes(0)) {
365
1533
  throw new ToolError(`Cannot read binary file: ${input.path}`);
@@ -372,7 +1540,7 @@ var readFileTool = defineTool({
372
1540
  maxReadBytes
373
1541
  );
374
1542
  }
375
- return import_node_fs2.default.readFileSync(safePath, "utf-8");
1543
+ return import_node_fs6.default.readFileSync(safePath, "utf-8");
376
1544
  }
377
1545
  });
378
1546
  async function readFileLineRange(filePath, startLine, endLine, maxReadBytes) {
@@ -382,7 +1550,7 @@ async function readFileLineRange(filePath, startLine, endLine, maxReadBytes) {
382
1550
  let currentLine = 1;
383
1551
  let pending = "";
384
1552
  let reachedEnd = false;
385
- const stream = import_node_fs2.default.createReadStream(filePath, { encoding: "utf-8" });
1553
+ const stream = import_node_fs6.default.createReadStream(filePath, { encoding: "utf-8" });
386
1554
  const selectLine = (line) => {
387
1555
  if (currentLine >= startLine && (endLine === void 0 || currentLine <= endLine)) {
388
1556
  const addedBytes = Buffer.byteLength(line, "utf-8") + (selectedLines.length > 0 ? 1 : 0);
@@ -445,11 +1613,11 @@ var writeFileTool = defineTool({
445
1613
  async execute(input, ctx) {
446
1614
  const sandbox = getSandbox(ctx.rootDir);
447
1615
  const safePath = sandbox.resolveSafePath(input.path);
448
- const dir = import_node_path2.default.dirname(safePath);
449
- if (!import_node_fs2.default.existsSync(dir)) {
450
- import_node_fs2.default.mkdirSync(dir, { recursive: true });
1616
+ const dir = import_node_path7.default.dirname(safePath);
1617
+ if (!import_node_fs6.default.existsSync(dir)) {
1618
+ import_node_fs6.default.mkdirSync(dir, { recursive: true });
451
1619
  }
452
- import_node_fs2.default.writeFileSync(safePath, input.content, "utf-8");
1620
+ import_node_fs6.default.writeFileSync(safePath, input.content, "utf-8");
453
1621
  return `Successfully wrote ${Buffer.byteLength(input.content, "utf-8")} bytes to ${input.path}`;
454
1622
  }
455
1623
  });
@@ -481,13 +1649,13 @@ var editFileTool = defineTool({
481
1649
  async execute(input, ctx) {
482
1650
  const sandbox = getSandbox(ctx.rootDir);
483
1651
  const safePath = sandbox.resolveSafePath(input.path);
484
- if (!import_node_fs2.default.existsSync(safePath)) {
1652
+ if (!import_node_fs6.default.existsSync(safePath)) {
485
1653
  throw new ToolError(`File not found: ${input.path}`);
486
1654
  }
487
1655
  if (!input.targetContent) {
488
1656
  throw new ToolError("Target content must not be empty");
489
1657
  }
490
- const content = import_node_fs2.default.readFileSync(safePath, "utf-8");
1658
+ const content = import_node_fs6.default.readFileSync(safePath, "utf-8");
491
1659
  if (!content.includes(input.targetContent)) {
492
1660
  throw new ToolError(
493
1661
  `Target content not found in ${input.path}. Ensure exact indentation and characters.`
@@ -500,7 +1668,7 @@ var editFileTool = defineTool({
500
1668
  );
501
1669
  }
502
1670
  const newContent = content.replace(input.targetContent, () => input.replacementContent);
503
- import_node_fs2.default.writeFileSync(safePath, newContent, "utf-8");
1671
+ import_node_fs6.default.writeFileSync(safePath, newContent, "utf-8");
504
1672
  return `Successfully replaced target content in ${input.path}`;
505
1673
  }
506
1674
  });
@@ -528,20 +1696,20 @@ var globTool = defineTool({
528
1696
  async execute(input, ctx) {
529
1697
  const sandbox = getSandbox(ctx.rootDir);
530
1698
  const searchRoot = input.subDirectory ? sandbox.resolveSafePath(input.subDirectory) : ctx.rootDir;
531
- if (!import_node_fs2.default.existsSync(searchRoot)) {
1699
+ if (!import_node_fs6.default.existsSync(searchRoot)) {
532
1700
  throw new ToolError(`Directory not found: ${input.subDirectory ?? "."}`);
533
1701
  }
534
1702
  const results = [];
535
1703
  const maxResults = 100;
536
1704
  function walk(currentDir) {
537
1705
  if (results.length >= maxResults) return;
538
- const entries = import_node_fs2.default.readdirSync(currentDir, { withFileTypes: true });
1706
+ const entries = import_node_fs6.default.readdirSync(currentDir, { withFileTypes: true });
539
1707
  for (const entry of entries) {
540
1708
  if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
541
1709
  continue;
542
1710
  }
543
- const fullPath = import_node_path2.default.join(currentDir, entry.name);
544
- const relPath = import_node_path2.default.relative(ctx.rootDir, fullPath).replace(/\\/g, "/");
1711
+ const fullPath = import_node_path7.default.join(currentDir, entry.name);
1712
+ const relPath = import_node_path7.default.relative(ctx.rootDir, fullPath).replace(/\\/g, "/");
545
1713
  if (entry.isDirectory()) {
546
1714
  walk(fullPath);
547
1715
  } else {
@@ -581,7 +1749,7 @@ var grepTool = defineTool({
581
1749
  if (ctx.signal?.aborted) throw new AbortError();
582
1750
  const sandbox = getSandbox(ctx.rootDir);
583
1751
  const target = input.path ? sandbox.resolveSafePath(input.path) : ctx.rootDir;
584
- if (!import_node_fs2.default.existsSync(target)) {
1752
+ if (!import_node_fs6.default.existsSync(target)) {
585
1753
  throw new ToolError(`Path not found: ${input.path ?? "."}`);
586
1754
  }
587
1755
  if (!input.pattern.trim()) throw new ToolError("Search pattern must not be empty");
@@ -593,8 +1761,8 @@ var grepTool = defineTool({
593
1761
  if (matches.length >= maxMatches) return;
594
1762
  const maxLineBytes = 512 * 1024;
595
1763
  const maxOutputBytes = 512 * 1024;
596
- const stream = import_node_fs2.default.createReadStream(filePath, { encoding: "utf-8" });
597
- const relPath = import_node_path2.default.relative(ctx.rootDir, filePath).replace(/\\/g, "/");
1764
+ const stream = import_node_fs6.default.createReadStream(filePath, { encoding: "utf-8" });
1765
+ const relPath = import_node_path7.default.relative(ctx.rootDir, filePath).replace(/\\/g, "/");
598
1766
  let lineNumber = 0;
599
1767
  let pending = "";
600
1768
  let skippingLongLine = false;
@@ -644,14 +1812,14 @@ var grepTool = defineTool({
644
1812
  }
645
1813
  async function walk(dirPath) {
646
1814
  if (matches.length >= maxMatches) return;
647
- const entries = import_node_fs2.default.readdirSync(dirPath, { withFileTypes: true });
1815
+ const entries = import_node_fs6.default.readdirSync(dirPath, { withFileTypes: true });
648
1816
  for (const entry of entries) {
649
1817
  if (ctx.signal?.aborted) throw new AbortError();
650
1818
  if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
651
1819
  continue;
652
1820
  }
653
1821
  if (entry.isSymbolicLink()) continue;
654
- const fullPath = import_node_path2.default.join(dirPath, entry.name);
1822
+ const fullPath = import_node_path7.default.join(dirPath, entry.name);
655
1823
  if (entry.isDirectory()) {
656
1824
  await walk(fullPath);
657
1825
  } else {
@@ -660,7 +1828,7 @@ var grepTool = defineTool({
660
1828
  if (matches.length >= maxMatches) return;
661
1829
  }
662
1830
  }
663
- const stat = import_node_fs2.default.statSync(target);
1831
+ const stat = import_node_fs6.default.statSync(target);
664
1832
  if (stat.isFile()) {
665
1833
  await searchFile(target);
666
1834
  } else {
@@ -700,6 +1868,7 @@ var HookRegistry = class {
700
1868
  };
701
1869
 
702
1870
  // src/executor/index.ts
1871
+ var import_node_crypto5 = require("crypto");
703
1872
  var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
704
1873
  var ToolExecutor = class {
705
1874
  tools;
@@ -708,54 +1877,310 @@ var ToolExecutor = class {
708
1877
  signal;
709
1878
  logger;
710
1879
  toolTimeoutMs;
1880
+ eventLogger;
1881
+ sessionId;
1882
+ runId;
1883
+ mode;
1884
+ permissionRules;
1885
+ approvalChannel;
1886
+ approvalCache;
1887
+ policy = new PermissionPolicy();
1888
+ neverFailures = /* @__PURE__ */ new Set();
1889
+ cancellation = new AbortController();
711
1890
  constructor(options) {
712
1891
  this.tools = options.tools;
713
1892
  this.hooks = options.hooks;
714
1893
  this.rootDir = options.rootDir;
715
- this.signal = options.signal;
1894
+ this.signal = options.signal ? AbortSignal.any([options.signal, this.cancellation.signal]) : this.cancellation.signal;
716
1895
  this.logger = options.logger;
717
1896
  this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
718
- }
719
- async runAll(calls) {
1897
+ this.eventLogger = options.eventLogger;
1898
+ this.sessionId = options.sessionId;
1899
+ this.runId = options.runId;
1900
+ this.mode = options.mode ?? "Approval";
1901
+ this.permissionRules = options.permissionRules ?? [];
1902
+ this.approvalChannel = options.approvalChannel;
1903
+ this.approvalCache = options.approvalCache ?? new SessionApprovalCache();
1904
+ }
1905
+ /** 中止本轮正在等待或执行的工具;调用方仍须等待 runAll 完成资源回收及结果审计。 */
1906
+ cancel() {
1907
+ this.cancellation.abort();
1908
+ }
1909
+ async runAll(calls, onProgress) {
720
1910
  const results = [];
1911
+ let requiresUserAction = false;
721
1912
  for (const call of calls) {
722
- if (this.signal?.aborted) {
723
- results.push({
724
- type: "tool_result",
725
- toolUseId: call.id,
726
- content: "Tool execution aborted",
727
- isError: true
728
- });
729
- continue;
730
- }
731
- results.push(await this.executeOne(call));
1913
+ const result = await this.executeOne(call, onProgress, requiresUserAction);
1914
+ results.push(result);
1915
+ if (result.retryPolicy === "after_user_action") requiresUserAction = true;
732
1916
  }
733
1917
  return results;
734
1918
  }
735
- async executeOne(call) {
1919
+ /** 在最终参数上裁决并审计一次调用,所有拒绝和异常均闭合为工具结果。 */
1920
+ async executeOne(call, onProgress, forcedDeny = false) {
736
1921
  const startedAt = Date.now();
1922
+ const operationId = createLogId();
1923
+ const failure = (message, errorCode, retryPolicy = "never") => ({
1924
+ type: "tool_result",
1925
+ toolUseId: call.id,
1926
+ content: JSON.stringify({ code: errorCode, retryPolicy, message }),
1927
+ isError: true,
1928
+ errorCode,
1929
+ retryPolicy,
1930
+ durationMs: Math.max(0, Date.now() - startedAt)
1931
+ });
1932
+ const audit = async (event, fields = {}) => {
1933
+ if (!this.eventLogger || !this.sessionId) return;
1934
+ await this.eventLogger.recordAudit({
1935
+ level: "info",
1936
+ event,
1937
+ sessionId: this.sessionId,
1938
+ runId: this.runId,
1939
+ operationId,
1940
+ toolUseId: call.id,
1941
+ fields: { toolName: call.name, ...fields }
1942
+ });
1943
+ };
1944
+ try {
1945
+ await audit("tool.requested", { status: "requested", processId: process.pid });
1946
+ } catch {
1947
+ return failure(
1948
+ "Audit unavailable; tool was not started",
1949
+ "AUDIT_UNAVAILABLE",
1950
+ "after_user_action"
1951
+ );
1952
+ }
1953
+ if (forcedDeny) {
1954
+ try {
1955
+ await audit("tool.decided", { decision: "deny", decisionSource: "prior_user_action" });
1956
+ await audit("tool.finished", { status: "skipped", errorCode: "SKIPPED_AFTER_USER_ACTION" });
1957
+ } catch {
1958
+ return failure(
1959
+ "Audit unavailable; tool was not started",
1960
+ "AUDIT_UNAVAILABLE",
1961
+ "after_user_action"
1962
+ );
1963
+ }
1964
+ return failure(
1965
+ "Earlier tool call requires user action",
1966
+ "SKIPPED_AFTER_USER_ACTION",
1967
+ "after_user_action"
1968
+ );
1969
+ }
737
1970
  const hookCtx = { signal: this.signal, logger: this.logger };
738
1971
  let currentInput = call.input;
739
- const beforeHooks = this.hooks.get("tool:before");
740
- for (const hook of beforeHooks) {
741
- const decision = await hook(hookCtx, { id: call.id, name: call.name, input: currentInput });
742
- if (decision.action === "skip") {
743
- return {
744
- type: "tool_result",
745
- toolUseId: call.id,
746
- content: decision.result,
747
- isError: decision.isError ?? false,
748
- durationMs: Math.max(0, Date.now() - startedAt)
749
- };
1972
+ try {
1973
+ for (const hook of this.hooks.get("tool:before")) {
1974
+ const decision = await hook(hookCtx, { id: call.id, name: call.name, input: currentInput });
1975
+ if (decision.action === "modify") currentInput = decision.input;
1976
+ if (decision.action === "skip") {
1977
+ await audit("tool.decided", { decision: "deny", decisionSource: "hook" });
1978
+ await audit("tool.finished", { status: "skipped" });
1979
+ return {
1980
+ type: "tool_result",
1981
+ toolUseId: call.id,
1982
+ content: decision.result,
1983
+ isError: decision.isError ?? false,
1984
+ durationMs: Math.max(0, Date.now() - startedAt)
1985
+ };
1986
+ }
1987
+ }
1988
+ } catch {
1989
+ try {
1990
+ await audit("tool.decided", { decision: "deny", decisionSource: "hook" });
1991
+ await audit("tool.finished", { status: "failed" });
1992
+ } catch {
1993
+ }
1994
+ return failure("Tool preparation failed", "HOOK_ERROR");
1995
+ }
1996
+ let tool;
1997
+ try {
1998
+ tool = this.tools.resolve(call.name);
1999
+ } catch (error) {
2000
+ const known = error instanceof KapibalaError ? error : void 0;
2001
+ try {
2002
+ await audit("tool.decided", { decision: "deny", decisionSource: "unavailable" });
2003
+ await audit("tool.finished", { status: "failed" });
2004
+ } catch {
2005
+ }
2006
+ return failure(
2007
+ known?.safeMessage ?? "Requested tool is unavailable",
2008
+ known?.code ?? "TOOL_NOT_FOUND"
2009
+ );
2010
+ }
2011
+ try {
2012
+ return await this.executeResolved(
2013
+ call,
2014
+ currentInput,
2015
+ tool,
2016
+ startedAt,
2017
+ audit,
2018
+ failure,
2019
+ hookCtx,
2020
+ onProgress
2021
+ );
2022
+ } catch {
2023
+ try {
2024
+ await audit("tool.finished", { status: "unknown", errorCode: "OUTCOME_UNKNOWN" });
2025
+ } catch {
750
2026
  }
751
- if (decision.action === "modify") {
752
- currentInput = decision.input;
2027
+ return failure(
2028
+ "Tool outcome is unknown after an executor failure",
2029
+ "OUTCOME_UNKNOWN",
2030
+ "after_user_action"
2031
+ );
2032
+ }
2033
+ }
2034
+ async executeResolved(call, currentInput, tool, startedAt, audit, failure, hookCtx, onProgress) {
2035
+ let shell;
2036
+ try {
2037
+ shell = tool.approvalScope?.(currentInput, this.rootDir);
2038
+ } catch {
2039
+ try {
2040
+ await audit("tool.decided", { decision: "deny", decisionSource: "invalid_target" });
2041
+ await audit("tool.finished", { status: "failed", errorCode: "INVALID_TARGET" });
2042
+ } catch {
753
2043
  }
2044
+ return failure("Invalid tool execution target", "INVALID_TARGET");
2045
+ }
2046
+ let auditFields = {};
2047
+ for (let attempt = 0; attempt < 2; attempt++) {
2048
+ const request = {
2049
+ toolName: call.name,
2050
+ capabilities: tool.metadata?.permissions ?? [],
2051
+ input: currentInput,
2052
+ rootDir: this.rootDir,
2053
+ ...shell ? { shell } : {},
2054
+ // 只有无参数的解释器内建查询能确认不依赖外部脚本;其余命令仅允许单次批准。
2055
+ sessionAllowed: !shell || /^(?:pwd|get-location)$/i.test(shell.command.trim()) || shell.scriptDigest !== void 0
2056
+ };
2057
+ const repeatKey = (0, import_node_crypto5.createHash)("sha256").update(stableJsonStringify([call.name, currentInput])).digest("hex");
2058
+ if (this.neverFailures.has(repeatKey)) {
2059
+ try {
2060
+ await audit("tool.decided", { decision: "deny", decisionSource: "repeat_block" });
2061
+ await audit("tool.finished", { status: "blocked", errorCode: "REPEAT_BLOCKED" });
2062
+ } catch {
2063
+ return failure(
2064
+ "Audit unavailable; tool was not started",
2065
+ "AUDIT_UNAVAILABLE",
2066
+ "after_user_action"
2067
+ );
2068
+ }
2069
+ return failure(
2070
+ "Identical failed tool call cannot be repeated in this run",
2071
+ "REPEAT_BLOCKED"
2072
+ );
2073
+ }
2074
+ const cached = this.approvalCache.get(request);
2075
+ const decision = this.policy.decide({
2076
+ mode: typeof this.mode === "function" ? this.mode() : this.mode,
2077
+ toolName: call.name,
2078
+ capabilities: request.capabilities,
2079
+ dangerous: tool.metadata?.dangerous,
2080
+ cachedApproval: cached === "allow",
2081
+ cachedDenial: cached === "deny",
2082
+ rules: this.permissionRules,
2083
+ ...shell ? { shell } : {}
2084
+ });
2085
+ let permitted = decision.decision === "allow";
2086
+ let source = decision.source;
2087
+ let approvalChoice;
2088
+ if (decision.decision === "ask") {
2089
+ let choice = "deny_once";
2090
+ try {
2091
+ choice = await this.approvalChannel?.requestApproval(request, this.signal) ?? "deny_once";
2092
+ } catch {
2093
+ choice = "cancel";
2094
+ }
2095
+ approvalChoice = choice;
2096
+ if (choice === "allow_session" && request.sessionAllowed)
2097
+ this.approvalCache.set(request, "allow");
2098
+ if (choice === "deny_session") this.approvalCache.set(request, "deny");
2099
+ permitted = choice === "allow_once" || choice === "allow_session" && request.sessionAllowed;
2100
+ source = this.approvalChannel ? "human" : "non_interactive";
2101
+ }
2102
+ auditFields = shell ? {
2103
+ shell: shell.interpreter,
2104
+ cwdDigest: (0, import_node_crypto5.createHash)("sha256").update(shell.cwd).digest("hex"),
2105
+ executableDigest: shell.executableDigest,
2106
+ executionFingerprint: shell.executionFingerprint,
2107
+ ...commandAuditSummary(shell.command)
2108
+ } : {};
2109
+ try {
2110
+ await audit("tool.decided", {
2111
+ decision: permitted ? "allow" : "deny",
2112
+ decisionSource: source,
2113
+ ...approvalChoice ? { approvalChoice } : {},
2114
+ ...auditFields
2115
+ });
2116
+ } catch {
2117
+ return failure(
2118
+ "Audit unavailable; tool was not started",
2119
+ "AUDIT_UNAVAILABLE",
2120
+ "after_user_action"
2121
+ );
2122
+ }
2123
+ if (!permitted || this.signal?.aborted) {
2124
+ const code = this.signal?.aborted ? "ABORTED" : "PERMISSION_DENIED";
2125
+ try {
2126
+ await audit("tool.finished", { status: "denied", errorCode: code });
2127
+ } catch {
2128
+ return failure("Audit result unavailable", "AUDIT_UNAVAILABLE", "after_user_action");
2129
+ }
2130
+ return failure(
2131
+ this.signal?.aborted ? "Operation cancelled" : "Tool permission denied",
2132
+ code,
2133
+ "after_user_action"
2134
+ );
2135
+ }
2136
+ if (shell) {
2137
+ try {
2138
+ const next = tool.approvalScope?.(currentInput, this.rootDir);
2139
+ if (next?.executionFingerprint !== shell.executionFingerprint) {
2140
+ if (attempt === 0 && next) {
2141
+ try {
2142
+ await audit("tool.target_changed", { status: "redeciding" });
2143
+ } catch {
2144
+ return failure(
2145
+ "Audit unavailable; tool was not started",
2146
+ "AUDIT_UNAVAILABLE",
2147
+ "after_user_action"
2148
+ );
2149
+ }
2150
+ shell = next;
2151
+ continue;
2152
+ }
2153
+ await audit("tool.finished", { status: "target_changed", errorCode: "TARGET_CHANGED" });
2154
+ return failure(
2155
+ "Execution target changed after approval",
2156
+ "TARGET_CHANGED",
2157
+ "after_user_action"
2158
+ );
2159
+ }
2160
+ } catch {
2161
+ return failure(
2162
+ "Execution target changed after approval",
2163
+ "TARGET_CHANGED",
2164
+ "after_user_action"
2165
+ );
2166
+ }
2167
+ }
2168
+ break;
2169
+ }
2170
+ try {
2171
+ await audit("tool.started", { status: "started", ...auditFields });
2172
+ } catch {
2173
+ return failure(
2174
+ "Audit unavailable; tool was not started",
2175
+ "AUDIT_UNAVAILABLE",
2176
+ "after_user_action"
2177
+ );
754
2178
  }
755
2179
  let outputStr;
756
2180
  let isError = false;
2181
+ let errorCode;
2182
+ let retryPolicy;
757
2183
  try {
758
- const tool = this.tools.resolve(call.name);
759
2184
  const executionController = new AbortController();
760
2185
  const abortFromSession = () => executionController.abort();
761
2186
  if (this.signal?.aborted) {
@@ -766,37 +2191,85 @@ var ToolExecutor = class {
766
2191
  const toolCtx = {
767
2192
  rootDir: this.rootDir,
768
2193
  signal: executionController.signal,
769
- logger: this.logger
2194
+ logger: this.logger,
2195
+ onProgress: (progress) => onProgress?.({ id: call.id, name: call.name, ...progress }),
2196
+ ...shell ? {
2197
+ onProcessSpawned: () => audit("process.spawned", { status: "spawned", ...auditFields })
2198
+ } : {}
770
2199
  };
771
2200
  try {
772
- const rawResult = await this.withTimeout(
2201
+ const rawResult = tool.metadata?.managesTimeout ? await tool.execute(currentInput, toolCtx) : await this.withTimeout(
773
2202
  tool.execute(currentInput, toolCtx),
774
2203
  call.name,
775
2204
  executionController
776
2205
  );
777
2206
  outputStr = typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult, null, 2);
2207
+ if (call.name === "run_command" && typeof rawResult === "object" && rawResult !== null && "exitCode" in rawResult && rawResult.exitCode !== 0) {
2208
+ isError = true;
2209
+ errorCode = "COMMAND_EXIT_NONZERO";
2210
+ retryPolicy = "never";
2211
+ }
778
2212
  } finally {
779
2213
  this.signal?.removeEventListener("abort", abortFromSession);
780
2214
  }
781
2215
  } catch (err) {
782
2216
  isError = true;
783
- outputStr = err instanceof Error ? err.message : String(err);
2217
+ const known = err instanceof KapibalaError ? err : void 0;
2218
+ outputStr = known?.safeMessage ?? "Tool execution failed";
2219
+ errorCode = known?.code ?? "TOOL_FAILED";
2220
+ retryPolicy = known?.retryPolicy ?? "never";
784
2221
  }
785
2222
  const resultBlock = {
786
2223
  type: "tool_result",
787
2224
  toolUseId: call.id,
788
2225
  content: outputStr,
789
- isError
2226
+ isError,
2227
+ ...errorCode ? { errorCode, retryPolicy } : {}
790
2228
  };
791
- const afterHooks = this.hooks.get("tool:after");
792
- for (const hook of afterHooks) {
793
- await hook(
794
- hookCtx,
795
- { id: call.id, name: call.name, input: currentInput },
796
- { output: outputStr, isError }
797
- );
2229
+ if (resultBlock.isError && resultBlock.errorCode) {
2230
+ resultBlock.content = JSON.stringify({
2231
+ code: resultBlock.errorCode,
2232
+ retryPolicy: resultBlock.retryPolicy,
2233
+ message: outputStr
2234
+ });
2235
+ }
2236
+ try {
2237
+ for (const hook of this.hooks.get("tool:after")) {
2238
+ await hook(
2239
+ hookCtx,
2240
+ { id: call.id, name: call.name, input: currentInput },
2241
+ { output: outputStr, isError }
2242
+ );
2243
+ }
2244
+ } catch {
2245
+ resultBlock.content = JSON.stringify({
2246
+ code: "HOOK_ERROR",
2247
+ retryPolicy: "after_user_action",
2248
+ message: "Tool finished but result processing failed"
2249
+ });
2250
+ resultBlock.isError = true;
2251
+ resultBlock.errorCode = "HOOK_ERROR";
2252
+ resultBlock.retryPolicy = "after_user_action";
798
2253
  }
799
2254
  resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
2255
+ if (resultBlock.isError && resultBlock.retryPolicy === "never") {
2256
+ this.neverFailures.add(
2257
+ (0, import_node_crypto5.createHash)("sha256").update(stableJsonStringify([call.name, currentInput])).digest("hex")
2258
+ );
2259
+ }
2260
+ try {
2261
+ await audit("tool.finished", {
2262
+ status: resultBlock.isError ? "failed" : "completed",
2263
+ ...resultBlock.errorCode ? { errorCode: resultBlock.errorCode } : {},
2264
+ durationMs: resultBlock.durationMs
2265
+ });
2266
+ } catch {
2267
+ return failure(
2268
+ "Tool outcome is unknown because the audit result could not be written",
2269
+ "OUTCOME_UNKNOWN",
2270
+ "after_user_action"
2271
+ );
2272
+ }
800
2273
  return resultBlock;
801
2274
  }
802
2275
  /**
@@ -810,7 +2283,14 @@ var ToolExecutor = class {
810
2283
  let timer;
811
2284
  const timeout = new Promise((_, reject) => {
812
2285
  timer = setTimeout(() => {
813
- const error = new ToolError(`Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`);
2286
+ const error = new KapibalaError(
2287
+ `Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`,
2288
+ {
2289
+ code: "OUTCOME_UNKNOWN",
2290
+ retryPolicy: "after_user_action",
2291
+ safeMessage: "Tool timed out; completion of side effects is unknown"
2292
+ }
2293
+ );
814
2294
  reject(error);
815
2295
  executionController.abort(error);
816
2296
  }, this.toolTimeoutMs);
@@ -1145,15 +2625,15 @@ var OpenAICompatibleProvider = class {
1145
2625
  };
1146
2626
 
1147
2627
  // src/store/jsonl.ts
1148
- var import_node_fs3 = __toESM(require("fs"), 1);
1149
- var import_node_path3 = __toESM(require("path"), 1);
2628
+ var import_node_fs7 = __toESM(require("fs"), 1);
2629
+ var import_node_path8 = __toESM(require("path"), 1);
1150
2630
  var JSONLMessageStore = class {
1151
2631
  filePath;
1152
2632
  constructor(filePath) {
1153
- this.filePath = import_node_path3.default.resolve(filePath);
1154
- const dir = import_node_path3.default.dirname(this.filePath);
1155
- if (!import_node_fs3.default.existsSync(dir)) {
1156
- import_node_fs3.default.mkdirSync(dir, { recursive: true });
2633
+ this.filePath = import_node_path8.default.resolve(filePath);
2634
+ const dir = import_node_path8.default.dirname(this.filePath);
2635
+ if (!import_node_fs7.default.existsSync(dir)) {
2636
+ import_node_fs7.default.mkdirSync(dir, { recursive: true });
1157
2637
  }
1158
2638
  }
1159
2639
  async append(message) {
@@ -1162,14 +2642,14 @@ var JSONLMessageStore = class {
1162
2642
  role: message.role,
1163
2643
  content: message.content
1164
2644
  };
1165
- import_node_fs3.default.appendFileSync(this.filePath, `${JSON.stringify(record)}
2645
+ import_node_fs7.default.appendFileSync(this.filePath, `${JSON.stringify(record)}
1166
2646
  `, "utf-8");
1167
2647
  }
1168
2648
  async load() {
1169
- if (!import_node_fs3.default.existsSync(this.filePath)) {
2649
+ if (!import_node_fs7.default.existsSync(this.filePath)) {
1170
2650
  return [];
1171
2651
  }
1172
- const content = import_node_fs3.default.readFileSync(this.filePath, "utf-8");
2652
+ const content = import_node_fs7.default.readFileSync(this.filePath, "utf-8");
1173
2653
  const lines = content.split("\n");
1174
2654
  const messages = [];
1175
2655
  for (const line of lines) {
@@ -1191,8 +2671,8 @@ var JSONLMessageStore = class {
1191
2671
  return messages;
1192
2672
  }
1193
2673
  async clear() {
1194
- if (import_node_fs3.default.existsSync(this.filePath)) {
1195
- import_node_fs3.default.unlinkSync(this.filePath);
2674
+ if (import_node_fs7.default.existsSync(this.filePath)) {
2675
+ import_node_fs7.default.unlinkSync(this.filePath);
1196
2676
  }
1197
2677
  }
1198
2678
  /**
@@ -1215,7 +2695,9 @@ var JSONLMessageStore = class {
1215
2695
  type: "tool_result",
1216
2696
  toolUseId: toolUse.id,
1217
2697
  content: "Tool execution was interrupted or crashed in previous session",
1218
- isError: true
2698
+ isError: true,
2699
+ errorCode: "OUTCOME_UNKNOWN",
2700
+ retryPolicy: "after_user_action"
1219
2701
  })
1220
2702
  ),
1221
2703
  timestamp: Date.now()
@@ -1247,17 +2729,21 @@ var JSONLMessageStore = class {
1247
2729
  };
1248
2730
 
1249
2731
  // src/prompt/index.ts
1250
- var import_node_os = __toESM(require("os"), 1);
2732
+ var import_node_os4 = __toESM(require("os"), 1);
1251
2733
  var PromptAssembler = class {
1252
2734
  tools;
1253
2735
  rootDir;
1254
2736
  agentName;
1255
2737
  customInstructions;
2738
+ instructionSources;
2739
+ visibleTools;
1256
2740
  constructor(options) {
1257
2741
  this.tools = options.tools;
1258
2742
  this.rootDir = options.rootDir;
1259
2743
  this.agentName = options.agentName ?? "Kapibala";
1260
2744
  this.customInstructions = options.customInstructions;
2745
+ this.instructionSources = options.instructionSources ?? [];
2746
+ this.visibleTools = options.visibleTools;
1261
2747
  }
1262
2748
  assemble() {
1263
2749
  const sections = [];
@@ -1269,6 +2755,13 @@ var PromptAssembler = class {
1269
2755
  if (this.customInstructions?.trim()) {
1270
2756
  sections.push(this.customInstructions.trim());
1271
2757
  }
2758
+ if (this.instructionSources.length > 0) {
2759
+ sections.push(
2760
+ `# L4 Project Instructions
2761
+ ${this.instructionSources.map((source, index) => `## ${index + 1}. ${source.path}
2762
+ ${source.content}`).join("\n\n")}`
2763
+ );
2764
+ }
1272
2765
  return sections.filter(Boolean).join("\n\n---\n\n");
1273
2766
  }
1274
2767
  getL1Behavior() {
@@ -1285,7 +2778,7 @@ Follow these key behavioral rules:
1285
2778
  }
1286
2779
  getL2Tools() {
1287
2780
  if (!this.tools) return "";
1288
- const tools = this.tools.list();
2781
+ const tools = this.visibleTools ?? this.tools.list();
1289
2782
  if (tools.length === 0) return "";
1290
2783
  const lines = ["# Available Tools:"];
1291
2784
  for (const tool of tools) {
@@ -1295,7 +2788,7 @@ Follow these key behavioral rules:
1295
2788
  }
1296
2789
  getL3Environment() {
1297
2790
  const now = (/* @__PURE__ */ new Date()).toISOString();
1298
- const platform = import_node_os.default.platform();
2791
+ const platform = import_node_os4.default.platform();
1299
2792
  return `# Environment Context:
1300
2793
  - Current Working Directory (Workspace Root): ${this.rootDir}
1301
2794
  - Operating System: ${platform}
@@ -1313,6 +2806,11 @@ var AgentLoop = class {
1313
2806
  maxSteps;
1314
2807
  maxConsecutiveErrors;
1315
2808
  signal;
2809
+ eventLogger;
2810
+ sessionId;
2811
+ runId;
2812
+ getMode;
2813
+ permissionRules;
1316
2814
  constructor(options) {
1317
2815
  this.provider = options.provider;
1318
2816
  this.executor = options.executor;
@@ -1322,6 +2820,11 @@ var AgentLoop = class {
1322
2820
  this.maxSteps = options.maxSteps ?? 20;
1323
2821
  this.maxConsecutiveErrors = options.maxConsecutiveErrors ?? 3;
1324
2822
  this.signal = options.signal;
2823
+ this.eventLogger = options.eventLogger;
2824
+ this.sessionId = options.sessionId;
2825
+ this.runId = options.runId;
2826
+ this.getMode = options.getMode ?? (() => "Approval");
2827
+ this.permissionRules = options.permissionRules ?? [];
1325
2828
  }
1326
2829
  async *run(history) {
1327
2830
  let step = 0;
@@ -1337,7 +2840,9 @@ var AgentLoop = class {
1337
2840
  let request = {
1338
2841
  systemPrompt: this.systemPrompt,
1339
2842
  messages: history,
1340
- tools: this.tools.definitions(),
2843
+ tools: visibleTools(this.tools.list(), this.getMode(), this.permissionRules).map(
2844
+ toToolDefinition
2845
+ ),
1341
2846
  signal: this.signal
1342
2847
  };
1343
2848
  const beforeModelHooks = this.hooks.get("model:before");
@@ -1360,6 +2865,15 @@ var AgentLoop = class {
1360
2865
  let turnUsage;
1361
2866
  let turnTtftMs;
1362
2867
  const modelStartTime = Date.now();
2868
+ if (this.eventLogger && this.sessionId) {
2869
+ await this.eventLogger.record({
2870
+ level: "info",
2871
+ event: "model.requested",
2872
+ sessionId: this.sessionId,
2873
+ runId: this.runId,
2874
+ fields: { attempt: step, count: request.messages.length }
2875
+ });
2876
+ }
1363
2877
  let firstTokenReceived = false;
1364
2878
  const buildMetrics = (modelDurationMs2, toolDurationMs2, toolCallsCount) => {
1365
2879
  const endTime = Date.now();
@@ -1420,6 +2934,15 @@ var AgentLoop = class {
1420
2934
  throw new AbortError();
1421
2935
  }
1422
2936
  const modelError = err instanceof Error ? err : new Error(String(err));
2937
+ if (this.eventLogger && this.sessionId) {
2938
+ await this.eventLogger.record({
2939
+ level: "error",
2940
+ event: "model.failed",
2941
+ sessionId: this.sessionId,
2942
+ runId: this.runId,
2943
+ fields: { attempt: step, durationMs: Date.now() - modelStartTime }
2944
+ });
2945
+ }
1423
2946
  await this.hooks.emit("error", hookCtx, modelError);
1424
2947
  yield { type: "error", error: modelError };
1425
2948
  yield {
@@ -1431,6 +2954,20 @@ var AgentLoop = class {
1431
2954
  break;
1432
2955
  }
1433
2956
  const modelDurationMs = Date.now() - modelStartTime;
2957
+ if (this.eventLogger && this.sessionId) {
2958
+ await this.eventLogger.record({
2959
+ level: "info",
2960
+ event: "model.finished",
2961
+ sessionId: this.sessionId,
2962
+ runId: this.runId,
2963
+ fields: {
2964
+ attempt: step,
2965
+ durationMs: modelDurationMs,
2966
+ promptTokens: turnUsage?.promptTokens ?? 0,
2967
+ completionTokens: turnUsage?.completionTokens ?? 0
2968
+ }
2969
+ });
2970
+ }
1434
2971
  yield {
1435
2972
  type: "step_log",
1436
2973
  log: {
@@ -1496,12 +3033,44 @@ var AgentLoop = class {
1496
3033
  yield { type: "tool_start", id: call.id, name: call.name, input: call.input };
1497
3034
  }
1498
3035
  const toolStartTime = Date.now();
1499
- const toolResults = await this.executor.runAll(toolCalls);
3036
+ const progressQueue = [];
3037
+ let progressWake;
3038
+ let toolsFinished = false;
3039
+ const toolRun = this.executor.runAll(toolCalls, (progress) => {
3040
+ progressQueue.push({ type: "tool_progress", ...progress });
3041
+ progressWake?.();
3042
+ });
3043
+ void toolRun.then(
3044
+ () => {
3045
+ toolsFinished = true;
3046
+ progressWake?.();
3047
+ },
3048
+ () => {
3049
+ toolsFinished = true;
3050
+ progressWake?.();
3051
+ }
3052
+ );
3053
+ let toolResults;
3054
+ let toolMessages;
3055
+ try {
3056
+ while (!toolsFinished || progressQueue.length > 0) {
3057
+ const next = progressQueue.shift();
3058
+ if (next) yield next;
3059
+ else
3060
+ await new Promise((resolve) => {
3061
+ progressWake = resolve;
3062
+ });
3063
+ progressWake = void 0;
3064
+ }
3065
+ } finally {
3066
+ if (!toolsFinished) this.executor.cancel();
3067
+ toolResults = await toolRun;
3068
+ toolMessages = this.provider.assembleToolResults(toolResults);
3069
+ history.push(assistantMessage, ...toolMessages);
3070
+ }
1500
3071
  const toolDurationMs = Date.now() - toolStartTime;
1501
3072
  const hasError = toolResults.some((result) => result.isError);
1502
3073
  const metrics = buildMetrics(modelDurationMs, toolDurationMs, toolCalls.length);
1503
- const toolMessages = this.provider.assembleToolResults(toolResults);
1504
- history.push(assistantMessage, ...toolMessages);
1505
3074
  yield {
1506
3075
  type: "message_stop",
1507
3076
  message: assistantMessage,
@@ -1531,6 +3100,19 @@ var AgentLoop = class {
1531
3100
  }
1532
3101
  };
1533
3102
  yield { type: "tool_messages", messages: toolMessages };
3103
+ if (toolResults.some((result) => result.retryPolicy === "after_user_action")) {
3104
+ if (this.signal?.aborted) {
3105
+ yield { type: "turn_finish", turn: step, usage: turnUsage, metrics };
3106
+ throw new AbortError();
3107
+ }
3108
+ yield {
3109
+ type: "error",
3110
+ error: new Error("Tool execution requires user action before continuing")
3111
+ };
3112
+ yield { type: "turn_finish", turn: step, usage: turnUsage, metrics };
3113
+ completed = true;
3114
+ break;
3115
+ }
1534
3116
  if (hasError) {
1535
3117
  consecutiveErrors++;
1536
3118
  if (consecutiveErrors >= this.maxConsecutiveErrors) {
@@ -1580,6 +3162,17 @@ var AgentSession = class {
1580
3162
  customSystemPrompt;
1581
3163
  maxSteps;
1582
3164
  logger;
3165
+ eventLogger;
3166
+ sessionId = createLogId();
3167
+ mode;
3168
+ permissionRules;
3169
+ approvalChannel;
3170
+ approvalCache = new SessionApprovalCache();
3171
+ projectRoot;
3172
+ cwd;
3173
+ userInstructionsPath;
3174
+ instructionSnapshot;
3175
+ auditRecoveryDirectory;
1583
3176
  history = [];
1584
3177
  totalTurns = 0;
1585
3178
  usageStats = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -1599,13 +3192,49 @@ var AgentSession = class {
1599
3192
  this.customSystemPrompt = config.systemPrompt;
1600
3193
  this.maxSteps = config.maxSteps ?? 20;
1601
3194
  this.logger = config.logger;
3195
+ validatePermissionRules(config.permissionRules ?? []);
3196
+ this.mode = config.mode ?? "Approval";
3197
+ this.permissionRules = config.permissionRules ?? [];
3198
+ this.approvalChannel = config.approvalChannel;
3199
+ this.projectRoot = config.projectRoot ?? this.rootDir;
3200
+ this.cwd = config.cwd ?? this.rootDir;
3201
+ this.userInstructionsPath = config.userInstructionsPath;
3202
+ const sinks = config.eventLogger ? void 0 : createDefaultLogSinks(config.loggingDirectory);
3203
+ this.eventLogger = config.eventLogger ?? new StructuredLogger({
3204
+ operationSink: sinks.operationSink,
3205
+ auditSink: sinks.auditSink,
3206
+ debug: config.logger !== void 0,
3207
+ onDiagnostic: config.onDiagnostic ?? config.logger
3208
+ });
3209
+ this.auditRecoveryDirectory = config.auditRecoveryDirectory ?? sinks?.auditSink.directory;
1602
3210
  }
1603
3211
  async init() {
1604
3212
  if (this.initialized) return;
3213
+ if (this.auditRecoveryDirectory) {
3214
+ const count = await recoverIncompleteAudit(this.auditRecoveryDirectory);
3215
+ if (count > 0)
3216
+ await this.eventLogger.record({
3217
+ level: "warn",
3218
+ event: "audit.recovered_unknown",
3219
+ sessionId: this.sessionId,
3220
+ fields: { count }
3221
+ });
3222
+ }
3223
+ this.instructionSnapshot = loadInstructions({
3224
+ projectRoot: this.projectRoot,
3225
+ cwd: this.cwd,
3226
+ userFile: this.userInstructionsPath
3227
+ });
1605
3228
  if (this.store) {
1606
3229
  this.history = await this.store.load();
1607
3230
  }
1608
3231
  await this.hooks.emit("session:start", { logger: this.logger });
3232
+ await this.eventLogger.record({
3233
+ level: "info",
3234
+ event: "session.started",
3235
+ sessionId: this.sessionId,
3236
+ fields: { modelId: this.router.getProfile("default").id }
3237
+ });
1609
3238
  this.initialized = true;
1610
3239
  }
1611
3240
  async use(plugin) {
@@ -1633,6 +3262,18 @@ var AgentSession = class {
1633
3262
  getActiveProfile(role = "default") {
1634
3263
  return this.router.getProfile(role);
1635
3264
  }
3265
+ /** 只在空闲时切换本次会话模式;宿主负责 FullAccess 的显式选择交互。 */
3266
+ switchMode(mode) {
3267
+ this.assertIdle("switch permission mode");
3268
+ this.mode = mode;
3269
+ }
3270
+ getMode() {
3271
+ return this.mode;
3272
+ }
3273
+ /** 返回最近一次成功装载的来源路径,不暴露指令正文。 */
3274
+ getInstructionSources() {
3275
+ return this.instructionSnapshot?.sources.map((source) => source.path) ?? [];
3276
+ }
1636
3277
  getHistory() {
1637
3278
  return [...this.history];
1638
3279
  }
@@ -1680,6 +3321,7 @@ var AgentSession = class {
1680
3321
  this.assertIdle("start another run");
1681
3322
  resolveContextWindow(this.router.getProfile("default").contextWindow);
1682
3323
  this.activeRun = true;
3324
+ const runId = createLogId();
1683
3325
  const runStartTime = Date.now();
1684
3326
  const aggregate = {
1685
3327
  modelDurationMs: 0,
@@ -1709,6 +3351,28 @@ var AgentSession = class {
1709
3351
  if (!this.initialized) {
1710
3352
  await this.init();
1711
3353
  }
3354
+ await this.eventLogger.record({
3355
+ level: "info",
3356
+ event: "run.started",
3357
+ sessionId: this.sessionId,
3358
+ runId
3359
+ });
3360
+ try {
3361
+ this.instructionSnapshot = loadInstructions({
3362
+ projectRoot: this.projectRoot,
3363
+ cwd: this.cwd,
3364
+ userFile: this.userInstructionsPath
3365
+ });
3366
+ } catch (error) {
3367
+ await this.eventLogger.record({
3368
+ level: "warn",
3369
+ event: "instructions.refresh_failed",
3370
+ sessionId: this.sessionId,
3371
+ runId,
3372
+ fields: { status: "retained_previous_snapshot" }
3373
+ });
3374
+ if (!this.instructionSnapshot) throw error;
3375
+ }
1712
3376
  this.totalTurns++;
1713
3377
  const userMessage = {
1714
3378
  role: "user",
@@ -1722,7 +3386,9 @@ var AgentSession = class {
1722
3386
  const promptAssembler = new PromptAssembler({
1723
3387
  tools: this.tools,
1724
3388
  rootDir: this.rootDir,
1725
- customInstructions: this.customSystemPrompt
3389
+ customInstructions: this.customSystemPrompt,
3390
+ instructionSources: this.instructionSnapshot.sources,
3391
+ visibleTools: visibleTools(this.tools.list(), this.mode, this.permissionRules)
1726
3392
  });
1727
3393
  const assembledSystemPrompt = promptAssembler.assemble();
1728
3394
  const executor = new ToolExecutor({
@@ -1730,7 +3396,14 @@ var AgentSession = class {
1730
3396
  hooks: this.hooks,
1731
3397
  rootDir: this.rootDir,
1732
3398
  signal: options?.signal,
1733
- logger: this.logger
3399
+ logger: this.logger,
3400
+ eventLogger: this.eventLogger,
3401
+ sessionId: this.sessionId,
3402
+ runId,
3403
+ mode: () => this.mode,
3404
+ permissionRules: this.permissionRules,
3405
+ approvalChannel: this.approvalChannel,
3406
+ approvalCache: this.approvalCache
1734
3407
  });
1735
3408
  const loop = new AgentLoop({
1736
3409
  provider: this.router.resolve("default"),
@@ -1739,7 +3412,12 @@ var AgentSession = class {
1739
3412
  hooks: this.hooks,
1740
3413
  systemPrompt: assembledSystemPrompt,
1741
3414
  maxSteps: this.maxSteps,
1742
- signal: options?.signal
3415
+ signal: options?.signal,
3416
+ eventLogger: this.eventLogger,
3417
+ sessionId: this.sessionId,
3418
+ runId,
3419
+ getMode: () => this.mode,
3420
+ permissionRules: this.permissionRules
1743
3421
  });
1744
3422
  let pendingAssistant;
1745
3423
  let pendingEvents = [];
@@ -1747,10 +3425,24 @@ var AgentSession = class {
1747
3425
  let pendingAssistantPersisted = false;
1748
3426
  let pendingPersistenceStarted = false;
1749
3427
  let pendingToolMessages = [];
3428
+ let persistedHistoryLength = this.history.length;
3429
+ let loopCompleted = false;
3430
+ let loopFailed = false;
1750
3431
  try {
1751
3432
  for await (const event of loop.run(this.history)) {
1752
3433
  if (event.type === "step_log") {
1753
3434
  this.logger?.(`[Turn ${event.log.turn} | ${event.log.stage}] ${event.log.message}`);
3435
+ await this.eventLogger.record({
3436
+ level: "debug",
3437
+ event: "diagnostic.step",
3438
+ sessionId: this.sessionId,
3439
+ runId,
3440
+ fields: {
3441
+ phase: event.log.stage,
3442
+ durationMs: event.log.durationMs ?? 0,
3443
+ attempt: event.log.turn
3444
+ }
3445
+ });
1754
3446
  }
1755
3447
  if (event.type === "turn_finish") {
1756
3448
  this.lastMetrics = event.metrics;
@@ -1785,6 +3477,7 @@ var AgentSession = class {
1785
3477
  await this.persistAssistant(pendingAssistant);
1786
3478
  pendingAssistantPersisted = true;
1787
3479
  await this.persistToolMessages(event.messages);
3480
+ persistedHistoryLength = this.history.length;
1788
3481
  const releasable = pendingEvents;
1789
3482
  pendingAssistant = void 0;
1790
3483
  pendingEvents = [];
@@ -1796,9 +3489,14 @@ var AgentSession = class {
1796
3489
  } else if (event.type === "tool_messages") {
1797
3490
  await this.persistToolMessages(event.messages);
1798
3491
  }
3492
+ if (event.type === "message_stop" || event.type === "tool_messages") {
3493
+ persistedHistoryLength = this.history.length;
3494
+ }
1799
3495
  yield event;
1800
3496
  }
3497
+ loopCompleted = true;
1801
3498
  } catch (error) {
3499
+ loopFailed = true;
1802
3500
  if (pendingAssistant && !pendingPersistenceStarted) {
1803
3501
  const interruptedMessages = pendingClosedInMemory ? pendingToolMessages : this.closeInterruptedToolCalls(pendingAssistant, error);
1804
3502
  if (!pendingAssistantPersisted) {
@@ -1809,16 +3507,36 @@ var AgentSession = class {
1809
3507
  yield { type: "tool_messages", messages: interruptedMessages };
1810
3508
  }
1811
3509
  throw error;
3510
+ } finally {
3511
+ if (!loopCompleted && !loopFailed && this.store) {
3512
+ for (const message of this.history.slice(persistedHistoryLength)) {
3513
+ await this.store.append(message);
3514
+ }
3515
+ }
1812
3516
  }
1813
3517
  const metrics = finalizeRun(runStatus);
1814
3518
  this.lastRunMetrics = metrics;
1815
3519
  runFinalized = true;
3520
+ await this.eventLogger.record({
3521
+ level: "info",
3522
+ event: "run.finished",
3523
+ sessionId: this.sessionId,
3524
+ runId,
3525
+ fields: { status: metrics.status, durationMs: metrics.totalDurationMs }
3526
+ });
1816
3527
  yield { type: "run_finish", metrics };
1817
3528
  } catch (error) {
1818
3529
  const status = error instanceof AbortError || options?.signal?.aborted ? "aborted" : "failed";
1819
3530
  const metrics = finalizeRun(status);
1820
3531
  this.lastRunMetrics = metrics;
1821
3532
  runFinalized = true;
3533
+ await this.eventLogger.record({
3534
+ level: "error",
3535
+ event: "run.finished",
3536
+ sessionId: this.sessionId,
3537
+ runId,
3538
+ fields: { status: metrics.status, durationMs: metrics.totalDurationMs }
3539
+ });
1822
3540
  yield { type: "run_finish", metrics };
1823
3541
  throw error;
1824
3542
  } finally {
@@ -1835,8 +3553,8 @@ var AgentSession = class {
1835
3553
  for (const plugin of this.plugins) {
1836
3554
  try {
1837
3555
  await plugin.teardown?.();
1838
- } catch (err) {
1839
- this.logger?.(`Plugin '${plugin.name}' teardown failed: ${err.message}`);
3556
+ } catch {
3557
+ this.logger?.("Plugin teardown failed");
1840
3558
  }
1841
3559
  }
1842
3560
  this.plugins.length = 0;
@@ -1876,8 +3594,14 @@ var AgentSession = class {
1876
3594
  content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
1877
3595
  type: "tool_result",
1878
3596
  toolUseId: toolUse.id,
1879
- content: `Tool execution interrupted before completion: ${error instanceof Error ? error.message : String(error)}`,
1880
- isError: true
3597
+ content: JSON.stringify({
3598
+ code: "OUTCOME_UNKNOWN",
3599
+ retryPolicy: "after_user_action",
3600
+ message: "Tool execution was interrupted; outcome is unknown"
3601
+ }),
3602
+ isError: true,
3603
+ errorCode: "OUTCOME_UNKNOWN",
3604
+ retryPolicy: "after_user_action"
1881
3605
  })),
1882
3606
  timestamp: Date.now()
1883
3607
  };
@@ -1909,32 +3633,50 @@ var AgentSession = class {
1909
3633
  AgentSession,
1910
3634
  DEFAULT_CONTEXT_WINDOW_TOKENS,
1911
3635
  FatalError,
3636
+ FileLogSink,
1912
3637
  HookRegistry,
1913
3638
  JSONLMessageStore,
1914
3639
  KapibalaError,
1915
3640
  ModelError,
1916
3641
  OpenAICompatibleProvider,
1917
3642
  PathSandbox,
3643
+ PermissionPolicy,
1918
3644
  PromptAssembler,
3645
+ SessionApprovalCache,
1919
3646
  SessionBusyError,
1920
3647
  SimpleModelRouter,
3648
+ StructuredLogger,
1921
3649
  ToolError,
1922
3650
  ToolExecutor,
1923
3651
  ToolNotFound,
1924
3652
  ToolRegistry,
1925
3653
  TransportError,
1926
3654
  builtinTools,
3655
+ classifyBashOutput,
3656
+ cleanupCommandResults,
3657
+ commandAuditSummary,
3658
+ createDefaultLogSinks,
3659
+ createLogId,
3660
+ createRunCommandTool,
1927
3661
  defineTool,
3662
+ detectShell,
1928
3663
  editFileTool,
3664
+ executionFingerprint,
1929
3665
  globTool,
1930
3666
  grepTool,
1931
3667
  isTextBlock,
1932
3668
  isThinkingBlock,
1933
3669
  isToolResultBlock,
1934
3670
  isToolUseBlock,
3671
+ loadInstructions,
3672
+ projectSafeFields,
1935
3673
  readFileTool,
3674
+ recoverIncompleteAudit,
1936
3675
  resolveContextWindow,
3676
+ stableJsonStringify,
1937
3677
  toToolDefinition,
3678
+ validatePermissionRules,
3679
+ visibleTools,
1938
3680
  writeFileTool
1939
3681
  });
1940
3682
  //# sourceMappingURL=index.cjs.map