@cryer/star-cli 0.3.2 → 0.3.4

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.
@@ -1,15 +1,134 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  computeRetryDelayMs,
4
+ gitTreesDir,
4
5
  isRetryableStreamError,
6
+ responseBodyOf,
5
7
  starHome,
6
8
  summarizeStreamError,
7
9
  userMemoryPath
8
- } from "./chunk-LADQCYKL.js";
10
+ } from "./chunk-B6P4VUBE.js";
9
11
 
10
12
  // src/agent/loop.ts
11
13
  import { tool as aiTool, generateText as generateText3 } from "ai";
12
14
 
15
+ // src/config/schema.ts
16
+ import { z } from "zod";
17
+ var ProviderConfigSchema = z.object({
18
+ name: z.string(),
19
+ // openai-responses talks to the Responses API (/responses) instead of chat
20
+ // completions — the wire format codex uses, and the native API of
21
+ // reasoning-model relays that otherwise hide reasoning behind empty chat
22
+ // completions. streamChat sends strictSchemas:false + store:false with it.
23
+ protocol: z.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
24
+ baseURL: z.string(),
25
+ apiKeyEnv: z.string().optional(),
26
+ apiKey: z.string().optional(),
27
+ headers: z.record(z.string()).optional()
28
+ });
29
+ var ModelConfigSchema = z.object({
30
+ name: z.string(),
31
+ provider: z.string(),
32
+ model: z.string(),
33
+ maxTokens: z.number().int().positive().optional(),
34
+ // Per-model context window; overrides the top-level contextMaxTokens for
35
+ // compaction and the status bar when set.
36
+ contextMaxTokens: z.number().int().positive().optional(),
37
+ // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
38
+ // the model is treated as unpriced.
39
+ promptPrice: z.number().nonnegative().optional(),
40
+ completionPrice: z.number().nonnegative().optional(),
41
+ // Optional price (USD per 1M tokens) for cache-read prompt tokens. When
42
+ // unset, OpenAI-style cached tokens bill at promptPrice and Anthropic-style
43
+ // cache reads are not billed — set this for accurate cache pricing.
44
+ cacheReadPrice: z.number().nonnegative().optional()
45
+ });
46
+ var PermissionsConfigSchema = z.object({
47
+ allow: z.array(z.string()).default([]),
48
+ deny: z.array(z.string()).default([])
49
+ });
50
+ var HookConfigSchema = z.object({
51
+ event: z.enum(["PreToolUse", "PostToolUse", "Stop"]),
52
+ matcher: z.string().refine(
53
+ (pattern) => {
54
+ try {
55
+ new RegExp(pattern);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ },
61
+ { message: "invalid regular expression" }
62
+ ).optional(),
63
+ command: z.string().min(1),
64
+ timeoutSec: z.number().positive().default(30)
65
+ });
66
+ var ConfigSchema = z.object({
67
+ defaultModel: z.string().default(""),
68
+ permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
69
+ providers: z.array(ProviderConfigSchema).default([]),
70
+ models: z.array(ModelConfigSchema).default([]),
71
+ maxSteps: z.number().int().positive().default(50),
72
+ contextMaxTokens: z.number().int().positive().default(1e5),
73
+ // Token threshold at which the agent loop auto-compacts the history.
74
+ // Defaults to the effective context window (compact only when full); set a
75
+ // lower value to compact earlier and keep headroom. Manual /compact ignores
76
+ // this — it always compacts.
77
+ compactThresholdTokens: z.number().int().positive().optional(),
78
+ // Seconds without any stream part before a stalled response is ended
79
+ // gracefully (some relays never send the terminal chunks). Applies once
80
+ // streaming has started; the first part gets a longer, fixed allowance.
81
+ streamIdleTimeoutSec: z.number().positive().default(20),
82
+ // Seconds to wait for the very first stream part before giving up; slow
83
+ // thinking-model relays can buffer for minutes before sending anything.
84
+ streamFirstChunkTimeoutSec: z.number().positive().default(300),
85
+ // Extra attempts per model request when a stream fails transiently
86
+ // (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a
87
+ // relay hiccup does not silently end a half-finished turn. 0 disables.
88
+ // The wait between attempts honors Retry-After headers and otherwise backs
89
+ // off exponentially with jitter (llm/retry.ts); retries after a
90
+ // timeout/empty failure also scale both stream timeouts up (attempt number
91
+ // ×, capped at 3x) since the relay is likely overloaded. Default 5 matches
92
+ // opencode/Codex retry budgets and rides out flaky relays (中转站) better
93
+ // than a minimal budget.
94
+ streamMaxRetries: z.number().int().min(0).default(5),
95
+ // How many consecutive unproductive replies may be nudged before the turn
96
+ // is handed back: a text-only reply that announces pending work ("我将…",
97
+ // "I will…") or answers a nudge with more words, or an empty reply that
98
+ // survived the stream retry budget (steered with a nudge — a changed
99
+ // request — since identical resends already failed). Tool calls reset the
100
+ // count, so a productive task keeps going; exhaustion surfaces a notice
101
+ // instead of stopping silently. Never fires in plan mode. 0 disables.
102
+ maxAutoContinues: z.number().int().min(0).default(2),
103
+ contextCompaction: z.enum(["summary", "truncate"]).default("summary"),
104
+ // Optional per-session dollar budget; when set, the REPL stops the session
105
+ // once the estimated cost for the session reaches this amount.
106
+ sessionBudgetUsd: z.number().positive().optional(),
107
+ // Terminal bell (REPL only): ring when a turn takes longer than
108
+ // notifyBellThresholdSec, and when a background task finishes.
109
+ // STAR_NO_NOTIFY=1 disables without touching the config.
110
+ notifyBell: z.boolean().default(true),
111
+ notifyBellThresholdSec: z.number().positive().default(10),
112
+ permissions: PermissionsConfigSchema.default({ allow: [], deny: [] }),
113
+ hooks: z.array(HookConfigSchema).default([]),
114
+ // Consecutive identical tool calls (same tool + same arguments) allowed
115
+ // before the loop refuses to execute the repeat and tells the model to
116
+ // change approach — a guard against agents stuck retrying a failing call
117
+ // (opencode's doom_loop). 0 disables.
118
+ doomLoopThreshold: z.number().int().min(0).default(3),
119
+ // Track the whole working tree with an internal git repo (outside the
120
+ // user's .git) at every turn boundary, so /undo and /redo can restore
121
+ // changes made by ANY tool (bash included), not just write_file/edit_file.
122
+ // Falls back silently to per-file snapshots when git is unavailable.
123
+ gitSnapshots: z.boolean().default(true)
124
+ });
125
+ function contextWindowTokens(config, modelName) {
126
+ return config.models.find((m) => m.name === modelName)?.contextMaxTokens ?? config.contextMaxTokens;
127
+ }
128
+ function resolveCompactThreshold(config, windowTokens) {
129
+ return Math.min(config.compactThresholdTokens ?? windowTokens, windowTokens);
130
+ }
131
+
13
132
  // src/context/compaction.ts
14
133
  import { generateText } from "ai";
15
134
 
@@ -89,19 +208,24 @@ function serializeMessage(message) {
89
208
  });
90
209
  return `${message.role}: ${parts.join("\n")}`;
91
210
  }
