@cryer/star-cli 0.3.8 → 0.3.10

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