@cryer/star-cli 0.3.7 → 0.3.9

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.
@@ -0,0 +1,3415 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ INTERRUPTED_TOOL_RESULT_TEXT,
4
+ UNFINISHED_TOOL_RESULT_TEXT,
5
+ coreMessageText,
6
+ diffTreeNames,
7
+ isSyntheticUserMessage,
8
+ reconcileStarMessages,
9
+ resolveCompactThreshold,
10
+ restoreTree,
11
+ retractLastTurn,
12
+ toCoreMessages,
13
+ toStarMessage,
14
+ trackTree
15
+ } from "./chunk-56ZIA57D.js";
16
+ import {
17
+ createModel,
18
+ streamActivity
19
+ } from "./chunk-MVAMPVTA.js";
20
+ import {
21
+ resolveModelConfig
22
+ } from "./chunk-4IRY44TG.js";
23
+ import {
24
+ ToolRegistry,
25
+ createDefaultRegistry,
26
+ debugStreamLog
27
+ } from "./chunk-OIJYNQFZ.js";
28
+ import {
29
+ TodoStore,
30
+ beginTurn,
31
+ currentTurnSeq,
32
+ defaultAgentTasks,
33
+ dropTurnSnapshots,
34
+ isOversizedImageError,
35
+ isSensitivePath,
36
+ isVisionUnsupportedError,
37
+ pendingTodoTitles,
38
+ responseBodyOf,
39
+ setSnapshotHooks,
40
+ setTodoPersistGuard,
41
+ starHome,
42
+ stripAllImages,
43
+ stripOversizedImages,
44
+ undoTurnSnapshots,
45
+ userMemoryPath
46
+ } from "./chunk-A5PXPWHB.js";
47
+
48
+ // src/agent/loop.ts
49
+ import { tool as aiTool, generateText as generateText3 } from "ai";
50
+
51
+ // src/context/compaction.ts
52
+ import { generateText } from "ai";
53
+
54
+ // src/context/tokens.ts
55
+ import { zodSchema } from "ai";
56
+ var CHARS_PER_TOKEN = 4;
57
+ var MESSAGE_OVERHEAD = 4;
58
+ var IMAGE_PART_TOKENS = 1024;
59
+ function isCjkCode(code) {
60
+ return code >= 12288 && code <= 12543 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 65280 && code <= 65519;
61
+ }
62
+ function textWeight(text) {
63
+ let cjk = 0;
64
+ for (let i = 0; i < text.length; i++) {
65
+ if (isCjkCode(text.charCodeAt(i))) cjk++;
66
+ }
67
+ return text.length + cjk * (CHARS_PER_TOKEN - 1);
68
+ }
69
+ function contentCharLength(message) {
70
+ if (message.role === "tool") {
71
+ let chars2 = 0;
72
+ for (const part of message.content) {
73
+ chars2 += textWeight(JSON.stringify(part.result ?? null));
74
+ }
75
+ return chars2;
76
+ }
77
+ if (typeof message.content === "string") {
78
+ return textWeight(message.content);
79
+ }
80
+ let chars = 0;
81
+ for (const part of message.content) {
82
+ if (part.type === "text" || part.type === "reasoning") {
83
+ chars += textWeight(part.text);
84
+ } else if (part.type === "tool-call") {
85
+ chars += textWeight(part.toolName) + textWeight(JSON.stringify(part.args ?? null));
86
+ }
87
+ }
88
+ return chars;
89
+ }
90
+ function imagePartCount(message) {
91
+ if (typeof message.content === "string" || message.role === "tool") return 0;
92
+ return message.content.filter((part) => part.type === "image" || part.type === "file").length;
93
+ }
94
+ var tokenCache = /* @__PURE__ */ new WeakMap();
95
+ function estimateMessageTokens(message) {
96
+ const cached = tokenCache.get(message);
97
+ if (cached !== void 0) return cached;
98
+ const tokens = Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
99
+ tokenCache.set(message, tokens);
100
+ return tokens;
101
+ }
102
+ function estimateTokens(messages) {
103
+ return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
104
+ }
105
+ function parametersWireJson(parameters) {
106
+ if (parameters == null) return "";
107
+ const wrapper = parameters;
108
+ if (wrapper.jsonSchema !== void 0) {
109
+ try {
110
+ return JSON.stringify(wrapper.jsonSchema) ?? "";
111
+ } catch {
112
+ return "";
113
+ }
114
+ }
115
+ try {
116
+ return JSON.stringify(zodSchema(parameters).jsonSchema) ?? "";
117
+ } catch {
118
+ try {
119
+ return JSON.stringify(parameters) ?? "";
120
+ } catch {
121
+ return "";
122
+ }
123
+ }
124
+ }
125
+ function estimateToolSchemaTokens(tools) {
126
+ let weight = 0;
127
+ for (const [name, tool] of Object.entries(tools)) {
128
+ weight += textWeight(name);
129
+ const description = tool.description;
130
+ if (typeof description === "string") weight += textWeight(description);
131
+ weight += textWeight(parametersWireJson(tool.parameters));
132
+ }
133
+ return Math.ceil(weight / CHARS_PER_TOKEN);
134
+ }
135
+
136
+ // src/context/compaction.ts
137
+ var MIN_KEPT_MESSAGES = 4;
138
+ function placeholderMessage(droppedCount) {
139
+ return {
140
+ message: {
141
+ role: "user",
142
+ content: `[context compacted: ${droppedCount} earlier messages dropped]`
143
+ }
144
+ };
145
+ }
146
+ var SUMMARY_SYSTEM_PROMPT = [
147
+ "You are summarizing an AI coding agent's conversation for context compaction.",
148
+ "Write a concise summary that preserves:",
149
+ "- the user's goals and requests",
150
+ "- decisions made and their rationale",
151
+ "- files and code touched (paths, key changes)",
152
+ "- tool results that matter (errors, key outputs)",
153
+ "- outstanding TODOs and next steps",
154
+ "Output only the summary, no preamble."
155
+ ].join("\n");
156
+ var MAX_SUMMARY_INPUT_CHARS = 8e4;
157
+ var SUMMARY_HEAD_CHARS = 24e3;
158
+ function serializeMessage(message) {
159
+ if (typeof message.content === "string") {
160
+ return `${message.role}: ${message.content}`;
161
+ }
162
+ const parts = message.content.map((part) => {
163
+ if (part.type === "text") return part.text;
164
+ if (part.type === "tool-call")
165
+ return `[tool-call ${part.toolName}] ${JSON.stringify(part.args)}`;
166
+ if (part.type === "tool-result")
167
+ return `[tool-result ${part.toolName}] ${JSON.stringify(part.result)}`;
168
+ return `[${part.type}]`;
169
+ });
170
+ return `${message.role}: ${parts.join("\n")}`;
171
+ }
172
+ function headSlice(parts, budget) {
173
+ const kept = [];
174
+ let used = 0;
175
+ for (const part of parts) {
176
+ if (used >= budget) break;
177
+ const sep = kept.length > 0 ? 1 : 0;
178
+ if (used + sep + part.length <= budget) {
179
+ kept.push(part);
180
+ used += sep + part.length;
181
+ } else {
182
+ kept.push(part.slice(0, budget - used - sep));
183
+ used = budget;
184
+ }
185
+ }
186
+ return kept.join("\n");
187
+ }
188
+ function tailSlice(parts, budget) {
189
+ const kept = [];
190
+ let used = 0;
191
+ for (let i = parts.length - 1; i >= 0; i--) {
192
+ if (used >= budget) break;
193
+ const part = parts[i] ?? "";
194
+ const sep = kept.length > 0 ? 1 : 0;
195
+ if (used + sep + part.length <= budget) {
196
+ kept.unshift(part);
197
+ used += sep + part.length;
198
+ } else {
199
+ kept.unshift(part.slice(part.length - (budget - used - sep)));
200
+ used = budget;
201
+ }
202
+ }
203
+ return kept.join("\n");
204
+ }
205
+ function serializeSummaryInput(messages) {
206
+ const parts = messages.map(serializeMessage);
207
+ let total = Math.max(0, parts.length - 1);
208
+ for (const part of parts) total += part.length;
209
+ if (total <= MAX_SUMMARY_INPUT_CHARS) return parts.join("\n");
210
+ const head = headSlice(parts, SUMMARY_HEAD_CHARS);
211
+ const tail = tailSlice(parts, MAX_SUMMARY_INPUT_CHARS - SUMMARY_HEAD_CHARS);
212
+ return `${head}
213
+ [... ${total - MAX_SUMMARY_INPUT_CHARS} characters omitted ...]
214
+ ${tail}`;
215
+ }
216
+ async function summarizeMessages(messages, model, signal, temperature) {
217
+ const transcript = serializeSummaryInput(messages);
218
+ const { text } = await generateText({
219
+ model,
220
+ system: SUMMARY_SYSTEM_PROMPT,
221
+ prompt: `Summarize this conversation so far:
222
+
223
+ ${transcript}`,
224
+ temperature,
225
+ // A hung relay must not stall the whole turn on the summary: cap it at
226
+ // 60s and let the caller's abort (Esc) cut it short as well; the caller
227
+ // falls back to the truncation placeholder on any failure.
228
+ abortSignal: signal ? AbortSignal.any([signal, AbortSignal.timeout(6e4)]) : AbortSignal.timeout(6e4)
229
+ });
230
+ return text.trim();
231
+ }
232
+ function compactMessages(messages, maxTokens, opts = {}) {
233
+ const limit = opts.force ? 0 : maxTokens;
234
+ const overhead = opts.overheadTokens ?? 0;
235
+ const tokens = messages.map((star) => estimateMessageTokens(star.message));
236
+ let total = overhead;
237
+ for (const messageTokens of tokens) total += messageTokens;
238
+ if (total <= limit) {
239
+ return { messages: [...messages], compacted: false, droppedCount: 0 };
240
+ }
241
+ const hasSystem = messages[0]?.message.role === "system";
242
+ const head = hasSystem ? [messages[0]] : [];
243
+ const restStart = hasSystem ? 1 : 0;
244
+ const restCount = messages.length - restStart;
245
+ const headTokens = hasSystem ? tokens[0] ?? 0 : 0;
246
+ let restTokens = 0;
247
+ for (let i = restStart; i < tokens.length; i++) restTokens += tokens[i] ?? 0;
248
+ const turns = [];
249
+ for (let i = restStart; i < messages.length; i++) {
250
+ const current = turns[turns.length - 1];
251
+ const star = messages[i];
252
+ if (star && star.message.role === "user" && !isSyntheticUserMessage(star) || !current) {
253
+ turns.push({ length: 1, tokens: tokens[i] ?? 0 });
254
+ } else {
255
+ current.length += 1;
256
+ current.tokens += tokens[i] ?? 0;
257
+ }
258
+ }
259
+ let droppedCount = 0;
260
+ let droppedTokens = 0;
261
+ for (const turn of turns) {
262
+ if (restCount - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
263
+ break;
264
+ }
265
+ droppedCount += turn.length;
266
+ droppedTokens += turn.tokens;
267
+ const candidateTokens = overhead + headTokens + estimateMessageTokens(placeholderMessage(droppedCount).message) + (restTokens - droppedTokens);
268
+ if (candidateTokens <= limit) {
269
+ break;
270
+ }
271
+ }
272
+ if (droppedCount === 0) {
273
+ return { messages: [...messages], compacted: false, droppedCount: 0 };
274
+ }
275
+ return {
276
+ messages: [
277
+ ...head,
278
+ placeholderMessage(droppedCount),
279
+ ...messages.slice(restStart + droppedCount)
280
+ ],
281
+ compacted: true,
282
+ droppedCount
283
+ };
284
+ }
285
+
286
+ // src/core/events.ts
287
+ function usageTotalTokens(usage) {
288
+ const summed = usage.promptTokens + usage.completionTokens;
289
+ return summed > 0 ? summed : usage.totalTokens;
290
+ }
291
+ function toStreamErrorInfo(error) {
292
+ const err = error instanceof Error ? error : new Error(String(error));
293
+ const info = { name: err.name, message: err.message };
294
+ const statusCode = err.statusCode;
295
+ if (typeof statusCode === "number") info.statusCode = statusCode;
296
+ if (err.isRetryable === true) info.isRetryable = true;
297
+ const code = err.code;
298
+ if (typeof code === "string") info.code = code;
299
+ const cause = err.cause;
300
+ const causeCode = cause && typeof cause === "object" ? cause.code : void 0;
301
+ if (typeof causeCode === "string") info.causeCode = causeCode;
302
+ const headers = err.responseHeaders;
303
+ if (headers && typeof headers === "object") {
304
+ const strings = {};
305
+ for (const [key, value] of Object.entries(headers)) {
306
+ if (typeof value === "string") strings[key] = value;
307
+ }
308
+ if (Object.keys(strings).length > 0) info.responseHeaders = strings;
309
+ }
310
+ const body = err.responseBody;
311
+ if (typeof body === "string" && body.length > 0) info.responseBody = body;
312
+ return info;
313
+ }
314
+
315
+ // src/core/git.ts
316
+ import { execFileSync } from "child_process";
317
+ var GIT_TIMEOUT_MS = 2e3;
318
+ var GIT_MAX_BUFFER = 8 * 1024 * 1024;
319
+ var COMMIT_DIFF_MAX_LINES = 2e3;
320
+ var WORKING_DIFF_MAX_LINES = 2e3;
321
+ function runGit(args, cwd) {
322
+ try {
323
+ const out = execFileSync("git", args, {
324
+ cwd,
325
+ timeout: GIT_TIMEOUT_MS,
326
+ encoding: "utf8",
327
+ maxBuffer: GIT_MAX_BUFFER,
328
+ stdio: ["ignore", "pipe", "ignore"]
329
+ });
330
+ return out.replace(/\n$/, "");
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+ function isGitRepo(cwd) {
336
+ return runGit(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
337
+ }
338
+ function getGitSummary(cwd) {
339
+ const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
340
+ if (branch === null) return null;
341
+ const status = runGit(["status", "--porcelain"], cwd) ?? "";
342
+ const dirtyCount = status === "" ? 0 : status.split("\n").length;
343
+ const log = runGit(["log", "--oneline", "-3"], cwd) ?? "";
344
+ return { branch, dirtyCount, recentCommits: log === "" ? [] : log.split("\n") };
345
+ }
346
+ var GIT_SUMMARY_CACHE_TTL_MS = 15e3;
347
+ var gitSummaryCache = /* @__PURE__ */ new Map();
348
+ function getGitSummaryCached(cwd) {
349
+ const now = Date.now();
350
+ const hit = gitSummaryCache.get(cwd);
351
+ if (hit && now - hit.at < GIT_SUMMARY_CACHE_TTL_MS) return hit.summary;
352
+ const summary = getGitSummary(cwd);
353
+ gitSummaryCache.set(cwd, { at: now, summary });
354
+ return summary;
355
+ }
356
+ function formatGitSummary(summary) {
357
+ const lines = [
358
+ "Git context for the working directory (refreshed each turn):",
359
+ `Branch: ${summary.branch}`,
360
+ summary.dirtyCount === 0 ? "Working tree: clean" : `Working tree: ${summary.dirtyCount} file(s) with uncommitted changes`
361
+ ];
362
+ if (summary.recentCommits.length > 0) {
363
+ lines.push("Recent commits:", ...summary.recentCommits);
364
+ }
365
+ return lines.join("\n");
366
+ }
367
+ function truncateLines(text, maxLines) {
368
+ const lines = text.split("\n");
369
+ if (lines.length <= maxLines) return { text, truncated: false };
370
+ return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
371
+ }
372
+ function collectDiffs(cwd) {
373
+ return {
374
+ staged: runGit(["diff", "--cached"], cwd) ?? "",
375
+ unstaged: runGit(["diff"], cwd) ?? ""
376
+ };
377
+ }
378
+ function collectCommitContext(cwd) {
379
+ if (!isGitRepo(cwd)) return null;
380
+ const status = runGit(["status", "--short"], cwd) ?? "";
381
+ const { staged, unstaged } = collectDiffs(cwd);
382
+ if (status === "" && staged === "" && unstaged === "") return null;
383
+ const rawDiff = [staged, unstaged].filter((part) => part !== "").join("\n");
384
+ const { text: diff, truncated } = truncateLines(rawDiff, COMMIT_DIFF_MAX_LINES);
385
+ const recentLog = runGit(["log", "--oneline", "-5"], cwd) ?? "";
386
+ return { status, diff, diffTruncated: truncated, recentLog };
387
+ }
388
+ function buildCommitPrompt(context, instructions = "") {
389
+ const parts = [
390
+ "Analyze the uncommitted git changes below and create a commit for them.",
391
+ "",
392
+ "Requirements:",
393
+ "- Draft a commit message in Conventional Commits style (e.g. `feat(scope): summary`), consistent with the repository's recent commit history.",
394
+ "- Stage the relevant files and create the commit with the bash tool (`git add` + `git commit`), through the normal permission flow.",
395
+ "- Do not push and do not amend existing commits.",
396
+ "- After committing, briefly report the commit hash and message."
397
+ ];
398
+ if (instructions) {
399
+ parts.push("", `Additional instructions from the user: ${instructions}`);
400
+ }
401
+ parts.push("", "## git status --short", "```", context.status || "(clean)", "```");
402
+ if (context.recentLog) {
403
+ parts.push("", "## recent commits (git log --oneline -5)", "```", context.recentLog, "```");
404
+ }
405
+ if (context.diff) {
406
+ parts.push("", "## git diff (staged + unstaged)", "```diff", context.diff, "```");
407
+ if (context.diffTruncated) {
408
+ parts.push(`(diff truncated to ${COMMIT_DIFF_MAX_LINES} lines)`);
409
+ }
410
+ }
411
+ return parts.join("\n");
412
+ }
413
+ function collectWorkingDiff(cwd) {
414
+ if (!isGitRepo(cwd)) return null;
415
+ const status = runGit(["status", "--short"], cwd) ?? "";
416
+ const { staged, unstaged } = collectDiffs(cwd);
417
+ const sections = [];
418
+ if (staged) sections.push(`# Staged changes
419
+ ${staged}`);
420
+ if (unstaged) sections.push(`# Unstaged changes
421
+ ${unstaged}`);
422
+ const { text, truncated } = truncateLines(sections.join("\n"), WORKING_DIFF_MAX_LINES);
423
+ return { status, diff: text, truncated };
424
+ }
425
+
426
+ // src/hooks/runner.ts
427
+ import { spawn } from "child_process";
428
+ import { StringDecoder } from "string_decoder";
429
+ var STDERR_LIMIT = 2e3;
430
+ var STDERR_CAPTURE_LIMIT = 64 * 1024;
431
+ var STDIN_PAYLOAD_THRESHOLD = 16 * 1024;
432
+ function truncate(text) {
433
+ const trimmed = text.trim();
434
+ return trimmed.length > STDERR_LIMIT ? `${trimmed.slice(0, STDERR_LIMIT)}\u2026` : trimmed;
435
+ }
436
+ function killTree(pid) {
437
+ if (pid === void 0) return;
438
+ if (process.platform === "win32") {
439
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true }).on(
440
+ "error",
441
+ () => {
442
+ }
443
+ );
444
+ } else {
445
+ try {
446
+ process.kill(-pid, "SIGKILL");
447
+ } catch {
448
+ try {
449
+ process.kill(pid, "SIGKILL");
450
+ } catch {
451
+ }
452
+ }
453
+ }
454
+ }
455
+ function runCommand(hook, event, ctx, timeoutSec) {
456
+ const env = {
457
+ ...process.env,
458
+ STAR_HOOK_EVENT: event,
459
+ STAR_CWD: ctx.cwd,
460
+ STAR_SESSION_ID: ctx.sessionId ?? ""
461
+ };
462
+ if (ctx.toolName !== void 0) env.STAR_TOOL_NAME = ctx.toolName;
463
+ const inputJson = ctx.toolInput !== void 0 ? JSON.stringify(ctx.toolInput) : void 0;
464
+ const inputViaStdin = inputJson !== void 0 && Buffer.byteLength(inputJson, "utf8") > STDIN_PAYLOAD_THRESHOLD;
465
+ if (inputJson !== void 0 && !inputViaStdin) env.STAR_TOOL_INPUT = inputJson;
466
+ if (inputViaStdin) env.STAR_TOOL_INPUT_STDIN = "1";
467
+ return new Promise((resolve) => {
468
+ let child;
469
+ try {
470
+ child = spawn(hook.command, [], {
471
+ shell: true,
472
+ cwd: ctx.cwd,
473
+ env,
474
+ windowsHide: true,
475
+ detached: process.platform !== "win32"
476
+ });
477
+ } catch (error) {
478
+ resolve({
479
+ code: 1,
480
+ stderr: "",
481
+ timedOut: false,
482
+ spawnError: error instanceof Error ? error.message : String(error)
483
+ });
484
+ return;
485
+ }
486
+ if (inputViaStdin && inputJson !== void 0 && child.stdin) {
487
+ child.stdin.on("error", () => {
488
+ });
489
+ child.stdin.end(inputJson);
490
+ }
491
+ let stderr = "";
492
+ const stderrDecoder = new StringDecoder("utf8");
493
+ let timedOut = false;
494
+ let settled = false;
495
+ const finish = (outcome) => {
496
+ if (settled) return;
497
+ settled = true;
498
+ clearTimeout(timer);
499
+ clearTimeout(fallback);
500
+ resolve({ ...outcome, stderr: outcome.stderr + stderrDecoder.end() });
501
+ };
502
+ const timer = setTimeout(() => {
503
+ timedOut = true;
504
+ killTree(child.pid);
505
+ child.stdout?.destroy();
506
+ child.stderr?.destroy();
507
+ fallback = setTimeout(() => {
508
+ finish({ code: 1, stderr, timedOut: true });
509
+ }, 2e3);
510
+ fallback.unref?.();
511
+ }, timeoutSec * 1e3);
512
+ let fallback;
513
+ child.stderr?.on("data", (chunk) => {
514
+ if (stderr.length < STDERR_CAPTURE_LIMIT) stderr += stderrDecoder.write(chunk);
515
+ });
516
+ child.stdout?.resume();
517
+ child.on("error", (error) => {
518
+ finish({ code: 1, stderr, timedOut, spawnError: error.message });
519
+ });
520
+ child.on("close", (code) => {
521
+ finish({ code: code ?? 1, stderr, timedOut });
522
+ });
523
+ });
524
+ }
525
+ function hookMatches(hook, event, toolName) {
526
+ if (hook.event !== event) return false;
527
+ if (event === "Stop" || !hook.matcher) return true;
528
+ if (toolName === void 0) return false;
529
+ try {
530
+ return new RegExp(hook.matcher).test(toolName);
531
+ } catch {
532
+ return false;
533
+ }
534
+ }
535
+ async function runHooks(event, hooks, ctx, opts) {
536
+ const result = { blocked: false, warnings: [] };
537
+ for (const hook of hooks) {
538
+ if (!hookMatches(hook, event, ctx.toolName)) continue;
539
+ const timeoutSec = opts?.timeoutSec ?? hook.timeoutSec;
540
+ let outcome;
541
+ try {
542
+ outcome = await runCommand(hook, event, ctx, timeoutSec);
543
+ } catch (error) {
544
+ result.warnings.push(
545
+ `Hook "${hook.command}" (${event}) failed: ${error instanceof Error ? error.message : String(error)}`
546
+ );
547
+ continue;
548
+ }
549
+ if (outcome.code === 0) continue;
550
+ const stderr = truncate(outcome.stderr);
551
+ if (event === "PreToolUse" && outcome.code === 2 && !outcome.timedOut) {
552
+ result.blocked = true;
553
+ result.reason = stderr || `hook "${hook.command}" exited with code 2`;
554
+ return result;
555
+ }
556
+ const detail = outcome.timedOut ? `timed out after ${timeoutSec}s` : outcome.spawnError ? `failed to start: ${outcome.spawnError}` : `exited with code ${outcome.code}`;
557
+ result.warnings.push(
558
+ `Hook "${hook.command}" (${event}) ${detail}${stderr ? `: ${stderr}` : ""}`
559
+ );
560
+ }
561
+ return result;
562
+ }
563
+
564
+ // src/llm/retry.ts
565
+ var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
566
+ "AI_NoSuchToolError",
567
+ "AI_InvalidToolArgumentsError",
568
+ "AI_InvalidPromptError",
569
+ "AI_NoSuchModelError",
570
+ "AI_LoadAPIKeyError",
571
+ "AI_TypeValidationError",
572
+ "AI_JSONParseError"
573
+ ]);
574
+ var RETRYABLE_MESSAGE_PATTERNS = [
575
+ /\b(408|425|429|500|502|503|504|520|521|522|523|524|529)\b/,
576
+ /rate.?limit|too many requests|rate increased/i,
577
+ /overloaded|service.?unavailable|internal (server )?error|server error|provider returned error|bad gateway|gateway timeout/i,
578
+ /terminated|fetch failed|failed to fetch|network.?error|upstream connect|connection (?:error|refused|lost|reset)|socket (?:hang up|connection)|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout|epipe|ehostunreach|enetunreach/i,
579
+ /\b(?:request|response|connection|network|stream|read|headers?|body) (?:timeout|timed out|time out)\b/i,
580
+ /resource.?exhausted|try (?:your request )?again|at capacity/i
581
+ ];
582
+ var NON_RETRYABLE_4XX = /* @__PURE__ */ new Set([400, 401, 403, 404, 413, 422]);
583
+ var NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
584
+ "ECONNRESET",
585
+ "ECONNREFUSED",
586
+ "ETIMEDOUT",
587
+ "EPIPE",
588
+ "EAI_AGAIN",
589
+ "ENOTFOUND",
590
+ "ECONNABORTED"
591
+ ]);
592
+ function isNetworkFailure(error) {
593
+ for (const code of [error.code, error.causeCode]) {
594
+ if (code && (NETWORK_ERROR_CODES.has(code) || code.startsWith("UND_ERR_"))) return true;
595
+ }
596
+ return error.name === "TypeError" && /fetch failed|failed to fetch|network/i.test(error.message);
597
+ }
598
+ function matchesRetryablePattern(text) {
599
+ return typeof text === "string" && RETRYABLE_MESSAGE_PATTERNS.some((p) => p.test(text));
600
+ }
601
+ function isRetryableStreamError(error) {
602
+ if (NON_RETRYABLE_ERROR_NAMES.has(error.name)) return false;
603
+ if (error.isRetryable === true) return true;
604
+ const status = error.statusCode;
605
+ if (status !== void 0) {
606
+ if (status === 408 || status === 429 || status >= 500 && status !== 501) return true;
607
+ if (NON_RETRYABLE_4XX.has(status)) return false;
608
+ return matchesRetryablePattern(error.message) || matchesRetryablePattern(responseBodyOf(error));
609
+ }
610
+ return isNetworkFailure(error);
611
+ }
612
+ var RETRY_JITTER_FACTOR = 0.25;
613
+ var MAX_RETRY_DELAY_MS = 6e4;
614
+ function retryAfterDelayMs(error) {
615
+ const headers = error.responseHeaders;
616
+ if (!headers) return void 0;
617
+ const normalized = {};
618
+ for (const [key, value] of Object.entries(headers)) {
619
+ normalized[key.toLowerCase()] = value;
620
+ }
621
+ const afterMs = Number.parseFloat(normalized["retry-after-ms"] ?? "");
622
+ if (!Number.isNaN(afterMs)) return Math.max(0, afterMs);
623
+ const after = normalized["retry-after"];
624
+ if (after) {
625
+ const seconds = Number.parseFloat(after);
626
+ if (!Number.isNaN(seconds)) return Math.max(0, Math.ceil(seconds * 1e3));
627
+ const dateMs = Date.parse(after) - Date.now();
628
+ if (!Number.isNaN(dateMs) && dateMs > 0) return Math.ceil(dateMs);
629
+ }
630
+ return void 0;
631
+ }
632
+ function computeRetryDelayMs(attempt, baseMs, error, random = Math.random()) {
633
+ const hinted = error ? retryAfterDelayMs(error) : void 0;
634
+ if (hinted !== void 0) return Math.min(hinted, MAX_RETRY_DELAY_MS);
635
+ const base = baseMs * 2 ** attempt;
636
+ return Math.min(Math.ceil(base + base * RETRY_JITTER_FACTOR * random), MAX_RETRY_DELAY_MS);
637
+ }
638
+ function summarizeStreamError(error) {
639
+ const parts = [error.message || error.name];
640
+ const status = error.statusCode;
641
+ if (status !== void 0 && !error.message.includes(String(status))) {
642
+ parts.push(`(HTTP ${status})`);
643
+ }
644
+ const body = responseBodyOf(error);
645
+ if (body && !error.message.includes(body.slice(0, 40))) {
646
+ const excerpt = body.replace(/\s+/g, " ").trim().slice(0, 200);
647
+ if (excerpt) parts.push(`\u2014 ${excerpt}`);
648
+ }
649
+ return parts.join(" ");
650
+ }
651
+
652
+ // src/llm/stream.ts
653
+ import { streamText } from "ai";
654
+ var DEFAULT_IDLE_TIMEOUT_MS = 2e4;
655
+ var DEFAULT_FIRST_PART_TIMEOUT_MS = 12e4;
656
+ var GENERATION_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "reasoning", "tool-call", "tool-call-delta"]);
657
+ var MAX_KEEPALIVE_SILENCE_MS = 10 * 6e4;
658
+ var CACHE_READ_KEYS = ["cacheReadInputTokens", "cache_read_input_tokens"];
659
+ var CACHED_PROMPT_KEYS = ["cachedPromptTokens", "cached_prompt_tokens", "cachedTokens"];
660
+ function withAnthropicCacheBreakpoints(model, messages) {
661
+ if (!model.provider.startsWith("anthropic")) return messages;
662
+ const mark = (message) => ({
663
+ ...message,
664
+ experimental_providerMetadata: {
665
+ ...message.experimental_providerMetadata,
666
+ anthropic: {
667
+ ...message.experimental_providerMetadata?.anthropic,
668
+ cacheControl: { type: "ephemeral" }
669
+ }
670
+ }
671
+ });
672
+ const result = messages.slice();
673
+ if (result[0]?.role === "system") result[0] = mark(result[0]);
674
+ const last = result.length - 1;
675
+ if (last > 0 && result[last]) result[last] = mark(result[last]);
676
+ return result;
677
+ }
678
+ function extractCacheUsage(providerMetadata) {
679
+ if (!providerMetadata || typeof providerMetadata !== "object") return {};
680
+ for (const value of Object.values(providerMetadata)) {
681
+ if (!value || typeof value !== "object") continue;
682
+ const record = value;
683
+ for (const key of CACHE_READ_KEYS) {
684
+ const n = record[key];
685
+ if (typeof n === "number" && Number.isFinite(n)) return { cacheReadInputTokens: n };
686
+ }
687
+ for (const key of CACHED_PROMPT_KEYS) {
688
+ const n = record[key];
689
+ if (typeof n === "number" && Number.isFinite(n)) return { cachedPromptTokens: n };
690
+ }
691
+ }
692
+ return {};
693
+ }
694
+ async function* streamChat(opts) {
695
+ const controller = new AbortController();
696
+ const onAbort = () => controller.abort();
697
+ opts.abortSignal?.addEventListener("abort", onAbort);
698
+ if (opts.abortSignal?.aborted) controller.abort();
699
+ const { openai: openaiMetadata, ...otherMetadata } = opts.providerMetadata ?? {};
700
+ const result = streamText({
701
+ model: opts.model,
702
+ messages: withAnthropicCacheBreakpoints(opts.model, opts.messages),
703
+ tools: opts.tools,
704
+ abortSignal: controller.signal,
705
+ maxTokens: opts.maxTokens,
706
+ temperature: opts.temperature,
707
+ // Disable the SDK's own retries (ai@4 defaults to 2 internal attempts with
708
+ // fixed backoff that ignores Retry-After and the abort signal, and
709
+ // exhaustion throws an AI_RetryError stripped of status/headers/body) —
710
+ // the retry policy in agent/loop.ts + llm/retry.ts owns the full budget
711
+ // and needs the raw error to classify.
712
+ maxRetries: 0,
713
+ // Pass tool-call argument deltas through (instead of buffering until the
714
+ // complete call) so large write_file payloads report receive progress.
715
+ toolCallStreaming: true,
716
+ experimental_providerMetadata: {
717
+ // Only the openai provider (our responses-protocol path) reads these;
718
+ // other providers ignore unknown metadata. The SDK's responses provider
719
+ // sends every tool with strict:true by default (strictSchemas), and
720
+ // strict mode requires every schema property to be required — our
721
+ // classic function-calling schemas use optional properties, which
722
+ // relays then reject. store:false matches how codex and opencode talk
723
+ // to relays (no server-side response storage).
724
+ ...otherMetadata,
725
+ openai: { strictSchemas: false, store: false, ...openaiMetadata }
726
+ }
727
+ });
728
+ const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
729
+ const firstPartTimeoutMs = opts.firstPartTimeoutMs ?? DEFAULT_FIRST_PART_TIMEOUT_MS;
730
+ const iterator = result.fullStream[Symbol.asyncIterator]();
731
+ let seenContent = false;
732
+ let deltas = 0;
733
+ let hasContent = false;
734
+ const argsProgress = /* @__PURE__ */ new Map();
735
+ let lastPartAt = Date.now();
736
+ debugStreamLog("request", {
737
+ messages: opts.messages.length,
738
+ idleTimeoutMs,
739
+ firstPartTimeoutMs
740
+ });
741
+ try {
742
+ let pending;
743
+ for (; ; ) {
744
+ pending ??= iterator.next();
745
+ let timer;
746
+ const windowMs = seenContent ? idleTimeoutMs : firstPartTimeoutMs;
747
+ const idle = new Promise((resolve) => {
748
+ timer = setTimeout(() => resolve(null), windowMs);
749
+ });
750
+ let next;
751
+ try {
752
+ next = await Promise.race([pending, idle]);
753
+ } catch (error) {
754
+ debugStreamLog("stream-throw", {
755
+ seenContent,
756
+ deltas,
757
+ error: error instanceof Error ? summarizeStreamError(error) : String(error)
758
+ });
759
+ throw error;
760
+ } finally {
761
+ if (timer) clearTimeout(timer);
762
+ }
763
+ if (next !== null) pending = void 0;
764
+ if (next === null) {
765
+ const activity = streamActivity(controller.signal);
766
+ const silentForMs = Date.now() - lastPartAt;
767
+ const lastChunkAgoMs = activity ? Date.now() - activity.lastChunkAt : void 0;
768
+ if (lastChunkAgoMs !== void 0 && lastChunkAgoMs < windowMs && silentForMs < MAX_KEEPALIVE_SILENCE_MS) {
769
+ debugStreamLog("keepalive", { seenContent, deltas, lastChunkAgoMs, silentForMs });
770
+ continue;
771
+ }
772
+ debugStreamLog("idle-timeout", {
773
+ seenContent,
774
+ deltas,
775
+ waitedMs: windowMs,
776
+ lastChunkAgoMs
777
+ });
778
+ yield {
779
+ type: "finish",
780
+ finishReason: "idle-timeout",
781
+ usage: void 0,
782
+ // Content already streamed means the reply may be cut off
783
+ // mid-thought; the loop surfaces a notice for that.
784
+ ...hasContent ? { truncated: true } : {}
785
+ };
786
+ return;
787
+ }
788
+ if (next.done) {
789
+ debugStreamLog("end", { seenContent, deltas });
790
+ return;
791
+ }
792
+ const part = next.value;
793
+ lastPartAt = Date.now();
794
+ if (GENERATION_PART_TYPES.has(part.type)) seenContent = true;
795
+ switch (part.type) {
796
+ case "text-delta":
797
+ deltas++;
798
+ hasContent = true;
799
+ yield { type: "text-delta", text: part.textDelta };
800
+ break;
801
+ case "reasoning":
802
+ deltas++;
803
+ yield { type: "reasoning", text: part.textDelta };
804
+ break;
805
+ case "tool-call":
806
+ hasContent = true;
807
+ yield {
808
+ type: "tool-call",
809
+ id: part.toolCallId,
810
+ name: part.toolName,
811
+ args: part.args
812
+ };
813
+ break;
814
+ case "tool-call-streaming-start": {
815
+ argsProgress.set(part.toolCallId, {
816
+ name: part.toolName,
817
+ chars: 0,
818
+ nextMark: 4096
819
+ });
820
+ break;
821
+ }
822
+ case "tool-call-delta": {
823
+ const progress = argsProgress.get(part.toolCallId) ?? {
824
+ name: part.toolName,
825
+ chars: 0,
826
+ nextMark: 4096
827
+ };
828
+ progress.chars += part.argsTextDelta.length;
829
+ argsProgress.set(part.toolCallId, progress);
830
+ if (progress.chars >= progress.nextMark) {
831
+ progress.nextMark *= 2;
832
+ yield { type: "tool-call-progress", name: progress.name, bytes: progress.chars };
833
+ }
834
+ break;
835
+ }
836
+ case "finish": {
837
+ const finite = (n) => Number.isFinite(n) ? n : 0;
838
+ const cache4 = extractCacheUsage(
839
+ part.providerMetadata ?? part.experimental_providerMetadata
840
+ );
841
+ debugStreamLog("finish", {
842
+ finishReason: part.finishReason,
843
+ usage: part.usage,
844
+ deltas
845
+ });
846
+ yield {
847
+ type: "finish",
848
+ finishReason: part.finishReason,
849
+ usage: part.usage ? {
850
+ promptTokens: finite(part.usage.promptTokens),
851
+ completionTokens: finite(part.usage.completionTokens),
852
+ totalTokens: finite(part.usage.totalTokens),
853
+ ...cache4
854
+ } : void 0
855
+ };
856
+ return;
857
+ }
858
+ case "error": {
859
+ if (opts.abortSignal?.aborted) return;
860
+ const error = toStreamErrorInfo(part.error);
861
+ debugStreamLog("error-part", { seenContent, deltas, error: summarizeStreamError(error) });
862
+ yield { type: "error", error };
863
+ return;
864
+ }
865
+ }
866
+ }
867
+ } finally {
868
+ opts.abortSignal?.removeEventListener("abort", onAbort);
869
+ await iterator.return?.();
870
+ controller.abort();
871
+ }
872
+ }
873
+
874
+ // src/permissions/gate.ts
875
+ import fs from "fs";
876
+ import path from "path";
877
+
878
+ // src/permissions/shell.ts
879
+ var REDIRECT_OPS = /* @__PURE__ */ new Set([">", ">>", "<", ">&", "<&"]);
880
+ var FILE_REDIRECT_OPS = /* @__PURE__ */ new Set([">", ">>", "<"]);
881
+ var SEGMENT_SEPARATORS = /* @__PURE__ */ new Set([
882
+ "&&",
883
+ "||",
884
+ "&",
885
+ "|",
886
+ ";",
887
+ "\n",
888
+ "(",
889
+ ")"
890
+ ]);
891
+ function lexShell(command) {
892
+ const tokens = [];
893
+ let word = "";
894
+ let doubleQuoted = false;
895
+ let i = 0;
896
+ const flush = () => {
897
+ if (word.length > 0) {
898
+ tokens.push({ kind: "word", text: word });
899
+ word = "";
900
+ }
901
+ };
902
+ const findCloseParen = (from) => {
903
+ let depth = 0;
904
+ let j = from;
905
+ while (j < command.length) {
906
+ const c = command[j];
907
+ if (c === "\\") {
908
+ j += 2;
909
+ continue;
910
+ }
911
+ if (c === "'" || c === '"') {
912
+ const close = command.indexOf(c, j + 1);
913
+ j = close === -1 ? command.length : close + 1;
914
+ continue;
915
+ }
916
+ if (c === "(") depth += 1;
917
+ if (c === ")") {
918
+ depth -= 1;
919
+ if (depth === 0) return j + 1;
920
+ }
921
+ j += 1;
922
+ }
923
+ return -1;
924
+ };
925
+ const pushSubstitution = (openParen, process2) => {
926
+ const close = findCloseParen(openParen);
927
+ const innerEnd = close === -1 ? command.length : close - 1;
928
+ flush();
929
+ tokens.push({ kind: "subst", inner: command.slice(openParen + 1, innerEnd), process: process2 });
930
+ return close === -1 ? command.length : close;
931
+ };
932
+ const lexDollarOrBacktick = (pos) => {
933
+ const c = command[pos];
934
+ if (c === "`") {
935
+ const close = command.indexOf("`", pos + 1);
936
+ const end = close === -1 ? command.length : close;
937
+ flush();
938
+ tokens.push({ kind: "subst", inner: command.slice(pos + 1, end), process: false });
939
+ return close === -1 ? command.length : close + 1;
940
+ }
941
+ const next = command[pos + 1];
942
+ if (next === "(") {
943
+ return pushSubstitution(pos + 1, false);
944
+ }
945
+ if (next === "{") {
946
+ const close = command.indexOf("}", pos + 2);
947
+ if (close !== -1) {
948
+ word += `$${command.slice(pos + 2, close)}`;
949
+ return close + 1;
950
+ }
951
+ word += "$";
952
+ return pos + 1;
953
+ }
954
+ if (next !== void 0 && /[A-Za-z_]/.test(next)) {
955
+ let end = pos + 1;
956
+ while (end < command.length && /[A-Za-z0-9_]/.test(command[end] ?? "")) {
957
+ end += 1;
958
+ }
959
+ word += command.slice(pos, end);
960
+ return end;
961
+ }
962
+ word += "$";
963
+ return pos + 1;
964
+ };
965
+ while (i < command.length) {
966
+ const c = command[i];
967
+ if (doubleQuoted) {
968
+ if (c === '"') {
969
+ doubleQuoted = false;
970
+ i += 1;
971
+ } else if (c === "\\") {
972
+ if (i + 1 < command.length) {
973
+ word += command[i + 1];
974
+ i += 2;
975
+ } else {
976
+ word += "\\";
977
+ i += 1;
978
+ }
979
+ } else if (c === "$" || c === "`") {
980
+ i = lexDollarOrBacktick(i);
981
+ } else {
982
+ word += c;
983
+ i += 1;
984
+ }
985
+ continue;
986
+ }
987
+ if (c === "\\") {
988
+ if (i + 1 < command.length) {
989
+ word += command[i + 1];
990
+ i += 2;
991
+ } else {
992
+ word += "\\";
993
+ i += 1;
994
+ }
995
+ continue;
996
+ }
997
+ if (c === "'") {
998
+ const close = command.indexOf("'", i + 1);
999
+ const end = close === -1 ? command.length : close;
1000
+ word += command.slice(i + 1, end);
1001
+ i = close === -1 ? command.length : close + 1;
1002
+ continue;
1003
+ }
1004
+ if (c === '"') {
1005
+ doubleQuoted = true;
1006
+ i += 1;
1007
+ continue;
1008
+ }
1009
+ if (c === "$" || c === "`") {
1010
+ i = lexDollarOrBacktick(i);
1011
+ continue;
1012
+ }
1013
+ if (c === " " || c === " " || c === "\r") {
1014
+ flush();
1015
+ i += 1;
1016
+ continue;
1017
+ }
1018
+ if (c === "\n") {
1019
+ flush();
1020
+ tokens.push({ kind: "op", op: "\n" });
1021
+ i += 1;
1022
+ continue;
1023
+ }
1024
+ if (c === "&" || c === "|") {
1025
+ flush();
1026
+ if (command[i + 1] === c) {
1027
+ tokens.push({ kind: "op", op: c === "&" ? "&&" : "||" });
1028
+ i += 2;
1029
+ } else {
1030
+ tokens.push({ kind: "op", op: c });
1031
+ i += 1;
1032
+ }
1033
+ continue;
1034
+ }
1035
+ if (c === ";" || c === "(" || c === ")") {
1036
+ flush();
1037
+ tokens.push({ kind: "op", op: c });
1038
+ i += 1;
1039
+ continue;
1040
+ }
1041
+ if (c === ">" || c === "<") {
1042
+ flush();
1043
+ const next = command[i + 1];
1044
+ if (next === "(") {
1045
+ i = pushSubstitution(i + 1, true);
1046
+ continue;
1047
+ }
1048
+ if (c === ">" && next === ">") {
1049
+ tokens.push({ kind: "op", op: ">>" });
1050
+ i += 2;
1051
+ } else if (next === "&") {
1052
+ tokens.push({ kind: "op", op: c === ">" ? ">&" : "<&" });
1053
+ i += 2;
1054
+ } else {
1055
+ tokens.push({ kind: "op", op: c });
1056
+ i += 1;
1057
+ }
1058
+ continue;
1059
+ }
1060
+ word += c;
1061
+ i += 1;
1062
+ }
1063
+ flush();
1064
+ return tokens;
1065
+ }
1066
+ function splitShellSegments(tokens) {
1067
+ const segments = [];
1068
+ let current = [];
1069
+ for (const token of tokens) {
1070
+ if (token.kind === "op" && SEGMENT_SEPARATORS.has(token.op)) {
1071
+ if (current.length > 0) {
1072
+ segments.push(current);
1073
+ current = [];
1074
+ }
1075
+ continue;
1076
+ }
1077
+ current.push(token);
1078
+ }
1079
+ if (current.length > 0) {
1080
+ segments.push(current);
1081
+ }
1082
+ return segments;
1083
+ }
1084
+ function segmentWords(segment) {
1085
+ const words = [];
1086
+ const redirectTargets = [];
1087
+ for (let k = 0; k < segment.length; k += 1) {
1088
+ const token = segment[k];
1089
+ if (token.kind === "op" && REDIRECT_OPS.has(token.op)) {
1090
+ const target = segment[k + 1];
1091
+ if (target?.kind === "word") {
1092
+ redirectTargets.push(target.text);
1093
+ k += 1;
1094
+ }
1095
+ continue;
1096
+ }
1097
+ if (token.kind === "word") {
1098
+ words.push(token.text);
1099
+ }
1100
+ }
1101
+ return { words, redirectTargets };
1102
+ }
1103
+ function renderSegment(segment) {
1104
+ return segment.map((token) => {
1105
+ if (token.kind === "word") return token.text;
1106
+ if (token.kind === "op") return token.op;
1107
+ return token.process ? ">(\u2026)" : "$(\u2026)";
1108
+ }).join(" ");
1109
+ }
1110
+
1111
+ // src/permissions/lex-cache.ts
1112
+ function lexCached(cache4, command) {
1113
+ if (cache4 === void 0) return lexShell(command);
1114
+ const hit = cache4.get(command);
1115
+ if (hit !== void 0) return hit;
1116
+ const tokens = lexShell(command);
1117
+ cache4.set(command, tokens);
1118
+ return tokens;
1119
+ }
1120
+
1121
+ // src/permissions/rules.ts
1122
+ function parseAllowRule(rule) {
1123
+ const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
1124
+ if (!match) return null;
1125
+ return { toolName: match[1], pattern: match[2] };
1126
+ }
1127
+ function globToRegExp(pattern) {
1128
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1129
+ return new RegExp(`^${escaped}$`);
1130
+ }
1131
+ function requestTarget(req) {
1132
+ if (typeof req.args !== "object" || req.args === null) return void 0;
1133
+ const args = req.args;
1134
+ const key = req.toolName === "bash" ? "command" : "path";
1135
+ const value = args[key];
1136
+ return typeof value === "string" ? value : void 0;
1137
+ }
1138
+ function compileRule(raw) {
1139
+ const parsed = parseAllowRule(raw);
1140
+ if (parsed === null) return null;
1141
+ if (parsed.pattern === void 0) return { toolName: parsed.toolName };
1142
+ return { toolName: parsed.toolName, pattern: globToRegExp(parsed.pattern) };
1143
+ }
1144
+ var compiledRuleCache = /* @__PURE__ */ new WeakMap();
1145
+ function compiledRules(rules) {
1146
+ const hit = compiledRuleCache.get(rules);
1147
+ if (hit !== void 0) return hit;
1148
+ const compiled = rules.map(compileRule);
1149
+ compiledRuleCache.set(rules, compiled);
1150
+ return compiled;
1151
+ }
1152
+ function matchCompiledRule(rule, req) {
1153
+ if (rule.toolName !== req.toolName) return false;
1154
+ if (rule.pattern === void 0) return true;
1155
+ const target = requestTarget(req);
1156
+ return target !== void 0 && rule.pattern.test(target);
1157
+ }
1158
+
1159
+ // src/permissions/allow.ts
1160
+ function splitCommandChain(command, lex) {
1161
+ if (command.includes("$(") || command.includes("`")) return null;
1162
+ const tokens = lexCached(lex, command);
1163
+ if (tokens.some((token) => token.kind === "subst")) return null;
1164
+ const segments = splitShellSegments(tokens);
1165
+ for (const segment of segments) {
1166
+ if (segment.some((token) => token.kind === "op" && FILE_REDIRECT_OPS.has(token.op))) {
1167
+ return null;
1168
+ }
1169
+ }
1170
+ return segments.map(renderSegment).filter((segment) => segment.length > 0);
1171
+ }
1172
+ function withCommand(req, command) {
1173
+ return {
1174
+ ...req,
1175
+ args: { ...req.args ?? {}, command }
1176
+ };
1177
+ }
1178
+ function isAllowedByRules(rules, req, lex) {
1179
+ if (req.toolName !== "bash") return matchesAnyRule(rules, req);
1180
+ const command = requestTarget(req);
1181
+ if (command === void 0) return matchesAnyRule(rules, req);
1182
+ const segments = splitCommandChain(command, lex);
1183
+ if (segments === null) return false;
1184
+ if (segments.length === 0) return matchesAnyRule(rules, req);
1185
+ return segments.every((segment) => matchesAnyRule(rules, withCommand(req, segment)));
1186
+ }
1187
+ function isDeniedByRules(rules, req, lex) {
1188
+ if (req.toolName !== "bash") return matchesAnyRule(rules, req);
1189
+ const command = requestTarget(req);
1190
+ if (command === void 0) return matchesAnyRule(rules, req);
1191
+ const segments = splitCommandChain(command, lex);
1192
+ if (segments === null || segments.length === 0) return matchesAnyRule(rules, req);
1193
+ return segments.some((segment) => matchesAnyRule(rules, withCommand(req, segment)));
1194
+ }
1195
+ function isAskedByRules(rules, req, lex) {
1196
+ if (req.toolName !== "bash") return matchesAnyRule(rules, req);
1197
+ const command = requestTarget(req);
1198
+ if (command === void 0) return matchesAnyRule(rules, req);
1199
+ const segments = splitCommandChain(command, lex);
1200
+ if (segments === null || segments.length === 0) return matchesAnyRule(rules, req);
1201
+ return segments.some((segment) => matchesAnyRule(rules, withCommand(req, segment)));
1202
+ }
1203
+ function matchesAnyRule(rules, req) {
1204
+ return compiledRules(rules).some((rule) => rule !== null && matchCompiledRule(rule, req));
1205
+ }
1206
+ function buildAllowRule(req) {
1207
+ const target = requestTarget(req);
1208
+ return target !== void 0 ? `${req.toolName}(${target})` : req.toolName;
1209
+ }
1210
+
1211
+ // src/permissions/gate.ts
1212
+ var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "read_image", "grep", "glob"]);
1213
+ var FORK_BOMB = /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/;
1214
+ var MAX_ANALYSIS_DEPTH = 8;
1215
+ var SH_FAMILY = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ash"]);
1216
+ var EGRESS_COMMANDS = /* @__PURE__ */ new Set([
1217
+ "curl",
1218
+ "wget",
1219
+ "nc",
1220
+ "ncat",
1221
+ "ssh",
1222
+ "scp",
1223
+ "sftp",
1224
+ "ftp",
1225
+ "telnet"
1226
+ ]);
1227
+ var BLOCK_DEVICE = /^\/dev\/(?:sd[a-z]+\d*|xvd[a-z]+\d*|vd[a-z]+\d*|hd[a-z]+\d*|nvme\d+n\d+(?:p\d+)?|mmcblk\d+(?:p\d+)?)$/i;
1228
+ function baseName(word) {
1229
+ const base = word.replace(/\\/g, "/").split("/").pop() ?? word;
1230
+ return base.toLowerCase().replace(/\.exe$/, "");
1231
+ }
1232
+ function isDangerousDeleteTarget(target) {
1233
+ if (/^\/+$/.test(target)) return true;
1234
+ const norm = target.replace(/\\/g, "/").replace(/\/+$/, "");
1235
+ if (norm === "/*" || norm === "*") return true;
1236
+ if (norm === "~" || norm.startsWith("~/") || /^~[^/]/.test(norm)) return true;
1237
+ if (norm === "." || norm === "./*") return true;
1238
+ if (/^[a-zA-Z]:$/.test(norm)) return true;
1239
+ const homeVars = ["$HOME", "$USERPROFILE", "$HOMEPATH"];
1240
+ if (homeVars.includes(norm)) return true;
1241
+ if (homeVars.some((v) => norm.startsWith(`${v}/`))) return true;
1242
+ if (/^%(USERPROFILE|HOMEDRIVE|HOMEPATH)%$/i.test(norm)) return true;
1243
+ if (/^%(USERPROFILE|HOMEPATH)%\//i.test(norm)) return true;
1244
+ return false;
1245
+ }
1246
+ function rmIsDangerous(args) {
1247
+ let recursive = false;
1248
+ let optionsDone = false;
1249
+ const targets = [];
1250
+ for (const arg of args) {
1251
+ if (!optionsDone && arg === "--") {
1252
+ optionsDone = true;
1253
+ continue;
1254
+ }
1255
+ if (!optionsDone && arg.startsWith("-")) {
1256
+ if (arg === "--recursive") {
1257
+ recursive = true;
1258
+ } else if (!arg.startsWith("--") && /[rR]/.test(arg)) {
1259
+ recursive = true;
1260
+ }
1261
+ continue;
1262
+ }
1263
+ targets.push(arg);
1264
+ }
1265
+ return recursive && targets.some(isDangerousDeleteTarget);
1266
+ }
1267
+ function findIsDangerous(args, depth, lex) {
1268
+ const paths = [];
1269
+ for (const arg of args) {
1270
+ if (arg.startsWith("-") || arg === "(" || arg === ")" || arg === "!" || arg === ",") {
1271
+ break;
1272
+ }
1273
+ paths.push(arg);
1274
+ }
1275
+ const rootishPaths = paths.length === 0 || paths.some(isDangerousDeleteTarget);
1276
+ if (args.includes("-delete") && rootishPaths) return true;
1277
+ for (let k = 0; k < args.length; k += 1) {
1278
+ const arg = args[k];
1279
+ if (arg !== "-exec" && arg !== "-execdir") continue;
1280
+ const cmd = [];
1281
+ for (let j = k + 1; j < args.length; j += 1) {
1282
+ const token = args[j];
1283
+ if (token === ";" || token === "+") break;
1284
+ cmd.push(token);
1285
+ }
1286
+ if (cmd.length === 0) continue;
1287
+ const argv = rootishPaths ? cmd.map((token) => token.replaceAll("{}", "~")) : cmd;
1288
+ const name = baseName(argv[0]);
1289
+ if (name === "rm" && rmIsDangerous(argv.slice(1))) return true;
1290
+ if (SH_FAMILY.has(name)) {
1291
+ const idx = argv.indexOf("-c");
1292
+ const script = idx >= 0 ? argv[idx + 1] : void 0;
1293
+ if (script !== void 0 && commandLineIsDangerous(script, depth + 1, lex)) return true;
1294
+ }
1295
+ if (commandLineIsDangerous(argv.join(" "), depth + 1, lex)) return true;
1296
+ }
1297
+ return false;
1298
+ }
1299
+ var XARGS_VALUE_OPTS = /* @__PURE__ */ new Set([
1300
+ "-I",
1301
+ "-n",
1302
+ "-P",
1303
+ "-s",
1304
+ "-L",
1305
+ "-d",
1306
+ "-E",
1307
+ "-a",
1308
+ "--replace",
1309
+ "--max-args",
1310
+ "--max-procs",
1311
+ "--max-lines",
1312
+ "--delimiter",
1313
+ "--eof",
1314
+ "--arg-file",
1315
+ "--interactive",
1316
+ "--exit"
1317
+ ]);
1318
+ function xargsIsDangerous(args, depth, lex) {
1319
+ let replstr;
1320
+ for (let k = 0; k < args.length; k += 1) {
1321
+ const arg = args[k];
1322
+ if (arg === "-I" || arg === "--replace") {
1323
+ replstr = args[k + 1];
1324
+ k += 1;
1325
+ continue;
1326
+ }
1327
+ if (arg.startsWith("--replace=")) {
1328
+ replstr = arg.slice("--replace=".length);
1329
+ continue;
1330
+ }
1331
+ if (arg.startsWith("-I") && arg.length > 2) {
1332
+ replstr = arg.slice(2);
1333
+ continue;
1334
+ }
1335
+ if (XARGS_VALUE_OPTS.has(arg)) {
1336
+ k += 1;
1337
+ continue;
1338
+ }
1339
+ if (arg.startsWith("-")) {
1340
+ continue;
1341
+ }
1342
+ const name = baseName(arg);
1343
+ if (name === "rm") return true;
1344
+ if (!SH_FAMILY.has(name)) return false;
1345
+ const scriptArgs = args.slice(k + 1);
1346
+ const idx = scriptArgs.indexOf("-c");
1347
+ const script = idx >= 0 ? scriptArgs[idx + 1] : void 0;
1348
+ if (script === void 0) return false;
1349
+ const substituted = replstr !== void 0 ? script.split(replstr).join("~") : script;
1350
+ return commandLineIsDangerous(substituted, depth + 1, lex);
1351
+ }
1352
+ return false;
1353
+ }
1354
+ function chmodIsDangerous(args) {
1355
+ let recursive = false;
1356
+ let optionsDone = false;
1357
+ const operands = [];
1358
+ for (const arg of args) {
1359
+ if (!optionsDone && arg === "--") {
1360
+ optionsDone = true;
1361
+ continue;
1362
+ }
1363
+ if (!optionsDone && arg.startsWith("-")) {
1364
+ if (arg === "--recursive" || !arg.startsWith("--") && arg.includes("R")) {
1365
+ recursive = true;
1366
+ }
1367
+ continue;
1368
+ }
1369
+ operands.push(arg);
1370
+ }
1371
+ return recursive && operands.slice(1).some(isDangerousDeleteTarget);
1372
+ }
1373
+ var WRAPPER_COMMANDS = {
1374
+ sudo: {
1375
+ valueOpts: /* @__PURE__ */ new Set([
1376
+ "-u",
1377
+ "-g",
1378
+ "-h",
1379
+ "-p",
1380
+ "-t",
1381
+ "-C",
1382
+ "-T",
1383
+ "-D",
1384
+ "-R",
1385
+ "-U",
1386
+ "--user",
1387
+ "--group",
1388
+ "--host",
1389
+ "--prompt",
1390
+ "--type"
1391
+ ]),
1392
+ positionals: 0
1393
+ },
1394
+ doas: { valueOpts: /* @__PURE__ */ new Set(["-u"]), positionals: 0 },
1395
+ env: { valueOpts: /* @__PURE__ */ new Set(["-u", "-C", "--unset", "--chdir"]), positionals: 0 },
1396
+ nice: { valueOpts: /* @__PURE__ */ new Set(["-n", "--adjustment"]), positionals: 0 },
1397
+ ionice: {
1398
+ valueOpts: /* @__PURE__ */ new Set(["-c", "-n", "-p", "--class", "--classdata", "--pid"]),
1399
+ positionals: 0
1400
+ },
1401
+ nohup: { valueOpts: /* @__PURE__ */ new Set(), positionals: 0 },
1402
+ timeout: {
1403
+ valueOpts: /* @__PURE__ */ new Set(["-k", "-s", "--kill-after", "--signal"]),
1404
+ positionals: 1
1405
+ },
1406
+ stdbuf: {
1407
+ valueOpts: /* @__PURE__ */ new Set(["-i", "-o", "-e", "--input", "--output", "--error"]),
1408
+ positionals: 0
1409
+ },
1410
+ setsid: { valueOpts: /* @__PURE__ */ new Set(), positionals: 0 },
1411
+ chrt: { valueOpts: /* @__PURE__ */ new Set(["-p", "--pid"]), positionals: 1 },
1412
+ taskset: {
1413
+ valueOpts: /* @__PURE__ */ new Set(["-c", "-p", "--cpu-list", "--pid"]),
1414
+ positionals: 1,
1415
+ positionalSubstitutes: /* @__PURE__ */ new Set(["-c", "--cpu-list"])
1416
+ }
1417
+ };
1418
+ function commandInvocation(words) {
1419
+ let sudo = false;
1420
+ let k = 0;
1421
+ for (; ; ) {
1422
+ const word = words[k];
1423
+ if (word === void 0) return null;
1424
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
1425
+ k += 1;
1426
+ continue;
1427
+ }
1428
+ const name = baseName(word);
1429
+ const spec = WRAPPER_COMMANDS[name];
1430
+ if (spec === void 0) {
1431
+ return { name, rest: words.slice(k + 1), sudo };
1432
+ }
1433
+ sudo = sudo || name === "sudo" || name === "doas";
1434
+ k += 1;
1435
+ let positionals = spec.positionals;
1436
+ for (; ; ) {
1437
+ const arg = words[k];
1438
+ if (arg === void 0) return null;
1439
+ if (arg.startsWith("-") && arg !== "-") {
1440
+ if (spec.valueOpts.has(arg)) {
1441
+ if (spec.positionalSubstitutes?.has(arg)) positionals = 0;
1442
+ k += 2;
1443
+ } else {
1444
+ k += 1;
1445
+ }
1446
+ continue;
1447
+ }
1448
+ if (positionals > 0) {
1449
+ positionals -= 1;
1450
+ k += 1;
1451
+ continue;
1452
+ }
1453
+ break;
1454
+ }
1455
+ }
1456
+ }
1457
+ function commandLineIsDangerous(command, depth, lex) {
1458
+ if (depth > MAX_ANALYSIS_DEPTH) return true;
1459
+ const tokens = lexCached(lex, command);
1460
+ for (const token of tokens) {
1461
+ if (token.kind === "subst" && commandLineIsDangerous(token.inner, depth + 1, lex)) {
1462
+ return true;
1463
+ }
1464
+ }
1465
+ return splitShellSegments(tokens).some((segment) => segmentIsDangerous(segment, depth, lex));
1466
+ }
1467
+ function decodePowerShellEncoded(encoded) {
1468
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) return null;
1469
+ try {
1470
+ return Buffer.from(encoded, "base64").toString("utf16le");
1471
+ } catch {
1472
+ return null;
1473
+ }
1474
+ }
1475
+ function segmentIsDangerous(segment, depth, lex) {
1476
+ const { words, redirectTargets } = segmentWords(segment);
1477
+ if (redirectTargets.some((target) => BLOCK_DEVICE.test(target))) {
1478
+ return true;
1479
+ }
1480
+ const invocation = commandInvocation(words);
1481
+ if (!invocation) return false;
1482
+ const { name, rest, sudo } = invocation;
1483
+ if (sudo && name === "rm") return true;
1484
+ switch (name) {
1485
+ case "shutdown":
1486
+ case "reboot":
1487
+ case "poweroff":
1488
+ case "halt":
1489
+ case "diskpart":
1490
+ case "wipefs":
1491
+ case "shred":
1492
+ case "format-volume":
1493
+ case "clear-disk":
1494
+ case "stop-computer":
1495
+ case "bcdedit":
1496
+ case "takeown":
1497
+ case "sdelete":
1498
+ return true;
1499
+ case "rm":
1500
+ return rmIsDangerous(rest);
1501
+ case "rmdir":
1502
+ case "rd":
1503
+ case "del":
1504
+ case "erase":
1505
+ return rest.some((arg) => /^\/s$/i.test(arg));
1506
+ case "remove-item": {
1507
+ let recursive = false;
1508
+ const targets = [];
1509
+ for (const arg of rest) {
1510
+ if (arg.startsWith("-") && /^-r/i.test(arg)) {
1511
+ recursive = true;
1512
+ } else if (!arg.startsWith("-")) {
1513
+ targets.push(arg);
1514
+ }
1515
+ }
1516
+ return recursive && targets.some(isDangerousDeleteTarget);
1517
+ }
1518
+ case "format":
1519
+ return rest.some((arg) => /^[a-z]:$/i.test(arg));
1520
+ case "dd":
1521
+ return rest.some(
1522
+ (arg) => arg.startsWith("if=") || arg.startsWith("of=") && BLOCK_DEVICE.test(arg.slice(3))
1523
+ );
1524
+ case "find":
1525
+ return findIsDangerous(rest, depth, lex);
1526
+ case "xargs":
1527
+ return xargsIsDangerous(rest, depth, lex);
1528
+ case "chmod":
1529
+ return chmodIsDangerous(rest);
1530
+ case "systemctl":
1531
+ return rest.some((arg) => ["poweroff", "halt", "reboot"].includes(arg.toLowerCase()));
1532
+ case "init":
1533
+ return rest.some((arg) => arg === "0" || arg === "6");
1534
+ case "vssadmin":
1535
+ return rest.some((arg) => /^delete$/i.test(arg)) && rest.some((arg) => /^shadows$/i.test(arg));
1536
+ case "wbadmin":
1537
+ return rest.some((arg) => /^delete$/i.test(arg));
1538
+ case "cipher":
1539
+ return rest.some((arg) => /^\/w/i.test(arg));
1540
+ case "icacls":
1541
+ return rest.some((arg) => /^\/reset$/i.test(arg));
1542
+ case "reg":
1543
+ return rest.some((arg) => /^delete$/i.test(arg)) && rest.some((arg) => /^hklm/i.test(arg));
1544
+ case "sh":
1545
+ case "bash":
1546
+ case "zsh":
1547
+ case "dash":
1548
+ case "ash": {
1549
+ const idx = rest.indexOf("-c");
1550
+ const script = idx >= 0 ? rest[idx + 1] : void 0;
1551
+ return script !== void 0 && commandLineIsDangerous(script, depth + 1, lex);
1552
+ }
1553
+ case "eval":
1554
+ return rest.length > 0 && commandLineIsDangerous(rest.join(" "), depth + 1, lex);
1555
+ case "cmd": {
1556
+ const idx = rest.findIndex((arg) => /^\/[ck]$/i.test(arg));
1557
+ if (idx < 0) return false;
1558
+ const script = rest.slice(idx + 1).join(" ");
1559
+ if (script.length === 0) return false;
1560
+ return commandLineIsDangerous(script.replace(/\^(.)/gs, "$1"), depth + 1, lex);
1561
+ }
1562
+ case "powershell":
1563
+ case "pwsh": {
1564
+ for (let k = 0; k < rest.length; k += 1) {
1565
+ const arg = rest[k];
1566
+ if (/^-(c|command)$/i.test(arg)) {
1567
+ const script = rest.slice(k + 1).join(" ");
1568
+ return script.length > 0 && commandLineIsDangerous(script, depth + 1, lex);
1569
+ }
1570
+ if (/^-(enc|encodedcommand)$/i.test(arg)) {
1571
+ const encoded = rest[k + 1];
1572
+ if (encoded === void 0) return true;
1573
+ const script = decodePowerShellEncoded(encoded);
1574
+ if (script === null) return true;
1575
+ return commandLineIsDangerous(script, depth + 1, lex);
1576
+ }
1577
+ }
1578
+ return false;
1579
+ }
1580
+ default:
1581
+ return name.startsWith("mkfs") && (name.length === 4 || /[.-]/.test(name.charAt(4)));
1582
+ }
1583
+ }
1584
+ function isDangerousCommand(command, lex) {
1585
+ if (FORK_BOMB.test(command)) return true;
1586
+ return commandLineIsDangerous(command, 0, lex);
1587
+ }
1588
+ var SENSITIVE_DIR_PREFIXES = [
1589
+ "~/.star-cli",
1590
+ "~/.ssh",
1591
+ "$home/.star-cli",
1592
+ "$home/.ssh",
1593
+ "$userprofile/.ssh",
1594
+ "%userprofile%/.ssh"
1595
+ ];
1596
+ function tokenTouchesSensitivePath(word) {
1597
+ const norm = word.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
1598
+ for (const prefix of SENSITIVE_DIR_PREFIXES) {
1599
+ if (norm === prefix || norm.startsWith(`${prefix}/`)) return true;
1600
+ }
1601
+ return isSensitivePath(word);
1602
+ }
1603
+ function bashHygiene(command, lex) {
1604
+ let sensitive = false;
1605
+ let egress = false;
1606
+ const visit = (tokens, depth) => {
1607
+ if (depth > MAX_ANALYSIS_DEPTH) return;
1608
+ for (const token of tokens) {
1609
+ if (token.kind === "subst") visit(lexCached(lex, token.inner), depth + 1);
1610
+ }
1611
+ for (const segment of splitShellSegments(tokens)) {
1612
+ const { words, redirectTargets } = segmentWords(segment);
1613
+ for (const word of [...words, ...redirectTargets]) {
1614
+ if (tokenTouchesSensitivePath(word)) sensitive = true;
1615
+ }
1616
+ const invocation = commandInvocation(words);
1617
+ if (!invocation) continue;
1618
+ if (EGRESS_COMMANDS.has(invocation.name)) egress = true;
1619
+ let script;
1620
+ if (SH_FAMILY.has(invocation.name)) {
1621
+ const idx = invocation.rest.indexOf("-c");
1622
+ script = idx >= 0 ? invocation.rest[idx + 1] : void 0;
1623
+ } else if (invocation.name === "cmd") {
1624
+ const idx = invocation.rest.findIndex((arg) => /^\/[ck]$/i.test(arg));
1625
+ script = idx >= 0 ? invocation.rest.slice(idx + 1).join(" ") : void 0;
1626
+ } else if (invocation.name === "eval" && invocation.rest.length > 0) {
1627
+ script = invocation.rest.join(" ");
1628
+ }
1629
+ if (script !== void 0) visit(lexCached(lex, script), depth + 1);
1630
+ }
1631
+ };
1632
+ visit(lexCached(lex, command), 0);
1633
+ return { sensitive, egress };
1634
+ }
1635
+ function getStringArg(args, key) {
1636
+ if (typeof args === "object" && args !== null) {
1637
+ const value = args[key];
1638
+ if (typeof value === "string") {
1639
+ return value;
1640
+ }
1641
+ }
1642
+ return void 0;
1643
+ }
1644
+ function normalizeAbsolute(p, cwd) {
1645
+ const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
1646
+ return abs.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
1647
+ }
1648
+ function isOutsideCwd(p, cwd) {
1649
+ const abs = normalizeAbsolute(p, cwd);
1650
+ const base = normalizeAbsolute(cwd, cwd);
1651
+ return abs !== base && !abs.startsWith(`${base}/`);
1652
+ }
1653
+ var REALPATH_TTL_MS = 5e3;
1654
+ var REALPATH_CACHE_MAX = 1e3;
1655
+ var RealpathCache = class {
1656
+ map = /* @__PURE__ */ new Map();
1657
+ get(key) {
1658
+ const entry = this.map.get(key);
1659
+ if (entry === void 0) return void 0;
1660
+ if (entry.expires <= Date.now()) {
1661
+ this.map.delete(key);
1662
+ return void 0;
1663
+ }
1664
+ this.map.delete(key);
1665
+ this.map.set(key, entry);
1666
+ return entry.value;
1667
+ }
1668
+ set(key, value) {
1669
+ this.map.delete(key);
1670
+ if (this.map.size >= REALPATH_CACHE_MAX) {
1671
+ const oldest = this.map.keys().next().value;
1672
+ if (oldest !== void 0) this.map.delete(oldest);
1673
+ }
1674
+ this.map.set(key, { value, expires: Date.now() + REALPATH_TTL_MS });
1675
+ }
1676
+ };
1677
+ var realpathCache = new RealpathCache();
1678
+ var resolvedPathCache = new RealpathCache();
1679
+ function realpathCached(p) {
1680
+ const hit = realpathCache.get(p);
1681
+ if (hit !== void 0) return hit;
1682
+ let value;
1683
+ try {
1684
+ value = fs.realpathSync(p);
1685
+ } catch {
1686
+ value = null;
1687
+ }
1688
+ realpathCache.set(p, value);
1689
+ return value;
1690
+ }
1691
+ function resolveRealPath(p, cwd) {
1692
+ const start = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
1693
+ const hit = resolvedPathCache.get(start);
1694
+ if (hit !== void 0) return hit;
1695
+ const value = resolveRealPathUncached(start);
1696
+ resolvedPathCache.set(start, value);
1697
+ return value;
1698
+ }
1699
+ function resolveRealPathUncached(start) {
1700
+ if (process.platform === "win32" && start.startsWith("\\\\")) {
1701
+ return realpathCached(start) ?? start;
1702
+ }
1703
+ let current = start;
1704
+ const missing = [];
1705
+ for (; ; ) {
1706
+ const resolved = realpathCached(current);
1707
+ if (resolved !== null) {
1708
+ return path.join(resolved, ...missing.reverse());
1709
+ }
1710
+ const parent = path.dirname(current);
1711
+ if (parent === current) return null;
1712
+ missing.push(path.basename(current));
1713
+ current = parent;
1714
+ }
1715
+ }
1716
+ function isOutsideResolved(p, cwd) {
1717
+ const resolved = resolveRealPath(p, cwd);
1718
+ if (resolved === null) return isOutsideCwd(p, cwd);
1719
+ const realCwd = realpathCached(cwd);
1720
+ if (realCwd === null) return isOutsideCwd(p, cwd);
1721
+ return isOutsideCwd(resolved, realCwd);
1722
+ }
1723
+ function checkPermission(mode, req, ctx, allowRules = [], denyRules = [], askRules = []) {
1724
+ if (mode === "yolo") {
1725
+ return "allow";
1726
+ }
1727
+ const command = getStringArg(req.args, "command");
1728
+ const filePath = getStringArg(req.args, "path");
1729
+ const lex = req.toolName === "bash" && command !== void 0 ? /* @__PURE__ */ new Map() : void 0;
1730
+ let bashTouchesSensitive = false;
1731
+ if (req.toolName === "bash" && command !== void 0) {
1732
+ if (isDangerousCommand(command, lex)) {
1733
+ return "deny";
1734
+ }
1735
+ const hygiene = bashHygiene(command, lex);
1736
+ if (hygiene.sensitive && hygiene.egress) {
1737
+ return "deny";
1738
+ }
1739
+ bashTouchesSensitive = hygiene.sensitive;
1740
+ }
1741
+ if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
1742
+ const isWriteTool = req.toolName === "write_file" || req.toolName === "edit_file";
1743
+ if (isWriteTool && isSensitivePath(filePath)) {
1744
+ return "deny";
1745
+ }
1746
+ if (isOutsideResolved(filePath, ctx.cwd)) {
1747
+ if (mode === "ask" && req.level === "read") {
1748
+ return "ask";
1749
+ }
1750
+ return "deny";
1751
+ }
1752
+ }
1753
+ if (isDeniedByRules(denyRules, req, lex)) {
1754
+ return "deny";
1755
+ }
1756
+ if (mode === "auto") {
1757
+ if (bashTouchesSensitive) {
1758
+ return "ask";
1759
+ }
1760
+ if (isAskedByRules(askRules, req, lex)) {
1761
+ return "ask";
1762
+ }
1763
+ return "allow";
1764
+ }
1765
+ if (mode === "readonly") {
1766
+ return req.level === "read" ? "allow" : "deny";
1767
+ }
1768
+ if (mode === "plan") {
1769
+ return req.level === "read" ? "allow" : "deny";
1770
+ }
1771
+ if (isAskedByRules(askRules, req, lex)) {
1772
+ return "ask";
1773
+ }
1774
+ if (isAllowedByRules(allowRules, req, lex)) {
1775
+ return "allow";
1776
+ }
1777
+ return req.level === "read" ? "allow" : "ask";
1778
+ }
1779
+
1780
+ // src/session/title.ts
1781
+ import { generateText as generateText2 } from "ai";
1782
+ var TITLE_MAX_CHARS = 50;
1783
+ var TITLE_INPUT_MAX_CHARS = 1e3;
1784
+ var TITLE_MAX_TOKENS = 20;
1785
+ var TITLE_TIMEOUT_MS = 15e3;
1786
+ var TITLE_SYSTEM_PROMPT = [
1787
+ "You write short titles for conversations.",
1788
+ "Summarize the user's first message into a single concise title:",
1789
+ "- at most 50 characters",
1790
+ "- no surrounding quotes, no trailing punctuation",
1791
+ "- same language as the user's message",
1792
+ "Output only the title, nothing else."
1793
+ ].join("\n");
1794
+ function sanitizeTitle(raw) {
1795
+ let title = raw.trim().split("\n")[0]?.trim() ?? "";
1796
+ let previous;
1797
+ do {
1798
+ previous = title;
1799
+ title = title.replace(/^["'“”‘’「」『』\s]+|["'“”‘’「」『』\s]+$/g, "").replace(/[。..!!??…::;;,,、\s]+$/u, "");
1800
+ } while (title !== previous);
1801
+ return title.slice(0, TITLE_MAX_CHARS).trim();
1802
+ }
1803
+ async function generateSessionTitle(userText, model) {
1804
+ const { text } = await generateText2({
1805
+ model,
1806
+ system: TITLE_SYSTEM_PROMPT,
1807
+ prompt: userText.slice(0, TITLE_INPUT_MAX_CHARS),
1808
+ maxTokens: TITLE_MAX_TOKENS,
1809
+ abortSignal: AbortSignal.timeout(TITLE_TIMEOUT_MS)
1810
+ });
1811
+ return sanitizeTitle(text);
1812
+ }
1813
+ function scheduleSessionTitle(store, userText, model) {
1814
+ void (async () => {
1815
+ const meta = await store.meta();
1816
+ if (meta.title) return;
1817
+ const title = await generateSessionTitle(userText, model);
1818
+ if (!title) return;
1819
+ const current = await store.meta();
1820
+ if (current.title) return;
1821
+ await store.setTitle(title);
1822
+ })().catch(() => {
1823
+ });
1824
+ }
1825
+
1826
+ // src/agent/project-memory.ts
1827
+ import { readFileSync, statSync } from "fs";
1828
+ import path2 from "path";
1829
+ var PROJECT_MEMORY_FILE = "AGENTS.md";
1830
+ var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
1831
+ var cache = /* @__PURE__ */ new Map();
1832
+ function readProjectMemory(cwd) {
1833
+ const file = path2.join(cwd, PROJECT_MEMORY_FILE);
1834
+ let mtimeMs;
1835
+ try {
1836
+ mtimeMs = statSync(file).mtimeMs;
1837
+ } catch {
1838
+ return null;
1839
+ }
1840
+ const hit = cache.get(file);
1841
+ if (hit && hit.mtimeMs === mtimeMs) return hit.block;
1842
+ let block = null;
1843
+ try {
1844
+ let body = readFileSync(file, "utf8").trim();
1845
+ if (body) {
1846
+ if (body.length > PROJECT_MEMORY_MAX_CHARS) {
1847
+ body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
1848
+
1849
+ [AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
1850
+ }
1851
+ block = `# Project instructions (AGENTS.md)
1852
+ ${body}`;
1853
+ }
1854
+ } catch {
1855
+ block = null;
1856
+ }
1857
+ cache.set(file, { mtimeMs, block });
1858
+ return block;
1859
+ }
1860
+
1861
+ // src/agent/skills.ts
1862
+ import { readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "fs";
1863
+ import path3 from "path";
1864
+ import { z } from "zod";
1865
+ var SKILL_FILE = "SKILL.md";
1866
+ var SKILL_MAX_CHARS = 32 * 1024;
1867
+ var NAME_PATTERN = /^[a-z0-9-]+$/;
1868
+ function parseFrontmatter(raw) {
1869
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
1870
+ if (!match) return null;
1871
+ const meta = {};
1872
+ for (const line of (match[1] ?? "").split(/\r?\n/)) {
1873
+ const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
1874
+ if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
1875
+ }
1876
+ return { meta, body: raw.slice(match[0].length).trim() };
1877
+ }
1878
+ var cache2 = /* @__PURE__ */ new Map();
1879
+ function parseSkillFile(filePath, dirName, scope) {
1880
+ let mtimeMs;
1881
+ try {
1882
+ mtimeMs = statSync2(filePath).mtimeMs;
1883
+ } catch {
1884
+ return null;
1885
+ }
1886
+ const hit = cache2.get(filePath);
1887
+ if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
1888
+ let skill = null;
1889
+ try {
1890
+ const parsed = parseFrontmatter(readFileSync2(filePath, "utf8"));
1891
+ const name = parsed?.meta.name || dirName;
1892
+ const description = parsed?.meta.description;
1893
+ if (parsed && NAME_PATTERN.test(name) && description) {
1894
+ skill = { name, description, filePath, dir: path3.dirname(filePath), scope };
1895
+ }
1896
+ } catch {
1897
+ skill = null;
1898
+ }
1899
+ cache2.set(filePath, { mtimeMs, skill });
1900
+ return skill;
1901
+ }
1902
+ function scanDir(base, scope, into) {
1903
+ let entries;
1904
+ try {
1905
+ entries = readdirSync(base);
1906
+ } catch {
1907
+ return;
1908
+ }
1909
+ for (const entry of entries.sort()) {
1910
+ if (!NAME_PATTERN.test(entry)) continue;
1911
+ const parsed = parseSkillFile(path3.join(base, entry, SKILL_FILE), entry, scope);
1912
+ if (parsed) into.set(parsed.name, parsed);
1913
+ }
1914
+ }
1915
+ function discoverSkills(cwd, home = starHome()) {
1916
+ const byName = /* @__PURE__ */ new Map();
1917
+ scanDir(path3.join(home, "skills"), "user", byName);
1918
+ scanDir(path3.join(cwd, ".star", "skills"), "project", byName);
1919
+ return [...byName.values()];
1920
+ }
1921
+ function formatSkillsBlock(skills) {
1922
+ const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
1923
+ return [
1924
+ "# Available skills",
1925
+ "When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
1926
+ ...lines
1927
+ ].join("\n");
1928
+ }
1929
+ function loadSkillBody(skill) {
1930
+ let parsed = null;
1931
+ try {
1932
+ parsed = parseFrontmatter(readFileSync2(skill.filePath, "utf8"));
1933
+ } catch {
1934
+ return null;
1935
+ }
1936
+ if (!parsed?.body) return null;
1937
+ if (parsed.body.length > SKILL_MAX_CHARS) {
1938
+ return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
1939
+
1940
+ [skill truncated to ${SKILL_MAX_CHARS} characters]`;
1941
+ }
1942
+ return parsed.body;
1943
+ }
1944
+ function createSkillTool(deps) {
1945
+ return {
1946
+ name: "skill",
1947
+ description: "Load the full instructions of a skill by name. Available skills are listed in the system prompt when present; call this before starting work that matches one of them.",
1948
+ permission: "read",
1949
+ parameters: z.object({
1950
+ name: z.string().describe("Name of the skill to load, as listed in the system prompt.")
1951
+ }),
1952
+ async execute(args) {
1953
+ const skills = discoverSkills(deps.cwd, deps.home);
1954
+ const skill = skills.find((s) => s.name === args.name);
1955
+ if (!skill) {
1956
+ const available = skills.map((s) => s.name).join(", ") || "none";
1957
+ return {
1958
+ content: `Unknown skill: "${args.name}". Available skills: ${available}`,
1959
+ isError: true
1960
+ };
1961
+ }
1962
+ const body = loadSkillBody(skill);
1963
+ if (!body) {
1964
+ return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
1965
+ }
1966
+ return {
1967
+ content: [
1968
+ `# Skill: ${skill.name}`,
1969
+ "",
1970
+ body,
1971
+ "",
1972
+ `Base directory for relative file references in this skill: ${skill.dir}`
1973
+ ].join("\n")
1974
+ };
1975
+ }
1976
+ };
1977
+ }
1978
+
1979
+ // src/agent/subagent.ts
1980
+ import { randomUUID } from "crypto";
1981
+ import { z as z2 } from "zod";
1982
+ var MAX_SUBAGENT_DEPTH = 1;
1983
+ var MAX_REPORT_CHARS = 2e4;
1984
+ var SUBAGENT_PROMPT = "You are a subagent spawned to handle a focused subtask on behalf of the main agent. Work autonomously with the tools available to you, then finish with a concise report of what you found or did. Your final text is returned to the main agent as the tool result \u2014 make it self-contained.";
1985
+ function convertUsagePricing(usage, from, to, config, names) {
1986
+ if (from === to) return usage;
1987
+ const fromCfg = findModelPricing(config, names?.fromName, from.modelId);
1988
+ const toCfg = findModelPricing(config, names?.toName, to.modelId);
1989
+ if (fromCfg?.promptPrice === void 0 || fromCfg.completionPrice === void 0 || toCfg?.promptPrice === void 0 || toCfg.completionPrice === void 0 || toCfg.promptPrice <= 0 || toCfg.completionPrice <= 0) {
1990
+ return usage;
1991
+ }
1992
+ const cached = Math.min(Math.max(usage.cachedPromptTokens ?? 0, 0), usage.promptTokens);
1993
+ const promptUsd = (usage.promptTokens - cached) * fromCfg.promptPrice;
1994
+ const cachedUsd = cached * (fromCfg.cacheReadPrice ?? fromCfg.promptPrice);
1995
+ const cacheReadUsd = (usage.cacheReadInputTokens ?? 0) * (fromCfg.cacheReadPrice ?? 0);
1996
+ const completionUsd = usage.completionTokens * fromCfg.completionPrice;
1997
+ const scaledCached = cachedUsd / (toCfg.cacheReadPrice ?? toCfg.promptPrice);
1998
+ const promptTokens = Math.round(promptUsd / toCfg.promptPrice + scaledCached);
1999
+ const cachedPromptTokens = Math.round(scaledCached);
2000
+ const toCacheReadPrice = toCfg.cacheReadPrice !== void 0 && toCfg.cacheReadPrice > 0 ? toCfg.cacheReadPrice : void 0;
2001
+ const completionTokens = Math.round(
2002
+ completionUsd / toCfg.completionPrice + (toCacheReadPrice === void 0 ? cacheReadUsd / toCfg.completionPrice : 0)
2003
+ );
2004
+ const cacheReadInputTokens = toCacheReadPrice === void 0 ? 0 : Math.round(cacheReadUsd / toCacheReadPrice);
2005
+ const result = {
2006
+ promptTokens,
2007
+ completionTokens,
2008
+ totalTokens: promptTokens + completionTokens
2009
+ };
2010
+ if (cachedPromptTokens > 0) result.cachedPromptTokens = cachedPromptTokens;
2011
+ if (cacheReadInputTokens > 0) result.cacheReadInputTokens = cacheReadInputTokens;
2012
+ return result;
2013
+ }
2014
+ function findModelPricing(config, name, modelId) {
2015
+ if (name !== void 0) {
2016
+ const byName = config.models.find((m) => m.name === name);
2017
+ if (byName) return byName;
2018
+ }
2019
+ return config.models.find((m) => m.model === modelId);
2020
+ }
2021
+ async function runSubagent(deps, args, cwd, signal, agentIdRef) {
2022
+ const { AgentLoop: AgentLoop2 } = await import("./loop-PVOSF6LR.js");
2023
+ const child = new AgentLoop2({
2024
+ model: deps.model,
2025
+ registry: createDefaultRegistry(),
2026
+ config: deps.config,
2027
+ cwd,
2028
+ system: deps.system ? `${deps.system}
2029
+
2030
+ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2031
+ providerMetadata: deps.providerMetadata,
2032
+ temperature: deps.temperature,
2033
+ streamIdleTimeoutSec: deps.streamIdleTimeoutSec,
2034
+ streamFirstChunkTimeoutSec: deps.streamFirstChunkTimeoutSec,
2035
+ vision: deps.vision,
2036
+ subagentDepth: deps.depth + 1,
2037
+ modelName: deps.modelName,
2038
+ // Read after the dynamic-import await: defaultAgentTasks.start() has
2039
+ // returned by then, so a background spawn's ref.id is already assigned.
2040
+ // Foreground spawns get a throwaway id.
2041
+ agentId: agentIdRef?.id ?? `subagent-${randomUUID().slice(0, 8)}`
2042
+ });
2043
+ const confirmHandler = deps.getConfirmHandler?.();
2044
+ if (confirmHandler) child.confirmHandler = confirmHandler;
2045
+ let toolCalls = 0;
2046
+ let error = null;
2047
+ const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
2048
+ try {
2049
+ for await (const event of child.stream(args.prompt, signal)) {
2050
+ if (event.type === "tool-result") {
2051
+ toolCalls++;
2052
+ } else if (event.type === "error") {
2053
+ error = event.error.message;
2054
+ break;
2055
+ } else if (event.type === "finish" && event.usage) {
2056
+ usage.promptTokens += event.usage.promptTokens;
2057
+ usage.completionTokens += event.usage.completionTokens;
2058
+ usage.totalTokens += usageTotalTokens(event.usage);
2059
+ usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + (event.usage.cachedPromptTokens ?? 0);
2060
+ usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + (event.usage.cacheReadInputTokens ?? 0);
2061
+ }
2062
+ }
2063
+ } catch (e) {
2064
+ error = e instanceof Error ? e.message : String(e);
2065
+ } finally {
2066
+ if (usage.totalTokens > 0) deps.onUsage?.(usage, deps.model, deps.modelName);
2067
+ }
2068
+ if (signal.aborted) {
2069
+ throw new Error("Tool execution aborted.");
2070
+ }
2071
+ let report = "";
2072
+ const messages = child.getMessages();
2073
+ for (let i = messages.length - 1; i >= 0; i--) {
2074
+ const message = messages[i];
2075
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
2076
+ const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
2077
+ if (text) {
2078
+ report = text;
2079
+ break;
2080
+ }
2081
+ }
2082
+ if (report.length > MAX_REPORT_CHARS) {
2083
+ report = `${report.slice(0, MAX_REPORT_CHARS)}
2084
+ ... (truncated)`;
2085
+ }
2086
+ const label = args.description ? ` "${args.description}"` : "";
2087
+ const stats = `[subagent${label}: ${toolCalls} tool call(s)]`;
2088
+ if (error) {
2089
+ throw new Error([`Subagent failed: ${error}`, stats, report].filter(Boolean).join("\n"));
2090
+ }
2091
+ return `${stats}
2092
+ ${report || "(subagent produced no text output)"}`;
2093
+ }
2094
+ function createSubagentTool(deps) {
2095
+ return {
2096
+ name: "subagent",
2097
+ description: "Spawn a subagent with its own agent loop to handle a focused subtask (research, exploration, or an isolated change). The subagent has the same tools but cannot spawn further subagents. Returns the subagent's final report. Prefer this for self-contained work that would otherwise clutter the main conversation. Set run_in_background to start it without blocking: independent subtasks then run concurrently and their reports are delivered to you automatically when they finish (also inspectable via task_list/task_output/task_kill).",
2098
+ permission: "exec",
2099
+ parameters: z2.object({
2100
+ prompt: z2.string().describe("Complete, self-contained instructions for the subagent."),
2101
+ description: z2.string().optional().describe("Short label for the subtask, shown in the result header."),
2102
+ run_in_background: z2.boolean().optional().describe(
2103
+ "Run concurrently in the background and return a task id immediately (default false). Use for independent subtasks that can run in parallel."
2104
+ )
2105
+ }),
2106
+ async execute(args, ctx) {
2107
+ if (args.run_in_background) {
2108
+ const ref = {};
2109
+ const task = defaultAgentTasks.start(
2110
+ (signal2) => runSubagent(deps, args, ctx.cwd, signal2, ref),
2111
+ {
2112
+ prompt: args.prompt,
2113
+ description: args.description
2114
+ }
2115
+ );
2116
+ ref.id = task.id;
2117
+ const label = args.description ? ` "${args.description}"` : "";
2118
+ return {
2119
+ content: `Background subagent ${task.id}${label} started. Its report will be delivered as a message when it finishes; use task_list/task_output to check progress.`
2120
+ };
2121
+ }
2122
+ const signal = ctx.abortSignal ?? new AbortController().signal;
2123
+ try {
2124
+ return { content: await runSubagent(deps, args, ctx.cwd, signal) };
2125
+ } catch (e) {
2126
+ return { content: e instanceof Error ? e.message : String(e), isError: true };
2127
+ }
2128
+ }
2129
+ };
2130
+ }
2131
+
2132
+ // src/agent/user-memory.ts
2133
+ import { appendFileSync, mkdirSync, readFileSync as readFileSync3, statSync as statSync3 } from "fs";
2134
+ import path4 from "path";
2135
+ import { z as z3 } from "zod";
2136
+ var USER_MEMORY_MAX_CHARS = 32 * 1024;
2137
+ var REMEMBER_TEXT_MAX_CHARS = 500;
2138
+ var cache3 = /* @__PURE__ */ new Map();
2139
+ function readUserMemory(home = starHome()) {
2140
+ const file = path4.join(home, "MEMORY.md");
2141
+ let mtimeMs;
2142
+ try {
2143
+ mtimeMs = statSync3(file).mtimeMs;
2144
+ } catch {
2145
+ return null;
2146
+ }
2147
+ const hit = cache3.get(file);
2148
+ if (hit && hit.mtimeMs === mtimeMs) return hit.block;
2149
+ let block = null;
2150
+ try {
2151
+ let body = readFileSync3(file, "utf8").trim();
2152
+ if (body) {
2153
+ if (body.length > USER_MEMORY_MAX_CHARS) {
2154
+ body = `${body.slice(0, USER_MEMORY_MAX_CHARS)}
2155
+
2156
+ [MEMORY.md truncated to ${USER_MEMORY_MAX_CHARS} characters]`;
2157
+ }
2158
+ block = [
2159
+ "# User memory (MEMORY.md)",
2160
+ "The following notes were saved by the user across sessions. Respect them unless they conflict with the current request.",
2161
+ body
2162
+ ].join("\n");
2163
+ }
2164
+ } catch {
2165
+ block = null;
2166
+ }
2167
+ cache3.set(file, { mtimeMs, block });
2168
+ return block;
2169
+ }
2170
+ function appendUserMemory(text, home = starHome()) {
2171
+ mkdirSync(home, { recursive: true });
2172
+ appendFileSync(path4.join(home, "MEMORY.md"), `- ${text}
2173
+ `, "utf8");
2174
+ }
2175
+ function createRememberTool(deps = {}) {
2176
+ return {
2177
+ name: "remember",
2178
+ description: `Save a note to the user's long-term memory (${userMemoryPath()}), injected into every future session. ONLY use when the user explicitly asks to remember or save something. Never use autonomously.`,
2179
+ permission: "write",
2180
+ parameters: z3.object({
2181
+ text: z3.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
2182
+ }),
2183
+ async execute(args) {
2184
+ appendUserMemory(args.text, deps.home);
2185
+ return { content: `Saved to user memory: ${args.text}` };
2186
+ }
2187
+ };
2188
+ }
2189
+
2190
+ // src/agent/loop.ts
2191
+ var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task using only the read-only tools available to you, then present a concrete, step-by-step implementation plan as your final answer. You must not modify files, run shell commands, or otherwise change the system \u2014 write/exec tools are unavailable in this mode. Do not ask the user to run commands for you; note manual steps in the plan instead. The user will review your plan and approve it before any execution begins.";
2192
+ var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?(?!知道)|I will|I'll|I am going to|I'm going to|I shall|\blet me(?!\s+know\b)|next,? I|I will now)/i;
2193
+ var AUTO_CONTINUE_NUDGE = "[auto-continue] You ended your turn with words instead of actions. Do not describe or restate the plan \u2014 continue the task NOW by calling tools. Reply with text only if the task is already fully complete.";
2194
+ var EMPTY_REPLY_NUDGE = "[auto-continue] Your previous reply was empty \u2014 no text and no tool calls. If the task is fully complete, reply with one short confirmation; otherwise continue NOW by calling tools.";
2195
+ var TRUNCATED_CONTINUE_NUDGE = "[auto-continue] Your previous reply was cut off mid-stream by a timeout \u2014 it is incomplete. Resume EXACTLY where you stopped: do not repeat or restate what you already wrote; continue with the next tool call or the remainder of the text.";
2196
+ function isFilteredEmptyFinish(finishReason) {
2197
+ return finishReason === "content-filter";
2198
+ }
2199
+ function isSteerableEmptyFinish(finishReason) {
2200
+ return finishReason === "stop" || finishReason === "content-filter";
2201
+ }
2202
+ function finalNudgeWarning(autoContinues, maxAutoContinues) {
2203
+ return autoContinues >= maxAutoContinues ? " This is the final automatic continuation \u2014 if your next reply again has no tool calls, nudging stops and the turn is handed back to the user. Finish the work now or summarize what remains." : "";
2204
+ }
2205
+ var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
2206
+ function attachTurnContext(messages, context) {
2207
+ if (!context) return messages;
2208
+ for (let i = messages.length - 1; i >= 0; i--) {
2209
+ const message = messages[i];
2210
+ if (message?.role !== "user") continue;
2211
+ const reminder = {
2212
+ type: "text",
2213
+ text: `<system-reminder>
2214
+ ${context}
2215
+ </system-reminder>`
2216
+ };
2217
+ const content = typeof message.content === "string" ? message.content ? [reminder, { type: "text", text: message.content }] : [reminder] : [reminder, ...message.content];
2218
+ const copy = messages.slice();
2219
+ copy[i] = { ...message, content };
2220
+ return copy;
2221
+ }
2222
+ return messages;
2223
+ }
2224
+ async function checkTaskComplete(model, request, finalReply, signal, temperature) {
2225
+ const controller = new AbortController();
2226
+ const timer = setTimeout(() => controller.abort(), COMPLETION_CHECK_TIMEOUT_MS);
2227
+ const onAbort = () => controller.abort();
2228
+ signal.addEventListener("abort", onAbort, { once: true });
2229
+ try {
2230
+ const { text } = await generateText3({
2231
+ model,
2232
+ maxTokens: 8,
2233
+ temperature,
2234
+ abortSignal: controller.signal,
2235
+ prompt: [
2236
+ "An AI coding agent was given this task by the user:",
2237
+ `<task>
2238
+ ${request.slice(0, 2e3)}
2239
+ </task>`,
2240
+ "The agent has stopped calling tools and ended with this reply:",
2241
+ `<reply>
2242
+ ${finalReply.slice(0, 2e3)}
2243
+ </reply>`,
2244
+ "Has the agent fully completed the task \u2014 every requested action actually performed, and the result verified by running a check (tests, build, or inspecting the produced output) rather than assumed? Or is it only describing, planning, or reporting progress with work still left? Answer with exactly one word: DONE or NOT_DONE."
2245
+ ].join("\n")
2246
+ });
2247
+ const verdict = text.trim().toUpperCase();
2248
+ if (verdict.startsWith("NOT_DONE")) return false;
2249
+ if (verdict.startsWith("DONE")) return true;
2250
+ return null;
2251
+ } catch {
2252
+ return null;
2253
+ } finally {
2254
+ clearTimeout(timer);
2255
+ signal.removeEventListener("abort", onAbort);
2256
+ }
2257
+ }
2258
+ var STREAM_RETRY_BASE_DELAY_MS = 1e3;
2259
+ var ABORT_STOP_HOOK_TIMEOUT_SEC = 3;
2260
+ var REDO_LABEL_MAX = 40;
2261
+ function stableStringify(value) {
2262
+ if (value === void 0) return "undefined";
2263
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
2264
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2265
+ const record = value;
2266
+ const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
2267
+ return `{${entries.join(",")}}`;
2268
+ }
2269
+ function sleep(ms, signal) {
2270
+ if (signal.aborted) return Promise.resolve(false);
2271
+ return new Promise((resolve) => {
2272
+ const onAbort = () => {
2273
+ clearTimeout(timer);
2274
+ resolve(false);
2275
+ };
2276
+ const timer = setTimeout(() => {
2277
+ signal.removeEventListener("abort", onAbort);
2278
+ resolve(true);
2279
+ }, ms);
2280
+ signal.addEventListener("abort", onAbort, { once: true });
2281
+ });
2282
+ }
2283
+ var AgentLoop = class {
2284
+ // History with side-band meta: synthetic user messages (nudges, background
2285
+ // reports, tool images) carry a MessageMeta so turn-boundary scans skip
2286
+ // them; the model only ever sees the mapped CoreMessage view.
2287
+ messages = [];
2288
+ opts;
2289
+ // Seq + user-message index of each turn started via stream(); lets /undo
2290
+ // match a retracted turn to the file snapshots it produced. `tree` is the
2291
+ // whole-tree git snapshot captured at the turn's start (root loop only,
2292
+ // config.gitSnapshots) — when present, /undo restores it instead of
2293
+ // replaying per-file snapshots, which also covers bash-made changes.
2294
+ // Cleared when history is replaced wholesale (resume/compact), because
2295
+ // indices no longer line up — in that case /undo only retracts messages,
2296
+ // never wrong files.
2297
+ turnMarkers = [];
2298
+ // Pre-undo tree snapshots, latest last: a git-path /undo pushes the
2299
+ // pre-undo working tree here so /redo can restore it exactly. Only the git
2300
+ // path feeds this — per-file snapshot undos stay non-redoable.
2301
+ redoStack = [];
2302
+ // Start of the most recently retracted turn's user message; undoLastTurn
2303
+ // consumes it as the redo label right after retractLastTurn sets it.
2304
+ lastUndoneLabel = "";
2305
+ // Doom-loop guard: signature of the most recent tool call and how many
2306
+ // times in a row it has repeated. Any different call resets the count, and
2307
+ // each new user turn restarts the streak.
2308
+ lastToolSignature = null;
2309
+ repeatedToolCalls = 0;
2310
+ // Token usage subagents consumed, waiting to fold into this loop's next
2311
+ // finish event with usage: sync children report when their tool call
2312
+ // settles, background ones whenever they finish (drained or not).
2313
+ // Consumers meter only root-loop finish events, so folding here bills
2314
+ // subagent spend with zero consumer changes — /cost, /usage and
2315
+ // sessionBudgetUsd all cover it. A background child still running when the
2316
+ // session's last finish has passed never reports (best effort, noted in
2317
+ // agent-tasks cleanup paths).
2318
+ subagentUsage = null;
2319
+ // Turn context of the in-flight stream() turn, stamped onto every tool
2320
+ // execution so file snapshots attribute root changes to a turn and mark
2321
+ // subagent changes as excluded from turn-level retraction.
2322
+ activeTurnSeq = 0;
2323
+ activeTurnUserIndex = -1;
2324
+ titleScheduled = false;
2325
+ titleInput;
2326
+ // Rendered git context for the in-flight turn (null when unavailable);
2327
+ // snapshotted at turn start so every step of the turn sends the same block.
2328
+ turnGitContext = null;
2329
+ // Request-view cache: the CoreMessage[] sent to the model, with the turn's
2330
+ // volatile context already attached. Rebuilt only when the history actually
2331
+ // changed — the cache key is the history array's identity plus its length
2332
+ // (wholesale rewrites like compaction/image-stripping replace the array,
2333
+ // appends grow it; history messages are never mutated in place) and the
2334
+ // turn context string. Saves a full toCoreMessages + attachTurnContext copy
2335
+ // per stream attempt, which retried steps used to pay every time.
2336
+ requestMessagesCache = null;
2337
+ // Memoized per (registry, plan flag): tools register on the registry only
2338
+ // at loop construction, and the plan filter re-reads the live permission
2339
+ // mode on every call — so a built map stays valid until either input
2340
+ // changes, and a runtime mode switch rebuilds exactly once. schemaTokens
2341
+ // (the tool map's share of the request window) is computed alongside since
2342
+ // it depends on the same inputs.
2343
+ aiToolsCache = null;
2344
+ // Lazily resolved auxiliary model (config.smallModel) for cheap side calls
2345
+ // — compaction summaries, session titles, completion checks. Undefined =
2346
+ // use the main model; a resolution failure is surfaced once as a notice.
2347
+ auxModelResolved = false;
2348
+ auxModelValue;
2349
+ auxTemperatureValue;
2350
+ auxModelError = null;
2351
+ confirmHandler;
2352
+ // Hook failures never block the turn (except an explicit PreToolUse block);
2353
+ // their stderr surfaces through this callback (REPL system message / stderr
2354
+ // in print mode).
2355
+ onHookWarning;
2356
+ constructor(opts) {
2357
+ this.opts = opts;
2358
+ const depth = opts.subagentDepth ?? 0;
2359
+ if (depth > 0) {
2360
+ opts.registry = new ToolRegistry(new TodoStore());
2361
+ } else {
2362
+ setTodoPersistGuard(() => {
2363
+ const mode = this.opts.config.permissionMode;
2364
+ return mode !== "readonly" && mode !== "plan";
2365
+ });
2366
+ }
2367
+ if (opts.system) {
2368
+ this.messages.push({ message: { role: "system", content: opts.system } });
2369
+ }
2370
+ if (opts.sessionStore) {
2371
+ this.bindSnapshotHooks(opts.sessionStore);
2372
+ }
2373
+ opts.registry?.register(createSkillTool({ cwd: opts.cwd }));
2374
+ opts.registry?.register(createRememberTool());
2375
+ if (opts.registry && depth < MAX_SUBAGENT_DEPTH) {
2376
+ opts.registry.register(
2377
+ createSubagentTool({
2378
+ model: opts.model,
2379
+ config: opts.config,
2380
+ cwd: opts.cwd,
2381
+ system: opts.system,
2382
+ providerMetadata: opts.providerMetadata,
2383
+ temperature: opts.temperature,
2384
+ streamIdleTimeoutSec: opts.streamIdleTimeoutSec,
2385
+ streamFirstChunkTimeoutSec: opts.streamFirstChunkTimeoutSec,
2386
+ vision: opts.vision,
2387
+ depth,
2388
+ modelName: opts.modelName,
2389
+ getConfirmHandler: () => this.confirmHandler,
2390
+ onUsage: (usage, childModel, childModelName) => this.addSubagentUsage(usage, childModel, childModelName)
2391
+ })
2392
+ );
2393
+ }
2394
+ }
2395
+ // CoreMessage view of the history (meta stripped): what the model, the
2396
+ // display and the token estimator consume. Element identity is preserved,
2397
+ // so the estimator's per-object cache (context/tokens.ts) still hits.
2398
+ getMessages() {
2399
+ return toCoreMessages(this.messages);
2400
+ }
2401
+ // Full-fidelity view including the side-band meta; for flows that must not
2402
+ // lose synthetic-message markers (model switch, manual /compact).
2403
+ getStarMessages() {
2404
+ return this.messages;
2405
+ }
2406
+ // Wire-schema token overhead of the active tool set (memoized by
2407
+ // buildAiTools). The auto-compact budget includes it; surfaced here so the
2408
+ // status bar ctx% can show the same total the compaction decision uses.
2409
+ getToolSchemaTokens() {
2410
+ return this.buildAiTools().schemaTokens;
2411
+ }
2412
+ // Auxiliary model for cheap side calls: config.smallModel, resolved once.
2413
+ // An unresolvable name/key degrades to the main model instead of breaking
2414
+ // turns; the failure rides out once as a notice at the next turn start.
2415
+ resolveAuxModel() {
2416
+ if (this.auxModelResolved) return;
2417
+ this.auxModelResolved = true;
2418
+ const name = this.opts.config.smallModel;
2419
+ if (!name) return;
2420
+ try {
2421
+ this.auxModelValue = createModel(this.opts.config, name);
2422
+ this.auxTemperatureValue = resolveModelConfig(this.opts.config, name).temperature ?? this.opts.temperature;
2423
+ } catch (error) {
2424
+ this.auxModelError = `smallModel "${name}" could not be resolved (${error instanceof Error ? error.message : String(error)}); using the main model for auxiliary calls.`;
2425
+ }
2426
+ }
2427
+ getAuxModel() {
2428
+ this.resolveAuxModel();
2429
+ return this.auxModelValue ?? this.opts.model;
2430
+ }
2431
+ getAuxTemperature() {
2432
+ this.resolveAuxModel();
2433
+ return this.auxModelValue ? this.auxTemperatureValue : this.opts.temperature;
2434
+ }
2435
+ // Rebinds the per-call provider metadata (e.g. after the reasoning effort
2436
+ // for the current model changed via /model) without recreating the loop.
2437
+ setProviderMetadata(metadata) {
2438
+ this.opts.providerMetadata = metadata;
2439
+ }
2440
+ // Swaps the persistence target (/new starts a fresh session mid-REPL).
2441
+ // Title generation is re-armed so the new session gets one after its first
2442
+ // turn, and snapshot checkpoints now flow to the new store. The redo stack
2443
+ // dies with the old session context: its trees describe working-tree states
2444
+ // that no longer line up with the new session's conversation. Subagent
2445
+ // usage still waiting to fold belongs to the old conversation too — it
2446
+ // must not bill the new session's first finish.
2447
+ setSessionStore(store) {
2448
+ this.opts.sessionStore = store;
2449
+ this.titleScheduled = false;
2450
+ this.titleInput = void 0;
2451
+ this.redoStack = [];
2452
+ this.subagentUsage = null;
2453
+ if (store) this.bindSnapshotHooks(store);
2454
+ }
2455
+ bindSnapshotHooks(store) {
2456
+ setSnapshotHooks({
2457
+ onPush: (snapshot) => store.appendCheckpoint(
2458
+ {
2459
+ id: snapshot.id,
2460
+ timestamp: snapshot.timestamp,
2461
+ path: snapshot.path,
2462
+ existed: snapshot.existed,
2463
+ toolName: snapshot.toolName,
2464
+ turn: snapshot.turn,
2465
+ messageIndex: snapshot.messageIndex,
2466
+ owner: snapshot.owner,
2467
+ contentTooLarge: snapshot.contentTooLarge
2468
+ },
2469
+ snapshot.content
2470
+ ),
2471
+ onRemove: (ids) => store.removeCheckpoints(ids)
2472
+ });
2473
+ }
2474
+ async loadMessages(messages) {
2475
+ this.messages = reconcileStarMessages(messages.map(toStarMessage));
2476
+ this.turnMarkers = [];
2477
+ this.redoStack = [];
2478
+ this.subagentUsage = null;
2479
+ this.opts.registry?.resetVolatileState();
2480
+ }
2481
+ // Read-only counterpart of retractLastTurn: how many messages /undo would
2482
+ // drop, which snapshot turn's file changes it would revert (undefined =
2483
+ // no verified turn, so no file snapshots may be reverted), and the turn's
2484
+ // whole-tree snapshot when one was captured. Mutates nothing, so the REPL
2485
+ // can show the preview before the user confirms.
2486
+ previewLastTurnRetraction() {
2487
+ const result = retractLastTurn([...this.messages]);
2488
+ if (result.removed === 0) return { removed: 0 };
2489
+ const userIndex = result.messages.length;
2490
+ const last = this.turnMarkers[this.turnMarkers.length - 1];
2491
+ const verified = last && last.userIndex === userIndex;
2492
+ return {
2493
+ removed: result.removed,
2494
+ turn: verified ? last.seq : void 0,
2495
+ tree: verified ? last.tree : void 0
2496
+ };
2497
+ }
2498
+ // Drops the final user message and everything after it, and persists the
2499
+ // trimmed history so a later /resume does not bring the turn back.
2500
+ // `turn` is the retracted turn's snapshot seq when it could be verified
2501
+ // against the marker recorded at turn start — undefined means the caller
2502
+ // must not revert any file snapshots. `tree` is the turn's whole-tree
2503
+ // snapshot when git snapshots tracked it.
2504
+ async retractLastTurn() {
2505
+ const result = retractLastTurn([...this.messages]);
2506
+ if (result.removed === 0) return { removed: 0 };
2507
+ const userIndex = result.messages.length;
2508
+ this.lastUndoneLabel = coreMessageText(this.messages[userIndex]?.message).slice(
2509
+ 0,
2510
+ REDO_LABEL_MAX
2511
+ );
2512
+ this.messages = result.messages;
2513
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
2514
+ this.opts.registry?.resetVolatileState();
2515
+ const last = this.turnMarkers[this.turnMarkers.length - 1];
2516
+ let turn;
2517
+ let tree;
2518
+ if (last && last.userIndex === userIndex) {
2519
+ turn = last.seq;
2520
+ tree = last.tree;
2521
+ this.turnMarkers.pop();
2522
+ }
2523
+ return { removed: result.removed, turn, tree };
2524
+ }
2525
+ // The confirmed /undo: retract the last turn, then revert its file changes.
2526
+ // With a whole-tree snapshot the working tree is restored from git — which
2527
+ // also reverts changes no tool snapshot saw (bash edits, deleted files) —
2528
+ // and the turn's per-file snapshots are dropped instead of replayed (they
2529
+ // would double-restore). The pre-undo tree feeds the redo stack, so /redo
2530
+ // returns to the exact state before the undo. Any git failure falls back
2531
+ // to the per-file snapshot revert, which never pushes the redo stack.
2532
+ async undoLastTurn() {
2533
+ const { removed, turn, tree } = await this.retractLastTurn();
2534
+ if (removed === 0) return { removed, reverted: [] };
2535
+ if (turn !== void 0 && tree !== void 0) {
2536
+ const preUndoTree = await trackTree(this.opts.cwd);
2537
+ if (preUndoTree !== null && await restoreTree(this.opts.cwd, tree)) {
2538
+ this.redoStack.push({ tree: preUndoTree, label: this.lastUndoneLabel });
2539
+ const dropped = await dropTurnSnapshots(turn, true);
2540
+ return {
2541
+ removed,
2542
+ reverted: [
2543
+ `Restored the working tree from the git snapshot taken at the turn's start (${tree.slice(0, 8)}; covers changes from any tool, bash included; ${dropped} per-file snapshot(s) discarded).`
2544
+ ],
2545
+ tree
2546
+ };
2547
+ }
2548
+ }
2549
+ const reverted = turn !== void 0 ? await undoTurnSnapshots(turn) : [];
2550
+ return { removed, reverted };
2551
+ }
2552
+ // The redo entry /redo would restore, without popping it (for the confirm
2553
+ // prompt); null when there is nothing to redo.
2554
+ peekRedo() {
2555
+ return this.redoStack[this.redoStack.length - 1] ?? null;
2556
+ }
2557
+ // Restores the working tree to its state just before the last git-path
2558
+ // /undo. Conversation messages are not re-added — redo is file-level only.
2559
+ // The entry stays on the stack when the restore fails, so it can be retried.
2560
+ async redoLastUndo() {
2561
+ const entry = this.redoStack[this.redoStack.length - 1];
2562
+ if (!entry) return null;
2563
+ const names = await diffTreeNames(this.opts.cwd, entry.tree);
2564
+ if (!await restoreTree(this.opts.cwd, entry.tree)) {
2565
+ return { ok: false, tree: entry.tree, label: entry.label, files: null };
2566
+ }
2567
+ this.redoStack.pop();
2568
+ return { ok: true, tree: entry.tree, label: entry.label, files: names?.length ?? null };
2569
+ }
2570
+ // Cut point for a rewind: messages[index] should be the user message that
2571
+ // started the rewound turn, but compaction may have shifted indices, so
2572
+ // walk back to the nearest real user message — a synthetic one (nudge,
2573
+ // background report, tool image) belongs to the turn before it and is
2574
+ // never a boundary. Never touches a leading system message.
2575
+ retractionCut(index) {
2576
+ const clamped = Math.max(0, Math.min(index, this.messages.length - 1));
2577
+ for (let i = clamped; i >= 0; i--) {
2578
+ const star = this.messages[i];
2579
+ if (star && star.message.role === "user" && !isSyntheticUserMessage(star)) return i;
2580
+ }
2581
+ return this.messages[0]?.message.role === "system" ? 1 : 0;
2582
+ }
2583
+ countRetraction(index) {
2584
+ return this.messages.length - this.retractionCut(index);
2585
+ }
2586
+ // Truncates the history back to the given message index (the user message
2587
+ // at that index and everything after it is dropped) and persists the
2588
+ // trimmed history, so /rewind stays consistent across resume. The redo
2589
+ // stack dies with the rewound context: its entries describe pre-/undo
2590
+ // working-tree states that no longer line up with this conversation, so
2591
+ // restoring one would clobber the rewound tree.
2592
+ async retractFromIndex(index) {
2593
+ const cut = this.retractionCut(index);
2594
+ const removed = this.messages.length - cut;
2595
+ if (removed === 0) return 0;
2596
+ this.messages = this.messages.slice(0, cut);
2597
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
2598
+ this.opts.registry?.resetVolatileState();
2599
+ this.turnMarkers = this.turnMarkers.filter((m) => m.userIndex < cut);
2600
+ this.redoStack = [];
2601
+ return removed;
2602
+ }
2603
+ async appendContextMessage(text, role = "user") {
2604
+ const message = { role, content: text };
2605
+ this.messages.push({ message });
2606
+ await this.persist(message);
2607
+ }
2608
+ async persist(message, meta) {
2609
+ await this.opts.sessionStore?.append(message, meta);
2610
+ }
2611
+ // Replaces oversized image parts in the in-memory history with text
2612
+ // placeholders and rewrites the persisted session, so the failed request
2613
+ // can be resent and a later resume never carries the offending images.
2614
+ async stripOversizedImages() {
2615
+ const { messages, removed } = stripOversizedImages(toCoreMessages(this.messages));
2616
+ if (removed === 0) return 0;
2617
+ this.messages = this.messages.map((star, i) => ({
2618
+ message: messages[i] ?? star.message,
2619
+ meta: star.meta
2620
+ }));
2621
+ await this.opts.sessionStore?.replaceMessages(this.messages);
2622
+ return removed;
2623
+ }
2624
+ // Same rewrite as above, but for text-only endpoints that reject any image
2625
+ // at all: every image part in the history is replaced with a placeholder,
2626
+ // which also heals sessions already poisoned by an attached image.
2627
+ async stripAllImages() {
2628
+ const { messages, removed } = stripAllImages(toCoreMessages(this.messages));
2629
+ if (removed === 0) return 0;
2630
+ this.messages = this.messages.map((star, i) => ({
2631
+ message: messages[i] ?? star.message,
2632
+ meta: star.meta
2633
+ }));
2634
+ await this.opts.sessionStore?.replaceMessages(this.messages);
2635
+ return removed;
2636
+ }
2637
+ // Background title generation runs once per loop, fed by the first real
2638
+ // user message of the session. It must NOT latch on the first assistant
2639
+ // step's text: that text is empty for a tool-call-only first step (the
2640
+ // common agentic case), which used to burn the one-shot flag on an empty
2641
+ // input and left every such session untitled. The store-level title check
2642
+ // makes resumed sessions that already have a title a no-op.
2643
+ maybeScheduleTitle() {
2644
+ if (this.titleScheduled) return;
2645
+ const store = this.opts.sessionStore;
2646
+ if (!store) return;
2647
+ const input = this.titleInput;
2648
+ if (!input) return;
2649
+ this.titleScheduled = true;
2650
+ scheduleSessionTitle(store, input, this.getAuxModel());
2651
+ }
2652
+ // A user interrupt (Esc) ends the turn mid-stream, before the normal
2653
+ // assistant-message persistence runs. Whatever text and tool calls already
2654
+ // arrived are still persisted — the text marked with "[interrupted]" so the
2655
+ // cut-off stays visible after a resume and the model can tell its reply was
2656
+ // cut short — and streamed tool calls are closed with synthetic results so
2657
+ // the stored history stays valid for the API.
2658
+ async *persistInterrupted(text, toolCalls) {
2659
+ if (text.length === 0 && toolCalls.length === 0) return;
2660
+ const assistantMessage = {
2661
+ role: "assistant",
2662
+ content: [
2663
+ ...text ? [{ type: "text", text: `${text} [interrupted]` }] : [],
2664
+ ...toolCalls.map((call) => ({
2665
+ type: "tool-call",
2666
+ toolCallId: call.id,
2667
+ toolName: call.name,
2668
+ args: call.args
2669
+ }))
2670
+ ]
2671
+ };
2672
+ this.messages.push({ message: assistantMessage });
2673
+ await this.persist(assistantMessage);
2674
+ for (const call of toolCalls) {
2675
+ const content = INTERRUPTED_TOOL_RESULT_TEXT;
2676
+ const synthetic = {
2677
+ role: "tool",
2678
+ content: [
2679
+ {
2680
+ type: "tool-result",
2681
+ toolCallId: call.id,
2682
+ toolName: call.name,
2683
+ result: content
2684
+ }
2685
+ ]
2686
+ };
2687
+ this.messages.push({ message: synthetic });
2688
+ await this.persist(synthetic).catch(() => {
2689
+ });
2690
+ yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
2691
+ }
2692
+ }
2693
+ async *stream(input, signal, opts) {
2694
+ this.syncSystemMessage();
2695
+ const git = getGitSummaryCached(this.opts.cwd);
2696
+ this.turnGitContext = git ? formatGitSummary(git) : null;
2697
+ this.requestMessagesCache = null;
2698
+ this.resolveAuxModel();
2699
+ if (this.auxModelError) {
2700
+ yield { type: "notice", message: this.auxModelError };
2701
+ this.auxModelError = null;
2702
+ }
2703
+ const inputText = typeof input === "string" ? input : input.text;
2704
+ const images = typeof input === "string" ? [] : input.images;
2705
+ if (this.titleInput === void 0 && inputText.trim().length > 0) {
2706
+ this.titleInput = inputText;
2707
+ }
2708
+ const userMessage = images.length > 0 ? {
2709
+ role: "user",
2710
+ content: [
2711
+ ...images.map((img) => ({
2712
+ type: "image",
2713
+ image: img.data,
2714
+ mimeType: img.mimeType
2715
+ })),
2716
+ { type: "text", text: inputText }
2717
+ ]
2718
+ } : { role: "user", content: inputText };
2719
+ const isRoot = (this.opts.subagentDepth ?? 0) === 0;
2720
+ const trackTreePromise = isRoot && this.opts.config.gitSnapshots ? trackTree(this.opts.cwd) : null;
2721
+ this.messages.push({ message: userMessage });
2722
+ const seq = isRoot ? beginTurn(this.messages.length - 1) : currentTurnSeq();
2723
+ const turnMarker = {
2724
+ seq,
2725
+ userIndex: this.messages.length - 1
2726
+ };
2727
+ this.turnMarkers.push(turnMarker);
2728
+ this.activeTurnSeq = seq;
2729
+ this.activeTurnUserIndex = this.messages.length - 1;
2730
+ let turnTreeReady = null;
2731
+ if (trackTreePromise !== null) {
2732
+ turnTreeReady = trackTreePromise.catch(() => null).then((tree) => {
2733
+ if (tree !== null && this.turnMarkers.includes(turnMarker)) {
2734
+ turnMarker.tree = tree;
2735
+ }
2736
+ });
2737
+ }
2738
+ await this.persist(
2739
+ opts?.persistAs !== void 0 ? { role: "user", content: opts.persistAs } : userMessage
2740
+ );
2741
+ this.maybeScheduleTitle();
2742
+ const { config, registry, cwd } = this.opts;
2743
+ const { tools: aiTools, schemaTokens: toolSchemaTokens } = this.buildAiTools();
2744
+ this.lastToolSignature = null;
2745
+ this.repeatedToolCalls = 0;
2746
+ let autoContinues = 0;
2747
+ let lastWasNudge = false;
2748
+ let usedToolsThisTurn = false;
2749
+ let streamCutContinues = 0;
2750
+ let openTodos = [];
2751
+ const maxSteps = config.maxSteps;
2752
+ let step = 0;
2753
+ let toolExecutedSinceCheckpoint = false;
2754
+ for (; ; ) {
2755
+ if (maxSteps > 0 && step >= maxSteps) {
2756
+ if (!toolExecutedSinceCheckpoint) {
2757
+ yield {
2758
+ type: "error",
2759
+ error: {
2760
+ name: "Error",
2761
+ message: `Max steps (${maxSteps}) reached with no tool execution since the last checkpoint, stopping.`
2762
+ }
2763
+ };
2764
+ await this.runStopHooks("max-steps");
2765
+ return;
2766
+ }
2767
+ toolExecutedSinceCheckpoint = false;
2768
+ step = 0;
2769
+ yield {
2770
+ type: "notice",
2771
+ message: `maxSteps (${maxSteps}) reached and the turn is still making progress; continuing. Press Esc to interrupt.`
2772
+ };
2773
+ }
2774
+ if ((this.opts.subagentDepth ?? 0) === 0) {
2775
+ for (const finished of defaultAgentTasks.drainNotifications()) {
2776
+ const label = finished.description ? ` "${finished.description}"` : "";
2777
+ const note = {
2778
+ role: "user",
2779
+ content: `[background subagent ${finished.id}${label} ${finished.status}]
2780
+ ${finished.result}`
2781
+ };
2782
+ const meta = { synthetic: "bg-report" };
2783
+ this.messages.push({ message: note, meta });
2784
+ await this.persist(note, meta);
2785
+ }
2786
+ }
2787
+ const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
2788
+ const compacted = compactMessages(this.messages, resolveCompactThreshold(config, maxTokens), {
2789
+ overheadTokens: toolSchemaTokens
2790
+ });
2791
+ if (compacted.compacted) {
2792
+ this.messages = await this.applyCompactionSummary(compacted, signal);
2793
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
2794
+ this.turnMarkers = [];
2795
+ this.opts.registry?.resetVolatileState();
2796
+ }
2797
+ const maxRetries = Math.max(0, config.streamMaxRetries);
2798
+ const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
2799
+ let text = "";
2800
+ const toolCalls = [];
2801
+ let failure = null;
2802
+ let lastFinishReason;
2803
+ let lastUsage;
2804
+ let sawReasoning = false;
2805
+ let wasTruncated = false;
2806
+ let oversizedImagesStripped = false;
2807
+ let visionUnsupportedStripped = false;
2808
+ const hasOutput = () => text.trim().length > 0 || toolCalls.length > 0;
2809
+ for (let attempt = 0; ; attempt++) {
2810
+ text = "";
2811
+ toolCalls.length = 0;
2812
+ failure = null;
2813
+ lastFinishReason = void 0;
2814
+ lastUsage = void 0;
2815
+ sawReasoning = false;
2816
+ wasTruncated = false;
2817
+ const timeoutScale = Math.min(attempt + 1, 3);
2818
+ try {
2819
+ for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
2820
+ if (event.type === "text-delta") {
2821
+ text += event.text;
2822
+ yield event;
2823
+ } else if (event.type === "reasoning") {
2824
+ sawReasoning = true;
2825
+ yield event;
2826
+ } else if (event.type === "tool-call") {
2827
+ toolCalls.push({ id: event.id, name: event.name, args: event.args });
2828
+ yield event;
2829
+ } else if (event.type === "finish") {
2830
+ lastFinishReason = event.finishReason;
2831
+ if (event.truncated) wasTruncated = true;
2832
+ lastUsage = event.usage;
2833
+ yield this.foldSubagentUsage(event);
2834
+ } else if (event.type === "error") {
2835
+ failure = event.error;
2836
+ } else {
2837
+ yield event;
2838
+ }
2839
+ }
2840
+ } catch (error) {
2841
+ if (!signal.aborted) {
2842
+ failure = toStreamErrorInfo(error);
2843
+ }
2844
+ }
2845
+ if (signal.aborted) {
2846
+ yield* this.persistInterrupted(text, toolCalls);
2847
+ await this.runStopHooks("aborted");
2848
+ return;
2849
+ }
2850
+ if (!failure && hasOutput()) break;
2851
+ if (failure && !oversizedImagesStripped && isOversizedImageError(failure)) {
2852
+ oversizedImagesStripped = true;
2853
+ const removed = await this.stripOversizedImages();
2854
+ if (removed > 0) {
2855
+ yield {
2856
+ type: "notice",
2857
+ message: `Removed ${removed} oversized image(s) the model rejected; retrying the request.`
2858
+ };
2859
+ continue;
2860
+ }
2861
+ }
2862
+ if (failure && !visionUnsupportedStripped && isVisionUnsupportedError(failure)) {
2863
+ visionUnsupportedStripped = true;
2864
+ const removed = await this.stripAllImages();
2865
+ if (removed > 0) {
2866
+ yield {
2867
+ type: "notice",
2868
+ message: `Removed ${removed} image(s) the model cannot read; retrying the request.`
2869
+ };
2870
+ continue;
2871
+ }
2872
+ }
2873
+ if (failure && !isRetryableStreamError(failure)) break;
2874
+ if (attempt >= maxRetries) break;
2875
+ if (!failure && !hasOutput() && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
2876
+ break;
2877
+ const delayMs = computeRetryDelayMs(attempt, failure ? baseDelay : baseDelay * 2, failure);
2878
+ yield {
2879
+ type: "retry",
2880
+ attempt: attempt + 2,
2881
+ maxAttempts: maxRetries + 1,
2882
+ delayMs,
2883
+ reason: failure ? summarizeStreamError(failure) : `empty response (finish: ${lastFinishReason ?? "none"})`
2884
+ };
2885
+ if (!await sleep(delayMs, signal)) {
2886
+ yield* this.persistInterrupted(text, toolCalls);
2887
+ await this.runStopHooks("aborted");
2888
+ return;
2889
+ }
2890
+ }
2891
+ if (failure) {
2892
+ yield { type: "error", error: failure };
2893
+ await this.runStopHooks("error");
2894
+ return;
2895
+ }
2896
+ if (!hasOutput()) {
2897
+ if (isSteerableEmptyFinish(lastFinishReason) && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
2898
+ autoContinues++;
2899
+ lastWasNudge = true;
2900
+ yield {
2901
+ type: "notice",
2902
+ message: `the model returned an empty reply; asking the model to continue (${autoContinues}/${config.maxAutoContinues}).`
2903
+ };
2904
+ const nudge = {
2905
+ role: "user",
2906
+ content: EMPTY_REPLY_NUDGE + finalNudgeWarning(autoContinues, config.maxAutoContinues)
2907
+ };
2908
+ const meta = { synthetic: "nudge" };
2909
+ this.messages.push({ message: nudge, meta });
2910
+ await this.persist(nudge, meta);
2911
+ continue;
2912
+ }
2913
+ yield {
2914
+ type: "error",
2915
+ error: {
2916
+ name: "Error",
2917
+ message: "The model returned an empty response after retries; ending the turn."
2918
+ }
2919
+ };
2920
+ await this.runStopHooks("empty");
2921
+ return;
2922
+ }
2923
+ const assistantMessage = {
2924
+ role: "assistant",
2925
+ content: [
2926
+ ...text ? [{ type: "text", text }] : [],
2927
+ ...toolCalls.map((call) => ({
2928
+ type: "tool-call",
2929
+ toolCallId: call.id,
2930
+ toolName: call.name,
2931
+ args: call.args
2932
+ }))
2933
+ ]
2934
+ };
2935
+ this.messages.push({ message: assistantMessage });
2936
+ await this.persist(assistantMessage);
2937
+ if (toolCalls.length === 0) {
2938
+ if (wasTruncated) {
2939
+ if (config.permissionMode !== "plan" && streamCutContinues < config.streamMaxRetries) {
2940
+ streamCutContinues++;
2941
+ yield {
2942
+ type: "notice",
2943
+ message: `reply cut short by a stream timeout; asking the model to resume where it stopped (${streamCutContinues}/${config.streamMaxRetries}).`
2944
+ };
2945
+ const nudge = { role: "user", content: TRUNCATED_CONTINUE_NUDGE };
2946
+ const meta = { synthetic: "nudge" };
2947
+ this.messages.push({ message: nudge, meta });
2948
+ await this.persist(nudge, meta);
2949
+ continue;
2950
+ }
2951
+ if (config.permissionMode !== "plan") {
2952
+ yield {
2953
+ type: "notice",
2954
+ message: 'reply was cut short by a stream timeout and resume attempts are exhausted, handing the turn back \u2014 reply "continue" to keep going.'
2955
+ };
2956
+ }
2957
+ await this.runStopHooks("completed");
2958
+ return;
2959
+ }
2960
+ let reason = null;
2961
+ let nudgeText = AUTO_CONTINUE_NUDGE;
2962
+ if (openTodos.length > 0) {
2963
+ reason = `${openTodos.length} todo item(s) still open`;
2964
+ nudgeText = `[auto-continue] You ended your turn with unfinished todo item(s): ${openTodos.map((t) => `"${t}"`).join(
2965
+ ", "
2966
+ )}. Complete them now with tool calls, or update the list with todo_write if they are no longer needed.`;
2967
+ } else if (usedToolsThisTurn && !signal.aborted) {
2968
+ const complete = await checkTaskComplete(
2969
+ this.getAuxModel(),
2970
+ inputText,
2971
+ text,
2972
+ signal,
2973
+ this.getAuxTemperature()
2974
+ );
2975
+ if (complete === false) {
2976
+ reason = "completion check reports the task unfinished";
2977
+ } else if (complete === null) {
2978
+ if (lastWasNudge) {
2979
+ reason = "reply still had no tool calls";
2980
+ } else if (PENDING_WORK_PATTERN.test(text)) {
2981
+ reason = "reply announced unfinished work";
2982
+ }
2983
+ }
2984
+ } else if (lastWasNudge) {
2985
+ reason = "reply still had no tool calls";
2986
+ } else if (PENDING_WORK_PATTERN.test(text)) {
2987
+ reason = "reply announced unfinished work";
2988
+ }
2989
+ if (reason !== null && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
2990
+ autoContinues++;
2991
+ lastWasNudge = true;
2992
+ yield {
2993
+ type: "notice",
2994
+ message: `${reason}; asking the model to continue (${autoContinues}/${config.maxAutoContinues}).`
2995
+ };
2996
+ const nudge = {
2997
+ role: "user",
2998
+ content: nudgeText + finalNudgeWarning(autoContinues, config.maxAutoContinues)
2999
+ };
3000
+ const meta = { synthetic: "nudge" };
3001
+ this.messages.push({ message: nudge, meta });
3002
+ await this.persist(nudge, meta);
3003
+ continue;
3004
+ }
3005
+ if (reason !== null && config.permissionMode !== "plan") {
3006
+ yield {
3007
+ type: "notice",
3008
+ message: `${reason}; auto-continue limit reached, handing the turn back \u2014 reply "continue" to keep going.`
3009
+ };
3010
+ }
3011
+ await this.runStopHooks("completed");
3012
+ return;
3013
+ }
3014
+ lastWasNudge = false;
3015
+ usedToolsThisTurn = true;
3016
+ autoContinues = 0;
3017
+ if (turnTreeReady !== null) {
3018
+ await turnTreeReady;
3019
+ turnTreeReady = null;
3020
+ }
3021
+ const answered = /* @__PURE__ */ new Set();
3022
+ try {
3023
+ for (const call of toolCalls) {
3024
+ if (signal.aborted) break;
3025
+ const refusal = this.doomLoopRefusal(call);
3026
+ if (refusal !== null) {
3027
+ yield { type: "notice", message: refusal };
3028
+ } else {
3029
+ toolExecutedSinceCheckpoint = true;
3030
+ }
3031
+ const result = refusal !== null ? { content: refusal, isError: true } : await this.executeTool(call, signal);
3032
+ const toolMessage = {
3033
+ role: "tool",
3034
+ content: [
3035
+ {
3036
+ type: "tool-result",
3037
+ toolCallId: call.id,
3038
+ toolName: call.name,
3039
+ result: result.content
3040
+ }
3041
+ ]
3042
+ };
3043
+ this.messages.push({ message: toolMessage });
3044
+ await this.persist(toolMessage);
3045
+ answered.add(call.id);
3046
+ if (result.images && result.images.length > 0) {
3047
+ const imageMessage = {
3048
+ role: "user",
3049
+ content: [
3050
+ ...result.images.map((img) => ({
3051
+ type: "image",
3052
+ image: img.data,
3053
+ mimeType: img.mimeType
3054
+ })),
3055
+ ...result.images.map((img) => ({
3056
+ type: "text",
3057
+ text: `[image from ${call.name}: ${img.path}]`
3058
+ }))
3059
+ ]
3060
+ };
3061
+ const meta = { synthetic: "tool-image" };
3062
+ this.messages.push({ message: imageMessage, meta });
3063
+ await this.persist(imageMessage, meta);
3064
+ }
3065
+ if (call.name === "todo_write" && !result.isError) {
3066
+ openTodos = pendingTodoTitles(call.args);
3067
+ }
3068
+ yield {
3069
+ type: "tool-result",
3070
+ id: call.id,
3071
+ name: call.name,
3072
+ content: result.content,
3073
+ isError: result.isError
3074
+ };
3075
+ }
3076
+ } finally {
3077
+ for (const call of toolCalls) {
3078
+ if (answered.has(call.id)) continue;
3079
+ const content = signal.aborted ? INTERRUPTED_TOOL_RESULT_TEXT : UNFINISHED_TOOL_RESULT_TEXT;
3080
+ const synthetic = {
3081
+ role: "tool",
3082
+ content: [
3083
+ {
3084
+ type: "tool-result",
3085
+ toolCallId: call.id,
3086
+ toolName: call.name,
3087
+ result: content
3088
+ }
3089
+ ]
3090
+ };
3091
+ this.messages.push({ message: synthetic });
3092
+ await this.persist(synthetic).catch(() => {
3093
+ });
3094
+ yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
3095
+ }
3096
+ }
3097
+ if (signal.aborted) {
3098
+ await this.runStopHooks("aborted");
3099
+ return;
3100
+ }
3101
+ step++;
3102
+ }
3103
+ }
3104
+ // The permission mode can change at runtime, so the system message is
3105
+ // recomputed at the start of every turn instead of being frozen by the
3106
+ // constructor. Also restores a system message after loadMessages() (resume,
3107
+ // /model switch) replaced the history with one that has none. Everything in
3108
+ // here is stable across turns (base prompt, plan-mode flag, memories, skills
3109
+ // — the latter two cached by mtime), which keeps the prompt-cache prefix
3110
+ // intact; volatile git state rides per-turn on the latest user message
3111
+ // instead (attachTurnContext).
3112
+ syncSystemMessage() {
3113
+ const base = this.opts.system;
3114
+ const plan = this.opts.config.permissionMode === "plan";
3115
+ const parts = [];
3116
+ if (base) parts.push(base);
3117
+ if (plan) parts.push(PLAN_MODE_PROMPT.trim());
3118
+ const memory = readProjectMemory(this.opts.cwd);
3119
+ if (memory) parts.push(memory);
3120
+ const userMemory = readUserMemory();
3121
+ if (userMemory) parts.push(userMemory);
3122
+ const skills = discoverSkills(this.opts.cwd);
3123
+ if (skills.length > 0) parts.push(formatSkillsBlock(skills));
3124
+ if (parts.length === 0) return;
3125
+ const content = parts.join("\n\n");
3126
+ const head = this.messages[0];
3127
+ if (head?.message.role === "system" && typeof head.message.content === "string") {
3128
+ if (head.message.content !== content) {
3129
+ this.messages[0] = { message: { role: "system", content } };
3130
+ }
3131
+ } else if (head?.message.role !== "system") {
3132
+ this.messages.unshift({ message: { role: "system", content } });
3133
+ }
3134
+ }
3135
+ async applyCompactionSummary(compacted, signal) {
3136
+ const { config } = this.opts;
3137
+ if (config.contextCompaction !== "summary") {
3138
+ return compacted.messages;
3139
+ }
3140
+ const headCount = compacted.messages[0]?.message.role === "system" ? 1 : 0;
3141
+ const dropped = toCoreMessages(
3142
+ this.messages.slice(headCount, headCount + compacted.droppedCount)
3143
+ );
3144
+ try {
3145
+ const summary = await summarizeMessages(
3146
+ dropped,
3147
+ this.getAuxModel(),
3148
+ signal,
3149
+ this.getAuxTemperature()
3150
+ );
3151
+ if (summary.trim().length === 0) return compacted.messages;
3152
+ const messages = compacted.messages.slice();
3153
+ messages[headCount] = {
3154
+ message: {
3155
+ role: "user",
3156
+ content: `[earlier conversation summarized]
3157
+ ${summary}`
3158
+ }
3159
+ };
3160
+ return messages;
3161
+ } catch {
3162
+ return compacted.messages;
3163
+ }
3164
+ }
3165
+ async *streamOnce(aiTools, signal, timeoutScale = 1) {
3166
+ for await (const event of streamChat({
3167
+ model: this.opts.model,
3168
+ messages: this.buildRequestMessages(),
3169
+ tools: aiTools,
3170
+ abortSignal: signal,
3171
+ providerMetadata: this.opts.providerMetadata,
3172
+ idleTimeoutMs: (this.opts.streamIdleTimeoutSec ?? this.opts.config.streamIdleTimeoutSec) * 1e3 * timeoutScale,
3173
+ firstPartTimeoutMs: (this.opts.streamFirstChunkTimeoutSec ?? this.opts.config.streamFirstChunkTimeoutSec) * 1e3 * timeoutScale,
3174
+ temperature: this.opts.temperature
3175
+ })) {
3176
+ if (event.type === "finish" && event.truncated) {
3177
+ yield {
3178
+ type: "notice",
3179
+ message: "Response was cut short by the stream idle timeout \u2014 the reply may be incomplete."
3180
+ };
3181
+ }
3182
+ yield event;
3183
+ }
3184
+ }
3185
+ // The CoreMessage[] view sent to the model, with the turn's volatile git
3186
+ // context attached to the latest user message (request-only — the result is
3187
+ // never persisted, see attachTurnContext). Memoized within a turn: stream
3188
+ // retries and multi-step turns rebuild only when the history changed
3189
+ // (new array identity after a wholesale rewrite, or a new length after
3190
+ // appends) or the turn context changed.
3191
+ buildRequestMessages() {
3192
+ const cached = this.requestMessagesCache;
3193
+ if (cached && cached.source === this.messages && cached.length === this.messages.length && cached.context === this.turnGitContext) {
3194
+ return cached.result;
3195
+ }
3196
+ const result = attachTurnContext(toCoreMessages(this.messages), this.turnGitContext);
3197
+ this.requestMessagesCache = {
3198
+ source: this.messages,
3199
+ length: this.messages.length,
3200
+ context: this.turnGitContext,
3201
+ result
3202
+ };
3203
+ return result;
3204
+ }
3205
+ buildAiTools() {
3206
+ const plan = this.opts.config.permissionMode === "plan";
3207
+ const registry = this.opts.registry;
3208
+ const cached = this.aiToolsCache;
3209
+ if (cached && cached.registry === registry && cached.plan === plan) {
3210
+ return { tools: cached.tools, schemaTokens: cached.schemaTokens };
3211
+ }
3212
+ const tools = {};
3213
+ for (const t of registry.list()) {
3214
+ if (plan && t.permission !== "read") continue;
3215
+ tools[t.name] = aiTool({
3216
+ description: t.description,
3217
+ parameters: t.parameters
3218
+ });
3219
+ }
3220
+ const schemaTokens = estimateToolSchemaTokens(tools);
3221
+ this.aiToolsCache = { registry, plan, tools, schemaTokens };
3222
+ return { tools, schemaTokens };
3223
+ }
3224
+ // Fires the Stop hooks for a turn's end, on every termination path (normal
3225
+ // completion, Esc abort, exhausted stream retries, terminal empty replies,
3226
+ // the maxSteps no-progress stop). The reason rides in STAR_TOOL_INPUT as
3227
+ // {"reason": ...}: Stop hooks ignore tool matchers and the variable was
3228
+ // never set for Stop before, so existing hooks are unaffected. An abort is
3229
+ // the user waiting at the prompt, so its hooks get a short leash instead
3230
+ // of the hooks' own (default 30s) timeouts.
3231
+ async runStopHooks(reason) {
3232
+ await this.runEventHooks(
3233
+ "Stop",
3234
+ void 0,
3235
+ { reason },
3236
+ reason === "aborted" ? ABORT_STOP_HOOK_TIMEOUT_SEC : void 0
3237
+ );
3238
+ }
3239
+ async runEventHooks(event, toolName, toolInput, timeoutSec) {
3240
+ const empty = { blocked: false, warnings: [] };
3241
+ if (this.opts.config.hooks.length === 0) return empty;
3242
+ try {
3243
+ const result = await runHooks(
3244
+ event,
3245
+ this.opts.config.hooks,
3246
+ {
3247
+ cwd: this.opts.cwd,
3248
+ sessionId: this.opts.sessionStore?.id,
3249
+ toolName,
3250
+ toolInput
3251
+ },
3252
+ timeoutSec !== void 0 ? { timeoutSec } : void 0
3253
+ );
3254
+ for (const warning of result.warnings) this.onHookWarning?.(warning);
3255
+ return result;
3256
+ } catch {
3257
+ return empty;
3258
+ }
3259
+ }
3260
+ // Accumulates a settled subagent's usage. When the child provably ran on a
3261
+ // different, fully priced model, the counts are first converted into
3262
+ // this-model-priced equivalents (convertUsagePricing): session cost is
3263
+ // token totals × the active model's price, so the conversion keeps the $
3264
+ // estimate accurate where raw sums would misprice. Price lookup goes by
3265
+ // the [[models]] entry names both loops were resolved from, not model id.
3266
+ addSubagentUsage(usage, childModel, childModelName) {
3267
+ const converted = convertUsagePricing(usage, childModel, this.opts.model, this.opts.config, {
3268
+ fromName: childModelName,
3269
+ toName: this.opts.modelName
3270
+ });
3271
+ const pending = this.subagentUsage ?? {
3272
+ promptTokens: 0,
3273
+ completionTokens: 0,
3274
+ totalTokens: 0
3275
+ };
3276
+ pending.promptTokens += converted.promptTokens;
3277
+ pending.completionTokens += converted.completionTokens;
3278
+ pending.totalTokens += usageTotalTokens(converted);
3279
+ if (converted.cachedPromptTokens) {
3280
+ pending.cachedPromptTokens = (pending.cachedPromptTokens ?? 0) + converted.cachedPromptTokens;
3281
+ }
3282
+ if (converted.cacheReadInputTokens) {
3283
+ pending.cacheReadInputTokens = (pending.cacheReadInputTokens ?? 0) + converted.cacheReadInputTokens;
3284
+ }
3285
+ this.subagentUsage = pending;
3286
+ }
3287
+ // Folds pending subagent usage into an outgoing finish event (and clears
3288
+ // it). A finish without usage reports nothing to consumers, so the pending
3289
+ // amount is kept for the next finish that carries usage instead. Totals
3290
+ // sum the billed classes (usageTotalTokens), not whatever extra classes a
3291
+ // provider folded into its reported totalTokens.
3292
+ foldSubagentUsage(event) {
3293
+ const pending = this.subagentUsage;
3294
+ if (!pending || !event.usage) return event;
3295
+ this.subagentUsage = null;
3296
+ const usage = {
3297
+ promptTokens: event.usage.promptTokens + pending.promptTokens,
3298
+ completionTokens: event.usage.completionTokens + pending.completionTokens,
3299
+ totalTokens: usageTotalTokens(event.usage) + usageTotalTokens(pending)
3300
+ };
3301
+ const cachedPromptTokens = (event.usage.cachedPromptTokens ?? 0) + (pending.cachedPromptTokens ?? 0);
3302
+ const cacheReadInputTokens = (event.usage.cacheReadInputTokens ?? 0) + (pending.cacheReadInputTokens ?? 0);
3303
+ if (cachedPromptTokens > 0) usage.cachedPromptTokens = cachedPromptTokens;
3304
+ if (cacheReadInputTokens > 0) usage.cacheReadInputTokens = cacheReadInputTokens;
3305
+ return { ...event, usage };
3306
+ }
3307
+ // opencode's doom_loop guard: returns a refusal once the identical tool
3308
+ // call (name + stable-serialized args) has repeated config.doomLoopThreshold
3309
+ // times in a row; 0 disables. Any different signature resets the streak.
3310
+ // Subagents are separate loop instances, so each counts its own streak.
3311
+ doomLoopRefusal(call) {
3312
+ const threshold = this.opts.config.doomLoopThreshold;
3313
+ if (threshold <= 0) return null;
3314
+ const signature = `${call.name} ${stableStringify(call.args)}`;
3315
+ if (signature === this.lastToolSignature) {
3316
+ this.repeatedToolCalls += 1;
3317
+ } else {
3318
+ this.lastToolSignature = signature;
3319
+ this.repeatedToolCalls = 1;
3320
+ }
3321
+ if (this.repeatedToolCalls < threshold) return null;
3322
+ return `Refused: tool "${call.name}" was called ${this.repeatedToolCalls} times in a row with identical arguments. Stop retrying it \u2014 change your approach or report that you are blocked.`;
3323
+ }
3324
+ async executeTool(call, signal) {
3325
+ const { registry, config, cwd } = this.opts;
3326
+ const tool = registry.get(call.name);
3327
+ if (!tool) {
3328
+ return { content: `Unknown tool: ${call.name}`, isError: true };
3329
+ }
3330
+ const decision = checkPermission(
3331
+ config.permissionMode,
3332
+ { toolName: call.name, args: call.args, level: tool.permission },
3333
+ { cwd },
3334
+ config.permissions.allow,
3335
+ config.permissions.deny,
3336
+ config.permissions.ask
3337
+ );
3338
+ if (decision === "deny") {
3339
+ return { content: `Permission denied for tool "${call.name}".`, isError: true };
3340
+ }
3341
+ if (decision === "ask") {
3342
+ let approved = false;
3343
+ try {
3344
+ approved = this.confirmHandler ? await this.confirmHandler({
3345
+ toolName: call.name,
3346
+ args: call.args,
3347
+ level: tool.permission
3348
+ }) : false;
3349
+ } catch {
3350
+ approved = false;
3351
+ }
3352
+ if (!approved) {
3353
+ return { content: `User rejected tool "${call.name}".`, isError: true };
3354
+ }
3355
+ }
3356
+ const parsed = tool.parameters.safeParse(call.args);
3357
+ if (!parsed.success) {
3358
+ return { content: `Invalid arguments: ${parsed.error.message}`, isError: true };
3359
+ }
3360
+ const pre = await this.runEventHooks("PreToolUse", call.name, call.args);
3361
+ if (pre.blocked) {
3362
+ return {
3363
+ content: `Tool "${call.name}" blocked by a PreToolUse hook: ${pre.reason}`,
3364
+ isError: true
3365
+ };
3366
+ }
3367
+ let result;
3368
+ try {
3369
+ result = await tool.execute(parsed.data, {
3370
+ cwd,
3371
+ abortSignal: signal,
3372
+ agentId: this.opts.agentId ?? "root",
3373
+ visionEnabled: this.opts.vision !== false,
3374
+ snapshotContext: {
3375
+ owner: (this.opts.subagentDepth ?? 0) > 0 ? "subagent" : "root",
3376
+ turn: this.activeTurnSeq,
3377
+ messageIndex: this.activeTurnUserIndex
3378
+ }
3379
+ });
3380
+ } catch (error) {
3381
+ if (signal.aborted) {
3382
+ return { content: "Tool execution aborted.", isError: true };
3383
+ }
3384
+ return {
3385
+ content: `Tool error: ${error instanceof Error ? error.message : String(error)}`,
3386
+ isError: true
3387
+ };
3388
+ }
3389
+ if (!result.isError) {
3390
+ await this.runEventHooks("PostToolUse", call.name, call.args);
3391
+ }
3392
+ return result;
3393
+ }
3394
+ };
3395
+
3396
+ export {
3397
+ estimateTokens,
3398
+ summarizeMessages,
3399
+ compactMessages,
3400
+ WORKING_DIFF_MAX_LINES,
3401
+ isGitRepo,
3402
+ getGitSummaryCached,
3403
+ collectCommitContext,
3404
+ buildCommitPrompt,
3405
+ collectWorkingDiff,
3406
+ isAllowedByRules,
3407
+ buildAllowRule,
3408
+ isDangerousCommand,
3409
+ discoverSkills,
3410
+ appendUserMemory,
3411
+ PLAN_MODE_PROMPT,
3412
+ attachTurnContext,
3413
+ AgentLoop
3414
+ };
3415
+ //# sourceMappingURL=chunk-RE3NOULF.js.map