92
- async function summarizeMessages(messages, model) {
211
+ async function summarizeMessages(messages, model, signal) {
93
212
  const transcript = messages.map(serializeMessage).join("\n");
94
213
  const { text } = await generateText({
95
214
  model,
96
215
  system: SUMMARY_SYSTEM_PROMPT,
97
216
  prompt: `Summarize this conversation so far:
98
217
 
99
- ${transcript}`
218
+ ${transcript}`,
219
+ // A hung relay must not stall the whole turn on the summary: cap it at
220
+ // 60s and let the caller's abort (Esc) cut it short as well; the caller
221
+ // falls back to the truncation placeholder on any failure.
222
+ abortSignal: signal ? AbortSignal.any([signal, AbortSignal.timeout(6e4)]) : AbortSignal.timeout(6e4)
100
223
  });
101
224
  return text.trim();
102
225
  }
103
- function compactMessages(messages, maxTokens) {
104
- if (estimateTokens(messages) <= maxTokens) {
226
+ function compactMessages(messages, maxTokens, opts = {}) {
227
+ const limit = opts.force ? 0 : maxTokens;
228
+ if (estimateTokens(messages) <= limit) {
105
229
  return { messages, compacted: false, droppedCount: 0 };
106
230
  }
107
231
  const first = messages[0];
@@ -127,7 +251,7 @@ function compactMessages(messages, maxTokens) {
127
251
  droppedCount += turn.length;
128
252
  turnIndex += 1;
129
253
  const candidate = [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)];
130
- if (estimateTokens(candidate) <= maxTokens) {
254
+ if (estimateTokens(candidate) <= limit) {
131
255
  break;
132
256
  }
133
257
  }
@@ -172,6 +296,16 @@ function getGitSummary(cwd) {
172
296
  const log = runGit(["log", "--oneline", "-3"], cwd) ?? "";
173
297
  return { branch, dirtyCount, recentCommits: log === "" ? [] : log.split("\n") };
174
298
  }
299
+ var GIT_SUMMARY_CACHE_TTL_MS = 15e3;
300
+ var gitSummaryCache = /* @__PURE__ */ new Map();
301
+ function getGitSummaryCached(cwd) {
302
+ const now = Date.now();
303
+ const hit = gitSummaryCache.get(cwd);
304
+ if (hit && now - hit.at < GIT_SUMMARY_CACHE_TTL_MS) return hit.summary;
305
+ const summary = getGitSummary(cwd);
306
+ gitSummaryCache.set(cwd, { at: now, summary });
307
+ return summary;
308
+ }
175
309
  function formatGitSummary(summary) {
176
310
  const lines = [
177
311
  "Git context for the working directory (refreshed each turn):",
@@ -332,7 +466,8 @@ var IMAGE_SIZE_KEYWORDS = /too[\s-]*(large|big)|exceeds?|dimensions?|resolution|
332
466
  function isOversizedImageError(error) {
333
467
  const status = error.statusCode;
334
468
  if (typeof status !== "number" || status < 400 || status >= 500) return false;
335
- return /image/i.test(error.message) && IMAGE_SIZE_KEYWORDS.test(error.message);
469
+ const text = [error.message, responseBodyOf(error)].filter((part) => typeof part === "string").join("\n");
470
+ return /image/i.test(text) && IMAGE_SIZE_KEYWORDS.test(text);
336
471
  }
337
472
  function imagePartBytes(image) {
338
473
  if (typeof image === "string") {
@@ -541,8 +676,50 @@ async function runHooks(event, hooks2, ctx) {
541
676
  }
542
677
 
543
678
  // src/permissions/gate.ts
679
+ import fs from "fs";
544
680
  import path from "path";
545
681
 
682
+ // src/core/sensitive.ts
683
+ var SENSITIVE_BASENAMES = /* @__PURE__ */ new Set([
684
+ ".env",
685
+ "id_rsa",
686
+ "id_dsa",
687
+ "id_ecdsa",
688
+ "id_ed25519",
689
+ ".npmrc",
690
+ ".netrc",
691
+ ".pgpass"
692
+ ]);
693
+ var SENSITIVE_SUFFIXES = [".pem", ".key", ".p12", ".pfx"];
694
+ var SENSITIVE_ENV_EXEMPTIONS = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template"]);
695
+ function pathSegments(p) {
696
+ return p.replace(/\\/g, "/").split("/").filter(Boolean).map((s) => s.toLowerCase());
697
+ }
698
+ function isSensitivePath(p) {
699
+ const segments = pathSegments(p);
700
+ const base = segments[segments.length - 1] ?? "";
701
+ if (SENSITIVE_ENV_EXEMPTIONS.has(base)) {
702
+ return false;
703
+ }
704
+ if (base === ".env" || base.startsWith(".env.")) {
705
+ return true;
706
+ }
707
+ if (SENSITIVE_BASENAMES.has(base)) {
708
+ return true;
709
+ }
710
+ if (SENSITIVE_SUFFIXES.some((suffix) => base.endsWith(suffix))) {
711
+ return true;
712
+ }
713
+ const dir = segments[segments.length - 2] ?? "";
714
+ if (dir === ".aws" && base === "credentials") {
715
+ return true;
716
+ }
717
+ if (dir === ".kube" && base === "config") {
718
+ return true;
719
+ }
720
+ return false;
721
+ }
722
+
546
723
  // src/permissions/allow.ts
547
724
  function parseAllowRule(rule) {
548
725
  const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
@@ -566,11 +743,32 @@ function matchAllowRule(rule, req) {
566
743
  const target = requestTarget(req);
567
744
  return target !== void 0 && globMatch(rule.pattern, target);
568
745
  }
746
+ function splitCommandChain(command) {
747
+ if (command.includes("$(") || command.includes("`")) return null;
748
+ return command.split(/&&|\|\||[;|\n]/).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
749
+ }
750
+ function withCommand(req, command) {
751
+ return {
752
+ ...req,
753
+ args: { ...req.args ?? {}, command }
754
+ };
755
+ }
569
756
  function isAllowedByRules(rules, req) {
570
- return matchesAnyRule(rules, req);
757
+ if (req.toolName !== "bash") return matchesAnyRule(rules, req);
758
+ const command = requestTarget(req);
759
+ if (command === void 0) return matchesAnyRule(rules, req);
760
+ const segments = splitCommandChain(command);
761
+ if (segments === null) return false;
762
+ if (segments.length === 0) return matchesAnyRule(rules, req);
763
+ return segments.every((segment) => matchesAnyRule(rules, withCommand(req, segment)));
571
764
  }
572
765
  function isDeniedByRules(rules, req) {
573
- return matchesAnyRule(rules, req);
766
+ if (req.toolName !== "bash") return matchesAnyRule(rules, req);
767
+ const command = requestTarget(req);
768
+ if (command === void 0) return matchesAnyRule(rules, req);
769
+ const segments = splitCommandChain(command);
770
+ if (segments === null || segments.length === 0) return matchesAnyRule(rules, req);
771
+ return segments.some((segment) => matchesAnyRule(rules, withCommand(req, segment)));
574
772
  }
575
773
  function matchesAnyRule(rules, req) {
576
774
  return rules.some((raw) => {
@@ -584,10 +782,17 @@ function buildAllowRule(req) {
584
782
  }
585
783
 
586
784
  // src/permissions/gate.ts
587
- var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file"]);
785
+ var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "grep", "glob"]);
588
786
  var DANGEROUS_COMMAND_PATTERNS = [
589
787
  /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
590
788
  /rm\s+-rf\s+~(?:\s|\/|$|;|&)/i,
789
+ /rm\s+-rf\s+\.\/?(?:\s|$|;|&)/i,
790
+ /rm\s+-rf\s+\*(?:\s|$|;|&)/i,
791
+ /\bsudo\s+rm\b/i,
792
+ /\bdel\s+\/s\b/i,
793
+ /\brd\s+\/s\b/i,
794
+ /\bformat\s+[a-z]:/i,
795
+ /\bdiskpart\b/i,
591
796
  /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
592
797
  /\bmkfs\b/i,
593
798
  /dd\s+[^|;]*\bif=/i,
@@ -617,12 +822,30 @@ function isOutsideCwd(p, cwd) {
617
822
  const base = normalizeAbsolute(cwd, cwd);
618
823
  return abs !== base && !abs.startsWith(`${base}/`);
619
824
  }
620
- function isSensitivePath(p) {
621
- const base = p.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
622
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
623
- return false;
825
+ function resolveRealPath(p, cwd) {
826
+ let current = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
827
+ const missing = [];
828
+ for (; ; ) {
829
+ try {
830
+ return path.join(fs.realpathSync(current), ...missing.reverse());
831
+ } catch {
832
+ const parent = path.dirname(current);
833
+ if (parent === current) return null;
834
+ missing.push(path.basename(current));
835
+ current = parent;
836
+ }
837
+ }
838
+ }
839
+ function isOutsideForWrite(p, cwd) {
840
+ const resolved = resolveRealPath(p, cwd);
841
+ if (resolved === null) return isOutsideCwd(p, cwd);
842
+ let realCwd;
843
+ try {
844
+ realCwd = fs.realpathSync(cwd);
845
+ } catch {
846
+ return isOutsideCwd(p, cwd);
624
847
  }
625
- return base === ".env" || base.startsWith(".env.") || base === "id_rsa" || base.endsWith(".pem");
848
+ return isOutsideCwd(resolved, realCwd);
626
849
  }
627
850
  function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
628
851
  if (mode === "yolo") {
@@ -634,10 +857,12 @@ function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
634
857
  return "deny";
635
858
  }
636
859
  if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
637
- if ((req.toolName === "write_file" || req.toolName === "edit_file") && isSensitivePath(filePath)) {
860
+ const isWriteTool = req.toolName === "write_file" || req.toolName === "edit_file";
861
+ if (isWriteTool && isSensitivePath(filePath)) {
638
862
  return "deny";
639
863
  }
640
- if (isOutsideCwd(filePath, ctx.cwd)) {
864
+ const outside = isWriteTool ? isOutsideForWrite(filePath, ctx.cwd) : isOutsideCwd(filePath, ctx.cwd);
865
+ if (outside) {
641
866
  if (mode === "ask" && req.level === "read") {
642
867
  return "ask";
643
868
  }
@@ -708,10 +933,93 @@ function scheduleSessionTitle(store, userText, model) {
708
933
  });
709
934
  }
710
935
 
711
- // src/tools/fs/snapshots.ts
712
- import { mkdir, readFile, rm, writeFile } from "fs/promises";
936
+ // src/snapshot/git-tree.ts
937
+ import { execFile } from "child_process";
938
+ import { existsSync } from "fs";
939
+ import { mkdir } from "fs/promises";
940
+ import os from "os";
713
941
  import path2 from "path";
942
+ var GIT_TIMEOUT_MS2 = 1e4;
943
+ var GIT_MAX_BUFFER2 = 8 * 1024 * 1024;
944
+ function runGit2(args) {
945
+ return new Promise((resolve) => {
946
+ execFile(
947
+ "git",
948
+ args,
949
+ { timeout: GIT_TIMEOUT_MS2, maxBuffer: GIT_MAX_BUFFER2, encoding: "utf8" },
950
+ (error, stdout) => {
951
+ resolve(error ? null : stdout.trim());
952
+ }
953
+ );
954
+ });
955
+ }
956
+ var gitAvailable = null;
957
+ async function isGitAvailable() {
958
+ if (gitAvailable === null) {
959
+ gitAvailable = await runGit2(["--version"]) !== null;
960
+ }
961
+ return gitAvailable;
962
+ }
963
+ function treeRepoDir(cwd) {
964
+ const key = Buffer.from(path2.resolve(cwd), "utf8").toString("base64url");
965
+ return path2.join(gitTreesDir(), key);
966
+ }
967
+ function isGuardedDir(cwd) {
968
+ const resolved = path2.resolve(cwd);
969
+ const home = path2.resolve(os.homedir());
970
+ const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
971
+ return same(resolved, home) || same(resolved, path2.parse(resolved).root);
972
+ }
973
+ async function ensureTreeRepo(cwd) {
974
+ const dir = treeRepoDir(cwd);
975
+ if (existsSync(path2.join(dir, "HEAD"))) return dir;
976
+ await mkdir(path2.dirname(dir), { recursive: true });
977
+ const out = await runGit2(["init", "--bare", dir]);
978
+ return out === null ? null : dir;
979
+ }
980
+ function repoArgs(dir, cwd) {
981
+ return [`--git-dir=${dir}`, `--work-tree=${cwd}`];
982
+ }
983
+ async function trackTree(cwd) {
984
+ if (isGuardedDir(cwd)) return null;
985
+ if (!await isGitAvailable()) return null;
986
+ const dir = await ensureTreeRepo(cwd);
987
+ if (!dir) return null;
988
+ const base = repoArgs(dir, cwd);
989
+ if (await runGit2([...base, "add", "-A"]) === null) return null;
990
+ return runGit2([...base, "write-tree"]);
991
+ }
992
+ async function restoreTree(cwd, hash) {
993
+ if (isGuardedDir(cwd)) return false;
994
+ if (!await isGitAvailable()) return false;
995
+ const dir = treeRepoDir(cwd);
996
+ if (!existsSync(path2.join(dir, "HEAD"))) return false;
997
+ const base = repoArgs(dir, cwd);
998
+ if (await runGit2([...base, "read-tree", hash]) === null) return false;
999
+ if (await runGit2([...base, "checkout-index", "-f", "-a"]) === null) return false;
1000
+ if (await runGit2([...base, "clean", "-fd"]) === null) return false;
1001
+ return true;
1002
+ }
1003
+ async function diffTreeNames(cwd, hash) {
1004
+ if (!await isGitAvailable()) return null;
1005
+ const dir = treeRepoDir(cwd);
1006
+ if (!existsSync(path2.join(dir, "HEAD"))) return null;
1007
+ const base = repoArgs(dir, cwd);
1008
+ const changed = await runGit2([...base, "diff", "--name-only", hash]);
1009
+ const untracked = await runGit2([...base, "ls-files", "--others", "--exclude-standard"]);
1010
+ if (changed === null || untracked === null) return null;
1011
+ const names = /* @__PURE__ */ new Set();
1012
+ for (const line of [...changed.split("\n"), ...untracked.split("\n")]) {
1013
+ if (line !== "") names.add(line);
1014
+ }
1015
+ return [...names];
1016
+ }
1017
+
1018
+ // src/tools/fs/snapshots.ts
1019
+ import { mkdir as mkdir2, readFile, rm, stat, writeFile } from "fs/promises";
1020
+ import path3 from "path";
714
1021
  var MAX_SNAPSHOTS = 50;
1022
+ var MAX_SNAPSHOT_CONTENT_BYTES = 5 * 1024 * 1024;
715
1023
  var stack = [];
716
1024
  var activeTurn = 0;
717
1025
  var activeTurnMessageIndex = -1;
@@ -733,28 +1041,48 @@ function currentTurnSeq() {
733
1041
  function currentTurnMessageIndex() {
734
1042
  return activeTurnMessageIndex;
735
1043
  }
736
- async function captureSnapshot(filePath, toolName) {
1044
+ function ownerOf(snapshot) {
1045
+ return snapshot.owner ?? "root";
1046
+ }
1047
+ async function captureSnapshot(filePath, toolName, snapshotContext) {
737
1048
  let content = null;
738
1049
  let existed = false;
739
- try {
740
- content = await readFile(filePath, "utf8");
1050
+ let contentTooLarge = false;
1051
+ const st = await stat(filePath).catch(() => null);
1052
+ if (st?.isFile()) {
741
1053
  existed = true;
742
- } catch {
1054
+ if (st.size > MAX_SNAPSHOT_CONTENT_BYTES) {
1055
+ contentTooLarge = true;
1056
+ } else {
1057
+ try {
1058
+ content = await readFile(filePath, "utf8");
1059
+ } catch {
1060
+ existed = false;
1061
+ }
1062
+ }
743
1063
  }
744
1064
  return {
745
1065
  id: nextSnapshotId(),
746
1066
  path: filePath,
747
1067
  existed,
748
1068
  content,
1069
+ contentTooLarge: contentTooLarge || void 0,
749
1070
  toolName,
750
1071
  timestamp: Date.now(),
751
- turn: activeTurn,
752
- messageIndex: activeTurnMessageIndex
1072
+ turn: snapshotContext?.turn ?? activeTurn,
1073
+ messageIndex: snapshotContext?.messageIndex ?? activeTurnMessageIndex,
1074
+ owner: snapshotContext?.owner ?? "root"
753
1075
  };
754
1076
  }
755
1077
  async function pushSnapshot(snapshot) {
756
1078
  stack.push(snapshot);
757
- await hooks?.onPush?.(snapshot);
1079
+ try {
1080
+ await hooks?.onPush?.(snapshot);
1081
+ } catch (err) {
1082
+ if (process.env.STAR_DEBUG) {
1083
+ console.error("[star] failed to persist snapshot checkpoint:", err);
1084
+ }
1085
+ }
758
1086
  if (stack.length > MAX_SNAPSHOTS) {
759
1087
  const evicted = stack.splice(0, stack.length - MAX_SNAPSHOTS);
760
1088
  await removeFromStore(evicted.map((s) => s.id));
@@ -787,191 +1115,156 @@ async function resolveContent(snapshot) {
787
1115
  }
788
1116
  async function revert(snapshot) {
789
1117
  if (snapshot.existed) {
1118
+ if (snapshot.contentTooLarge) {
1119
+ return `Skipped ${snapshot.path}: content too large, not restored.`;
1120
+ }
790
1121
  const content = await resolveContent(snapshot);
791
1122
  if (content === null) {
792
1123
  return `Skipped ${snapshot.path}: the snapshot content is no longer available.`;
793
1124
  }
794
- await mkdir(path2.dirname(snapshot.path), { recursive: true });
1125
+ await mkdir2(path3.dirname(snapshot.path), { recursive: true });
795
1126
  await writeFile(snapshot.path, content, "utf8");
796
1127
  return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
797
1128
  }
798
1129
  await rm(snapshot.path, { force: true });
799
1130
  return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
800
1131
  }
801
- function listTurnSnapshots(turn) {
802
- return stack.filter((snapshot) => snapshot.turn === turn).reverse();
1132
+ function listTurnSnapshots(turn, includeSubagent = false) {
1133
+ return stack.filter(
1134
+ (snapshot) => snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")
1135
+ ).reverse();
803
1136
  }
804
1137
  function resolveSnapshotContent(snapshot) {
805
1138
  return resolveContent(snapshot);
806
1139
  }
807
- async function undoTurnSnapshots(turn) {
1140
+ async function undoTurnSnapshots(turn, includeSubagent = false) {
808
1141
  const matches = [];
809
1142
  for (let i = stack.length - 1; i >= 0; i--) {
810
1143
  const snapshot = stack[i];
811
- if (snapshot && snapshot.turn === turn) {
1144
+ if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
812
1145
  matches.push(snapshot);
813
- stack.splice(i, 1);
814
1146
  }
815
1147
  }
816
- await removeFromStore(matches.map((s) => s.id));
817
1148
  const messages = [];
1149
+ const revertedIds = [];
818
1150
  for (const snapshot of matches) {
819
- messages.push(await revert(snapshot));
1151
+ try {
1152
+ messages.push(await revert(snapshot));
1153
+ const idx = stack.indexOf(snapshot);
1154
+ if (idx >= 0) stack.splice(idx, 1);
1155
+ revertedIds.push(snapshot.id);
1156
+ } catch (err) {
1157
+ messages.push(
1158
+ `Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
1159
+ );
1160
+ }
820
1161
  }
1162
+ await removeFromStore(revertedIds);
821
1163
  return messages;
822
1164
  }
823
- async function rewindToSnapshot(id) {
1165
+ async function dropTurnSnapshots(turn, includeSubagent = false) {
1166
+ const dropped = [];
1167
+ for (let i = stack.length - 1; i >= 0; i--) {
1168
+ const snapshot = stack[i];
1169
+ if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
1170
+ dropped.push(snapshot.id);
1171
+ stack.splice(i, 1);
1172
+ }
1173
+ }
1174
+ await removeFromStore(dropped);
1175
+ return dropped.length;
1176
+ }
1177
+ async function rewindToSnapshot(id, includeSubagent = false) {
824
1178
  if (!stack.some((snapshot) => snapshot.id === id)) return null;
825
1179
  const matches = [];
826
1180
  for (let i = stack.length - 1; i >= 0; i--) {
827
1181
  const snapshot = stack[i];
828
- if (snapshot && snapshot.id >= id) {
1182
+ if (snapshot && snapshot.id >= id && (includeSubagent || ownerOf(snapshot) === "root")) {
829
1183
  matches.push(snapshot);
830
- stack.splice(i, 1);
831
1184
  }
832
1185
  }
833
- await removeFromStore(matches.map((s) => s.id));
834
1186
  const reverted = [];
1187
+ const revertedIds = [];
835
1188
  let messageIndex = null;
836
1189
  for (const snapshot of matches) {
837
- reverted.push(await revert(snapshot));
838
- if (snapshot.messageIndex >= 0) {
839
- messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
840
- }
841
- }
842
- return { reverted, messageIndex };
843
- }
844
-
845
- // src/tools/todo.ts
846
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
847
- import path3 from "path";
848
- import { z } from "zod";
849
- var todoItemSchema = z.object({
850
- id: z.number().int(),
851
- title: z.string(),
852
- status: z.enum(["pending", "in_progress", "done"])
853
- });
854
- var writeSchema = z.object({
855
- todos: z.array(todoItemSchema).describe("The full todo list, replacing the current one")
856
- });
857
- var readSchema = z.object({});
858
- function fileFor(cwd) {
859
- return path3.join(cwd, ".star", "todos.json");
860
- }
861
- var TodoStore = class {
862
- items = /* @__PURE__ */ new Map();
863
- list() {
864
- return [...this.items.values()];
865
- }
866
- replace(items) {
867
- this.items.clear();
868
- for (const item of items) {
869
- this.items.set(item.id, item);
870
- }
871
- }
872
- async load(cwd) {
873
- let raw;
874
- try {
875
- raw = await readFile2(fileFor(cwd), "utf8");
876
- } catch {
877
- return;
878
- }
879
1190
  try {
880
- const parsed = JSON.parse(raw);
881
- if (Array.isArray(parsed)) {
882
- this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
1191
+ reverted.push(await revert(snapshot));
1192
+ const idx = stack.indexOf(snapshot);
1193
+ if (idx >= 0) stack.splice(idx, 1);
1194
+ revertedIds.push(snapshot.id);
1195
+ if (snapshot.messageIndex >= 0) {
1196
+ messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
883
1197
  }
884
- } catch {
1198
+ } catch (err) {
1199
+ reverted.push(
1200
+ `Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
1201
+ );
885
1202
  }
886
1203
  }
887
- async save(cwd) {
888
- await mkdir2(path3.join(cwd, ".star"), { recursive: true });
889
- await writeFile2(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
890
- `);
891
- }
892
- };
893
- var SYMBOLS = {
894
- pending: "[ ]",
895
- in_progress: "[~]",
896
- done: "[x]"
897
- };
898
- function formatTodos(items) {
899
- if (items.length === 0) {
900
- return "No todos.";
901
- }
902
- const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
903
- const width = Math.max(...lines.map((l) => l.length));
904
- return lines.map((line, i) => {
905
- const status = items[i]?.status;
906
- return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
907
- }).join("\n");
908
- }
909
- var defaultStore = new TodoStore();
910
- function parseTodoArgs(args) {
911
- const parsed = writeSchema.safeParse(args);
912
- return parsed.success ? parsed.data.todos : null;
913
- }
914
- function pendingTodoTitles(args) {
915
- return (parseTodoArgs(args) ?? []).filter((t) => t.status !== "done").map((t) => t.title);
916
- }
917
- async function loadTodos(cwd) {
918
- await defaultStore.load(cwd);
919
- return defaultStore.list();
920
- }
921
- function resetTodos() {
922
- defaultStore.replace([]);
923
- }
924
- function createTodoTools(store = defaultStore) {
925
- const todoWrite = {
926
- name: "todo_write",
927
- description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
928
- permission: "read",
929
- parameters: writeSchema,
930
- async execute(args, ctx) {
931
- const parsed = writeSchema.safeParse(args);
932
- if (!parsed.success) {
933
- return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
934
- }
935
- store.replace(parsed.data.todos);
936
- await store.save(ctx.cwd);
937
- const items = store.list();
938
- const done = items.filter((it) => it.status === "done").length;
939
- return { content: `Todo list updated (${done}/${items.length} done).` };
940
- }
941
- };
942
- const todoRead = {
943
- name: "todo_read",
944
- description: "Read the current todo list.",
945
- permission: "read",
946
- parameters: readSchema,
947
- async execute() {
948
- return { content: formatTodos(store.list()) };
949
- }
950
- };
951
- return [todoWrite, todoRead];
1204
+ await removeFromStore(revertedIds);
1205
+ return { reverted, messageIndex };
952
1206
  }
953
1207
 
954
- // src/agent/agent-tasks.ts
1208
+ // src/tools/bash.ts
1209
+ import { spawn as spawn3 } from "child_process";
1210
+ import { existsSync as existsSync2 } from "fs";
1211
+ import path4 from "path";
1212
+ import { z as z2 } from "zod";
1213
+
1214
+ // src/tasks/manager.ts
1215
+ import { spawn as spawn2 } from "child_process";
955
1216
  import { EventEmitter } from "events";
956
- var AgentTaskManager = class extends EventEmitter {
1217
+ var MAX_TASK_OUTPUT = 1e5;
1218
+ var DEFAULT_TASK_TIMEOUT = 3600;
1219
+ var MAX_FINISHED_RECORDS = 50;
1220
+ var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
1221
+ var TaskManager = class extends EventEmitter {
957
1222
  records = /* @__PURE__ */ new Map();
958
- counter = 0;
959
- start(run, meta) {
960
- const id = `agent-${++this.counter}`;
1223
+ seq = 0;
1224
+ start(opts) {
1225
+ const id = `task-${++this.seq}`;
1226
+ const spec = resolveShell();
1227
+ const child = spawn2(spec.shell, spec.wrap(opts.command), {
1228
+ cwd: opts.cwd,
1229
+ windowsHide: true,
1230
+ // POSIX: the child leads its own process group so killTree can SIGKILL
1231
+ // the whole tree with a negative pid (Windows uses taskkill /t).
1232
+ detached: process.platform !== "win32"
1233
+ });
961
1234
  const rec = {
962
1235
  id,
963
- prompt: meta.prompt,
964
- description: meta.description,
1236
+ command: opts.command,
1237
+ description: opts.description,
965
1238
  status: "running",
966
1239
  startedAt: Date.now(),
967
- result: "",
968
- controller: new AbortController(),
969
- notified: false
1240
+ output: "",
1241
+ truncated: false,
1242
+ child
970
1243
  };
971
1244
  this.records.set(id, rec);
972
- void run(rec.controller.signal).then((result) => this.finish(rec, "completed", result)).catch(
973
- (error) => this.finish(rec, "failed", error instanceof Error ? error.message : String(error))
974
- );
1245
+ const append = (d) => {
1246
+ rec.output += d.toString("utf8");
1247
+ if (rec.output.length > MAX_TASK_OUTPUT) {
1248
+ rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
1249
+ rec.truncated = true;
1250
+ }
1251
+ };
1252
+ child.stdout.on("data", append);
1253
+ child.stderr.on("data", append);
1254
+ child.on("error", (err) => {
1255
+ append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
1256
+ `));
1257
+ if (rec.status === "running") {
1258
+ this.finish(rec, "failed", null);
1259
+ }
1260
+ });
1261
+ child.on("close", (code) => {
1262
+ if (rec.status !== "running") return;
1263
+ this.finish(rec, code === 0 ? "completed" : "failed", code);
1264
+ });
1265
+ const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
1266
+ rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
1267
+ rec.timer.unref();
975
1268
  this.emitUpdate(rec);
976
1269
  return this.snapshot(rec);
977
1270
  }
@@ -992,312 +1285,39 @@ var AgentTaskManager = class extends EventEmitter {
992
1285
  kill(id) {
993
1286
  const rec = this.records.get(id);
994
1287
  if (!rec || rec.status !== "running") return void 0;
995
- rec.controller.abort();
996
- this.finish(rec, "stopped", rec.result || "Stopped by user.");
1288
+ this.terminate(rec, "stopped");
997
1289
  return this.snapshot(rec);
998
1290
  }
999
1291
  cleanup() {
1000
1292
  const killed = [];
1001
1293
  for (const rec of this.records.values()) {
1002
1294
  if (rec.status === "running") {
1003
- rec.controller.abort();
1004
- this.finish(rec, "stopped", rec.result || "Stopped on shutdown.");
1295
+ this.terminate(rec, "stopped");
1005
1296
  killed.push(this.snapshot(rec));
1006
1297
  }
1007
1298
  }
1008
1299
  return killed;
1009
1300
  }
1010
- // Finished tasks whose result has not yet been handed to the parent loop.
1011
- drainNotifications() {
1012
- const drained = [];
1013
- for (const rec of this.records.values()) {
1014
- if (rec.status !== "running" && !rec.notified) {
1015
- rec.notified = true;
1016
- drained.push(this.snapshot(rec));
1017
- }
1018
- }
1019
- return drained;
1020
- }
1021
- finish(rec, status, result) {
1022
- if (rec.status !== "running") return;
1023
- rec.status = status;
1024
- rec.result = result;
1025
- rec.endedAt = Date.now();
1026
- this.emitUpdate(rec);
1027
- }
1028
- emitUpdate(rec) {
1029
- this.emit("update", this.snapshot(rec));
1030
- }
1031
- snapshot(rec) {
1032
- return {
1033
- id: rec.id,
1034
- prompt: rec.prompt,
1035
- description: rec.description,
1036
- status: rec.status,
1037
- startedAt: rec.startedAt,
1038
- endedAt: rec.endedAt,
1039
- result: rec.result
1040
- };
1041
- }
1042
- };
1043
- var defaultAgentTasks = new AgentTaskManager();
1044
-
1045
- // src/agent/project-memory.ts
1046
- import { readFileSync, statSync } from "fs";
1047
- import path4 from "path";
1048
- var PROJECT_MEMORY_FILE = "AGENTS.md";
1049
- var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
1050
- var cache = /* @__PURE__ */ new Map();
1051
- function readProjectMemory(cwd) {
1052
- const file = path4.join(cwd, PROJECT_MEMORY_FILE);
1053
- let mtimeMs;
1054
- try {
1055
- mtimeMs = statSync(file).mtimeMs;
1056
- } catch {
1057
- return null;
1058
- }
1059
- const hit = cache.get(file);
1060
- if (hit && hit.mtimeMs === mtimeMs) return hit.block;
1061
- let block = null;
1062
- try {
1063
- let body = readFileSync(file, "utf8").trim();
1064
- if (body) {
1065
- if (body.length > PROJECT_MEMORY_MAX_CHARS) {
1066
- body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
1067
-
1068
- [AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
1069
- }
1070
- block = `# Project instructions (AGENTS.md)
1071
- ${body}`;
1072
- }
1073
- } catch {
1074
- block = null;
1075
- }
1076
- cache.set(file, { mtimeMs, block });
1077
- return block;
1078
- }
1079
-
1080
- // src/agent/skills.ts
1081
- import { readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "fs";
1082
- import path5 from "path";
1083
- import { z as z2 } from "zod";
1084
- var SKILL_FILE = "SKILL.md";
1085
- var SKILL_MAX_CHARS = 32 * 1024;
1086
- var NAME_PATTERN = /^[a-z0-9-]+$/;
1087
- function parseFrontmatter(raw) {
1088
- const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
1089
- if (!match) return null;
1090
- const meta = {};
1091
- for (const line of (match[1] ?? "").split(/\r?\n/)) {
1092
- const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
1093
- if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
1094
- }
1095
- return { meta, body: raw.slice(match[0].length).trim() };
1096
- }
1097
- var cache2 = /* @__PURE__ */ new Map();
1098
- function parseSkillFile(filePath, dirName, scope) {
1099
- let mtimeMs;
1100
- try {
1101
- mtimeMs = statSync2(filePath).mtimeMs;
1102
- } catch {
1103
- return null;
1104
- }
1105
- const hit = cache2.get(filePath);
1106
- if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
1107
- let skill = null;
1108
- try {
1109
- const parsed = parseFrontmatter(readFileSync2(filePath, "utf8"));
1110
- const name = parsed?.meta.name || dirName;
1111
- const description = parsed?.meta.description;
1112
- if (parsed && NAME_PATTERN.test(name) && description) {
1113
- skill = { name, description, filePath, dir: path5.dirname(filePath), scope };
1114
- }
1115
- } catch {
1116
- skill = null;
1117
- }
1118
- cache2.set(filePath, { mtimeMs, skill });
1119
- return skill;
1120
- }
1121
- function scanDir(base, scope, into) {
1122
- let entries;
1123
- try {
1124
- entries = readdirSync(base);
1125
- } catch {
1126
- return;
1127
- }
1128
- for (const entry of entries.sort()) {
1129
- if (!NAME_PATTERN.test(entry)) continue;
1130
- const parsed = parseSkillFile(path5.join(base, entry, SKILL_FILE), entry, scope);
1131
- if (parsed) into.set(parsed.name, parsed);
1132
- }
1133
- }
1134
- function discoverSkills(cwd, home = starHome()) {
1135
- const byName = /* @__PURE__ */ new Map();
1136
- scanDir(path5.join(home, "skills"), "user", byName);
1137
- scanDir(path5.join(cwd, ".star", "skills"), "project", byName);
1138
- return [...byName.values()];
1139
- }
1140
- function formatSkillsBlock(skills) {
1141
- const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
1142
- return [
1143
- "# Available skills",
1144
- "When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
1145
- ...lines
1146
- ].join("\n");
1147
- }
1148
- function loadSkillBody(skill) {
1149
- let parsed = null;
1150
- try {
1151
- parsed = parseFrontmatter(readFileSync2(skill.filePath, "utf8"));
1152
- } catch {
1153
- return null;
1154
- }
1155
- if (!parsed?.body) return null;
1156
- if (parsed.body.length > SKILL_MAX_CHARS) {
1157
- return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
1158
-
1159
- [skill truncated to ${SKILL_MAX_CHARS} characters]`;
1160
- }
1161
- return parsed.body;
1162
- }
1163
- function createSkillTool(deps) {
1164
- return {
1165
- name: "skill",
1166
- 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.",
1167
- permission: "read",
1168
- parameters: z2.object({
1169
- name: z2.string().describe("Name of the skill to load, as listed in the system prompt.")
1170
- }),
1171
- async execute(args) {
1172
- const skills = discoverSkills(deps.cwd, deps.home);
1173
- const skill = skills.find((s) => s.name === args.name);
1174
- if (!skill) {
1175
- const available = skills.map((s) => s.name).join(", ") || "none";
1176
- return {
1177
- content: `Unknown skill: "${args.name}". Available skills: ${available}`,
1178
- isError: true
1179
- };
1180
- }
1181
- const body = loadSkillBody(skill);
1182
- if (!body) {
1183
- return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
1184
- }
1185
- return {
1186
- content: [
1187
- `# Skill: ${skill.name}`,
1188
- "",
1189
- body,
1190
- "",
1191
- `Base directory for relative file references in this skill: ${skill.dir}`
1192
- ].join("\n")
1193
- };
1194
- }
1195
- };
1196
- }
1197
-
1198
- // src/agent/subagent.ts
1199
- import { z as z12 } from "zod";
1200
-
1201
- // src/tools/bash.ts
1202
- import { spawn as spawn3 } from "child_process";
1203
- import { existsSync } from "fs";
1204
- import path6 from "path";
1205
- import { z as z3 } from "zod";
1206
-
1207
- // src/tasks/manager.ts
1208
- import { spawn as spawn2 } from "child_process";
1209
- import { EventEmitter as EventEmitter2 } from "events";
1210
- var MAX_TASK_OUTPUT = 1e5;
1211
- var DEFAULT_TASK_TIMEOUT = 3600;
1212
- var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
1213
- var TaskManager = class extends EventEmitter2 {
1214
- records = /* @__PURE__ */ new Map();
1215
- seq = 0;
1216
- start(opts) {
1217
- const id = `task-${++this.seq}`;
1218
- const spec = resolveShell();
1219
- const child = spawn2(spec.shell, spec.wrap(opts.command), {
1220
- cwd: opts.cwd,
1221
- windowsHide: true
1222
- });
1223
- const rec = {
1224
- id,
1225
- command: opts.command,
1226
- description: opts.description,
1227
- status: "running",
1228
- startedAt: Date.now(),
1229
- output: "",
1230
- truncated: false,
1231
- child
1232
- };
1233
- this.records.set(id, rec);
1234
- const append = (d) => {
1235
- rec.output += d.toString("utf8");
1236
- if (rec.output.length > MAX_TASK_OUTPUT) {
1237
- rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
1238
- rec.truncated = true;
1239
- }
1240
- };
1241
- child.stdout.on("data", append);
1242
- child.stderr.on("data", append);
1243
- child.on("error", (err) => {
1244
- append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
1245
- `));
1246
- if (rec.status === "running") {
1247
- this.finish(rec, "failed", null);
1248
- }
1249
- });
1250
- child.on("close", (code) => {
1251
- if (rec.status !== "running") return;
1252
- this.finish(rec, code === 0 ? "completed" : "failed", code);
1253
- });
1254
- const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
1255
- rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
1256
- rec.timer.unref();
1257
- this.emitUpdate(rec);
1258
- return this.snapshot(rec);
1259
- }
1260
- get(id) {
1261
- const rec = this.records.get(id);
1262
- return rec ? this.snapshot(rec) : void 0;
1263
- }
1264
- list() {
1265
- return [...this.records.values()].map((rec) => this.snapshot(rec));
1266
- }
1267
- runningCount() {
1268
- let count = 0;
1269
- for (const rec of this.records.values()) {
1270
- if (rec.status === "running") count += 1;
1271
- }
1272
- return count;
1273
- }
1274
- kill(id) {
1275
- const rec = this.records.get(id);
1276
- if (!rec || rec.status !== "running") return void 0;
1277
- this.terminate(rec, "stopped");
1278
- return this.snapshot(rec);
1279
- }
1280
- cleanup() {
1281
- const killed = [];
1282
- for (const rec of this.records.values()) {
1283
- if (rec.status === "running") {
1284
- this.terminate(rec, "stopped");
1285
- killed.push(this.snapshot(rec));
1286
- }
1287
- }
1288
- return killed;
1289
- }
1290
- terminate(rec, status) {
1291
- killTree2(rec.child);
1292
- this.finish(rec, status, null);
1301
+ terminate(rec, status) {
1302
+ killTree2(rec.child);
1303
+ this.finish(rec, status, null);
1293
1304
  }
1294
1305
  finish(rec, status, exitCode) {
1295
1306
  if (rec.timer) clearTimeout(rec.timer);
1296
1307
  rec.status = status;
1297
1308
  rec.exitCode = exitCode;
1298
1309
  rec.endedAt = Date.now();
1310
+ this.pruneFinished();
1299
1311
  this.emitUpdate(rec);
1300
1312
  }
1313
+ pruneFinished() {
1314
+ const finished = [...this.records.values()].filter((rec) => rec.status !== "running");
1315
+ if (finished.length <= MAX_FINISHED_RECORDS) return;
1316
+ finished.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0));
1317
+ for (const rec of finished.slice(0, finished.length - MAX_FINISHED_RECORDS)) {
1318
+ this.records.delete(rec.id);
1319
+ }
1320
+ }
1301
1321
  emitUpdate(rec) {
1302
1322
  this.emit("update", this.snapshot(rec));
1303
1323
  }
@@ -1318,21 +1338,22 @@ var defaultTaskManager = new TaskManager();
1318
1338
 
1319
1339
  // src/tools/bash.ts
1320
1340
  var MAX_OUTPUT = 3e4;
1341
+ var HALF_OUTPUT = MAX_OUTPUT / 2;
1321
1342
  var DEFAULT_TIMEOUT = 120;
1322
1343
  var MAX_TIMEOUT = 600;
1323
1344
  function findOnPath(exe, exclude) {
1324
- for (const dir of (process.env.PATH ?? "").split(path6.delimiter)) {
1345
+ for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
1325
1346
  if (!dir || exclude?.(dir)) {
1326
1347
  continue;
1327
1348
  }
1328
- const full = path6.join(dir, exe);
1329
- if (existsSync(full)) {
1349
+ const full = path4.join(dir, exe);
1350
+ if (existsSync2(full)) {
1330
1351
  return full;
1331
1352
  }
1332
1353
  }
1333
1354
  return null;
1334
1355
  }
1335
- function resolveShell() {
1356
+ function resolveShellUncached() {
1336
1357
  if (process.platform !== "win32") {
1337
1358
  return { shell: "sh", wrap: (c) => ["-c", c], label: "sh" };
1338
1359
  }
@@ -1343,8 +1364,8 @@ function resolveShell() {
1343
1364
  }
1344
1365
  const gitExe = findOnPath("git.exe");
1345
1366
  if (gitExe) {
1346
- const sibling = path6.join(path6.dirname(path6.dirname(gitExe)), "bin", "bash.exe");
1347
- if (existsSync(sibling)) {
1367
+ const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
1368
+ if (existsSync2(sibling)) {
1348
1369
  return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
1349
1370
  }
1350
1371
  }
@@ -1353,42 +1374,82 @@ function resolveShell() {
1353
1374
  "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
1354
1375
  "D:\\Git\\bin\\bash.exe"
1355
1376
  ]) {
1356
- if (existsSync(candidate)) {
1377
+ if (existsSync2(candidate)) {
1357
1378
  return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
1358
1379
  }
1359
1380
  }
1360
1381
  const comspec = process.env.ComSpec ?? "cmd.exe";
1361
1382
  return { shell: comspec, wrap: (c) => ["/d", "/s", "/c", c], label: "cmd" };
1362
1383
  }
1363
- var schema = z3.object({
1364
- command: z3.string().describe("Shell command to execute"),
1365
- timeout: z3.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
1366
- description: z3.string().optional().describe("Short description of what the command does"),
1367
- run_in_background: z3.boolean().optional().describe(
1384
+ var cachedSpec = null;
1385
+ function resolveShell() {
1386
+ if (!cachedSpec) {
1387
+ cachedSpec = resolveShellUncached();
1388
+ }
1389
+ return cachedSpec;
1390
+ }
1391
+ var schema = z2.object({
1392
+ command: z2.string().describe("Shell command to execute"),
1393
+ timeout: z2.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
1394
+ description: z2.string().optional().describe("Short description of what the command does"),
1395
+ run_in_background: z2.boolean().optional().describe(
1368
1396
  "Run the command in the background and return a task id immediately (default false). Use task_output/task_kill to inspect or stop it."
1369
1397
  )
1370
1398
  });
1371
1399
  function killTree2(child) {
1372
- if (process.platform === "win32" && child.pid) {
1373
- spawn3("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
1374
- windowsHide: true,
1375
- stdio: "ignore"
1376
- }).unref();
1400
+ if (process.platform === "win32") {
1401
+ if (child.pid) {
1402
+ spawn3("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
1403
+ windowsHide: true,
1404
+ stdio: "ignore"
1405
+ }).unref();
1406
+ }
1377
1407
  return;
1378
1408
  }
1379
- child.kill("SIGKILL");
1380
- }
1381
- function truncateMiddle(s) {
1382
- if (s.length <= MAX_OUTPUT) {
1383
- return s;
1409
+ if (child.pid) {
1410
+ try {
1411
+ process.kill(-child.pid, "SIGKILL");
1412
+ return;
1413
+ } catch {
1414
+ }
1415
+ }
1416
+ child.kill("SIGKILL");
1417
+ }
1418
+ var BoundedOutput = class {
1419
+ head = "";
1420
+ tail = "";
1421
+ dropped = 0;
1422
+ overflow = false;
1423
+ append(text) {
1424
+ if (!this.overflow) {
1425
+ this.head += text;
1426
+ if (this.head.length <= MAX_OUTPUT) {
1427
+ return;
1428
+ }
1429
+ this.overflow = true;
1430
+ this.tail = this.head.slice(HALF_OUTPUT);
1431
+ this.head = this.head.slice(0, HALF_OUTPUT);
1432
+ if (this.tail.length > HALF_OUTPUT) {
1433
+ this.dropped = this.tail.length - HALF_OUTPUT;
1434
+ this.tail = this.tail.slice(-HALF_OUTPUT);
1435
+ }
1436
+ return;
1437
+ }
1438
+ this.tail += text;
1439
+ if (this.tail.length > HALF_OUTPUT) {
1440
+ this.dropped += this.tail.length - HALF_OUTPUT;
1441
+ this.tail = this.tail.slice(-HALF_OUTPUT);
1442
+ }
1443
+ }
1444
+ toString() {
1445
+ if (!this.overflow) {
1446
+ return this.head;
1447
+ }
1448
+ return `${this.head}
1449
+ ... [${this.dropped} characters truncated] ...
1450
+ ${this.tail}`;
1384
1451
  }
1385
- const half = Math.floor(MAX_OUTPUT / 2);
1386
- const head = s.slice(0, half);
1387
- const tail = s.slice(-half);
1388
- return `${head}
1389
- ... [${s.length - MAX_OUTPUT} characters truncated] ...
1390
- ${tail}`;
1391
- }
1452
+ };
1392
1453
  var bashTool = {
1393
1454
  name: "bash",
1394
1455
  description: "Execute a shell command (Git Bash on Windows when available, otherwise cmd; sh elsewhere). Stdout and stderr are merged. Output is truncated to 30000 characters. Set run_in_background for long-running commands; it returns a task id for task_list/task_output/task_kill.",
@@ -1413,9 +1474,10 @@ description: ${args.description ?? "(none)"}`
1413
1474
  return new Promise((resolve) => {
1414
1475
  const child = spawn3(spec.shell, spec.wrap(args.command), {
1415
1476
  cwd: ctx.cwd,
1416
- windowsHide: true
1477
+ windowsHide: true,
1478
+ detached: process.platform !== "win32"
1417
1479
  });
1418
- let output = "";
1480
+ const output = new BoundedOutput();
1419
1481
  let settled = false;
1420
1482
  const finish = (result) => {
1421
1483
  if (settled) {
@@ -1428,36 +1490,32 @@ description: ${args.description ?? "(none)"}`
1428
1490
  };
1429
1491
  const onAbort = () => {
1430
1492
  killTree2(child);
1431
- finish({ content: `${truncateMiddle(output)}
1493
+ finish({ content: `${output}
1432
1494
  Command aborted`, isError: true });
1433
1495
  };
1434
1496
  const timer = setTimeout(() => {
1435
1497
  killTree2(child);
1436
1498
  finish({
1437
- content: `${truncateMiddle(output)}
1499
+ content: `${output}
1438
1500
  Command timed out after ${timeoutSeconds}s`,
1439
1501
  isError: true
1440
1502
  });
1441
1503
  }, timeoutSeconds * 1e3);
1442
- child.stdout.on("data", (d) => {
1443
- output += d.toString("utf8");
1444
- });
1445
- child.stderr.on("data", (d) => {
1446
- output += d.toString("utf8");
1447
- });
1504
+ timer.unref();
1505
+ child.stdout.on("data", (d) => output.append(d.toString("utf8")));
1506
+ child.stderr.on("data", (d) => output.append(d.toString("utf8")));
1448
1507
  child.on("error", (err) => {
1449
1508
  finish({ content: `Failed to start shell '${spec.label}': ${err.message}`, isError: true });
1450
1509
  });
1451
1510
  ctx.abortSignal?.addEventListener("abort", onAbort);
1452
1511
  child.on("close", (code) => {
1453
- const trimmed = output.replace(/\s+$/, "");
1512
+ const trimmed = output.toString().replace(/\s+$/, "");
1454
1513
  if (code === 0) {
1455
- finish({ content: truncateMiddle(trimmed) || "(no output)" });
1514
+ finish({ content: trimmed || "(no output)" });
1456
1515
  return;
1457
1516
  }
1458
- const body = truncateMiddle(trimmed);
1459
1517
  finish({
1460
- content: `${body}${body ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
1518
+ content: `${trimmed}${trimmed ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
1461
1519
  isError: true
1462
1520
  });
1463
1521
  });
@@ -1466,14 +1524,14 @@ Command timed out after ${timeoutSeconds}s`,
1466
1524
  };
1467
1525
 
1468
1526
  // src/tools/fs/edit.ts
1469
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1470
- import path7 from "path";
1471
- import { z as z4 } from "zod";
1472
- var schema2 = z4.object({
1473
- path: z4.string().describe("File path, absolute or relative to the working directory"),
1474
- old_string: z4.string().min(1).describe("Exact text to replace"),
1475
- new_string: z4.string().describe("Replacement text"),
1476
- replace_all: z4.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
1527
+ import { readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1528
+ import path5 from "path";
1529
+ import { z as z3 } from "zod";
1530
+ var schema2 = z3.object({
1531
+ path: z3.string().describe("File path, absolute or relative to the working directory"),
1532
+ old_string: z3.string().min(1).describe("Exact text to replace"),
1533
+ new_string: z3.string().describe("Replacement text"),
1534
+ replace_all: z3.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
1477
1535
  });
1478
1536
  var editFileTool = {
1479
1537
  name: "edit_file",
@@ -1481,10 +1539,12 @@ var editFileTool = {
1481
1539
  permission: "write",
1482
1540
  parameters: schema2,
1483
1541
  async execute(args, ctx) {
1484
- const filePath = path7.resolve(ctx.cwd, args.path);
1542
+ const filePath = path5.resolve(ctx.cwd, args.path);
1485
1543
  let content;
1544
+ let readMtimeMs;
1486
1545
  try {
1487
- content = await readFile3(filePath, "utf8");
1546
+ content = await readFile2(filePath, "utf8");
1547
+ readMtimeMs = (await stat2(filePath)).mtimeMs;
1488
1548
  } catch (err) {
1489
1549
  return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
1490
1550
  }
@@ -1503,34 +1563,44 @@ var editFileTool = {
1503
1563
  isError: true
1504
1564
  };
1505
1565
  }
1506
- const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, args.new_string);
1566
+ const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, () => args.new_string);
1507
1567
  try {
1508
- await writeFile3(filePath, updated, "utf8");
1568
+ const now = await stat2(filePath).catch(() => null);
1569
+ if (!now || now.mtimeMs !== readMtimeMs) {
1570
+ return {
1571
+ content: `Failed to edit ${args.path}: file changed since it was read; re-read and retry`,
1572
+ isError: true
1573
+ };
1574
+ }
1575
+ await writeFile2(filePath, updated, "utf8");
1509
1576
  } catch (err) {
1510
1577
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1511
1578
  }
1579
+ const contentTooLarge = Buffer.byteLength(content, "utf8") > MAX_SNAPSHOT_CONTENT_BYTES;
1512
1580
  await pushSnapshot({
1513
1581
  id: nextSnapshotId(),
1514
1582
  path: filePath,
1515
1583
  existed: true,
1516
- content,
1584
+ content: contentTooLarge ? null : content,
1585
+ contentTooLarge: contentTooLarge || void 0,
1517
1586
  toolName: "edit_file",
1518
1587
  timestamp: Date.now(),
1519
- turn: currentTurnSeq(),
1520
- messageIndex: currentTurnMessageIndex()
1588
+ turn: ctx.snapshotContext?.turn ?? currentTurnSeq(),
1589
+ messageIndex: ctx.snapshotContext?.messageIndex ?? currentTurnMessageIndex(),
1590
+ owner: ctx.snapshotContext?.owner ?? "root"
1521
1591
  });
1522
1592
  return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
1523
1593
  }
1524
1594
  };
1525
1595
 
1526
1596
  // src/tools/fs/glob.ts
1527
- import path9 from "path";
1528
- import { z as z5 } from "zod";
1597
+ import path7 from "path";
1598
+ import { z as z4 } from "zod";
1529
1599
 
1530
1600
  // src/tools/fs/util.ts
1531
- import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
1532
- import { readdir, stat } from "fs/promises";
1533
- import path8 from "path";
1601
+ import { readFileSync, statSync } from "fs";
1602
+ import { readdir, stat as stat3 } from "fs/promises";
1603
+ import path6 from "path";
1534
1604
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
1535
1605
  async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1536
1606
  const out = [];
@@ -1540,7 +1610,7 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1540
1610
  return;
1541
1611
  }
1542
1612
  for (const entry of entries) {
1543
- const abs = path8.join(dir, entry.name);
1613
+ const abs = path6.join(dir, entry.name);
1544
1614
  const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1545
1615
  if (entry.isDirectory()) {
1546
1616
  if (skipDirs.has(entry.name) || options.ignore?.(abs, true)) {
@@ -1551,8 +1621,12 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1551
1621
  if (options.ignore?.(abs, false)) {
1552
1622
  continue;
1553
1623
  }
1624
+ if (options.withMtime === false) {
1625
+ out.push({ abs, rel, mtimeMs: 0 });
1626
+ continue;
1627
+ }
1554
1628
  try {
1555
- const st = await stat(abs);
1629
+ const st = await stat3(abs);
1556
1630
  out.push({ abs, rel, mtimeMs: st.mtimeMs });
1557
1631
  } catch {
1558
1632
  }
@@ -1563,32 +1637,107 @@ async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1563
1637
  return out;
1564
1638
  }
1565
1639
  var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
1640
+ function escapeRe(c) {
1641
+ return c.replace(REGEX_SPECIALS, "\\$&");
1642
+ }
1566
1643
  function globToRegExp(pattern) {
1567
- let re = "";
1568
1644
  let i = 0;
1569
- while (i < pattern.length) {
1570
- const c = pattern.charAt(i);
1571
- if (c === "*") {
1572
- if (pattern.charAt(i + 1) === "*") {
1573
- i += 2;
1574
- if (pattern.charAt(i) === "/") {
1575
- i += 1;
1576
- re += "(?:[^/]+/)*";
1645
+ function parseClass() {
1646
+ let j = i + 1;
1647
+ let negate = false;
1648
+ if (pattern.charAt(j) === "!") {
1649
+ negate = true;
1650
+ j += 1;
1651
+ }
1652
+ let body = "";
1653
+ if (pattern.charAt(j) === "]") {
1654
+ body += "\\]";
1655
+ j += 1;
1656
+ }
1657
+ while (j < pattern.length && pattern.charAt(j) !== "]") {
1658
+ const ch = pattern.charAt(j);
1659
+ body += ch === "\\" ? "\\\\" : ch;
1660
+ j += 1;
1661
+ }
1662
+ if (j < pattern.length) {
1663
+ i = j + 1;
1664
+ if (body.startsWith("^")) body = `\\${body}`;
1665
+ return `[${negate ? "^" : ""}${body}]`;
1666
+ }
1667
+ i += 1;
1668
+ return "\\[";
1669
+ }
1670
+ function parseSegment(depth) {
1671
+ let re2 = "";
1672
+ while (i < pattern.length) {
1673
+ const c = pattern.charAt(i);
1674
+ if (depth > 0 && (c === "," || c === "}")) {
1675
+ return { re: re2, end: c };
1676
+ }
1677
+ if (c === "*") {
1678
+ if (pattern.charAt(i + 1) === "*") {
1679
+ i += 2;
1680
+ if (pattern.charAt(i) === "/") {
1681
+ i += 1;
1682
+ re2 += "(?:[^/]+/)*";
1683
+ while (pattern.startsWith("**/", i)) {
1684
+ i += 3;
1685
+ }
1686
+ } else {
1687
+ re2 += ".*";
1688
+ }
1577
1689
  } else {
1578
- re += ".*";
1690
+ re2 += "[^/]*";
1691
+ i += 1;
1579
1692
  }
1580
- } else {
1581
- re += "[^/]*";
1693
+ continue;
1694
+ }
1695
+ if (c === "?") {
1696
+ re2 += "[^/]";
1582
1697
  i += 1;
1698
+ continue;
1583
1699
  }
1584
- } else if (c === "?") {
1585
- re += "[^/]";
1586
- i += 1;
1587
- } else {
1588
- re += c.replace(REGEX_SPECIALS, "\\$&");
1700
+ if (c === "[") {
1701
+ re2 += parseClass();
1702
+ continue;
1703
+ }
1704
+ if (c === "{" && depth < 2) {
1705
+ const close = braceEnd(i);
1706
+ if (close === -1) {
1707
+ re2 += "\\{";
1708
+ i += 1;
1709
+ continue;
1710
+ }
1711
+ i += 1;
1712
+ const alts = [];
1713
+ for (; ; ) {
1714
+ const part = parseSegment(depth + 1);
1715
+ alts.push(part.re);
1716
+ if (part.end !== ",") break;
1717
+ i += 1;
1718
+ }
1719
+ i += 1;
1720
+ re2 += `(?:${alts.join("|")})`;
1721
+ continue;
1722
+ }
1723
+ re2 += escapeRe(c);
1589
1724
  i += 1;
1590
1725
  }
1726
+ return { re: re2, end: "" };
1727
+ }
1728
+ function braceEnd(from) {
1729
+ let depth = 0;
1730
+ for (let j = from; j < pattern.length; j++) {
1731
+ const ch = pattern.charAt(j);
1732
+ if (ch === "{") depth += 1;
1733
+ else if (ch === "}") {
1734
+ depth -= 1;
1735
+ if (depth === 0) return j;
1736
+ }
1737
+ }
1738
+ return -1;
1591
1739
  }
1740
+ const { re } = parseSegment(0);
1592
1741
  return new RegExp(`^${re}$`);
1593
1742
  }
1594
1743
  function matchesGlob(pattern, relPath) {
@@ -1620,10 +1769,10 @@ function parseIgnorePatterns(body) {
1620
1769
  return out;
1621
1770
  }
1622
1771
  function loadIgnorePatterns(dir) {
1623
- const file = path8.join(dir, IGNORE_FILE);
1772
+ const file = path6.join(dir, IGNORE_FILE);
1624
1773
  let mtimeMs;
1625
1774
  try {
1626
- mtimeMs = statSync3(file).mtimeMs;
1775
+ mtimeMs = statSync(file).mtimeMs;
1627
1776
  } catch {
1628
1777
  return [];
1629
1778
  }
@@ -1633,7 +1782,7 @@ function loadIgnorePatterns(dir) {
1633
1782
  }
1634
1783
  let patterns = [];
1635
1784
  try {
1636
- patterns = parseIgnorePatterns(readFileSync3(file, "utf8"));
1785
+ patterns = parseIgnorePatterns(readFileSync(file, "utf8"));
1637
1786
  } catch {
1638
1787
  patterns = [];
1639
1788
  }
@@ -1645,9 +1794,9 @@ function createIgnorePredicate(cwd) {
1645
1794
  if (patterns.length === 0) {
1646
1795
  return void 0;
1647
1796
  }
1648
- const root = path8.resolve(cwd);
1797
+ const root = path6.resolve(cwd);
1649
1798
  return (absPath, isDir) => {
1650
- const rel = path8.relative(root, absPath).split(path8.sep).join("/");
1799
+ const rel = path6.relative(root, absPath).split(path6.sep).join("/");
1651
1800
  if (!rel || rel.startsWith("..")) {
1652
1801
  return false;
1653
1802
  }
@@ -1662,27 +1811,26 @@ function createIgnorePredicate(cwd) {
1662
1811
  return false;
1663
1812
  };
1664
1813
  }
1665
- function isSensitivePath2(filePath) {
1666
- const base = path8.basename(filePath);
1667
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
1668
- return false;
1669
- }
1670
- if (base === ".env" || base.startsWith(".env.")) {
1671
- return true;
1672
- }
1673
- if (base === "id_rsa" || base.endsWith(".pem")) {
1674
- return true;
1675
- }
1676
- return false;
1677
- }
1678
1814
 
1679
1815
  // src/tools/fs/glob.ts
1680
1816
  var MAX_RESULTS = 100;
1681
- var schema3 = z5.object({
1682
- pattern: z5.string().describe(
1683
- "Glob pattern, e.g. 'src/**/*.ts'. Supports **, * and ?. A bare pattern like '*.ts' matches basenames recursively."
1817
+ var GLOB_MAGIC = /[*?[{]/;
1818
+ function literalDirPrefix(pattern) {
1819
+ const segments = pattern.replace(/\\/g, "/").split("/").slice(0, -1);
1820
+ const out = [];
1821
+ for (const segment of segments) {
1822
+ if (segment === "" || GLOB_MAGIC.test(segment)) {
1823
+ break;
1824
+ }
1825
+ out.push(segment);
1826
+ }
1827
+ return out;
1828
+ }
1829
+ var schema3 = z4.object({
1830
+ pattern: z4.string().describe(
1831
+ "Glob pattern, e.g. 'src/**/*.ts'. Supports **, *, ?, {a,b} braces and [abc] character classes. A bare pattern like '*.ts' matches basenames recursively."
1684
1832
  ),
1685
- path: z5.string().optional().describe("Directory to search, defaults to the working directory")
1833
+ path: z4.string().optional().describe("Directory to search, defaults to the working directory")
1686
1834
  });
1687
1835
  var globTool = {
1688
1836
  name: "glob",
@@ -1690,47 +1838,72 @@ var globTool = {
1690
1838
  permission: "read",
1691
1839
  parameters: schema3,
1692
1840
  async execute(args, ctx) {
1693
- const root = path9.resolve(ctx.cwd, args.path ?? ".");
1694
- const files = await walkFiles(root, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1695
- const matched = files.filter((f) => matchesGlob(args.pattern, f.rel)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
1841
+ const root = path7.resolve(ctx.cwd, args.path ?? ".");
1842
+ const prefixSegments = literalDirPrefix(args.pattern);
1843
+ const walkRoot = prefixSegments.length > 0 ? path7.join(root, ...prefixSegments) : root;
1844
+ const prefix = prefixSegments.length > 0 ? `${prefixSegments.join("/")}/` : "";
1845
+ const files = await walkFiles(walkRoot, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1846
+ const matched = files.filter((f) => matchesGlob(args.pattern, `${prefix}${f.rel}`)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
1696
1847
  if (matched.length === 0) {
1697
1848
  return { content: `No files matched pattern: ${args.pattern}` };
1698
1849
  }
1699
- return { content: matched.map((f) => f.rel).join("\n") };
1850
+ return { content: matched.map((f) => `${prefix}${f.rel}`).join("\n") };
1700
1851
  }
1701
1852
  };
1702
1853
 
1703
1854
  // src/tools/fs/grep.ts
1704
- import { readFile as readFile4, stat as stat2 } from "fs/promises";
1705
- import path10 from "path";
1706
- import { z as z6 } from "zod";
1855
+ import { readFile as readFile3, stat as stat4 } from "fs/promises";
1856
+ import path8 from "path";
1857
+ import { z as z5 } from "zod";
1707
1858
  var MAX_MATCHES = 250;
1708
- var schema4 = z6.object({
1709
- pattern: z6.string().describe("Regular expression to search for"),
1710
- path: z6.string().optional().describe("File or directory to search, defaults to the working directory"),
1711
- glob: z6.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
1712
- ignoreCase: z6.boolean().optional().describe("Case-insensitive matching")
1859
+ var MAX_LINE_CHARS = 500;
1860
+ var MAX_PATTERN_CHARS = 200;
1861
+ var MAX_FILE_BYTES = 5 * 1024 * 1024;
1862
+ function isTooComplexPattern(pattern) {
1863
+ return pattern.length > MAX_PATTERN_CHARS || /\)[+*]/.test(pattern) || /\)\{\d+(,\d*)?\}/.test(pattern);
1864
+ }
1865
+ var schema4 = z5.object({
1866
+ pattern: z5.string().describe("Regular expression to search for"),
1867
+ path: z5.string().optional().describe("File or directory to search, defaults to the working directory"),
1868
+ glob: z5.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
1869
+ ignoreCase: z5.boolean().optional().describe("Case-insensitive matching")
1713
1870
  });
1714
1871
  var grepTool = {
1715
1872
  name: "grep",
1716
- description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, node_modules and .git.",
1873
+ description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, files over 5MB, sensitive files, node_modules and .git.",
1717
1874
  permission: "read",
1718
1875
  parameters: schema4,
1719
1876
  async execute(args, ctx) {
1877
+ if (isTooComplexPattern(args.pattern)) {
1878
+ return {
1879
+ content: `pattern too complex: keep it under ${MAX_PATTERN_CHARS} characters and avoid quantified groups like (a+)+`,
1880
+ isError: true
1881
+ };
1882
+ }
1720
1883
  let re;
1721
1884
  try {
1722
1885
  re = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
1723
1886
  } catch (err) {
1724
1887
  return { content: `Invalid regular expression: ${err.message}`, isError: true };
1725
1888
  }
1726
- const root = path10.resolve(ctx.cwd, args.path ?? ".");
1889
+ const root = path8.resolve(ctx.cwd, args.path ?? ".");
1727
1890
  let files;
1728
1891
  try {
1729
- const st = await stat2(root);
1892
+ const st = await stat4(root);
1730
1893
  if (st.isFile()) {
1731
- files = [{ abs: root, rel: path10.basename(root), mtimeMs: st.mtimeMs }];
1894
+ if (isSensitivePath(root)) {
1895
+ return {
1896
+ content: `Refused to grep sensitive file: ${args.path ?? "."}`,
1897
+ isError: true
1898
+ };
1899
+ }
1900
+ files = [{ abs: root, rel: path8.basename(root), mtimeMs: st.mtimeMs }];
1732
1901
  } else {
1733
- files = await walkFiles(root, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1902
+ const walked = await walkFiles(root, void 0, {
1903
+ ignore: createIgnorePredicate(ctx.cwd),
1904
+ withMtime: false
1905
+ });
1906
+ files = walked.filter((f) => !isSensitivePath(f.abs));
1734
1907
  }
1735
1908
  } catch {
1736
1909
  return { content: `Path not found: ${args.path ?? "."}`, isError: true };
@@ -1741,10 +1914,19 @@ var grepTool = {
1741
1914
  }
1742
1915
  const out = [];
1743
1916
  let truncated = false;
1917
+ let skippedLarge = 0;
1744
1918
  outer: for (const file of files) {
1919
+ const st = await stat4(file.abs).catch(() => null);
1920
+ if (!st) {
1921
+ continue;
1922
+ }
1923
+ if (st.size > MAX_FILE_BYTES) {
1924
+ skippedLarge += 1;
1925
+ continue;
1926
+ }
1745
1927
  let buf;
1746
1928
  try {
1747
- buf = await readFile4(file.abs);
1929
+ buf = await readFile3(file.abs);
1748
1930
  } catch {
1749
1931
  continue;
1750
1932
  }
@@ -1755,7 +1937,8 @@ var grepTool = {
1755
1937
  for (let i = 0; i < lines.length; i++) {
1756
1938
  const line = lines[i] ?? "";
1757
1939
  if (re.test(line)) {
1758
- out.push(`${file.rel}:${i + 1}:${line}`);
1940
+ const shown = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}\u2026` : line;
1941
+ out.push(`${file.rel}:${i + 1}:${shown}`);
1759
1942
  if (out.length >= MAX_MATCHES) {
1760
1943
  truncated = true;
1761
1944
  break outer;
@@ -1763,26 +1946,55 @@ var grepTool = {
1763
1946
  }
1764
1947
  }
1765
1948
  }
1949
+ const skippedNote = skippedLarge > 0 ? `(skipped ${skippedLarge} file(s) larger than 5MB)` : null;
1766
1950
  if (out.length === 0) {
1767
- return { content: `No matches for pattern: ${args.pattern}` };
1951
+ return {
1952
+ content: `No matches for pattern: ${args.pattern}${skippedNote ? ` ${skippedNote}` : ""}`
1953
+ };
1768
1954
  }
1769
1955
  if (truncated) {
1770
1956
  out.push(`... (truncated: more than ${MAX_MATCHES} matches)`);
1771
1957
  }
1958
+ if (skippedNote) {
1959
+ out.push(`... ${skippedNote}`);
1960
+ }
1772
1961
  return { content: out.join("\n") };
1773
1962
  }
1774
1963
  };
1775
1964
 
1776
1965
  // src/tools/fs/read.ts
1777
- import { readFile as readFile5, stat as stat3 } from "fs/promises";
1778
- import path11 from "path";
1779
- import { z as z7 } from "zod";
1966
+ import { open, stat as stat5 } from "fs/promises";
1967
+ import path9 from "path";
1968
+ import { z as z6 } from "zod";
1780
1969
  var DEFAULT_LIMIT = 2e3;
1781
1970
  var MAX_CHARS = 100 * 1024;
1782
- var schema5 = z7.object({
1783
- path: z7.string().describe("File path, absolute or relative to the working directory"),
1784
- offset: z7.number().int().positive().optional().describe("1-based line number to start reading from"),
1785
- limit: z7.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
1971
+ var MAX_READ_BYTES = 1024 * 1024;
1972
+ var BINARY_PROBE_BYTES = 8192;
1973
+ function decodeUtf16be(buf) {
1974
+ const len = buf.length - buf.length % 2;
1975
+ const swapped = Buffer.allocUnsafe(len);
1976
+ for (let k = 0; k < len; k += 2) {
1977
+ swapped[k] = buf[k + 1] ?? 0;
1978
+ swapped[k + 1] = buf[k] ?? 0;
1979
+ }
1980
+ return swapped.toString("utf16le");
1981
+ }
1982
+ function decodeTextBuffer(buf) {
1983
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
1984
+ return { text: buf.subarray(2).toString("utf16le") };
1985
+ }
1986
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
1987
+ return { text: decodeUtf16be(buf.subarray(2)) };
1988
+ }
1989
+ if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
1990
+ return { binary: true };
1991
+ }
1992
+ return { text: buf.toString("utf8") };
1993
+ }
1994
+ var schema5 = z6.object({
1995
+ path: z6.string().describe("File path, absolute or relative to the working directory"),
1996
+ offset: z6.number().int().positive().optional().describe("1-based line number to start reading from"),
1997
+ limit: z6.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
1786
1998
  });
1787
1999
  var readFileTool = {
1788
2000
  name: "read_file",
@@ -1790,30 +2002,48 @@ var readFileTool = {
1790
2002
  permission: "read",
1791
2003
  parameters: schema5,
1792
2004
  async execute(args, ctx) {
1793
- const filePath = path11.resolve(ctx.cwd, args.path);
1794
- if (isSensitivePath2(filePath)) {
2005
+ const filePath = path9.resolve(ctx.cwd, args.path);
2006
+ if (isSensitivePath(filePath)) {
1795
2007
  return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
1796
2008
  }
1797
- const st = await stat3(filePath).catch(() => null);
2009
+ const st = await stat5(filePath).catch(() => null);
1798
2010
  if (!st) {
1799
2011
  return { content: `File not found: ${args.path}`, isError: true };
1800
2012
  }
1801
2013
  if (st.isDirectory()) {
1802
2014
  return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
1803
2015
  }
1804
- const raw = await readFile5(filePath, "utf8");
1805
- const lines = raw.split("\n");
2016
+ const bytesToRead = Math.min(st.size, MAX_READ_BYTES);
2017
+ const fileCapped = st.size > MAX_READ_BYTES;
2018
+ const handle = await open(filePath, "r").catch(() => null);
2019
+ if (!handle) {
2020
+ return { content: `Failed to read ${args.path}`, isError: true };
2021
+ }
2022
+ let buf;
2023
+ try {
2024
+ const { bytesRead, buffer } = await handle.read(Buffer.alloc(bytesToRead), 0, bytesToRead, 0);
2025
+ buf = buffer.subarray(0, bytesRead);
2026
+ } finally {
2027
+ await handle.close().catch(() => {
2028
+ });
2029
+ }
2030
+ const decoded = decodeTextBuffer(buf);
2031
+ if ("binary" in decoded) {
2032
+ return { content: `Cannot read binary file: ${args.path}`, isError: true };
2033
+ }
2034
+ const lines = decoded.text.split("\n");
2035
+ const totalDesc = fileCapped ? `at least ${lines.length}` : `${lines.length}`;
1806
2036
  const start = args.offset ?? 1;
1807
2037
  const limit = args.limit ?? DEFAULT_LIMIT;
2038
+ if (start > lines.length) {
2039
+ return { content: `(offset ${start} beyond end of file; file has ${totalDesc} lines)` };
2040
+ }
1808
2041
  const end = Math.min(lines.length, start - 1 + limit);
1809
2042
  const out = [];
1810
2043
  let size = 0;
1811
2044
  let truncatedBySize = false;
1812
2045
  for (let i = start - 1; i < end; i++) {
1813
- const line = lines[i];
1814
- if (line === void 0) {
1815
- break;
1816
- }
2046
+ const line = (lines[i] ?? "").replace(/\r$/, "");
1817
2047
  const numbered = `${i + 1} ${line}`;
1818
2048
  if (size + numbered.length > MAX_CHARS) {
1819
2049
  truncatedBySize = true;
@@ -1825,22 +2055,22 @@ var readFileTool = {
1825
2055
  const lastShown = start - 1 + out.length;
1826
2056
  if (truncatedBySize) {
1827
2057
  out.push(
1828
- `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${lines.length})`
2058
+ `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${totalDesc})`
1829
2059
  );
1830
- } else if (end < lines.length) {
1831
- out.push(`... (truncated: showing lines ${start}-${end} of ${lines.length})`);
2060
+ } else if (end < lines.length || fileCapped) {
2061
+ out.push(`... (truncated: showing lines ${start}-${end} of ${totalDesc})`);
1832
2062
  }
1833
2063
  return { content: out.join("\n") };
1834
2064
  }
1835
2065
  };
1836
2066
 
1837
2067
  // src/tools/fs/write.ts
1838
- import { mkdir as mkdir3, writeFile as writeFile4 } from "fs/promises";
1839
- import path12 from "path";
1840
- import { z as z8 } from "zod";
1841
- var schema6 = z8.object({
1842
- path: z8.string().describe("File path, absolute or relative to the working directory"),
1843
- content: z8.string().describe("Full content to write to the file")
2068
+ import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
2069
+ import path10 from "path";
2070
+ import { z as z7 } from "zod";
2071
+ var schema6 = z7.object({
2072
+ path: z7.string().describe("File path, absolute or relative to the working directory"),
2073
+ content: z7.string().describe("Full content to write to the file")
1844
2074
  });
1845
2075
  var writeFileTool = {
1846
2076
  name: "write_file",
@@ -1848,11 +2078,11 @@ var writeFileTool = {
1848
2078
  permission: "write",
1849
2079
  parameters: schema6,
1850
2080
  async execute(args, ctx) {
1851
- const filePath = path12.resolve(ctx.cwd, args.path);
1852
- const snapshot = await captureSnapshot(filePath, "write_file");
2081
+ const filePath = path10.resolve(ctx.cwd, args.path);
2082
+ const snapshot = await captureSnapshot(filePath, "write_file", ctx.snapshotContext);
1853
2083
  try {
1854
- await mkdir3(path12.dirname(filePath), { recursive: true });
1855
- await writeFile4(filePath, args.content, "utf8");
2084
+ await mkdir3(path10.dirname(filePath), { recursive: true });
2085
+ await writeFile3(filePath, args.content, "utf8");
1856
2086
  } catch (err) {
1857
2087
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1858
2088
  }
@@ -1862,7 +2092,132 @@ var writeFileTool = {
1862
2092
  };
1863
2093
 
1864
2094
  // src/tools/tasks.ts
1865
- import { z as z9 } from "zod";
2095
+ import { z as z8 } from "zod";
2096
+
2097
+ // src/agent/agent-tasks.ts
2098
+ import { EventEmitter as EventEmitter2 } from "events";
2099
+ var MAX_FINISHED_RECORDS2 = 50;
2100
+ var AgentTaskManager = class extends EventEmitter2 {
2101
+ records = /* @__PURE__ */ new Map();
2102
+ counter = 0;
2103
+ start(run, meta) {
2104
+ const id = `agent-${++this.counter}`;
2105
+ const rec = {
2106
+ id,
2107
+ prompt: meta.prompt,
2108
+ description: meta.description,
2109
+ status: "running",
2110
+ startedAt: Date.now(),
2111
+ result: "",
2112
+ controller: new AbortController(),
2113
+ notified: false
2114
+ };
2115
+ this.records.set(id, rec);
2116
+ void run(rec.controller.signal).then((result) => this.finish(rec, "completed", result)).catch(
2117
+ (error) => this.finish(rec, "failed", error instanceof Error ? error.message : String(error))
2118
+ );
2119
+ this.emitUpdate(rec);
2120
+ return this.snapshot(rec);
2121
+ }
2122
+ get(id) {
2123
+ const rec = this.records.get(id);
2124
+ return rec ? this.snapshot(rec) : void 0;
2125
+ }
2126
+ list() {
2127
+ return [...this.records.values()].map((rec) => this.snapshot(rec));
2128
+ }
2129
+ runningCount() {
2130
+ let count = 0;
2131
+ for (const rec of this.records.values()) {
2132
+ if (rec.status === "running") count += 1;
2133
+ }
2134
+ return count;
2135
+ }
2136
+ kill(id) {
2137
+ const rec = this.records.get(id);
2138
+ if (!rec || rec.status !== "running") return void 0;
2139
+ rec.controller.abort();
2140
+ this.finish(rec, "stopped", rec.result || "Stopped by user.");
2141
+ return this.snapshot(rec);
2142
+ }
2143
+ cleanup() {
2144
+ const killed = [];
2145
+ for (const rec of this.records.values()) {
2146
+ if (rec.status === "running") {
2147
+ rec.controller.abort();
2148
+ this.finish(rec, "stopped", rec.result || "Stopped on shutdown.");
2149
+ killed.push(this.snapshot(rec));
2150
+ }
2151
+ }
2152
+ return killed;
2153
+ }
2154
+ // Finished tasks whose result has not yet been handed to the parent loop.
2155
+ drainNotifications() {
2156
+ const drained = [];
2157
+ for (const rec of this.records.values()) {
2158
+ if (rec.status !== "running" && !rec.notified) {
2159
+ rec.notified = true;
2160
+ drained.push(this.snapshot(rec));
2161
+ }
2162
+ }
2163
+ this.pruneFinished();
2164
+ return drained;
2165
+ }
2166
+ // Map iteration is insertion-ordered, so the first finished records met are
2167
+ // the oldest. Never drop an un-notified record: the parent has not seen its
2168
+ // result yet. Running records are untouched.
2169
+ pruneFinished() {
2170
+ let finished = 0;
2171
+ for (const rec of this.records.values()) {
2172
+ if (rec.status !== "running") finished += 1;
2173
+ }
2174
+ let excess = finished - MAX_FINISHED_RECORDS2;
2175
+ if (excess <= 0) return;
2176
+ for (const rec of this.records.values()) {
2177
+ if (excess <= 0) return;
2178
+ if (rec.status !== "running" && rec.notified) {
2179
+ this.records.delete(rec.id);
2180
+ excess -= 1;
2181
+ }
2182
+ }
2183
+ }
2184
+ finish(rec, status, result) {
2185
+ if (rec.status !== "running") return;
2186
+ rec.status = status;
2187
+ rec.result = result;
2188
+ rec.endedAt = Date.now();
2189
+ this.emitUpdate(rec);
2190
+ }
2191
+ emitUpdate(rec) {
2192
+ this.emit("update", this.snapshot(rec));
2193
+ }
2194
+ snapshot(rec) {
2195
+ return {
2196
+ id: rec.id,
2197
+ prompt: rec.prompt,
2198
+ description: rec.description,
2199
+ status: rec.status,
2200
+ startedAt: rec.startedAt,
2201
+ endedAt: rec.endedAt,
2202
+ result: rec.result
2203
+ };
2204
+ }
2205
+ };
2206
+ var defaultAgentTasks = new AgentTaskManager();
2207
+ function formatAgentTaskUpdate(task) {
2208
+ const label = task.description ? `: ${task.description}` : "";
2209
+ if (task.status === "running") {
2210
+ return `Background subagent ${task.id} started${label}`;
2211
+ }
2212
+ let text = `Background subagent ${task.id} ${task.status}${label}`;
2213
+ if (task.status === "failed") {
2214
+ const reason = (task.result.split("\n")[0] ?? "").replace(/^Subagent failed:\s*/, "");
2215
+ if (reason) {
2216
+ text += ` \u2014 ${reason.length > 140 ? `${reason.slice(0, 140)}\u2026` : reason}`;
2217
+ }
2218
+ }
2219
+ return text;
2220
+ }
1866
2221
 
1867
2222
  // src/tasks/format.ts
1868
2223
  function formatDuration(startedAt, endedAt) {
@@ -1908,9 +2263,14 @@ function formatTaskFinished(task) {
1908
2263
  }
1909
2264
 
1910
2265
  // src/tools/tasks.ts
1911
- var idSchema = z9.object({
1912
- id: z9.string().describe("Task id, e.g. task-1 or agent-1")
2266
+ var idSchema = z8.object({
2267
+ id: z8.string().describe("Task id, e.g. task-1 or agent-1")
1913
2268
  });
2269
+ function truncateOutput(s) {
2270
+ if (s.length <= MAX_OUTPUT) return s;
2271
+ return `[... earlier output truncated; showing the last ${MAX_OUTPUT} characters ...]
2272
+ ${s.slice(-MAX_OUTPUT)}`;
2273
+ }
1914
2274
  function formatAgentTaskLine(task) {
1915
2275
  const duration = formatDuration(task.startedAt, task.endedAt);
1916
2276
  const label = task.description ?? task.prompt.slice(0, 60);
@@ -1921,7 +2281,7 @@ var taskListTool = {
1921
2281
  name: "task_list",
1922
2282
  description: "List background tasks (shell commands and subagents) with status and runtime.",
1923
2283
  permission: "read",
1924
- parameters: z9.object({}),
2284
+ parameters: z8.object({}),
1925
2285
  execute() {
1926
2286
  const shell = formatTaskList(defaultTaskManager.list());
1927
2287
  const agents = defaultAgentTasks.list();
@@ -1939,13 +2299,13 @@ var taskOutputTool = {
1939
2299
  execute(args) {
1940
2300
  const shell = defaultTaskManager.get(args.id);
1941
2301
  if (shell) {
1942
- const body = shell.output.replace(/\s+$/, "") || "(no output yet)";
2302
+ const body = truncateOutput(shell.output.replace(/\s+$/, "") || "(no output yet)");
1943
2303
  return Promise.resolve({ content: `${formatTaskLine(shell)}
1944
2304
  ${body}` });
1945
2305
  }
1946
2306
  const agent = defaultAgentTasks.get(args.id);
1947
2307
  if (agent) {
1948
- const body = agent.status === "running" ? "(still running)" : agent.result.replace(/\s+$/, "") || "(no report)";
2308
+ const body = agent.status === "running" ? "(still running)" : truncateOutput(agent.result.replace(/\s+$/, "") || "(no report)");
1949
2309
  return Promise.resolve({ content: `${formatAgentTaskLine(agent)}
1950
2310
  ${body}` });
1951
2311
  }
@@ -1979,6 +2339,133 @@ var taskKillTool = {
1979
2339
  }
1980
2340
  };
1981
2341
 
2342
+ // src/tools/todo.ts
2343
+ import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
2344
+ import path11 from "path";
2345
+ import { z as z9 } from "zod";
2346
+ var todoItemSchema = z9.object({
2347
+ id: z9.number().int(),
2348
+ title: z9.string(),
2349
+ status: z9.enum(["pending", "in_progress", "done"])
2350
+ });
2351
+ var writeSchema = z9.object({
2352
+ todos: z9.array(todoItemSchema).describe("The full todo list, replacing the current one")
2353
+ });
2354
+ var readSchema = z9.object({});
2355
+ function fileFor(cwd) {
2356
+ return path11.join(cwd, ".star", "todos.json");
2357
+ }
2358
+ var persistGuard = () => true;
2359
+ function setTodoPersistGuard(guard) {
2360
+ persistGuard = guard;
2361
+ }
2362
+ var TodoStore = class {
2363
+ items = /* @__PURE__ */ new Map();
2364
+ list() {
2365
+ return [...this.items.values()];
2366
+ }
2367
+ replace(items) {
2368
+ this.items.clear();
2369
+ for (const item of items) {
2370
+ this.items.set(item.id, item);
2371
+ }
2372
+ }
2373
+ async load(cwd) {
2374
+ let raw;
2375
+ try {
2376
+ raw = await readFile4(fileFor(cwd), "utf8");
2377
+ } catch {
2378
+ return;
2379
+ }
2380
+ try {
2381
+ const parsed = JSON.parse(raw);
2382
+ if (Array.isArray(parsed)) {
2383
+ this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
2384
+ }
2385
+ } catch {
2386
+ }
2387
+ }
2388
+ // Atomic write (tmp + rename): a crash or failed save can never leave a
2389
+ // truncated todos.json behind.
2390
+ async save(cwd) {
2391
+ if (!persistGuard()) {
2392
+ return;
2393
+ }
2394
+ await mkdir4(path11.join(cwd, ".star"), { recursive: true });
2395
+ const file = fileFor(cwd);
2396
+ const tmp = `${file}.tmp`;
2397
+ await writeFile4(tmp, `${JSON.stringify(this.list(), null, 2)}
2398
+ `);
2399
+ await rename(tmp, file);
2400
+ }
2401
+ };
2402
+ var SYMBOLS = {
2403
+ pending: "[ ]",
2404
+ in_progress: "[~]",
2405
+ done: "[x]"
2406
+ };
2407
+ function formatTodos(items) {
2408
+ if (items.length === 0) {
2409
+ return "No todos.";
2410
+ }
2411
+ const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
2412
+ const width = Math.max(...lines.map((l) => l.length));
2413
+ return lines.map((line, i) => {
2414
+ const status = items[i]?.status;
2415
+ return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
2416
+ }).join("\n");
2417
+ }
2418
+ var defaultStore = new TodoStore();
2419
+ function parseTodoArgs(args) {
2420
+ const parsed = writeSchema.safeParse(args);
2421
+ return parsed.success ? parsed.data.todos : null;
2422
+ }
2423
+ function pendingTodoTitles(args) {
2424
+ return (parseTodoArgs(args) ?? []).filter((t) => t.status !== "done").map((t) => t.title);
2425
+ }
2426
+ async function loadTodos(cwd) {
2427
+ await defaultStore.load(cwd);
2428
+ return defaultStore.list();
2429
+ }
2430
+ function resetTodos() {
2431
+ defaultStore.replace([]);
2432
+ }
2433
+ function createTodoTools(store = defaultStore) {
2434
+ const todoWrite = {
2435
+ name: "todo_write",
2436
+ description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
2437
+ permission: "read",
2438
+ parameters: writeSchema,
2439
+ async execute(args, ctx) {
2440
+ const parsed = writeSchema.safeParse(args);
2441
+ if (!parsed.success) {
2442
+ return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
2443
+ }
2444
+ const previous = store.list();
2445
+ store.replace(parsed.data.todos);
2446
+ try {
2447
+ await store.save(ctx.cwd);
2448
+ } catch (err) {
2449
+ store.replace(previous);
2450
+ return { content: `Failed to save todos: ${err.message}`, isError: true };
2451
+ }
2452
+ const items = store.list();
2453
+ const done = items.filter((it) => it.status === "done").length;
2454
+ return { content: `Todo list updated (${done}/${items.length} done).` };
2455
+ }
2456
+ };
2457
+ const todoRead = {
2458
+ name: "todo_read",
2459
+ description: "Read the current todo list.",
2460
+ permission: "read",
2461
+ parameters: readSchema,
2462
+ async execute() {
2463
+ return { content: formatTodos(store.list()) };
2464
+ }
2465
+ };
2466
+ return [todoWrite, todoRead];
2467
+ }
2468
+
1982
2469
  // src/tools/web/fetch.ts
1983
2470
  import { z as z10 } from "zod";
1984
2471
  var DEFAULT_MAX_CHARS = 2e4;
@@ -1988,8 +2475,14 @@ var schema7 = z10.object({
1988
2475
  url: z10.string().describe("The http(s) URL to fetch"),
1989
2476
  maxChars: z10.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
1990
2477
  });
2478
+ function safeCodePoint(n, raw) {
2479
+ if (!Number.isInteger(n) || n < 0 || n > 1114111) {
2480
+ return raw;
2481
+ }
2482
+ return String.fromCodePoint(n);
2483
+ }
1991
2484
  function decodeEntities(s) {
1992
- return s.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-fA-F]+);/gi, (_, n) => String.fromCodePoint(Number.parseInt(n, 16))).replace(/&(quot|lt|gt|nbsp);/g, (m) => {
2485
+ return s.replace(/&#(\d+);/g, (m, n) => safeCodePoint(Number(n), m)).replace(/&#x([0-9a-fA-F]+);/gi, (m, n) => safeCodePoint(Number.parseInt(n, 16), m)).replace(/&(quot|lt|gt|nbsp);/g, (m) => {
1993
2486
  switch (m) {
1994
2487
  case "&quot;":
1995
2488
  return '"';
@@ -2049,7 +2542,7 @@ function truncate2(s, maxChars) {
2049
2542
  }
2050
2543
  var webFetchTool = {
2051
2544
  name: "web_fetch",
2052
- description: "Fetch a URL over http(s) and return its contents as readable text. HTML pages are converted to plain text (tags, scripts and styles removed). Output is truncated to maxChars (default 20000).",
2545
+ description: "Fetch a URL over http(s) and return its contents as readable text. HTML pages are converted to plain text (tags, scripts and styles removed). Output is truncated to maxChars (default 20000). Can reach internal/intranet addresses, so do not use it for untrusted URLs.",
2053
2546
  permission: "read",
2054
2547
  parameters: schema7,
2055
2548
  async execute(args, ctx) {
@@ -2231,7 +2724,9 @@ var webSearchTool = {
2231
2724
  // src/tools/registry.ts
2232
2725
  var ToolRegistry = class {
2233
2726
  tools = /* @__PURE__ */ new Map();
2234
- constructor() {
2727
+ // todoStore defaults to the module-level shared store; subagent loops pass
2728
+ // their own instance so a child's todo_write cannot clobber the parent list.
2729
+ constructor(todoStore) {
2235
2730
  for (const tool of [
2236
2731
  readFileTool,
2237
2732
  writeFileTool,
@@ -2244,7 +2739,7 @@ var ToolRegistry = class {
2244
2739
  taskListTool,
2245
2740
  taskOutputTool,
2246
2741
  taskKillTool,
2247
- ...createTodoTools()
2742
+ ...createTodoTools(todoStore)
2248
2743
  ]) {
2249
2744
  this.register(tool);
2250
2745
  }
@@ -2263,13 +2758,165 @@ var ToolRegistry = class {
2263
2758
  }
2264
2759
  };
2265
2760
 
2266
- // src/tools/index.ts
2267
- function createDefaultRegistry() {
2268
- const registry = new ToolRegistry();
2269
- for (const tool of createTodoTools()) {
2270
- registry.register(tool);
2761
+ // src/agent/project-memory.ts
2762
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
2763
+ import path12 from "path";
2764
+ var PROJECT_MEMORY_FILE = "AGENTS.md";
2765
+ var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
2766
+ var cache = /* @__PURE__ */ new Map();
2767
+ function readProjectMemory(cwd) {
2768
+ const file = path12.join(cwd, PROJECT_MEMORY_FILE);
2769
+ let mtimeMs;
2770
+ try {
2771
+ mtimeMs = statSync2(file).mtimeMs;
2772
+ } catch {
2773
+ return null;
2774
+ }
2775
+ const hit = cache.get(file);
2776
+ if (hit && hit.mtimeMs === mtimeMs) return hit.block;
2777
+ let block = null;
2778
+ try {
2779
+ let body = readFileSync2(file, "utf8").trim();
2780
+ if (body) {
2781
+ if (body.length > PROJECT_MEMORY_MAX_CHARS) {
2782
+ body = `${body.slice(0, PROJECT_MEMORY_MAX_CHARS)}
2783
+
2784
+ [AGENTS.md truncated to ${PROJECT_MEMORY_MAX_CHARS} characters]`;
2785
+ }
2786
+ block = `# Project instructions (AGENTS.md)
2787
+ ${body}`;
2788
+ }
2789
+ } catch {
2790
+ block = null;
2791
+ }
2792
+ cache.set(file, { mtimeMs, block });
2793
+ return block;
2794
+ }
2795
+
2796
+ // src/agent/skills.ts
2797
+ import { readFileSync as readFileSync3, readdirSync, statSync as statSync3 } from "fs";
2798
+ import path13 from "path";
2799
+ import { z as z12 } from "zod";
2800
+ var SKILL_FILE = "SKILL.md";
2801
+ var SKILL_MAX_CHARS = 32 * 1024;
2802
+ var NAME_PATTERN = /^[a-z0-9-]+$/;
2803
+ function parseFrontmatter(raw) {
2804
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
2805
+ if (!match) return null;
2806
+ const meta = {};
2807
+ for (const line of (match[1] ?? "").split(/\r?\n/)) {
2808
+ const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
2809
+ if (kv?.[1]) meta[kv[1]] = (kv[2] ?? "").trim();
2810
+ }
2811
+ return { meta, body: raw.slice(match[0].length).trim() };
2812
+ }
2813
+ var cache2 = /* @__PURE__ */ new Map();
2814
+ function parseSkillFile(filePath, dirName, scope) {
2815
+ let mtimeMs;
2816
+ try {
2817
+ mtimeMs = statSync3(filePath).mtimeMs;
2818
+ } catch {
2819
+ return null;
2820
+ }
2821
+ const hit = cache2.get(filePath);
2822
+ if (hit && hit.mtimeMs === mtimeMs) return hit.skill;
2823
+ let skill = null;
2824
+ try {
2825
+ const parsed = parseFrontmatter(readFileSync3(filePath, "utf8"));
2826
+ const name = parsed?.meta.name || dirName;
2827
+ const description = parsed?.meta.description;
2828
+ if (parsed && NAME_PATTERN.test(name) && description) {
2829
+ skill = { name, description, filePath, dir: path13.dirname(filePath), scope };
2830
+ }
2831
+ } catch {
2832
+ skill = null;
2271
2833
  }
2272
- return registry;
2834
+ cache2.set(filePath, { mtimeMs, skill });
2835
+ return skill;
2836
+ }
2837
+ function scanDir(base, scope, into) {
2838
+ let entries;
2839
+ try {
2840
+ entries = readdirSync(base);
2841
+ } catch {
2842
+ return;
2843
+ }
2844
+ for (const entry of entries.sort()) {
2845
+ if (!NAME_PATTERN.test(entry)) continue;
2846
+ const parsed = parseSkillFile(path13.join(base, entry, SKILL_FILE), entry, scope);
2847
+ if (parsed) into.set(parsed.name, parsed);
2848
+ }
2849
+ }
2850
+ function discoverSkills(cwd, home = starHome()) {
2851
+ const byName = /* @__PURE__ */ new Map();
2852
+ scanDir(path13.join(home, "skills"), "user", byName);
2853
+ scanDir(path13.join(cwd, ".star", "skills"), "project", byName);
2854
+ return [...byName.values()];
2855
+ }
2856
+ function formatSkillsBlock(skills) {
2857
+ const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
2858
+ return [
2859
+ "# Available skills",
2860
+ "When the user's request matches one of these skills, call the skill tool with its name to load the full instructions before proceeding:",
2861
+ ...lines
2862
+ ].join("\n");
2863
+ }
2864
+ function loadSkillBody(skill) {
2865
+ let parsed = null;
2866
+ try {
2867
+ parsed = parseFrontmatter(readFileSync3(skill.filePath, "utf8"));
2868
+ } catch {
2869
+ return null;
2870
+ }
2871
+ if (!parsed?.body) return null;
2872
+ if (parsed.body.length > SKILL_MAX_CHARS) {
2873
+ return `${parsed.body.slice(0, SKILL_MAX_CHARS)}
2874
+
2875
+ [skill truncated to ${SKILL_MAX_CHARS} characters]`;
2876
+ }
2877
+ return parsed.body;
2878
+ }
2879
+ function createSkillTool(deps) {
2880
+ return {
2881
+ name: "skill",
2882
+ 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.",
2883
+ permission: "read",
2884
+ parameters: z12.object({
2885
+ name: z12.string().describe("Name of the skill to load, as listed in the system prompt.")
2886
+ }),
2887
+ async execute(args) {
2888
+ const skills = discoverSkills(deps.cwd, deps.home);
2889
+ const skill = skills.find((s) => s.name === args.name);
2890
+ if (!skill) {
2891
+ const available = skills.map((s) => s.name).join(", ") || "none";
2892
+ return {
2893
+ content: `Unknown skill: "${args.name}". Available skills: ${available}`,
2894
+ isError: true
2895
+ };
2896
+ }
2897
+ const body = loadSkillBody(skill);
2898
+ if (!body) {
2899
+ return { content: `Skill "${skill.name}" is empty or unreadable.`, isError: true };
2900
+ }
2901
+ return {
2902
+ content: [
2903
+ `# Skill: ${skill.name}`,
2904
+ "",
2905
+ body,
2906
+ "",
2907
+ `Base directory for relative file references in this skill: ${skill.dir}`
2908
+ ].join("\n")
2909
+ };
2910
+ }
2911
+ };
2912
+ }
2913
+
2914
+ // src/agent/subagent.ts
2915
+ import { z as z13 } from "zod";
2916
+
2917
+ // src/tools/index.ts
2918
+ function createDefaultRegistry(todoStore) {
2919
+ return new ToolRegistry(todoStore);
2273
2920
  }
2274
2921
 
2275
2922
  // src/agent/subagent.ts
@@ -2277,7 +2924,7 @@ var MAX_SUBAGENT_DEPTH = 1;
2277
2924
  var MAX_REPORT_CHARS = 2e4;
2278
2925
  var SUBAGENT_PROMPT = "You are a subagent spawned to handle a focused subtask on behalf of the main agent. Work autonomously with the tools available to you, then finish with a concise report of what you found or did. Your final text is returned to the main agent as the tool result \u2014 make it self-contained.";
2279
2926
  async function runSubagent(deps, args, cwd, signal) {
2280
- const { AgentLoop: AgentLoop2 } = await import("./loop-XKC5HQBT.js");
2927
+ const { AgentLoop: AgentLoop2 } = await import("./loop-SDZVEQND.js");
2281
2928
  const child = new AgentLoop2({
2282
2929
  model: deps.model,
2283
2930
  registry: createDefaultRegistry(),
@@ -2304,6 +2951,9 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2304
2951
  } catch (e) {
2305
2952
  error = e instanceof Error ? e.message : String(e);
2306
2953
  }
2954
+ if (signal.aborted) {
2955
+ throw new Error("Tool execution aborted.");
2956
+ }
2307
2957
  let report = "";
2308
2958
  const messages = child.getMessages();
2309
2959
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -2332,10 +2982,10 @@ function createSubagentTool(deps) {
2332
2982
  name: "subagent",
2333
2983
  description: "Spawn a subagent with its own agent loop to handle a focused subtask (research, exploration, or an isolated change). The subagent has the same tools but cannot spawn further subagents. Returns the subagent's final report. Prefer this for self-contained work that would otherwise clutter the main conversation. Set run_in_background to start it without blocking: independent subtasks then run concurrently and their reports are delivered to you automatically when they finish (also inspectable via task_list/task_output/task_kill).",
2334
2984
  permission: "exec",
2335
- parameters: z12.object({
2336
- prompt: z12.string().describe("Complete, self-contained instructions for the subagent."),
2337
- description: z12.string().optional().describe("Short label for the subtask, shown in the result header."),
2338
- run_in_background: z12.boolean().optional().describe(
2985
+ parameters: z13.object({
2986
+ prompt: z13.string().describe("Complete, self-contained instructions for the subagent."),
2987
+ description: z13.string().optional().describe("Short label for the subtask, shown in the result header."),
2988
+ run_in_background: z13.boolean().optional().describe(
2339
2989
  "Run concurrently in the background and return a task id immediately (default false). Use for independent subtasks that can run in parallel."
2340
2990
  )
2341
2991
  }),
@@ -2362,13 +3012,13 @@ function createSubagentTool(deps) {
2362
3012
 
2363
3013
  // src/agent/user-memory.ts
2364
3014
  import { appendFileSync, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
2365
- import path13 from "path";
2366
- import { z as z13 } from "zod";
3015
+ import path14 from "path";
3016
+ import { z as z14 } from "zod";
2367
3017
  var USER_MEMORY_MAX_CHARS = 32 * 1024;
2368
3018
  var REMEMBER_TEXT_MAX_CHARS = 500;
2369
3019
  var cache3 = /* @__PURE__ */ new Map();
2370
3020
  function readUserMemory(home = starHome()) {
2371
- const file = path13.join(home, "MEMORY.md");
3021
+ const file = path14.join(home, "MEMORY.md");
2372
3022
  let mtimeMs;
2373
3023
  try {
2374
3024
  mtimeMs = statSync4(file).mtimeMs;
@@ -2400,7 +3050,7 @@ function readUserMemory(home = starHome()) {
2400
3050
  }
2401
3051
  function appendUserMemory(text, home = starHome()) {
2402
3052
  mkdirSync(home, { recursive: true });
2403
- appendFileSync(path13.join(home, "MEMORY.md"), `- ${text}
3053
+ appendFileSync(path14.join(home, "MEMORY.md"), `- ${text}
2404
3054
  `, "utf8");
2405
3055
  }
2406
3056
  function createRememberTool(deps = {}) {
@@ -2408,8 +3058,8 @@ function createRememberTool(deps = {}) {
2408
3058
  name: "remember",
2409
3059
  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.`,
2410
3060
  permission: "write",
2411
- parameters: z13.object({
2412
- text: z13.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
3061
+ parameters: z14.object({
3062
+ text: z14.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
2413
3063
  }),
2414
3064
  async execute(args) {
2415
3065
  appendUserMemory(args.text, deps.home);
@@ -2467,6 +3117,24 @@ ${finalReply.slice(0, 2e3)}
2467
3117
  }
2468
3118
  }
2469
3119
  var STREAM_RETRY_BASE_DELAY_MS = 1e3;
3120
+ var REDO_LABEL_MAX = 40;
3121
+ function messageText(message) {
3122
+ if (!message) return "";
3123
+ const content = message.content;
3124
+ if (typeof content === "string") return content;
3125
+ if (Array.isArray(content)) {
3126
+ return content.filter((part) => part.type === "text").map((part) => "text" in part ? part.text : "").join(" ");
3127
+ }
3128
+ return "";
3129
+ }
3130
+ function stableStringify(value) {
3131
+ if (value === void 0) return "undefined";
3132
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
3133
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
3134
+ const record = value;
3135
+ const entries = Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
3136
+ return `{${entries.join(",")}}`;
3137
+ }
2470
3138
  function sleep(ms, signal) {
2471
3139
  if (signal.aborted) return Promise.resolve(false);
2472
3140
  return new Promise((resolve) => {
@@ -2485,11 +3153,32 @@ var AgentLoop = class {
2485
3153
  messages = [];
2486
3154
  opts;
2487
3155
  // Seq + user-message index of each turn started via stream(); lets /undo
2488
- // match a retracted turn to the file snapshots it produced. Cleared when
2489
- // history is replaced wholesale (resume/compact), because indices no longer
2490
- // line up — in that case /undo only retracts messages, never wrong files.
3156
+ // match a retracted turn to the file snapshots it produced. `tree` is the
3157
+ // whole-tree git snapshot captured at the turn's start (root loop only,
3158
+ // config.gitSnapshots) — when present, /undo restores it instead of
3159
+ // replaying per-file snapshots, which also covers bash-made changes.
3160
+ // Cleared when history is replaced wholesale (resume/compact), because
3161
+ // indices no longer line up — in that case /undo only retracts messages,
3162
+ // never wrong files.
2491
3163
  turnMarkers = [];
3164
+ // Pre-undo tree snapshots, latest last: a git-path /undo pushes the
3165
+ // pre-undo working tree here so /redo can restore it exactly. Only the git
3166
+ // path feeds this — per-file snapshot undos stay non-redoable.
3167
+ redoStack = [];
3168
+ // Start of the most recently retracted turn's user message; undoLastTurn
3169
+ // consumes it as the redo label right after retractLastTurn sets it.
3170
+ lastUndoneLabel = "";
3171
+ // Doom-loop guard: signature of the most recent tool call and how many
3172
+ // times in a row it has repeated. Any different call resets the count.
3173
+ lastToolSignature = null;
3174
+ repeatedToolCalls = 0;
3175
+ // Turn context of the in-flight stream() turn, stamped onto every tool
3176
+ // execution so file snapshots attribute root changes to a turn and mark
3177
+ // subagent changes as excluded from turn-level retraction.
3178
+ activeTurnSeq = 0;
3179
+ activeTurnUserIndex = -1;
2492
3180
  titleScheduled = false;
3181
+ titleInput;
2493
3182
  confirmHandler;
2494
3183
  // Hook failures never block the turn (except an explicit PreToolUse block);
2495
3184
  // their stderr surfaces through this callback (REPL system message / stderr
@@ -2497,6 +3186,15 @@ var AgentLoop = class {
2497
3186
  onHookWarning;
2498
3187
  constructor(opts) {
2499
3188
  this.opts = opts;
3189
+ const depth = opts.subagentDepth ?? 0;
3190
+ if (depth > 0) {
3191
+ opts.registry = new ToolRegistry(new TodoStore());
3192
+ } else {
3193
+ setTodoPersistGuard(() => {
3194
+ const mode = this.opts.config.permissionMode;
3195
+ return mode !== "readonly" && mode !== "plan";
3196
+ });
3197
+ }
2500
3198
  if (opts.system) {
2501
3199
  this.messages.push({ role: "system", content: opts.system });
2502
3200
  }
@@ -2505,7 +3203,6 @@ var AgentLoop = class {
2505
3203
  }
2506
3204
  opts.registry?.register(createSkillTool({ cwd: opts.cwd }));
2507
3205
  opts.registry?.register(createRememberTool());
2508
- const depth = opts.subagentDepth ?? 0;
2509
3206
  if (opts.registry && depth < MAX_SUBAGENT_DEPTH) {
2510
3207
  opts.registry.register(
2511
3208
  createSubagentTool({
@@ -2524,10 +3221,14 @@ var AgentLoop = class {
2524
3221
  }
2525
3222
  // Swaps the persistence target (/new starts a fresh session mid-REPL).
2526
3223
  // Title generation is re-armed so the new session gets one after its first
2527
- // turn, and snapshot checkpoints now flow to the new store.
3224
+ // turn, and snapshot checkpoints now flow to the new store. The redo stack
3225
+ // dies with the old session context: its trees describe working-tree states
3226
+ // that no longer line up with the new session's conversation.
2528
3227
  setSessionStore(store) {
2529
3228
  this.opts.sessionStore = store;
2530
3229
  this.titleScheduled = false;
3230
+ this.titleInput = void 0;
3231
+ this.redoStack = [];
2531
3232
  if (store) this.bindSnapshotHooks(store);
2532
3233
  }
2533
3234
  bindSnapshotHooks(store) {
@@ -2540,7 +3241,9 @@ var AgentLoop = class {
2540
3241
  existed: snapshot.existed,
2541
3242
  toolName: snapshot.toolName,
2542
3243
  turn: snapshot.turn,
2543
- messageIndex: snapshot.messageIndex
3244
+ messageIndex: snapshot.messageIndex,
3245
+ owner: snapshot.owner,
3246
+ contentTooLarge: snapshot.contentTooLarge
2544
3247
  },
2545
3248
  snapshot.content
2546
3249
  ),
@@ -2550,37 +3253,92 @@ var AgentLoop = class {
2550
3253
  async loadMessages(messages) {
2551
3254
  this.messages = reconcileToolCalls(messages);
2552
3255
  this.turnMarkers = [];
3256
+ this.redoStack = [];
2553
3257
  }
2554
3258
  // Read-only counterpart of retractLastTurn: how many messages /undo would
2555
- // drop and which snapshot turn's file changes it would revert (undefined =
2556
- // no verified turn, so no file snapshots may be reverted). Mutates nothing,
2557
- // so the REPL can show the preview before the user confirms.
3259
+ // drop, which snapshot turn's file changes it would revert (undefined =
3260
+ // no verified turn, so no file snapshots may be reverted), and the turn's
3261
+ // whole-tree snapshot when one was captured. Mutates nothing, so the REPL
3262
+ // can show the preview before the user confirms.
2558
3263
  previewLastTurnRetraction() {
2559
3264
  const result = retractLastTurn([...this.messages]);
2560
3265
  if (result.removed === 0) return { removed: 0 };
2561
3266
  const userIndex = result.messages.length;
2562
3267
  const last = this.turnMarkers[this.turnMarkers.length - 1];
2563
- const turn = last && last.userIndex === userIndex ? last.seq : void 0;
2564
- return { removed: result.removed, turn };
3268
+ const verified = last && last.userIndex === userIndex;
3269
+ return {
3270
+ removed: result.removed,
3271
+ turn: verified ? last.seq : void 0,
3272
+ tree: verified ? last.tree : void 0
3273
+ };
2565
3274
  }
2566
3275
  // Drops the final user message and everything after it, and persists the
2567
3276
  // trimmed history so a later /resume does not bring the turn back.
2568
3277
  // `turn` is the retracted turn's snapshot seq when it could be verified
2569
3278
  // against the marker recorded at turn start — undefined means the caller
2570
- // must not revert any file snapshots.
3279
+ // must not revert any file snapshots. `tree` is the turn's whole-tree
3280
+ // snapshot when git snapshots tracked it.
2571
3281
  async retractLastTurn() {
2572
3282
  const result = retractLastTurn([...this.messages]);
2573
3283
  if (result.removed === 0) return { removed: 0 };
2574
3284
  const userIndex = result.messages.length;
3285
+ this.lastUndoneLabel = messageText(this.messages[userIndex]).slice(0, REDO_LABEL_MAX);
2575
3286
  this.messages = result.messages;
2576
3287
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
2577
3288
  const last = this.turnMarkers[this.turnMarkers.length - 1];
2578
3289
  let turn;
3290
+ let tree;
2579
3291
  if (last && last.userIndex === userIndex) {
2580
3292
  turn = last.seq;
3293
+ tree = last.tree;
2581
3294
  this.turnMarkers.pop();
2582
3295
  }
2583
- return { removed: result.removed, turn };
3296
+ return { removed: result.removed, turn, tree };
3297
+ }
3298
+ // The confirmed /undo: retract the last turn, then revert its file changes.
3299
+ // With a whole-tree snapshot the working tree is restored from git — which
3300
+ // also reverts changes no tool snapshot saw (bash edits, deleted files) —
3301
+ // and the turn's per-file snapshots are dropped instead of replayed (they
3302
+ // would double-restore). The pre-undo tree feeds the redo stack, so /redo
3303
+ // returns to the exact state before the undo. Any git failure falls back
3304
+ // to the per-file snapshot revert, which never pushes the redo stack.
3305
+ async undoLastTurn() {
3306
+ const { removed, turn, tree } = await this.retractLastTurn();
3307
+ if (removed === 0) return { removed, reverted: [] };
3308
+ if (turn !== void 0 && tree !== void 0) {
3309
+ const preUndoTree = await trackTree(this.opts.cwd);
3310
+ if (preUndoTree !== null && await restoreTree(this.opts.cwd, tree)) {
3311
+ this.redoStack.push({ tree: preUndoTree, label: this.lastUndoneLabel });
3312
+ const dropped = await dropTurnSnapshots(turn, true);
3313
+ return {
3314
+ removed,
3315
+ reverted: [
3316
+ `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).`
3317
+ ],
3318
+ tree
3319
+ };
3320
+ }
3321
+ }
3322
+ const reverted = turn !== void 0 ? await undoTurnSnapshots(turn) : [];
3323
+ return { removed, reverted };
3324
+ }
3325
+ // The redo entry /redo would restore, without popping it (for the confirm
3326
+ // prompt); null when there is nothing to redo.
3327
+ peekRedo() {
3328
+ return this.redoStack[this.redoStack.length - 1] ?? null;
3329
+ }
3330
+ // Restores the working tree to its state just before the last git-path
3331
+ // /undo. Conversation messages are not re-added — redo is file-level only.
3332
+ // The entry stays on the stack when the restore fails, so it can be retried.
3333
+ async redoLastUndo() {
3334
+ const entry = this.redoStack[this.redoStack.length - 1];
3335
+ if (!entry) return null;
3336
+ const names = await diffTreeNames(this.opts.cwd, entry.tree);
3337
+ if (!await restoreTree(this.opts.cwd, entry.tree)) {
3338
+ return { ok: false, tree: entry.tree, label: entry.label, files: null };
3339
+ }
3340
+ this.redoStack.pop();
3341
+ return { ok: true, tree: entry.tree, label: entry.label, files: names?.length ?? null };
2584
3342
  }
2585
3343
  // Cut point for a rewind: messages[index] should be the user message that
2586
3344
  // started the rewound turn, but compaction may have shifted indices, so
@@ -2626,13 +3384,18 @@ var AgentLoop = class {
2626
3384
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
2627
3385
  return removed;
2628
3386
  }
2629
- // After the first assistant reply, kick off background title generation
2630
- // for the session. Runs once per loop; the store-level title check makes
2631
- // resumed sessions that already have a title a no-op.
2632
- maybeScheduleTitle(input) {
3387
+ // Background title generation runs once per loop, fed by the first real
3388
+ // user message of the session. It must NOT latch on the first assistant
3389
+ // step's text: that text is empty for a tool-call-only first step (the
3390
+ // common agentic case), which used to burn the one-shot flag on an empty
3391
+ // input and left every such session untitled. The store-level title check
3392
+ // makes resumed sessions that already have a title a no-op.
3393
+ maybeScheduleTitle() {
2633
3394
  if (this.titleScheduled) return;
2634
3395
  const store = this.opts.sessionStore;
2635
3396
  if (!store) return;
3397
+ const input = this.titleInput;
3398
+ if (!input) return;
2636
3399
  this.titleScheduled = true;
2637
3400
  scheduleSessionTitle(store, input, this.opts.model);
2638
3401
  }
@@ -2681,6 +3444,9 @@ var AgentLoop = class {
2681
3444
  this.syncSystemMessage();
2682
3445
  const inputText = typeof input === "string" ? input : input.text;
2683
3446
  const images = typeof input === "string" ? [] : input.images;
3447
+ if (this.titleInput === void 0 && inputText.trim().length > 0) {
3448
+ this.titleInput = inputText;
3449
+ }
2684
3450
  const userMessage = images.length > 0 ? {
2685
3451
  role: "user",
2686
3452
  content: [
@@ -2692,12 +3458,17 @@ var AgentLoop = class {
2692
3458
  { type: "text", text: inputText }
2693
3459
  ]
2694
3460
  } : { role: "user", content: inputText };
3461
+ const isRoot = (this.opts.subagentDepth ?? 0) === 0;
3462
+ const tree = isRoot && this.opts.config.gitSnapshots ? await trackTree(this.opts.cwd) ?? void 0 : void 0;
2695
3463
  this.messages.push(userMessage);
2696
- const seq = (this.opts.subagentDepth ?? 0) === 0 ? beginTurn(this.messages.length - 1) : currentTurnSeq();
2697
- this.turnMarkers.push({ seq, userIndex: this.messages.length - 1 });
3464
+ const seq = isRoot ? beginTurn(this.messages.length - 1) : currentTurnSeq();
3465
+ this.turnMarkers.push({ seq, userIndex: this.messages.length - 1, tree });
3466
+ this.activeTurnSeq = seq;
3467
+ this.activeTurnUserIndex = this.messages.length - 1;
2698
3468
  await this.persist(
2699
3469
  opts?.persistAs !== void 0 ? { role: "user", content: opts.persistAs } : userMessage
2700
3470
  );
3471
+ this.maybeScheduleTitle();
2701
3472
  const { config, registry, cwd } = this.opts;
2702
3473
  const aiTools = this.buildAiTools();
2703
3474
  let autoContinues = 0;
@@ -2718,9 +3489,13 @@ ${finished.result}`
2718
3489
  }
2719
3490
  }
2720
3491
  const maxTokens = this.opts.contextMaxTokens ?? config.contextMaxTokens;
2721
- const compacted = compactMessages([...this.messages], maxTokens);
3492
+ const compacted = compactMessages(
3493
+ [...this.messages],
3494
+ resolveCompactThreshold(config, maxTokens)
3495
+ );
2722
3496
  if (compacted.compacted) {
2723
- this.messages = await this.applyCompactionSummary(compacted);
3497
+ this.messages = await this.applyCompactionSummary(compacted, signal);
3498
+ this.turnMarkers = [];
2724
3499
  }
2725
3500
  const maxRetries = Math.max(0, config.streamMaxRetries);
2726
3501
  const baseDelay = this.opts.retryDelayMs ?? STREAM_RETRY_BASE_DELAY_MS;
@@ -2785,7 +3560,7 @@ ${finished.result}`
2785
3560
  if (attempt >= maxRetries) break;
2786
3561
  if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
2787
3562
  break;
2788
- const delayMs = computeRetryDelayMs(attempt, baseDelay, failure);
3563
+ const delayMs = computeRetryDelayMs(attempt, failure ? baseDelay : baseDelay * 2, failure);
2789
3564
  yield {
2790
3565
  type: "retry",
2791
3566
  attempt: attempt + 2,
@@ -2793,7 +3568,10 @@ ${finished.result}`
2793
3568
  delayMs,
2794
3569
  reason: failure ? summarizeStreamError(failure) : `empty response (finish: ${lastFinishReason ?? "none"})`
2795
3570
  };
2796
- if (!await sleep(delayMs, signal)) return;
3571
+ if (!await sleep(delayMs, signal)) {
3572
+ yield* this.persistInterrupted(text, toolCalls);
3573
+ return;
3574
+ }
2797
3575
  }
2798
3576
  if (failure) {
2799
3577
  yield { type: "error", error: failure };
@@ -2835,7 +3613,6 @@ ${finished.result}`
2835
3613
  };
2836
3614
  this.messages.push(assistantMessage);
2837
3615
  await this.persist(assistantMessage);
2838
- this.maybeScheduleTitle(text);
2839
3616
  if (toolCalls.length === 0) {
2840
3617
  let reason = null;
2841
3618
  let nudgeText = AUTO_CONTINUE_NUDGE;
@@ -2883,7 +3660,11 @@ ${finished.result}`
2883
3660
  try {
2884
3661
  for (const call of toolCalls) {
2885
3662
  if (signal.aborted) break;
2886
- const result = await this.executeTool(call, signal);
3663
+ const refusal = this.doomLoopRefusal(call);
3664
+ if (refusal !== null) {
3665
+ yield { type: "notice", message: refusal };
3666
+ }
3667
+ const result = refusal !== null ? { content: refusal, isError: true } : await this.executeTool(call, signal);
2887
3668
  const toolMessage = {
2888
3669
  role: "tool",
2889
3670
  content: [
@@ -2960,7 +3741,7 @@ ${finished.result}`
2960
3741
  if (userMemory) parts.push(userMemory);
2961
3742
  const skills = discoverSkills(this.opts.cwd);
2962
3743
  if (skills.length > 0) parts.push(formatSkillsBlock(skills));
2963
- const git = getGitSummary(this.opts.cwd);
3744
+ const git = getGitSummaryCached(this.opts.cwd);
2964
3745
  if (git) parts.push(formatGitSummary(git));
2965
3746
  if (parts.length === 0) return;
2966
3747
  const content = parts.join("\n\n");
@@ -2971,7 +3752,7 @@ ${finished.result}`
2971
3752
  this.messages.unshift({ role: "system", content });
2972
3753
  }
2973
3754
  }
2974
- async applyCompactionSummary(compacted) {
3755
+ async applyCompactionSummary(compacted, signal) {
2975
3756
  const { config, model } = this.opts;
2976
3757
  if (config.contextCompaction !== "summary" || !model) {
2977
3758
  return compacted.messages;
@@ -2979,7 +3760,7 @@ ${finished.result}`
2979
3760
  const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
2980
3761
  const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
2981
3762
  try {
2982
- const summary = await summarizeMessages(dropped, model);
3763
+ const summary = await summarizeMessages(dropped, model, signal);
2983
3764
  const messages = compacted.messages.slice();
2984
3765
  messages[headCount] = {
2985
3766
  role: "user",
@@ -2992,15 +3773,23 @@ ${summary}`
2992
3773
  }
2993
3774
  }
2994
3775
  async *streamOnce(aiTools, signal, timeoutScale = 1) {
2995
- const { streamChat } = await import("./stream-OY2MKWYE.js");
2996
- yield* streamChat({
3776
+ const { streamChat } = await import("./stream-H4BYBVAZ.js");
3777
+ for await (const event of streamChat({
2997
3778
  model: this.opts.model,
2998
3779
  messages: this.messages,
2999
3780
  tools: aiTools,
3000
3781
  abortSignal: signal,
3001
3782
  idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3 * timeoutScale,
3002
3783
  firstPartTimeoutMs: this.opts.config.streamFirstChunkTimeoutSec * 1e3 * timeoutScale
3003
- });
3784
+ })) {
3785
+ if (event.type === "finish" && event.truncated) {
3786
+ yield {
3787
+ type: "notice",
3788
+ message: "Response was cut short by the stream idle timeout \u2014 the reply may be incomplete."
3789
+ };
3790
+ }
3791
+ yield event;
3792
+ }
3004
3793
  }
3005
3794
  buildAiTools() {
3006
3795
  const plan = this.opts.config.permissionMode === "plan";
@@ -3030,6 +3819,23 @@ ${summary}`
3030
3819
  return empty;
3031
3820
  }
3032
3821
  }
3822
+ // opencode's doom_loop guard: returns a refusal once the identical tool
3823
+ // call (name + stable-serialized args) has repeated config.doomLoopThreshold
3824
+ // times in a row; 0 disables. Any different signature resets the streak.
3825
+ // Subagents are separate loop instances, so each counts its own streak.
3826
+ doomLoopRefusal(call) {
3827
+ const threshold = this.opts.config.doomLoopThreshold;
3828
+ if (threshold <= 0) return null;
3829
+ const signature = `${call.name} ${stableStringify(call.args)}`;
3830
+ if (signature === this.lastToolSignature) {
3831
+ this.repeatedToolCalls += 1;
3832
+ } else {
3833
+ this.lastToolSignature = signature;
3834
+ this.repeatedToolCalls = 1;
3835
+ }
3836
+ if (this.repeatedToolCalls < threshold) return null;
3837
+ 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.`;
3838
+ }
3033
3839
  async executeTool(call, signal) {
3034
3840
  const { registry, config, cwd } = this.opts;
3035
3841
  const tool = registry.get(call.name);
@@ -3074,7 +3880,15 @@ ${summary}`
3074
3880
  }
3075
3881
  let result;
3076
3882
  try {
3077
- result = await tool.execute(parsed.data, { cwd, abortSignal: signal });
3883
+ result = await tool.execute(parsed.data, {
3884
+ cwd,
3885
+ abortSignal: signal,
3886
+ snapshotContext: {
3887
+ owner: (this.opts.subagentDepth ?? 0) > 0 ? "subagent" : "root",
3888
+ turn: this.activeTurnSeq,
3889
+ messageIndex: this.activeTurnUserIndex
3890
+ }
3891
+ });
3078
3892
  } catch (error) {
3079
3893
  if (signal.aborted) {
3080
3894
  return { content: "Tool execution aborted.", isError: true };
@@ -3093,6 +3907,9 @@ ${summary}`
3093
3907
 
3094
3908
  export {
3095
3909
  defaultAgentTasks,
3910
+ formatAgentTaskUpdate,
3911
+ ConfigSchema,
3912
+ contextWindowTokens,
3096
3913
  estimateTokens,
3097
3914
  summarizeMessages,
3098
3915
  compactMessages,
@@ -3105,34 +3922,34 @@ export {
3105
3922
  MAX_IMAGE_DIMENSION,
3106
3923
  probeImageDimensions,
3107
3924
  reconcileToolCalls,
3925
+ isSensitivePath,
3108
3926
  isAllowedByRules,
3109
3927
  buildAllowRule,
3110
3928
  isDangerousCommand,
3929
+ diffTreeNames,
3111
3930
  listSnapshots,
3112
3931
  clearSnapshots,
3113
3932
  hydrateSnapshots,
3114
3933
  listTurnSnapshots,
3115
3934
  resolveSnapshotContent,
3116
- undoTurnSnapshots,
3117
3935
  rewindToSnapshot,
3118
- TodoStore,
3119
- formatTodos,
3120
- parseTodoArgs,
3121
- loadTodos,
3122
- resetTodos,
3123
- discoverSkills,
3124
3936
  defaultTaskManager,
3125
3937
  resolveShell,
3126
3938
  bashTool,
3127
3939
  SKIP_DIRS,
3128
3940
  createIgnorePredicate,
3129
- isSensitivePath2 as isSensitivePath,
3130
3941
  formatTaskList,
3131
3942
  formatTaskStarted,
3132
3943
  formatTaskFinished,
3944
+ TodoStore,
3945
+ formatTodos,
3946
+ parseTodoArgs,
3947
+ loadTodos,
3948
+ resetTodos,
3949
+ discoverSkills,
3133
3950
  createDefaultRegistry,
3134
3951
  appendUserMemory,
3135
3952
  PLAN_MODE_PROMPT,
3136
3953
  AgentLoop
3137
3954
  };
3138
- //# sourceMappingURL=chunk-BV4PLM6X.js.map
3955
+ //# sourceMappingURL=chunk-K3R42LOX.js.map