@cryer/star-cli 0.3.6 → 0.3.7

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,13 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- computeRetryDelayMs,
4
- gitTreesDir,
5
- isRetryableStreamError,
6
- responseBodyOf,
7
- starHome,
8
- summarizeStreamError,
9
- userMemoryPath
10
- } from "./chunk-B6P4VUBE.js";
11
2
 
12
3
  // src/agent/loop.ts
13
4
  import { tool as aiTool, generateText as generateText3 } from "ai";
@@ -42,6 +33,17 @@ var ModelConfigSchema = z.object({
42
33
  // request carries no effort field and the server-side default applies.
43
34
  // Anthropic maps effort to a thinking budget instead — ignored there.
44
35
  reasoningEffort: z.string().min(1).optional(),
36
+ // Sampling temperature forwarded on every request. Unset = the AI SDK's
37
+ // own default applies (ai@4 sends 0 — it does NOT omit the field). Some
38
+ // endpoints reject anything but one explicit value (e.g. kimi-for-coding:
39
+ // "invalid temperature: only 1 is allowed") — set it here. No upper
40
+ // bound: ranges vary by provider and the server validates.
41
+ temperature: z.number().min(0).optional(),
42
+ // Per-model overrides of the top-level stream watchdog timeouts (unset =
43
+ // the global values apply). Raise streamIdleTimeoutSec for relays that
44
+ // buffer long generations and stall mid-stream between flushes.
45
+ streamIdleTimeoutSec: z.number().positive().optional(),
46
+ streamFirstChunkTimeoutSec: z.number().positive().optional(),
45
47
  // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
46
48
  // the model is treated as unpriced.
47
49
  promptPrice: z.number().nonnegative().optional(),
@@ -91,10 +93,12 @@ var ConfigSchema = z.object({
91
93
  compactThresholdTokens: z.number().int().positive().optional(),
92
94
  // Seconds without any stream part before a stalled response is ended
93
95
  // gracefully (some relays never send the terminal chunks). Applies once
94
- // streaming has started; the first part gets a longer, fixed allowance.
96
+ // content has started streaming; the wait for the first content part gets
97
+ // a longer allowance (control/metadata parts don't count as content).
95
98
  streamIdleTimeoutSec: z.number().positive().default(20),
96
- // Seconds to wait for the very first stream part before giving up; slow
97
- // thinking-model relays can buffer for minutes before sending anything.
99
+ // Seconds to wait for the very first content part before giving up; slow
100
+ // thinking-model endpoints can stay silent for minutes after accepting
101
+ // the request.
98
102
  streamFirstChunkTimeoutSec: z.number().positive().default(300),
99
103
  // Extra attempts per model request when a stream fails transiently
100
104
  // (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a
@@ -150,11 +154,13 @@ import { generateText } from "ai";
150
154
  var CHARS_PER_TOKEN = 4;
151
155
  var MESSAGE_OVERHEAD = 4;
152
156
  var IMAGE_PART_TOKENS = 1024;
153
- var CJK_RE = /[\u3000-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/;
157
+ function isCjkCode(code) {
158
+ return code >= 12288 && code <= 12543 || code >= 13312 && code <= 19903 || code >= 19968 && code <= 40959 || code >= 63744 && code <= 64255 || code >= 65280 && code <= 65519;
159
+ }
154
160
  function textWeight(text) {
155
161
  let cjk = 0;
156
- for (const ch of text) {
157
- if (CJK_RE.test(ch)) cjk++;
162
+ for (let i = 0; i < text.length; i++) {
163
+ if (isCjkCode(text.charCodeAt(i))) cjk++;
158
164
  }
159
165
  return text.length + cjk * (CHARS_PER_TOKEN - 1);
160
166
  }
@@ -183,8 +189,13 @@ function imagePartCount(message) {
183
189
  if (typeof message.content === "string" || message.role === "tool") return 0;
184
190
  return message.content.filter((part) => part.type === "image" || part.type === "file").length;
185
191
  }
192
+ var tokenCache = /* @__PURE__ */ new WeakMap();
186
193
  function estimateMessageTokens(message) {
187
- return Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
194
+ const cached = tokenCache.get(message);
195
+ if (cached !== void 0) return cached;
196
+ const tokens = Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD + imagePartCount(message) * IMAGE_PART_TOKENS;
197
+ tokenCache.set(message, tokens);
198
+ return tokens;
188
199
  }
189
200
  function estimateTokens(messages) {
190
201
  return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
@@ -222,7 +233,7 @@ function serializeMessage(message) {
222
233
  });
223
234
  return `${message.role}: ${parts.join("\n")}`;
224
235
  }
225
- async function summarizeMessages(messages, model, signal) {
236
+ async function summarizeMessages(messages, model, signal, temperature) {
226
237
  const transcript = messages.map(serializeMessage).join("\n");
227
238
  const { text } = await generateText({
228
239
  model,
@@ -230,6 +241,7 @@ async function summarizeMessages(messages, model, signal) {
230
241
  prompt: `Summarize this conversation so far:
231
242
 
232
243
  ${transcript}`,
244
+ temperature,
233
245
  // A hung relay must not stall the whole turn on the summary: cap it at
234
246
  // 60s and let the caller's abort (Esc) cut it short as well; the caller
235
247
  // falls back to the truncation placeholder on any failure.
@@ -246,26 +258,29 @@ function compactMessages(messages, maxTokens, opts = {}) {
246
258
  const hasSystem = first?.role === "system";
247
259
  const head = hasSystem && first ? [first] : [];
248
260
  const rest = hasSystem ? messages.slice(1) : messages.slice();
261
+ const messageTokens = rest.map((message) => estimateMessageTokens(message));
249
262
  const turns = [];
250
- for (const message of rest) {
263
+ for (let i = 0; i < rest.length; i++) {
251
264
  const current = turns[turns.length - 1];
252
- if (message.role === "user" || !current) {
253
- turns.push([message]);
265
+ if (rest[i]?.role === "user" || !current) {
266
+ turns.push({ length: 1, tokens: messageTokens[i] ?? 0 });
254
267
  } else {
255
- current.push(message);
268
+ current.length += 1;
269
+ current.tokens += messageTokens[i] ?? 0;
256
270
  }
257
271
  }
272
+ const headTokens = estimateTokens(head);
273
+ const restTokens = estimateTokens(rest);
258
274
  let droppedCount = 0;
259
- let turnIndex = 0;
260
- while (turnIndex < turns.length) {
261
- const turn = turns[turnIndex];
275
+ let droppedTokens = 0;
276
+ for (const turn of turns) {
262
277
  if (rest.length - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
263
278
  break;
264
279
  }
265
280
  droppedCount += turn.length;
266
- turnIndex += 1;
267
- const candidate = [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)];
268
- if (estimateTokens(candidate) <= limit) {
281
+ droppedTokens += turn.tokens;
282
+ const candidateTokens = headTokens + estimateMessageTokens(placeholderMessage(droppedCount)) + (restTokens - droppedTokens);
283
+ if (candidateTokens <= limit) {
269
284
  break;
270
285
  }
271
286
  }
@@ -390,6 +405,12 @@ ${unstaged}`);
390
405
  return { status, diff: text, truncated };
391
406
  }
392
407
 
408
+ // src/core/http-error.ts
409
+ function responseBodyOf(error) {
410
+ const body = error.responseBody;
411
+ return typeof body === "string" && body.length > 0 ? body : void 0;
412
+ }
413
+
393
414
  // src/core/image.ts
394
415
  var MAX_IMAGE_DIMENSION = 2e3;
395
416
  var OVERSIZED_IMAGE_FALLBACK_BYTES = 5 * 1024 * 1024;
@@ -689,9 +710,365 @@ async function runHooks(event, hooks2, ctx) {
689
710
  return result;
690
711
  }
691
712
 
713
+ // src/llm/retry.ts
714
+ var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
715
+ "AI_NoSuchToolError",
716
+ "AI_InvalidToolArgumentsError",
717
+ "AI_InvalidPromptError",
718
+ "AI_NoSuchModelError",
719
+ "AI_LoadAPIKeyError",
720
+ "AI_TypeValidationError",
721
+ "AI_JSONParseError"
722
+ ]);
723
+ var RETRYABLE_MESSAGE_PATTERNS = [
724
+ /\b(408|425|429|500|502|503|504|520|521|522|523|524|529)\b/,
725
+ /rate.?limit|too many requests|rate increased/i,
726
+ /overloaded|service.?unavailable|internal (server )?error|server error|provider returned error|bad gateway|gateway timeout/i,
727
+ /terminated|fetch failed|failed to fetch|network.?error|upstream connect|connection (?:error|refused|lost|reset)|socket (?:hang up|connection)|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout|epipe|ehostunreach|enetunreach/i,
728
+ /\b(?:request|response|connection|network|stream|read|headers?|body) (?:timeout|timed out|time out)\b/i,
729
+ /resource.?exhausted|try (?:your request )?again|at capacity/i
730
+ ];
731
+ function statusCodeOf(error) {
732
+ const status = error.statusCode;
733
+ return typeof status === "number" ? status : void 0;
734
+ }
735
+ function matchesRetryablePattern(text) {
736
+ return typeof text === "string" && RETRYABLE_MESSAGE_PATTERNS.some((p) => p.test(text));
737
+ }
738
+ function isRetryableStreamError(error) {
739
+ if (NON_RETRYABLE_ERROR_NAMES.has(error.name)) return false;
740
+ if (error.isRetryable === true) return true;
741
+ const status = statusCodeOf(error);
742
+ if (status !== void 0) {
743
+ if (status === 408 || status === 429 || status >= 500 && status !== 501) return true;
744
+ return matchesRetryablePattern(error.message) || matchesRetryablePattern(responseBodyOf(error));
745
+ }
746
+ return true;
747
+ }
748
+ var RETRY_JITTER_FACTOR = 0.25;
749
+ var MAX_RETRY_DELAY_MS = 6e4;
750
+ function retryAfterDelayMs(error) {
751
+ const headers = error.responseHeaders;
752
+ if (!headers || typeof headers !== "object") return void 0;
753
+ const normalized = {};
754
+ for (const [key, value] of Object.entries(headers)) {
755
+ if (typeof value === "string") normalized[key.toLowerCase()] = value;
756
+ }
757
+ const afterMs = Number.parseFloat(normalized["retry-after-ms"] ?? "");
758
+ if (!Number.isNaN(afterMs)) return Math.max(0, afterMs);
759
+ const after = normalized["retry-after"];
760
+ if (after) {
761
+ const seconds = Number.parseFloat(after);
762
+ if (!Number.isNaN(seconds)) return Math.max(0, Math.ceil(seconds * 1e3));
763
+ const dateMs = Date.parse(after) - Date.now();
764
+ if (!Number.isNaN(dateMs) && dateMs > 0) return Math.ceil(dateMs);
765
+ }
766
+ return void 0;
767
+ }
768
+ function computeRetryDelayMs(attempt, baseMs, error, random = Math.random()) {
769
+ const hinted = error ? retryAfterDelayMs(error) : void 0;
770
+ if (hinted !== void 0) return Math.min(hinted, MAX_RETRY_DELAY_MS);
771
+ const base = baseMs * 2 ** attempt;
772
+ return Math.min(Math.ceil(base + base * RETRY_JITTER_FACTOR * random), MAX_RETRY_DELAY_MS);
773
+ }
774
+ function summarizeStreamError(error) {
775
+ const parts = [error.message || error.name];
776
+ const status = statusCodeOf(error);
777
+ if (status !== void 0 && !error.message.includes(String(status))) {
778
+ parts.push(`(HTTP ${status})`);
779
+ }
780
+ const body = responseBodyOf(error);
781
+ if (body && !error.message.includes(body.slice(0, 40))) {
782
+ const excerpt = body.replace(/\s+/g, " ").trim().slice(0, 200);
783
+ if (excerpt) parts.push(`\u2014 ${excerpt}`);
784
+ }
785
+ return parts.join(" ");
786
+ }
787
+
788
+ // src/llm/stream.ts
789
+ import { streamText } from "ai";
790
+
791
+ // src/llm/activity.ts
792
+ var trackers = /* @__PURE__ */ new WeakMap();
793
+ function streamActivity(signal) {
794
+ return trackers.get(signal);
795
+ }
796
+ function wrapFetchWithActivity(baseFetch = fetch) {
797
+ return async (input, init) => {
798
+ const response = await baseFetch(input, init);
799
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : null);
800
+ if (!signal || !response.body) return response;
801
+ const tracker = { lastChunkAt: Date.now() };
802
+ trackers.set(signal, tracker);
803
+ const reader = response.body.getReader();
804
+ const body = new ReadableStream({
805
+ async pull(controller) {
806
+ try {
807
+ const { done, value } = await reader.read();
808
+ if (done) {
809
+ controller.close();
810
+ return;
811
+ }
812
+ tracker.lastChunkAt = Date.now();
813
+ controller.enqueue(value);
814
+ } catch (error) {
815
+ controller.error(error);
816
+ }
817
+ },
818
+ cancel(reason) {
819
+ return reader.cancel(reason);
820
+ }
821
+ });
822
+ return new Response(body, {
823
+ status: response.status,
824
+ statusText: response.statusText,
825
+ headers: response.headers
826
+ });
827
+ };
828
+ }
829
+
830
+ // src/llm/debug.ts
831
+ import { appendFileSync } from "fs";
832
+ import path2 from "path";
833
+
834
+ // src/config/paths.ts
835
+ import os from "os";
836
+ import path from "path";
837
+ function starHome() {
838
+ return process.env.STAR_HOME ?? path.join(os.homedir(), ".star-cli");
839
+ }
840
+ function globalConfigPath() {
841
+ return path.join(starHome(), "config.toml");
842
+ }
843
+ function sessionsDir() {
844
+ return path.join(starHome(), "sessions");
845
+ }
846
+ function userMemoryPath() {
847
+ return path.join(starHome(), "MEMORY.md");
848
+ }
849
+ function gitTreesDir() {
850
+ return path.join(starHome(), "git-trees");
851
+ }
852
+ function envFilePath() {
853
+ return path.join(starHome(), ".env");
854
+ }
855
+ function projectConfigPath(cwd) {
856
+ return path.join(cwd, ".star", "config.toml");
857
+ }
858
+
859
+ // src/llm/debug.ts
860
+ function debugStreamLog(event, data) {
861
+ if (!process.env.STAR_DEBUG) return;
862
+ try {
863
+ const line = `${JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), event, ...data })}
864
+ `;
865
+ appendFileSync(path2.join(starHome(), "debug.log"), line);
866
+ } catch {
867
+ }
868
+ }
869
+
870
+ // src/llm/stream.ts
871
+ var DEFAULT_IDLE_TIMEOUT_MS = 2e4;
872
+ var DEFAULT_FIRST_PART_TIMEOUT_MS = 12e4;
873
+ var GENERATION_PART_TYPES = /* @__PURE__ */ new Set(["text-delta", "reasoning", "tool-call", "tool-call-delta"]);
874
+ var MAX_KEEPALIVE_SILENCE_MS = 10 * 6e4;
875
+ var CACHE_READ_KEYS = ["cacheReadInputTokens", "cache_read_input_tokens"];
876
+ var CACHED_PROMPT_KEYS = ["cachedPromptTokens", "cached_prompt_tokens", "cachedTokens"];
877
+ function extractCacheUsage(providerMetadata) {
878
+ if (!providerMetadata || typeof providerMetadata !== "object") return {};
879
+ for (const value of Object.values(providerMetadata)) {
880
+ if (!value || typeof value !== "object") continue;
881
+ const record = value;
882
+ for (const key of CACHE_READ_KEYS) {
883
+ const n = record[key];
884
+ if (typeof n === "number" && Number.isFinite(n)) return { cacheReadInputTokens: n };
885
+ }
886
+ for (const key of CACHED_PROMPT_KEYS) {
887
+ const n = record[key];
888
+ if (typeof n === "number" && Number.isFinite(n)) return { cachedPromptTokens: n };
889
+ }
890
+ }
891
+ return {};
892
+ }
893
+ async function* streamChat(opts) {
894
+ const controller = new AbortController();
895
+ const onAbort = () => controller.abort();
896
+ opts.abortSignal?.addEventListener("abort", onAbort);
897
+ if (opts.abortSignal?.aborted) controller.abort();
898
+ const { openai: openaiMetadata, ...otherMetadata } = opts.providerMetadata ?? {};
899
+ const result = streamText({
900
+ model: opts.model,
901
+ messages: opts.messages,
902
+ tools: opts.tools,
903
+ abortSignal: controller.signal,
904
+ maxTokens: opts.maxTokens,
905
+ temperature: opts.temperature,
906
+ // Disable the SDK's own retries (ai@4 defaults to 2 internal attempts with
907
+ // fixed backoff that ignores Retry-After and the abort signal, and
908
+ // exhaustion throws an AI_RetryError stripped of status/headers/body) —
909
+ // the retry policy in agent/loop.ts + llm/retry.ts owns the full budget
910
+ // and needs the raw error to classify.
911
+ maxRetries: 0,
912
+ // Pass tool-call argument deltas through (instead of buffering until the
913
+ // complete call) so large write_file payloads report receive progress.
914
+ toolCallStreaming: true,
915
+ experimental_providerMetadata: {
916
+ // Only the openai provider (our responses-protocol path) reads these;
917
+ // other providers ignore unknown metadata. The SDK's responses provider
918
+ // sends every tool with strict:true by default (strictSchemas), and
919
+ // strict mode requires every schema property to be required — our
920
+ // classic function-calling schemas use optional properties, which
921
+ // relays then reject. store:false matches how codex and opencode talk
922
+ // to relays (no server-side response storage).
923
+ ...otherMetadata,
924
+ openai: { strictSchemas: false, store: false, ...openaiMetadata }
925
+ }
926
+ });
927
+ const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
928
+ const firstPartTimeoutMs = opts.firstPartTimeoutMs ?? DEFAULT_FIRST_PART_TIMEOUT_MS;
929
+ const iterator = result.fullStream[Symbol.asyncIterator]();
930
+ let seenContent = false;
931
+ let deltas = 0;
932
+ let hasContent = false;
933
+ const argsProgress = /* @__PURE__ */ new Map();
934
+ let lastPartAt = Date.now();
935
+ debugStreamLog("request", {
936
+ messages: opts.messages.length,
937
+ idleTimeoutMs,
938
+ firstPartTimeoutMs
939
+ });
940
+ try {
941
+ for (; ; ) {
942
+ let timer;
943
+ const windowMs = seenContent ? idleTimeoutMs : firstPartTimeoutMs;
944
+ const idle = new Promise((resolve) => {
945
+ timer = setTimeout(() => resolve(null), windowMs);
946
+ });
947
+ let next;
948
+ try {
949
+ next = await Promise.race([iterator.next(), idle]);
950
+ } catch (error) {
951
+ debugStreamLog("stream-throw", {
952
+ seenContent,
953
+ deltas,
954
+ error: error instanceof Error ? summarizeStreamError(error) : String(error)
955
+ });
956
+ throw error;
957
+ } finally {
958
+ if (timer) clearTimeout(timer);
959
+ }
960
+ if (next === null) {
961
+ const activity = streamActivity(controller.signal);
962
+ const silentForMs = Date.now() - lastPartAt;
963
+ const lastChunkAgoMs = activity ? Date.now() - activity.lastChunkAt : void 0;
964
+ if (lastChunkAgoMs !== void 0 && lastChunkAgoMs < windowMs && silentForMs < MAX_KEEPALIVE_SILENCE_MS) {
965
+ debugStreamLog("keepalive", { seenContent, deltas, lastChunkAgoMs, silentForMs });
966
+ continue;
967
+ }
968
+ debugStreamLog("idle-timeout", {
969
+ seenContent,
970
+ deltas,
971
+ waitedMs: windowMs,
972
+ lastChunkAgoMs
973
+ });
974
+ yield {
975
+ type: "finish",
976
+ finishReason: "idle-timeout",
977
+ usage: void 0,
978
+ // Content already streamed means the reply may be cut off
979
+ // mid-thought; the loop surfaces a notice for that.
980
+ ...hasContent ? { truncated: true } : {}
981
+ };
982
+ return;
983
+ }
984
+ if (next.done) {
985
+ debugStreamLog("end", { seenContent, deltas });
986
+ return;
987
+ }
988
+ const part = next.value;
989
+ lastPartAt = Date.now();
990
+ if (GENERATION_PART_TYPES.has(part.type)) seenContent = true;
991
+ switch (part.type) {
992
+ case "text-delta":
993
+ deltas++;
994
+ hasContent = true;
995
+ yield { type: "text-delta", text: part.textDelta };
996
+ break;
997
+ case "reasoning":
998
+ deltas++;
999
+ yield { type: "reasoning", text: part.textDelta };
1000
+ break;
1001
+ case "tool-call":
1002
+ hasContent = true;
1003
+ yield {
1004
+ type: "tool-call",
1005
+ id: part.toolCallId,
1006
+ name: part.toolName,
1007
+ args: part.args
1008
+ };
1009
+ break;
1010
+ case "tool-call-streaming-start": {
1011
+ argsProgress.set(part.toolCallId, {
1012
+ name: part.toolName,
1013
+ chars: 0,
1014
+ nextMark: 4096
1015
+ });
1016
+ break;
1017
+ }
1018
+ case "tool-call-delta": {
1019
+ const progress = argsProgress.get(part.toolCallId) ?? {
1020
+ name: part.toolName,
1021
+ chars: 0,
1022
+ nextMark: 4096
1023
+ };
1024
+ progress.chars += part.argsTextDelta.length;
1025
+ argsProgress.set(part.toolCallId, progress);
1026
+ if (progress.chars >= progress.nextMark) {
1027
+ progress.nextMark *= 2;
1028
+ yield { type: "tool-call-progress", name: progress.name, bytes: progress.chars };
1029
+ }
1030
+ break;
1031
+ }
1032
+ case "finish": {
1033
+ const finite = (n) => Number.isFinite(n) ? n : 0;
1034
+ const cache4 = extractCacheUsage(
1035
+ part.providerMetadata ?? part.experimental_providerMetadata
1036
+ );
1037
+ debugStreamLog("finish", {
1038
+ finishReason: part.finishReason,
1039
+ usage: part.usage,
1040
+ deltas
1041
+ });
1042
+ yield {
1043
+ type: "finish",
1044
+ finishReason: part.finishReason,
1045
+ usage: part.usage ? {
1046
+ promptTokens: finite(part.usage.promptTokens),
1047
+ completionTokens: finite(part.usage.completionTokens),
1048
+ totalTokens: finite(part.usage.totalTokens),
1049
+ ...cache4
1050
+ } : void 0
1051
+ };
1052
+ return;
1053
+ }
1054
+ case "error": {
1055
+ if (opts.abortSignal?.aborted) return;
1056
+ const error = part.error instanceof Error ? part.error : new Error(String(part.error));
1057
+ debugStreamLog("error-part", { seenContent, deltas, error: summarizeStreamError(error) });
1058
+ yield { type: "error", error };
1059
+ break;
1060
+ }
1061
+ }
1062
+ }
1063
+ } finally {
1064
+ opts.abortSignal?.removeEventListener("abort", onAbort);
1065
+ controller.abort();
1066
+ }
1067
+ }
1068
+
692
1069
  // src/permissions/gate.ts
693
1070
  import fs from "fs";
694
- import path from "path";
1071
+ import path3 from "path";
695
1072
 
696
1073
  // src/core/sensitive.ts
697
1074
  var SENSITIVE_BASENAMES = /* @__PURE__ */ new Set([
@@ -734,6 +1111,239 @@ function isSensitivePath(p) {
734
1111
  return false;
735
1112
  }
736
1113
 
1114
+ // src/permissions/shell.ts
1115
+ var REDIRECT_OPS = /* @__PURE__ */ new Set([">", ">>", "<", ">&", "<&"]);
1116
+ var FILE_REDIRECT_OPS = /* @__PURE__ */ new Set([">", ">>", "<"]);
1117
+ var SEGMENT_SEPARATORS = /* @__PURE__ */ new Set([
1118
+ "&&",
1119
+ "||",
1120
+ "&",
1121
+ "|",
1122
+ ";",
1123
+ "\n",
1124
+ "(",
1125
+ ")"
1126
+ ]);
1127
+ function lexShell(command) {
1128
+ const tokens = [];
1129
+ let word = "";
1130
+ let doubleQuoted = false;
1131
+ let i = 0;
1132
+ const flush = () => {
1133
+ if (word.length > 0) {
1134
+ tokens.push({ kind: "word", text: word });
1135
+ word = "";
1136
+ }
1137
+ };
1138
+ const findCloseParen = (from) => {
1139
+ let depth = 0;
1140
+ let j = from;
1141
+ while (j < command.length) {
1142
+ const c = command[j];
1143
+ if (c === "\\") {
1144
+ j += 2;
1145
+ continue;
1146
+ }
1147
+ if (c === "'" || c === '"') {
1148
+ const close = command.indexOf(c, j + 1);
1149
+ j = close === -1 ? command.length : close + 1;
1150
+ continue;
1151
+ }
1152
+ if (c === "(") depth += 1;
1153
+ if (c === ")") {
1154
+ depth -= 1;
1155
+ if (depth === 0) return j + 1;
1156
+ }
1157
+ j += 1;
1158
+ }
1159
+ return -1;
1160
+ };
1161
+ const pushSubstitution = (openParen, process2) => {
1162
+ const close = findCloseParen(openParen);
1163
+ const innerEnd = close === -1 ? command.length : close - 1;
1164
+ flush();
1165
+ tokens.push({ kind: "subst", inner: command.slice(openParen + 1, innerEnd), process: process2 });
1166
+ return close === -1 ? command.length : close;
1167
+ };
1168
+ const lexDollarOrBacktick = (pos) => {
1169
+ const c = command[pos];
1170
+ if (c === "`") {
1171
+ const close = command.indexOf("`", pos + 1);
1172
+ const end = close === -1 ? command.length : close;
1173
+ flush();
1174
+ tokens.push({ kind: "subst", inner: command.slice(pos + 1, end), process: false });
1175
+ return close === -1 ? command.length : close + 1;
1176
+ }
1177
+ const next = command[pos + 1];
1178
+ if (next === "(") {
1179
+ return pushSubstitution(pos + 1, false);
1180
+ }
1181
+ if (next === "{") {
1182
+ const close = command.indexOf("}", pos + 2);
1183
+ if (close !== -1) {
1184
+ word += `$${command.slice(pos + 2, close)}`;
1185
+ return close + 1;
1186
+ }
1187
+ word += "$";
1188
+ return pos + 1;
1189
+ }
1190
+ if (next !== void 0 && /[A-Za-z_]/.test(next)) {
1191
+ let end = pos + 1;
1192
+ while (end < command.length && /[A-Za-z0-9_]/.test(command[end] ?? "")) {
1193
+ end += 1;
1194
+ }
1195
+ word += command.slice(pos, end);
1196
+ return end;
1197
+ }
1198
+ word += "$";
1199
+ return pos + 1;
1200
+ };
1201
+ while (i < command.length) {
1202
+ const c = command[i];
1203
+ if (doubleQuoted) {
1204
+ if (c === '"') {
1205
+ doubleQuoted = false;
1206
+ i += 1;
1207
+ } else if (c === "\\") {
1208
+ if (i + 1 < command.length) {
1209
+ word += command[i + 1];
1210
+ i += 2;
1211
+ } else {
1212
+ word += "\\";
1213
+ i += 1;
1214
+ }
1215
+ } else if (c === "$" || c === "`") {
1216
+ i = lexDollarOrBacktick(i);
1217
+ } else {
1218
+ word += c;
1219
+ i += 1;
1220
+ }
1221
+ continue;
1222
+ }
1223
+ if (c === "\\") {
1224
+ if (i + 1 < command.length) {
1225
+ word += command[i + 1];
1226
+ i += 2;
1227
+ } else {
1228
+ word += "\\";
1229
+ i += 1;
1230
+ }
1231
+ continue;
1232
+ }
1233
+ if (c === "'") {
1234
+ const close = command.indexOf("'", i + 1);
1235
+ const end = close === -1 ? command.length : close;
1236
+ word += command.slice(i + 1, end);
1237
+ i = close === -1 ? command.length : close + 1;
1238
+ continue;
1239
+ }
1240
+ if (c === '"') {
1241
+ doubleQuoted = true;
1242
+ i += 1;
1243
+ continue;
1244
+ }
1245
+ if (c === "$" || c === "`") {
1246
+ i = lexDollarOrBacktick(i);
1247
+ continue;
1248
+ }
1249
+ if (c === " " || c === " " || c === "\r") {
1250
+ flush();
1251
+ i += 1;
1252
+ continue;
1253
+ }
1254
+ if (c === "\n") {
1255
+ flush();
1256
+ tokens.push({ kind: "op", op: "\n" });
1257
+ i += 1;
1258
+ continue;
1259
+ }
1260
+ if (c === "&" || c === "|") {
1261
+ flush();
1262
+ if (command[i + 1] === c) {
1263
+ tokens.push({ kind: "op", op: c === "&" ? "&&" : "||" });
1264
+ i += 2;
1265
+ } else {
1266
+ tokens.push({ kind: "op", op: c });
1267
+ i += 1;
1268
+ }
1269
+ continue;
1270
+ }
1271
+ if (c === ";" || c === "(" || c === ")") {
1272
+ flush();
1273
+ tokens.push({ kind: "op", op: c });
1274
+ i += 1;
1275
+ continue;
1276
+ }
1277
+ if (c === ">" || c === "<") {
1278
+ flush();
1279
+ const next = command[i + 1];
1280
+ if (next === "(") {
1281
+ i = pushSubstitution(i + 1, true);
1282
+ continue;
1283
+ }
1284
+ if (c === ">" && next === ">") {
1285
+ tokens.push({ kind: "op", op: ">>" });
1286
+ i += 2;
1287
+ } else if (next === "&") {
1288
+ tokens.push({ kind: "op", op: c === ">" ? ">&" : "<&" });
1289
+ i += 2;
1290
+ } else {
1291
+ tokens.push({ kind: "op", op: c });
1292
+ i += 1;
1293
+ }
1294
+ continue;
1295
+ }
1296
+ word += c;
1297
+ i += 1;
1298
+ }
1299
+ flush();
1300
+ return tokens;
1301
+ }
1302
+ function splitShellSegments(tokens) {
1303
+ const segments = [];
1304
+ let current = [];
1305
+ for (const token of tokens) {
1306
+ if (token.kind === "op" && SEGMENT_SEPARATORS.has(token.op)) {
1307
+ if (current.length > 0) {
1308
+ segments.push(current);
1309
+ current = [];
1310
+ }
1311
+ continue;
1312
+ }
1313
+ current.push(token);
1314
+ }
1315
+ if (current.length > 0) {
1316
+ segments.push(current);
1317
+ }
1318
+ return segments;
1319
+ }
1320
+ function segmentWords(segment) {
1321
+ const words = [];
1322
+ const redirectTargets = [];
1323
+ for (let k = 0; k < segment.length; k += 1) {
1324
+ const token = segment[k];
1325
+ if (token.kind === "op" && REDIRECT_OPS.has(token.op)) {
1326
+ const target = segment[k + 1];
1327
+ if (target?.kind === "word") {
1328
+ redirectTargets.push(target.text);
1329
+ k += 1;
1330
+ }
1331
+ continue;
1332
+ }
1333
+ if (token.kind === "word") {
1334
+ words.push(token.text);
1335
+ }
1336
+ }
1337
+ return { words, redirectTargets };
1338
+ }
1339
+ function renderSegment(segment) {
1340
+ return segment.map((token) => {
1341
+ if (token.kind === "word") return token.text;
1342
+ if (token.kind === "op") return token.op;
1343
+ return token.process ? ">(\u2026)" : "$(\u2026)";
1344
+ }).join(" ");
1345
+ }
1346
+
737
1347
  // src/permissions/allow.ts
738
1348
  function parseAllowRule(rule) {
739
1349
  const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
@@ -759,7 +1369,15 @@ function matchAllowRule(rule, req) {
759
1369
  }
760
1370
  function splitCommandChain(command) {
761
1371
  if (command.includes("$(") || command.includes("`")) return null;
762
- return command.split(/&&|\|\||[;|\n]/).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
1372
+ const tokens = lexShell(command);
1373
+ if (tokens.some((token) => token.kind === "subst")) return null;
1374
+ const segments = splitShellSegments(tokens);
1375
+ for (const segment of segments) {
1376
+ if (segment.some((token) => token.kind === "op" && FILE_REDIRECT_OPS.has(token.op))) {
1377
+ return null;
1378
+ }
1379
+ }
1380
+ return segments.map(renderSegment).filter((segment) => segment.length > 0);
763
1381
  }
764
1382
  function withCommand(req, command) {
765
1383
  return {
@@ -796,27 +1414,206 @@ function buildAllowRule(req) {
796
1414
  }
797
1415
 
798
1416
  // src/permissions/gate.ts
799
- var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "grep", "glob"]);
800
- var DANGEROUS_COMMAND_PATTERNS = [
801
- /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
802
- /rm\s+-rf\s+~(?:\s|\/|$|;|&)/i,
803
- /rm\s+-rf\s+\.\/?(?:\s|$|;|&)/i,
804
- /rm\s+-rf\s+\*(?:\s|$|;|&)/i,
805
- /\bsudo\s+rm\b/i,
806
- /\bdel\s+\/s\b/i,
807
- /\brd\s+\/s\b/i,
808
- /\bformat\s+[a-z]:/i,
809
- /\bdiskpart\b/i,
810
- /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
811
- /\bmkfs\b/i,
812
- /dd\s+[^|;]*\bif=/i,
813
- />\s*\/dev\/sd[a-z]/i,
814
- /\bshutdown\b/i,
815
- /\breboot\b/i,
816
- /\bpoweroff\b/i
817
- ];
1417
+ var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file", "read_image", "grep", "glob"]);
1418
+ var FORK_BOMB = /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/;
1419
+ var MAX_ANALYSIS_DEPTH = 8;
1420
+ function baseName(word) {
1421
+ const base = word.replace(/\\/g, "/").split("/").pop() ?? word;
1422
+ return base.toLowerCase().replace(/\.exe$/, "");
1423
+ }
1424
+ function isDangerousDeleteTarget(target) {
1425
+ if (/^\/+$/.test(target)) return true;
1426
+ const norm = target.replace(/\\/g, "/").replace(/\/+$/, "");
1427
+ if (norm === "/*" || norm === "*") return true;
1428
+ if (norm === "~" || norm.startsWith("~/")) return true;
1429
+ if (norm === "." || norm === "./*") return true;
1430
+ if (/^[a-zA-Z]:$/.test(norm)) return true;
1431
+ if (["$HOME", "$USERPROFILE", "$HOMEPATH"].includes(norm)) return true;
1432
+ if (/^%(USERPROFILE|HOMEDRIVE|HOMEPATH)%$/i.test(norm)) return true;
1433
+ return false;
1434
+ }
1435
+ function rmIsDangerous(args) {
1436
+ let recursive = false;
1437
+ let optionsDone = false;
1438
+ const targets = [];
1439
+ for (const arg of args) {
1440
+ if (!optionsDone && arg === "--") {
1441
+ optionsDone = true;
1442
+ continue;
1443
+ }
1444
+ if (!optionsDone && arg.startsWith("-")) {
1445
+ if (arg === "--recursive") {
1446
+ recursive = true;
1447
+ } else if (!arg.startsWith("--") && /[rR]/.test(arg)) {
1448
+ recursive = true;
1449
+ }
1450
+ continue;
1451
+ }
1452
+ targets.push(arg);
1453
+ }
1454
+ return recursive && targets.some(isDangerousDeleteTarget);
1455
+ }
1456
+ function findIsDangerous(args) {
1457
+ if (!args.includes("-delete")) return false;
1458
+ const paths = [];
1459
+ for (const arg of args) {
1460
+ if (arg.startsWith("-") || arg === "(" || arg === ")" || arg === "!" || arg === ",") {
1461
+ break;
1462
+ }
1463
+ paths.push(arg);
1464
+ }
1465
+ return paths.length === 0 || paths.some(isDangerousDeleteTarget);
1466
+ }
1467
+ var XARGS_VALUE_OPTS = /* @__PURE__ */ new Set([
1468
+ "-I",
1469
+ "-n",
1470
+ "-P",
1471
+ "-s",
1472
+ "-L",
1473
+ "-d",
1474
+ "-E",
1475
+ "-a",
1476
+ "--replace",
1477
+ "--max-args",
1478
+ "--max-procs",
1479
+ "--max-lines",
1480
+ "--delimiter",
1481
+ "--eof",
1482
+ "--arg-file",
1483
+ "--interactive",
1484
+ "--exit"
1485
+ ]);
1486
+ function xargsIsDangerous(args) {
1487
+ for (let k = 0; k < args.length; k += 1) {
1488
+ const arg = args[k];
1489
+ if (XARGS_VALUE_OPTS.has(arg)) {
1490
+ k += 1;
1491
+ continue;
1492
+ }
1493
+ if (arg.startsWith("-")) {
1494
+ continue;
1495
+ }
1496
+ return baseName(arg) === "rm";
1497
+ }
1498
+ return false;
1499
+ }
1500
+ function chmodIsDangerous(args) {
1501
+ let recursive = false;
1502
+ let optionsDone = false;
1503
+ const operands = [];
1504
+ for (const arg of args) {
1505
+ if (!optionsDone && arg === "--") {
1506
+ optionsDone = true;
1507
+ continue;
1508
+ }
1509
+ if (!optionsDone && arg.startsWith("-")) {
1510
+ if (arg === "--recursive" || !arg.startsWith("--") && arg.includes("R")) {
1511
+ recursive = true;
1512
+ }
1513
+ continue;
1514
+ }
1515
+ operands.push(arg);
1516
+ }
1517
+ return recursive && operands.slice(1).some(isDangerousDeleteTarget);
1518
+ }
1519
+ function commandInvocation(words) {
1520
+ let sudo = false;
1521
+ let wrapped = false;
1522
+ let k = 0;
1523
+ for (; ; ) {
1524
+ const word = words[k];
1525
+ if (word === void 0) return null;
1526
+ const lower = word.toLowerCase();
1527
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
1528
+ k += 1;
1529
+ continue;
1530
+ }
1531
+ if (lower === "sudo" || lower === "doas" || lower === "env") {
1532
+ sudo = sudo || lower !== "env";
1533
+ wrapped = true;
1534
+ k += 1;
1535
+ continue;
1536
+ }
1537
+ if (wrapped && word.startsWith("-")) {
1538
+ k += 1;
1539
+ continue;
1540
+ }
1541
+ return { name: baseName(word), rest: words.slice(k + 1), sudo };
1542
+ }
1543
+ }
1544
+ function commandLineIsDangerous(command, depth) {
1545
+ if (depth > MAX_ANALYSIS_DEPTH) return true;
1546
+ const tokens = lexShell(command);
1547
+ for (const token of tokens) {
1548
+ if (token.kind === "subst" && commandLineIsDangerous(token.inner, depth + 1)) {
1549
+ return true;
1550
+ }
1551
+ }
1552
+ return splitShellSegments(tokens).some((segment) => segmentIsDangerous(segment, depth));
1553
+ }
1554
+ function segmentIsDangerous(segment, depth) {
1555
+ const { words, redirectTargets } = segmentWords(segment);
1556
+ if (redirectTargets.some((target) => /^\/dev\/sd[a-z]/i.test(target))) {
1557
+ return true;
1558
+ }
1559
+ const invocation = commandInvocation(words);
1560
+ if (!invocation) return false;
1561
+ const { name, rest, sudo } = invocation;
1562
+ if (sudo && name === "rm") return true;
1563
+ switch (name) {
1564
+ case "shutdown":
1565
+ case "reboot":
1566
+ case "poweroff":
1567
+ case "halt":
1568
+ case "diskpart":
1569
+ case "wipefs":
1570
+ case "shred":
1571
+ return true;
1572
+ case "rm":
1573
+ return rmIsDangerous(rest);
1574
+ case "rmdir":
1575
+ case "rd":
1576
+ case "del":
1577
+ case "erase":
1578
+ return rest.some((arg) => /^\/s$/i.test(arg));
1579
+ case "format":
1580
+ return rest.some((arg) => /^[a-z]:$/i.test(arg));
1581
+ case "dd":
1582
+ return rest.some((arg) => arg.startsWith("if="));
1583
+ case "find":
1584
+ return findIsDangerous(rest);
1585
+ case "xargs":
1586
+ return xargsIsDangerous(rest);
1587
+ case "chmod":
1588
+ return chmodIsDangerous(rest);
1589
+ case "systemctl":
1590
+ return rest.some((arg) => ["poweroff", "halt", "reboot"].includes(arg.toLowerCase()));
1591
+ case "init":
1592
+ return rest.some((arg) => arg === "0" || arg === "6");
1593
+ case "sh":
1594
+ case "bash":
1595
+ case "zsh":
1596
+ case "dash":
1597
+ case "ash": {
1598
+ const idx = rest.indexOf("-c");
1599
+ const script = idx >= 0 ? rest[idx + 1] : void 0;
1600
+ return script !== void 0 && commandLineIsDangerous(script, depth + 1);
1601
+ }
1602
+ case "eval":
1603
+ return rest.length > 0 && commandLineIsDangerous(rest.join(" "), depth + 1);
1604
+ case "cmd": {
1605
+ const idx = rest.findIndex((arg) => /^\/c$/i.test(arg));
1606
+ if (idx < 0) return false;
1607
+ const script = rest.slice(idx + 1).join(" ");
1608
+ return script.length > 0 && commandLineIsDangerous(script, depth + 1);
1609
+ }
1610
+ default:
1611
+ return name.startsWith("mkfs") && (name.length === 4 || /[.-]/.test(name.charAt(4)));
1612
+ }
1613
+ }
818
1614
  function isDangerousCommand(command) {
819
- return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
1615
+ if (FORK_BOMB.test(command)) return true;
1616
+ return commandLineIsDangerous(command, 0);
820
1617
  }
821
1618
  function getStringArg(args, key) {
822
1619
  if (typeof args === "object" && args !== null) {
@@ -828,7 +1625,7 @@ function getStringArg(args, key) {
828
1625
  return void 0;
829
1626
  }
830
1627
  function normalizeAbsolute(p, cwd) {
831
- const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
1628
+ const abs = path3.isAbsolute(p) ? path3.normalize(p) : path3.resolve(cwd, p);
832
1629
  return abs.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
833
1630
  }
834
1631
  function isOutsideCwd(p, cwd) {
@@ -837,20 +1634,20 @@ function isOutsideCwd(p, cwd) {
837
1634
  return abs !== base && !abs.startsWith(`${base}/`);
838
1635
  }
839
1636
  function resolveRealPath(p, cwd) {
840
- let current = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
1637
+ let current = path3.isAbsolute(p) ? path3.normalize(p) : path3.resolve(cwd, p);
841
1638
  const missing = [];
842
1639
  for (; ; ) {
843
1640
  try {
844
- return path.join(fs.realpathSync(current), ...missing.reverse());
1641
+ return path3.join(fs.realpathSync(current), ...missing.reverse());
845
1642
  } catch {
846
- const parent = path.dirname(current);
1643
+ const parent = path3.dirname(current);
847
1644
  if (parent === current) return null;
848
- missing.push(path.basename(current));
1645
+ missing.push(path3.basename(current));
849
1646
  current = parent;
850
1647
  }
851
1648
  }
852
1649
  }
853
- function isOutsideForWrite(p, cwd) {
1650
+ function isOutsideResolved(p, cwd) {
854
1651
  const resolved = resolveRealPath(p, cwd);
855
1652
  if (resolved === null) return isOutsideCwd(p, cwd);
856
1653
  let realCwd;
@@ -875,8 +1672,7 @@ function checkPermission(mode, req, ctx, allowRules = [], denyRules = []) {
875
1672
  if (isWriteTool && isSensitivePath(filePath)) {
876
1673
  return "deny";
877
1674
  }
878
- const outside = isWriteTool ? isOutsideForWrite(filePath, ctx.cwd) : isOutsideCwd(filePath, ctx.cwd);
879
- if (outside) {
1675
+ if (isOutsideResolved(filePath, ctx.cwd)) {
880
1676
  if (mode === "ask" && req.level === "read") {
881
1677
  return "ask";
882
1678
  }
@@ -950,17 +1746,40 @@ function scheduleSessionTitle(store, userText, model) {
950
1746
  // src/snapshot/git-tree.ts
951
1747
  import { execFile } from "child_process";
952
1748
  import { existsSync } from "fs";
953
- import { mkdir } from "fs/promises";
954
- import os from "os";
955
- import path2 from "path";
1749
+ import { mkdir, readdir, rm, writeFile } from "fs/promises";
1750
+ import os2 from "os";
1751
+ import path4 from "path";
956
1752
  var GIT_TIMEOUT_MS2 = 1e4;
1753
+ var GC_TIMEOUT_MS = 6e4;
957
1754
  var GIT_MAX_BUFFER2 = 8 * 1024 * 1024;
958
- function runGit2(args) {
1755
+ var GC_INTERVAL = 50;
1756
+ var TRACK_FAILURE_TTL_MS = 5 * 60 * 1e3;
1757
+ var SENSITIVE_EXCLUDE_PATTERNS = [
1758
+ ".env",
1759
+ ".env.*",
1760
+ "!.env.example",
1761
+ "!.env.sample",
1762
+ "!.env.template",
1763
+ "id_rsa",
1764
+ "id_dsa",
1765
+ "id_ecdsa",
1766
+ "id_ed25519",
1767
+ ".npmrc",
1768
+ ".netrc",
1769
+ ".pgpass",
1770
+ "*.pem",
1771
+ "*.key",
1772
+ "*.p12",
1773
+ "*.pfx",
1774
+ "**/.aws/credentials",
1775
+ "**/.kube/config"
1776
+ ];
1777
+ function runGit2(args, timeoutMs = GIT_TIMEOUT_MS2) {
959
1778
  return new Promise((resolve) => {
960
1779
  execFile(
961
1780
  "git",
962
1781
  args,
963
- { timeout: GIT_TIMEOUT_MS2, maxBuffer: GIT_MAX_BUFFER2, encoding: "utf8" },
1782
+ { timeout: timeoutMs, maxBuffer: GIT_MAX_BUFFER2, encoding: "utf8" },
964
1783
  (error, stdout) => {
965
1784
  resolve(error ? null : stdout.trim());
966
1785
  }
@@ -968,6 +1787,9 @@ function runGit2(args) {
968
1787
  });
969
1788
  }
970
1789
  var gitAvailable = null;
1790
+ var trackFailureUntil = /* @__PURE__ */ new Map();
1791
+ var trackCounts = /* @__PURE__ */ new Map();
1792
+ var scrubbedRepos = /* @__PURE__ */ new Set();
971
1793
  async function isGitAvailable() {
972
1794
  if (gitAvailable === null) {
973
1795
  gitAvailable = await runGit2(["--version"]) !== null;
@@ -975,41 +1797,119 @@ async function isGitAvailable() {
975
1797
  return gitAvailable;
976
1798
  }
977
1799
  function treeRepoDir(cwd) {
978
- const key = Buffer.from(path2.resolve(cwd), "utf8").toString("base64url");
979
- return path2.join(gitTreesDir(), key);
1800
+ const key = Buffer.from(path4.resolve(cwd), "utf8").toString("base64url");
1801
+ return path4.join(gitTreesDir(), key);
1802
+ }
1803
+ async function removeTreeRepo(cwd) {
1804
+ const dir = treeRepoDir(cwd);
1805
+ trackFailureUntil.delete(path4.resolve(cwd));
1806
+ trackCounts.delete(dir);
1807
+ scrubbedRepos.delete(dir);
1808
+ await rm(dir, { recursive: true, force: true }).catch(() => {
1809
+ });
1810
+ }
1811
+ async function pruneTreeReposExcept(remainingCwds) {
1812
+ let entries;
1813
+ try {
1814
+ entries = await readdir(gitTreesDir());
1815
+ } catch {
1816
+ return;
1817
+ }
1818
+ const keep = /* @__PURE__ */ new Set();
1819
+ for (const cwd of remainingCwds) keep.add(path4.basename(treeRepoDir(cwd)));
1820
+ await Promise.all(
1821
+ entries.filter((entry) => !keep.has(entry)).map(
1822
+ (entry) => rm(path4.join(gitTreesDir(), entry), { recursive: true, force: true }).catch(() => {
1823
+ })
1824
+ )
1825
+ );
980
1826
  }
981
1827
  function isGuardedDir(cwd) {
982
- const resolved = path2.resolve(cwd);
983
- const home = path2.resolve(os.homedir());
1828
+ const resolved = path4.resolve(cwd);
1829
+ const home = path4.resolve(os2.homedir());
984
1830
  const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
985
- return same(resolved, home) || same(resolved, path2.parse(resolved).root);
1831
+ return same(resolved, home) || same(resolved, path4.parse(resolved).root);
986
1832
  }
987
1833
  async function ensureTreeRepo(cwd) {
988
1834
  const dir = treeRepoDir(cwd);
989
- if (existsSync(path2.join(dir, "HEAD"))) return dir;
990
- await mkdir(path2.dirname(dir), { recursive: true });
991
- const out = await runGit2(["init", "--bare", dir]);
992
- return out === null ? null : dir;
1835
+ try {
1836
+ await mkdir(dir, { recursive: true, mode: 448 });
1837
+ if (!existsSync(path4.join(dir, "HEAD"))) {
1838
+ const out = await runGit2(["init", "--bare", dir]);
1839
+ if (out === null) return null;
1840
+ }
1841
+ await mkdir(path4.join(dir, "info"), { recursive: true });
1842
+ await writeFile(
1843
+ path4.join(dir, "info", "exclude"),
1844
+ `${SENSITIVE_EXCLUDE_PATTERNS.join("\n")}
1845
+ `,
1846
+ "utf8"
1847
+ );
1848
+ } catch {
1849
+ return null;
1850
+ }
1851
+ return dir;
993
1852
  }
994
1853
  function repoArgs(dir, cwd) {
995
1854
  return [`--git-dir=${dir}`, `--work-tree=${cwd}`];
996
1855
  }
1856
+ async function untrackExcluded(dir, cwd) {
1857
+ const base = repoArgs(dir, cwd);
1858
+ const listed = await runGit2([...base, "ls-files", "-z", "-c", "-i", "--exclude-standard"]);
1859
+ if (listed === null) return false;
1860
+ const files = listed.split("\0").filter(Boolean);
1861
+ if (files.length === 0) return true;
1862
+ const removed = await runGit2([
1863
+ ...base,
1864
+ "rm",
1865
+ "--cached",
1866
+ "--ignore-unmatch",
1867
+ "-q",
1868
+ "--",
1869
+ ...files.map((file) => `:(literal)${file}`)
1870
+ ]);
1871
+ return removed !== null;
1872
+ }
997
1873
  async function trackTree(cwd) {
998
1874
  if (isGuardedDir(cwd)) return null;
1875
+ const key = path4.resolve(cwd);
1876
+ const failedUntil = trackFailureUntil.get(key);
1877
+ if (failedUntil !== void 0 && Date.now() < failedUntil) return null;
1878
+ const tree = await trackTreeUncached(cwd);
1879
+ if (tree === null) {
1880
+ trackFailureUntil.set(key, Date.now() + TRACK_FAILURE_TTL_MS);
1881
+ return null;
1882
+ }
1883
+ trackFailureUntil.delete(key);
1884
+ return tree;
1885
+ }
1886
+ async function trackTreeUncached(cwd) {
999
1887
  if (!await isGitAvailable()) return null;
1000
1888
  const dir = await ensureTreeRepo(cwd);
1001
1889
  if (!dir) return null;
1002
1890
  const base = repoArgs(dir, cwd);
1891
+ if (!scrubbedRepos.has(dir)) {
1892
+ if (!await untrackExcluded(dir, cwd)) return null;
1893
+ scrubbedRepos.add(dir);
1894
+ }
1003
1895
  if (await runGit2([...base, "add", "-A"]) === null) return null;
1004
- return runGit2([...base, "write-tree"]);
1896
+ const tree = await runGit2([...base, "write-tree"]);
1897
+ if (tree === null) return null;
1898
+ const count = (trackCounts.get(dir) ?? 0) + 1;
1899
+ trackCounts.set(dir, count);
1900
+ if (count % GC_INTERVAL === 0) {
1901
+ await runGit2([`--git-dir=${dir}`, "gc", "--auto"], GC_TIMEOUT_MS);
1902
+ }
1903
+ return tree;
1005
1904
  }
1006
1905
  async function restoreTree(cwd, hash) {
1007
1906
  if (isGuardedDir(cwd)) return false;
1008
1907
  if (!await isGitAvailable()) return false;
1009
- const dir = treeRepoDir(cwd);
1010
- if (!existsSync(path2.join(dir, "HEAD"))) return false;
1908
+ const dir = await ensureTreeRepo(cwd);
1909
+ if (!dir) return false;
1011
1910
  const base = repoArgs(dir, cwd);
1012
1911
  if (await runGit2([...base, "read-tree", hash]) === null) return false;
1912
+ if (!await untrackExcluded(dir, cwd)) return false;
1013
1913
  if (await runGit2([...base, "checkout-index", "-f", "-a"]) === null) return false;
1014
1914
  if (await runGit2([...base, "clean", "-fd"]) === null) return false;
1015
1915
  return true;
@@ -1017,21 +1917,23 @@ async function restoreTree(cwd, hash) {
1017
1917
  async function diffTreeNames(cwd, hash) {
1018
1918
  if (!await isGitAvailable()) return null;
1019
1919
  const dir = treeRepoDir(cwd);
1020
- if (!existsSync(path2.join(dir, "HEAD"))) return null;
1920
+ if (!existsSync(path4.join(dir, "HEAD"))) return null;
1021
1921
  const base = repoArgs(dir, cwd);
1022
1922
  const changed = await runGit2([...base, "diff", "--name-only", hash]);
1023
1923
  const untracked = await runGit2([...base, "ls-files", "--others", "--exclude-standard"]);
1024
1924
  if (changed === null || untracked === null) return null;
1025
1925
  const names = /* @__PURE__ */ new Set();
1026
1926
  for (const line of [...changed.split("\n"), ...untracked.split("\n")]) {
1027
- if (line !== "") names.add(line);
1927
+ if (line !== "" && !isSensitivePath(line)) names.add(line);
1028
1928
  }
1029
1929
  return [...names];
1030
1930
  }
1031
1931
 
1032
1932
  // src/tools/fs/snapshots.ts
1033
- import { mkdir as mkdir2, readFile, rm, stat, writeFile } from "fs/promises";
1034
- import path3 from "path";
1933
+ import { randomUUID } from "crypto";
1934
+ import { mkdir as mkdir2, readFile, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
1935
+ import os3 from "os";
1936
+ import path5 from "path";
1035
1937
  var MAX_SNAPSHOTS = 50;
1036
1938
  var MAX_SNAPSHOT_CONTENT_BYTES = 5 * 1024 * 1024;
1037
1939
  var stack = [];
@@ -1039,6 +1941,30 @@ var activeTurn = 0;
1039
1941
  var activeTurnMessageIndex = -1;
1040
1942
  var checkpointSeq = 0;
1041
1943
  var hooks = null;
1944
+ var spillDir = path5.join(
1945
+ os3.tmpdir(),
1946
+ `star-cli-snapshots-${process.pid}-${randomUUID().slice(0, 8)}`
1947
+ );
1948
+ function isSpillPath(p) {
1949
+ return p.startsWith(spillDir + path5.sep);
1950
+ }
1951
+ async function spillContent(snapshot) {
1952
+ if (snapshot.content === null || snapshot.contentFile) return;
1953
+ try {
1954
+ const file = path5.join(spillDir, `${snapshot.id}.snap`);
1955
+ await mkdir2(spillDir, { recursive: true, mode: 448 });
1956
+ await writeFile2(file, snapshot.content, { encoding: "utf8", mode: 384 });
1957
+ snapshot.contentFile = file;
1958
+ snapshot.content = null;
1959
+ } catch {
1960
+ }
1961
+ }
1962
+ async function discardSpill(snapshot) {
1963
+ if (snapshot.contentFile && isSpillPath(snapshot.contentFile)) {
1964
+ await rm2(snapshot.contentFile, { force: true }).catch(() => {
1965
+ });
1966
+ }
1967
+ }
1042
1968
  function setSnapshotHooks(next) {
1043
1969
  hooks = next;
1044
1970
  }
@@ -1097,8 +2023,10 @@ async function pushSnapshot(snapshot) {
1097
2023
  console.error("[star] failed to persist snapshot checkpoint:", err);
1098
2024
  }
1099
2025
  }
2026
+ await spillContent(snapshot);
1100
2027
  if (stack.length > MAX_SNAPSHOTS) {
1101
2028
  const evicted = stack.splice(0, stack.length - MAX_SNAPSHOTS);
2029
+ for (const old of evicted) await discardSpill(old);
1102
2030
  await removeFromStore(evicted.map((s) => s.id));
1103
2031
  }
1104
2032
  }
@@ -1109,9 +2037,11 @@ function listSnapshots() {
1109
2037
  return [...stack];
1110
2038
  }
1111
2039
  function clearSnapshots() {
2040
+ for (const snapshot of stack) void discardSpill(snapshot);
1112
2041
  stack.length = 0;
1113
2042
  }
1114
2043
  function hydrateSnapshots(snapshots) {
2044
+ for (const snapshot of stack) void discardSpill(snapshot);
1115
2045
  stack.length = 0;
1116
2046
  stack.push(...snapshots.slice(-MAX_SNAPSHOTS));
1117
2047
  for (const snapshot of stack) {
@@ -1136,11 +2066,11 @@ async function revert(snapshot) {
1136
2066
  if (content === null) {
1137
2067
  return `Skipped ${snapshot.path}: the snapshot content is no longer available.`;
1138
2068
  }
1139
- await mkdir2(path3.dirname(snapshot.path), { recursive: true });
1140
- await writeFile(snapshot.path, content, "utf8");
2069
+ await mkdir2(path5.dirname(snapshot.path), { recursive: true });
2070
+ await writeFile2(snapshot.path, content, "utf8");
1141
2071
  return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
1142
2072
  }
1143
- await rm(snapshot.path, { force: true });
2073
+ await rm2(snapshot.path, { force: true });
1144
2074
  return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
1145
2075
  }
1146
2076
  function listTurnSnapshots(turn, includeSubagent = false) {
@@ -1167,6 +2097,7 @@ async function undoTurnSnapshots(turn, includeSubagent = false) {
1167
2097
  const idx = stack.indexOf(snapshot);
1168
2098
  if (idx >= 0) stack.splice(idx, 1);
1169
2099
  revertedIds.push(snapshot.id);
2100
+ await discardSpill(snapshot);
1170
2101
  } catch (err) {
1171
2102
  messages.push(
1172
2103
  `Failed to revert ${snapshot.path}: ${err.message} (checkpoint kept)`
@@ -1178,13 +2109,16 @@ async function undoTurnSnapshots(turn, includeSubagent = false) {
1178
2109
  }
1179
2110
  async function dropTurnSnapshots(turn, includeSubagent = false) {
1180
2111
  const dropped = [];
2112
+ const droppedSnapshots = [];
1181
2113
  for (let i = stack.length - 1; i >= 0; i--) {
1182
2114
  const snapshot = stack[i];
1183
2115
  if (snapshot && snapshot.turn === turn && (includeSubagent || ownerOf(snapshot) === "root")) {
1184
2116
  dropped.push(snapshot.id);
2117
+ droppedSnapshots.push(snapshot);
1185
2118
  stack.splice(i, 1);
1186
2119
  }
1187
2120
  }
2121
+ for (const snapshot of droppedSnapshots) await discardSpill(snapshot);
1188
2122
  await removeFromStore(dropped);
1189
2123
  return dropped.length;
1190
2124
  }
@@ -1206,6 +2140,7 @@ async function rewindToSnapshot(id, includeSubagent = false) {
1206
2140
  const idx = stack.indexOf(snapshot);
1207
2141
  if (idx >= 0) stack.splice(idx, 1);
1208
2142
  revertedIds.push(snapshot.id);
2143
+ await discardSpill(snapshot);
1209
2144
  if (snapshot.messageIndex >= 0) {
1210
2145
  messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
1211
2146
  }
@@ -1222,7 +2157,7 @@ async function rewindToSnapshot(id, includeSubagent = false) {
1222
2157
  // src/tools/bash.ts
1223
2158
  import { spawn as spawn3 } from "child_process";
1224
2159
  import { existsSync as existsSync2 } from "fs";
1225
- import path4 from "path";
2160
+ import path6 from "path";
1226
2161
  import { z as z2 } from "zod";
1227
2162
 
1228
2163
  // src/tasks/manager.ts
@@ -1241,6 +2176,9 @@ var TaskManager = class extends EventEmitter {
1241
2176
  const child = spawn2(spec.shell, spec.wrap(opts.command), {
1242
2177
  cwd: opts.cwd,
1243
2178
  windowsHide: true,
2179
+ // No stdin: background commands that read it would hang until the
2180
+ // (much longer) task timeout; give them EOF immediately.
2181
+ stdio: ["ignore", "pipe", "pipe"],
1244
2182
  // POSIX: the child leads its own process group so killTree can SIGKILL
1245
2183
  // the whole tree with a negative pid (Windows uses taskkill /t).
1246
2184
  detached: process.platform !== "win32"
@@ -1356,11 +2294,11 @@ var HALF_OUTPUT = MAX_OUTPUT / 2;
1356
2294
  var DEFAULT_TIMEOUT = 120;
1357
2295
  var MAX_TIMEOUT = 600;
1358
2296
  function findOnPath(exe, exclude) {
1359
- for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
2297
+ for (const dir of (process.env.PATH ?? "").split(path6.delimiter)) {
1360
2298
  if (!dir || exclude?.(dir)) {
1361
2299
  continue;
1362
2300
  }
1363
- const full = path4.join(dir, exe);
2301
+ const full = path6.join(dir, exe);
1364
2302
  if (existsSync2(full)) {
1365
2303
  return full;
1366
2304
  }
@@ -1378,16 +2316,22 @@ function resolveShellUncached() {
1378
2316
  }
1379
2317
  const gitExe = findOnPath("git.exe");
1380
2318
  if (gitExe) {
1381
- const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
2319
+ const sibling = path6.join(path6.dirname(path6.dirname(gitExe)), "bin", "bash.exe");
1382
2320
  if (existsSync2(sibling)) {
1383
2321
  return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
1384
2322
  }
1385
2323
  }
1386
- for (const candidate of [
1387
- "C:\\Program Files\\Git\\bin\\bash.exe",
1388
- "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
1389
- "D:\\Git\\bin\\bash.exe"
2324
+ const seen = /* @__PURE__ */ new Set();
2325
+ for (const root of [
2326
+ process.env.ProgramFiles,
2327
+ process.env["ProgramFiles(x86)"],
2328
+ process.env.ProgramW6432
1390
2329
  ]) {
2330
+ if (!root) continue;
2331
+ const candidate = path6.join(root, "Git", "bin", "bash.exe");
2332
+ const key = candidate.toLowerCase();
2333
+ if (seen.has(key)) continue;
2334
+ seen.add(key);
1391
2335
  if (existsSync2(candidate)) {
1392
2336
  return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
1393
2337
  }
@@ -1489,7 +2433,10 @@ description: ${args.description ?? "(none)"}`
1489
2433
  const child = spawn3(spec.shell, spec.wrap(args.command), {
1490
2434
  cwd: ctx.cwd,
1491
2435
  windowsHide: true,
1492
- detached: process.platform !== "win32"
2436
+ detached: process.platform !== "win32",
2437
+ // No stdin: a command that reads it (cat, read, input()) must get
2438
+ // EOF immediately instead of hanging until the timeout.
2439
+ stdio: ["ignore", "pipe", "pipe"]
1493
2440
  });
1494
2441
  const output = new BoundedOutput();
1495
2442
  let settled = false;
@@ -1538,9 +2485,10 @@ Command timed out after ${timeoutSeconds}s`,
1538
2485
  };
1539
2486
 
1540
2487
  // src/tools/fs/edit.ts
1541
- import { readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1542
- import path5 from "path";
2488
+ import { readFile as readFile2, stat as stat2, writeFile as writeFile3 } from "fs/promises";
2489
+ import path7 from "path";
1543
2490
  import { z as z3 } from "zod";
2491
+ var BINARY_PROBE_BYTES = 8192;
1544
2492
  var schema2 = z3.object({
1545
2493
  path: z3.string().describe("File path, absolute or relative to the working directory"),
1546
2494
  old_string: z3.string().min(1).describe("Exact text to replace"),
@@ -1549,16 +2497,27 @@ var schema2 = z3.object({
1549
2497
  });
1550
2498
  var editFileTool = {
1551
2499
  name: "edit_file",
1552
- description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set.",
2500
+ description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set. Refuses binary files and files over 5MB.",
1553
2501
  permission: "write",
1554
2502
  parameters: schema2,
1555
2503
  async execute(args, ctx) {
1556
- const filePath = path5.resolve(ctx.cwd, args.path);
2504
+ const filePath = path7.resolve(ctx.cwd, args.path);
1557
2505
  let content;
1558
2506
  let readMtimeMs;
1559
2507
  try {
1560
- content = await readFile2(filePath, "utf8");
1561
- readMtimeMs = (await stat2(filePath)).mtimeMs;
2508
+ const st = await stat2(filePath);
2509
+ if (st.size > MAX_SNAPSHOT_CONTENT_BYTES) {
2510
+ return {
2511
+ content: `Refused to edit ${args.path}: file is ${st.size} bytes, over the 5MB limit (edits past it could not be restored by /undo)`,
2512
+ isError: true
2513
+ };
2514
+ }
2515
+ readMtimeMs = st.mtimeMs;
2516
+ const buf = await readFile2(filePath);
2517
+ if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
2518
+ return { content: `Refused to edit binary file: ${args.path}`, isError: true };
2519
+ }
2520
+ content = buf.toString("utf8");
1562
2521
  } catch (err) {
1563
2522
  return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
1564
2523
  }
@@ -1586,7 +2545,7 @@ var editFileTool = {
1586
2545
  isError: true
1587
2546
  };
1588
2547
  }
1589
- await writeFile2(filePath, updated, "utf8");
2548
+ await writeFile3(filePath, updated, "utf8");
1590
2549
  } catch (err) {
1591
2550
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1592
2551
  }
@@ -1608,23 +2567,23 @@ var editFileTool = {
1608
2567
  };
1609
2568
 
1610
2569
  // src/tools/fs/glob.ts
1611
- import path7 from "path";
2570
+ import path9 from "path";
1612
2571
  import { z as z4 } from "zod";
1613
2572
 
1614
2573
  // src/tools/fs/util.ts
1615
2574
  import { readFileSync, statSync } from "fs";
1616
- import { readdir, stat as stat3 } from "fs/promises";
1617
- import path6 from "path";
2575
+ import { readdir as readdir2, stat as stat3 } from "fs/promises";
2576
+ import path8 from "path";
1618
2577
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
1619
2578
  async function walkFiles(root, skipDirs = SKIP_DIRS, options = {}) {
1620
2579
  const out = [];
1621
2580
  async function walk(dir, relBase) {
1622
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
2581
+ const entries = await readdir2(dir, { withFileTypes: true }).catch(() => null);
1623
2582
  if (!entries) {
1624
2583
  return;
1625
2584
  }
1626
2585
  for (const entry of entries) {
1627
- const abs = path6.join(dir, entry.name);
2586
+ const abs = path8.join(dir, entry.name);
1628
2587
  const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
1629
2588
  if (entry.isDirectory()) {
1630
2589
  if (skipDirs.has(entry.name) || options.ignore?.(abs, true)) {
@@ -1783,7 +2742,7 @@ function parseIgnorePatterns(body) {
1783
2742
  return out;
1784
2743
  }
1785
2744
  function loadIgnorePatterns(dir) {
1786
- const file = path6.join(dir, IGNORE_FILE);
2745
+ const file = path8.join(dir, IGNORE_FILE);
1787
2746
  let mtimeMs;
1788
2747
  try {
1789
2748
  mtimeMs = statSync(file).mtimeMs;
@@ -1808,9 +2767,9 @@ function createIgnorePredicate(cwd) {
1808
2767
  if (patterns.length === 0) {
1809
2768
  return void 0;
1810
2769
  }
1811
- const root = path6.resolve(cwd);
2770
+ const root = path8.resolve(cwd);
1812
2771
  return (absPath, isDir) => {
1813
- const rel = path6.relative(root, absPath).split(path6.sep).join("/");
2772
+ const rel = path8.relative(root, absPath).split(path8.sep).join("/");
1814
2773
  if (!rel || rel.startsWith("..")) {
1815
2774
  return false;
1816
2775
  }
@@ -1846,15 +2805,24 @@ var schema3 = z4.object({
1846
2805
  ),
1847
2806
  path: z4.string().optional().describe("Directory to search, defaults to the working directory")
1848
2807
  });
2808
+ function hasParentSegment(pattern) {
2809
+ return pattern.replace(/\\/g, "/").split("/").some((segment) => segment === "..");
2810
+ }
1849
2811
  var globTool = {
1850
2812
  name: "glob",
1851
2813
  description: "Find files by glob pattern. Skips node_modules, .git and dist. Returns paths sorted by modification time, newest first, up to 100 results.",
1852
2814
  permission: "read",
1853
2815
  parameters: schema3,
1854
2816
  async execute(args, ctx) {
1855
- const root = path7.resolve(ctx.cwd, args.path ?? ".");
2817
+ if (hasParentSegment(args.pattern)) {
2818
+ return {
2819
+ content: `Refused glob pattern with '..' segment: ${args.pattern}`,
2820
+ isError: true
2821
+ };
2822
+ }
2823
+ const root = path9.resolve(ctx.cwd, args.path ?? ".");
1856
2824
  const prefixSegments = literalDirPrefix(args.pattern);
1857
- const walkRoot = prefixSegments.length > 0 ? path7.join(root, ...prefixSegments) : root;
2825
+ const walkRoot = prefixSegments.length > 0 ? path9.join(root, ...prefixSegments) : root;
1858
2826
  const prefix = prefixSegments.length > 0 ? `${prefixSegments.join("/")}/` : "";
1859
2827
  const files = await walkFiles(walkRoot, void 0, { ignore: createIgnorePredicate(ctx.cwd) });
1860
2828
  const matched = files.filter((f) => matchesGlob(args.pattern, `${prefix}${f.rel}`)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
@@ -1866,8 +2834,8 @@ var globTool = {
1866
2834
  };
1867
2835
 
1868
2836
  // src/tools/fs/grep.ts
1869
- import { readFile as readFile3, stat as stat4 } from "fs/promises";
1870
- import path8 from "path";
2837
+ import { readFile as readFile3, realpath, stat as stat4 } from "fs/promises";
2838
+ import path10 from "path";
1871
2839
  import { z as z5 } from "zod";
1872
2840
  var MAX_MATCHES = 250;
1873
2841
  var MAX_LINE_CHARS = 500;
@@ -1900,18 +2868,19 @@ var grepTool = {
1900
2868
  } catch (err) {
1901
2869
  return { content: `Invalid regular expression: ${err.message}`, isError: true };
1902
2870
  }
1903
- const root = path8.resolve(ctx.cwd, args.path ?? ".");
2871
+ const root = path10.resolve(ctx.cwd, args.path ?? ".");
1904
2872
  let files;
1905
2873
  try {
1906
2874
  const st = await stat4(root);
1907
2875
  if (st.isFile()) {
1908
- if (isSensitivePath(root)) {
2876
+ const resolved = await realpath(root).catch(() => null);
2877
+ if (isSensitivePath(root) || resolved !== null && isSensitivePath(resolved)) {
1909
2878
  return {
1910
2879
  content: `Refused to grep sensitive file: ${args.path ?? "."}`,
1911
2880
  isError: true
1912
2881
  };
1913
2882
  }
1914
- files = [{ abs: root, rel: path8.basename(root), mtimeMs: st.mtimeMs }];
2883
+ files = [{ abs: root, rel: path10.basename(root), mtimeMs: st.mtimeMs }];
1915
2884
  } else {
1916
2885
  const walked = await walkFiles(root, void 0, {
1917
2886
  ignore: createIgnorePredicate(ctx.cwd),
@@ -1977,13 +2946,13 @@ var grepTool = {
1977
2946
  };
1978
2947
 
1979
2948
  // src/tools/fs/read.ts
1980
- import { open, stat as stat5 } from "fs/promises";
1981
- import path9 from "path";
2949
+ import { open, realpath as realpath2, stat as stat5 } from "fs/promises";
2950
+ import path11 from "path";
1982
2951
  import { z as z6 } from "zod";
1983
2952
  var DEFAULT_LIMIT = 2e3;
1984
2953
  var MAX_CHARS = 100 * 1024;
1985
2954
  var MAX_READ_BYTES = 1024 * 1024;
1986
- var BINARY_PROBE_BYTES = 8192;
2955
+ var BINARY_PROBE_BYTES2 = 8192;
1987
2956
  function decodeUtf16be(buf) {
1988
2957
  const len = buf.length - buf.length % 2;
1989
2958
  const swapped = Buffer.allocUnsafe(len);
@@ -2000,7 +2969,7 @@ function decodeTextBuffer(buf) {
2000
2969
  if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
2001
2970
  return { text: decodeUtf16be(buf.subarray(2)) };
2002
2971
  }
2003
- if (buf.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
2972
+ if (buf.subarray(0, BINARY_PROBE_BYTES2).includes(0)) {
2004
2973
  return { binary: true };
2005
2974
  }
2006
2975
  return { text: buf.toString("utf8") };
@@ -2016,8 +2985,9 @@ var readFileTool = {
2016
2985
  permission: "read",
2017
2986
  parameters: schema5,
2018
2987
  async execute(args, ctx) {
2019
- const filePath = path9.resolve(ctx.cwd, args.path);
2020
- if (isSensitivePath(filePath)) {
2988
+ const filePath = path11.resolve(ctx.cwd, args.path);
2989
+ const resolvedPath = await realpath2(filePath).catch(() => null);
2990
+ if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
2021
2991
  return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
2022
2992
  }
2023
2993
  const st = await stat5(filePath).catch(() => null);
@@ -2078,25 +3048,224 @@ var readFileTool = {
2078
3048
  }
2079
3049
  };
2080
3050
 
2081
- // src/tools/fs/write.ts
2082
- import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
2083
- import path10 from "path";
3051
+ // src/tools/fs/read-image.ts
3052
+ import { readFile as readFile5, realpath as realpath3, stat as stat6 } from "fs/promises";
3053
+ import path13 from "path";
2084
3054
  import { z as z7 } from "zod";
3055
+
3056
+ // src/core/image-resize.ts
3057
+ import { execFile as execFile2 } from "child_process";
3058
+ import { randomUUID as randomUUID2 } from "crypto";
3059
+ import { readFile as readFile4, unlink, writeFile as writeFile4 } from "fs/promises";
3060
+ import os4 from "os";
3061
+ import path12 from "path";
3062
+ import { promisify } from "util";
3063
+ var RESIZE_TIMEOUT_MS = 1e4;
3064
+ var execFileAsync = promisify(execFile2);
3065
+ function windowsResizeScript(inputFile, outputFile, maxDimension) {
3066
+ const input = inputFile.replace(/'/g, "''");
3067
+ const output = outputFile.replace(/'/g, "''");
3068
+ return [
3069
+ "Add-Type -AssemblyName System.Drawing",
3070
+ `$img = [System.Drawing.Image]::FromFile('${input}')`,
3071
+ `$scale = [Math]::Min(1.0, ${maxDimension} / [Math]::Max($img.Width, $img.Height))`,
3072
+ "$w = [int][Math]::Max(1, [Math]::Round($img.Width * $scale))",
3073
+ "$h = [int][Math]::Max(1, [Math]::Round($img.Height * $scale))",
3074
+ "$bmp = New-Object System.Drawing.Bitmap($w, $h)",
3075
+ "$g = [System.Drawing.Graphics]::FromImage($bmp)",
3076
+ "$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic",
3077
+ "$g.DrawImage($img, 0, 0, $w, $h)",
3078
+ `$bmp.Save('${output}', [System.Drawing.Imaging.ImageFormat]::Png)`,
3079
+ "$g.Dispose(); $bmp.Dispose(); $img.Dispose()"
3080
+ ].join("; ");
3081
+ }
3082
+ function resizeImagePlan(platform, inputFile, outputFile, maxDimension, tools = {}) {
3083
+ if (platform === "win32") {
3084
+ return {
3085
+ command: "powershell.exe",
3086
+ args: [
3087
+ "-NoProfile",
3088
+ "-NonInteractive",
3089
+ "-Command",
3090
+ windowsResizeScript(inputFile, outputFile, maxDimension)
3091
+ ]
3092
+ };
3093
+ }
3094
+ if (platform === "darwin") {
3095
+ return {
3096
+ command: "sips",
3097
+ args: ["-Z", String(maxDimension), inputFile, "--out", outputFile]
3098
+ };
3099
+ }
3100
+ if (platform === "linux") {
3101
+ if (tools.hasConvert) {
3102
+ return {
3103
+ command: "convert",
3104
+ // -auto-orient keeps EXIF-rotated photos upright after the resize
3105
+ // strips their orientation metadata.
3106
+ args: [
3107
+ inputFile,
3108
+ "-auto-orient",
3109
+ "-resize",
3110
+ `${maxDimension}x${maxDimension}>`,
3111
+ outputFile
3112
+ ]
3113
+ };
3114
+ }
3115
+ if (tools.hasFfmpeg) {
3116
+ return {
3117
+ command: "ffmpeg",
3118
+ args: [
3119
+ "-y",
3120
+ "-i",
3121
+ inputFile,
3122
+ "-vf",
3123
+ `scale='if(gt(iw,ih),min(iw,${maxDimension}),-1)':'if(gt(iw,ih),-1,min(ih,${maxDimension}))'`,
3124
+ outputFile
3125
+ ]
3126
+ };
3127
+ }
3128
+ return null;
3129
+ }
3130
+ return null;
3131
+ }
3132
+ async function commandExists(command) {
3133
+ const checker = process.platform === "win32" ? "where" : "which";
3134
+ try {
3135
+ await execFileAsync(checker, [command], { timeout: RESIZE_TIMEOUT_MS });
3136
+ return true;
3137
+ } catch {
3138
+ return false;
3139
+ }
3140
+ }
3141
+ async function downsampleImageIfNeeded(image) {
3142
+ const buf = Buffer.from(image.data, "base64");
3143
+ const dims = probeImageDimensions(buf);
3144
+ if (!dims || Math.max(dims.width, dims.height) <= MAX_IMAGE_DIMENSION) {
3145
+ return { image, oversized: false, resized: false };
3146
+ }
3147
+ const inputFile = path12.join(os4.tmpdir(), `star-image-${randomUUID2()}.src`);
3148
+ const outputFile = path12.join(os4.tmpdir(), `star-image-${randomUUID2()}.png`);
3149
+ try {
3150
+ const [hasConvert, hasFfmpeg] = process.platform === "linux" ? await Promise.all([commandExists("convert"), commandExists("ffmpeg")]) : [false, false];
3151
+ const plan = resizeImagePlan(process.platform, inputFile, outputFile, MAX_IMAGE_DIMENSION, {
3152
+ hasConvert,
3153
+ hasFfmpeg
3154
+ });
3155
+ if (!plan) return { image, oversized: true, resized: false };
3156
+ await writeFile4(inputFile, buf);
3157
+ await execFileAsync(plan.command, plan.args, { timeout: RESIZE_TIMEOUT_MS });
3158
+ const out = await readFile4(outputFile).catch(() => null);
3159
+ if (!out || out.length === 0) return { image, oversized: true, resized: false };
3160
+ return {
3161
+ image: { path: image.path, mimeType: "image/png", data: out.toString("base64") },
3162
+ oversized: true,
3163
+ resized: true
3164
+ };
3165
+ } catch {
3166
+ return { image, oversized: true, resized: false };
3167
+ } finally {
3168
+ await unlink(inputFile).catch(() => {
3169
+ });
3170
+ await unlink(outputFile).catch(() => {
3171
+ });
3172
+ }
3173
+ }
3174
+
3175
+ // src/tools/fs/read-image.ts
3176
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3177
+ var IMAGE_MIME_TYPES = {
3178
+ ".png": "image/png",
3179
+ ".jpg": "image/jpeg",
3180
+ ".jpeg": "image/jpeg",
3181
+ ".gif": "image/gif",
3182
+ ".webp": "image/webp"
3183
+ };
2085
3184
  var schema6 = z7.object({
2086
- path: z7.string().describe("File path, absolute or relative to the working directory"),
2087
- content: z7.string().describe("Full content to write to the file")
3185
+ path: z7.string().describe(
3186
+ "Image file path (png/jpg/jpeg/gif/webp), absolute or relative to the working directory"
3187
+ )
3188
+ });
3189
+ var readImageTool = {
3190
+ name: "read_image",
3191
+ description: "Read an image file (png, jpg, gif, webp) and show it to the model. Use this whenever the user references a screenshot, diagram, or picture file you need to see. Images larger than 2000px are downsampled automatically.",
3192
+ permission: "read",
3193
+ parameters: schema6,
3194
+ async execute(args, ctx) {
3195
+ const filePath = path13.resolve(ctx.cwd, args.path);
3196
+ const resolvedPath = await realpath3(filePath).catch(() => null);
3197
+ if (isSensitivePath(filePath) || resolvedPath !== null && isSensitivePath(resolvedPath)) {
3198
+ return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
3199
+ }
3200
+ const mimeType = IMAGE_MIME_TYPES[path13.extname(filePath).toLowerCase()];
3201
+ if (!mimeType) {
3202
+ return {
3203
+ content: `Not a supported image (png/jpg/jpeg/gif/webp): ${args.path}`,
3204
+ isError: true
3205
+ };
3206
+ }
3207
+ const st = await stat6(filePath).catch(() => null);
3208
+ if (!st || !st.isFile()) {
3209
+ return { content: `File not found: ${args.path}`, isError: true };
3210
+ }
3211
+ if (st.size > MAX_IMAGE_BYTES) {
3212
+ return {
3213
+ content: `Image too large (${Math.round(st.size / 1024)}KB > ${MAX_IMAGE_BYTES / 1024}KB): ${args.path}`,
3214
+ isError: true
3215
+ };
3216
+ }
3217
+ const buf = await readFile5(filePath).catch(() => null);
3218
+ if (!buf || buf.length === 0) {
3219
+ return { content: `Failed to read ${args.path}`, isError: true };
3220
+ }
3221
+ if (!probeImageDimensions(buf)) {
3222
+ return {
3223
+ content: `File is not a valid image (unrecognized header): ${args.path}`,
3224
+ isError: true
3225
+ };
3226
+ }
3227
+ const downsampled = await downsampleImageIfNeeded({
3228
+ path: args.path,
3229
+ mimeType,
3230
+ data: buf.toString("base64")
3231
+ });
3232
+ if (downsampled.oversized && !downsampled.resized) {
3233
+ return {
3234
+ content: `Image exceeds 2000px and could not be downsampled on this platform: ${args.path}`,
3235
+ isError: true
3236
+ };
3237
+ }
3238
+ const image = downsampled.image;
3239
+ const dims = probeImageDimensions(Buffer.from(image.data, "base64"));
3240
+ const sizeKB = Math.round(image.data.length / 1024);
3241
+ const desc = dims ? `${dims.width}x${dims.height}` : "unknown dimensions";
3242
+ const resizedNote = downsampled.resized ? " (downsampled to fit 2000px)" : "";
3243
+ return {
3244
+ content: `Image loaded: ${args.path} (${desc}, ~${sizeKB}KB)${resizedNote}. The image is attached below \u2014 look at it now.`,
3245
+ images: [image]
3246
+ };
3247
+ }
3248
+ };
3249
+
3250
+ // src/tools/fs/write.ts
3251
+ import { mkdir as mkdir3, writeFile as writeFile5 } from "fs/promises";
3252
+ import path14 from "path";
3253
+ import { z as z8 } from "zod";
3254
+ var schema7 = z8.object({
3255
+ path: z8.string().describe("File path, absolute or relative to the working directory"),
3256
+ content: z8.string().describe("Full content to write to the file")
2088
3257
  });
2089
3258
  var writeFileTool = {
2090
3259
  name: "write_file",
2091
3260
  description: "Write content to a file, creating parent directories as needed. Overwrites existing files.",
2092
3261
  permission: "write",
2093
- parameters: schema6,
3262
+ parameters: schema7,
2094
3263
  async execute(args, ctx) {
2095
- const filePath = path10.resolve(ctx.cwd, args.path);
3264
+ const filePath = path14.resolve(ctx.cwd, args.path);
2096
3265
  const snapshot = await captureSnapshot(filePath, "write_file", ctx.snapshotContext);
2097
3266
  try {
2098
- await mkdir3(path10.dirname(filePath), { recursive: true });
2099
- await writeFile3(filePath, args.content, "utf8");
3267
+ await mkdir3(path14.dirname(filePath), { recursive: true });
3268
+ await writeFile5(filePath, args.content, "utf8");
2100
3269
  } catch (err) {
2101
3270
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
2102
3271
  }
@@ -2106,7 +3275,7 @@ var writeFileTool = {
2106
3275
  };
2107
3276
 
2108
3277
  // src/tools/tasks.ts
2109
- import { z as z8 } from "zod";
3278
+ import { z as z9 } from "zod";
2110
3279
 
2111
3280
  // src/agent/agent-tasks.ts
2112
3281
  import { EventEmitter as EventEmitter2 } from "events";
@@ -2277,8 +3446,8 @@ function formatTaskFinished(task) {
2277
3446
  }
2278
3447
 
2279
3448
  // src/tools/tasks.ts
2280
- var idSchema = z8.object({
2281
- id: z8.string().describe("Task id, e.g. task-1 or agent-1")
3449
+ var idSchema = z9.object({
3450
+ id: z9.string().describe("Task id, e.g. task-1 or agent-1")
2282
3451
  });
2283
3452
  function truncateOutput(s) {
2284
3453
  if (s.length <= MAX_OUTPUT) return s;
@@ -2295,7 +3464,7 @@ var taskListTool = {
2295
3464
  name: "task_list",
2296
3465
  description: "List background tasks (shell commands and subagents) with status and runtime.",
2297
3466
  permission: "read",
2298
- parameters: z8.object({}),
3467
+ parameters: z9.object({}),
2299
3468
  execute() {
2300
3469
  const shell = formatTaskList(defaultTaskManager.list());
2301
3470
  const agents = defaultAgentTasks.list();
@@ -2354,20 +3523,20 @@ var taskKillTool = {
2354
3523
  };
2355
3524
 
2356
3525
  // src/tools/todo.ts
2357
- import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
2358
- import path11 from "path";
2359
- import { z as z9 } from "zod";
2360
- var todoItemSchema = z9.object({
2361
- id: z9.number().int(),
2362
- title: z9.string(),
2363
- status: z9.enum(["pending", "in_progress", "done"])
3526
+ import { mkdir as mkdir4, readFile as readFile6, rename, writeFile as writeFile6 } from "fs/promises";
3527
+ import path15 from "path";
3528
+ import { z as z10 } from "zod";
3529
+ var todoItemSchema = z10.object({
3530
+ id: z10.number().int(),
3531
+ title: z10.string(),
3532
+ status: z10.enum(["pending", "in_progress", "done"])
2364
3533
  });
2365
- var writeSchema = z9.object({
2366
- todos: z9.array(todoItemSchema).describe("The full todo list, replacing the current one")
3534
+ var writeSchema = z10.object({
3535
+ todos: z10.array(todoItemSchema).describe("The full todo list, replacing the current one")
2367
3536
  });
2368
- var readSchema = z9.object({});
3537
+ var readSchema = z10.object({});
2369
3538
  function fileFor(cwd) {
2370
- return path11.join(cwd, ".star", "todos.json");
3539
+ return path15.join(cwd, ".star", "todos.json");
2371
3540
  }
2372
3541
  var persistGuard = () => true;
2373
3542
  function setTodoPersistGuard(guard) {
@@ -2387,7 +3556,7 @@ var TodoStore = class {
2387
3556
  async load(cwd) {
2388
3557
  let raw;
2389
3558
  try {
2390
- raw = await readFile4(fileFor(cwd), "utf8");
3559
+ raw = await readFile6(fileFor(cwd), "utf8");
2391
3560
  } catch {
2392
3561
  return;
2393
3562
  }
@@ -2405,10 +3574,10 @@ var TodoStore = class {
2405
3574
  if (!persistGuard()) {
2406
3575
  return;
2407
3576
  }
2408
- await mkdir4(path11.join(cwd, ".star"), { recursive: true });
3577
+ await mkdir4(path15.join(cwd, ".star"), { recursive: true });
2409
3578
  const file = fileFor(cwd);
2410
3579
  const tmp = `${file}.tmp`;
2411
- await writeFile4(tmp, `${JSON.stringify(this.list(), null, 2)}
3580
+ await writeFile6(tmp, `${JSON.stringify(this.list(), null, 2)}
2412
3581
  `);
2413
3582
  await rename(tmp, file);
2414
3583
  }
@@ -2481,13 +3650,13 @@ function createTodoTools(store = defaultStore) {
2481
3650
  }
2482
3651
 
2483
3652
  // src/tools/web/fetch.ts
2484
- import { z as z10 } from "zod";
3653
+ import { z as z11 } from "zod";
2485
3654
  var DEFAULT_MAX_CHARS = 2e4;
2486
3655
  var MAX_BYTES = 2 * 1024 * 1024;
2487
3656
  var TIMEOUT_MS = 3e4;
2488
- var schema7 = z10.object({
2489
- url: z10.string().describe("The http(s) URL to fetch"),
2490
- maxChars: z10.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
3657
+ var schema8 = z11.object({
3658
+ url: z11.string().describe("The http(s) URL to fetch"),
3659
+ maxChars: z11.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
2491
3660
  });
2492
3661
  function safeCodePoint(n, raw) {
2493
3662
  if (!Number.isInteger(n) || n < 0 || n > 1114111) {
@@ -2558,7 +3727,7 @@ var webFetchTool = {
2558
3727
  name: "web_fetch",
2559
3728
  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.",
2560
3729
  permission: "read",
2561
- parameters: schema7,
3730
+ parameters: schema8,
2562
3731
  async execute(args, ctx) {
2563
3732
  let url;
2564
3733
  try {
@@ -2621,15 +3790,15 @@ var webFetchTool = {
2621
3790
  };
2622
3791
 
2623
3792
  // src/tools/web/search.ts
2624
- import { z as z11 } from "zod";
3793
+ import { z as z12 } from "zod";
2625
3794
  var TIMEOUT_MS2 = 15e3;
2626
3795
  var DEFAULT_MAX_RESULTS = 5;
2627
3796
  var MAX_RESULTS_LIMIT = 10;
2628
3797
  var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
2629
3798
  var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
2630
- var schema8 = z11.object({
2631
- query: z11.string().min(1).describe("The search query"),
2632
- maxResults: z11.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
3799
+ var schema9 = z12.object({
3800
+ query: z12.string().min(1).describe("The search query"),
3801
+ maxResults: z12.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
2633
3802
  `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
2634
3803
  )
2635
3804
  });
@@ -2680,7 +3849,7 @@ var webSearchTool = {
2680
3849
  name: "web_search",
2681
3850
  description: "Search the web using DuckDuckGo and return a numbered list of results with title, URL and snippet. No API key required. Use web_fetch to read a result's full page.",
2682
3851
  permission: "read",
2683
- parameters: schema8,
3852
+ parameters: schema9,
2684
3853
  async execute(args, ctx) {
2685
3854
  const maxResults = Math.min(args.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT);
2686
3855
  const url = `${SEARCH_URL}${encodeURIComponent(args.query)}`;
@@ -2743,6 +3912,7 @@ var ToolRegistry = class {
2743
3912
  constructor(todoStore) {
2744
3913
  for (const tool of [
2745
3914
  readFileTool,
3915
+ readImageTool,
2746
3916
  writeFileTool,
2747
3917
  editFileTool,
2748
3918
  globTool,
@@ -2774,12 +3944,12 @@ var ToolRegistry = class {
2774
3944
 
2775
3945
  // src/agent/project-memory.ts
2776
3946
  import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
2777
- import path12 from "path";
3947
+ import path16 from "path";
2778
3948
  var PROJECT_MEMORY_FILE = "AGENTS.md";
2779
3949
  var PROJECT_MEMORY_MAX_CHARS = 32 * 1024;
2780
3950
  var cache = /* @__PURE__ */ new Map();
2781
3951
  function readProjectMemory(cwd) {
2782
- const file = path12.join(cwd, PROJECT_MEMORY_FILE);
3952
+ const file = path16.join(cwd, PROJECT_MEMORY_FILE);
2783
3953
  let mtimeMs;
2784
3954
  try {
2785
3955
  mtimeMs = statSync2(file).mtimeMs;
@@ -2809,8 +3979,8 @@ ${body}`;
2809
3979
 
2810
3980
  // src/agent/skills.ts
2811
3981
  import { readFileSync as readFileSync3, readdirSync, statSync as statSync3 } from "fs";
2812
- import path13 from "path";
2813
- import { z as z12 } from "zod";
3982
+ import path17 from "path";
3983
+ import { z as z13 } from "zod";
2814
3984
  var SKILL_FILE = "SKILL.md";
2815
3985
  var SKILL_MAX_CHARS = 32 * 1024;
2816
3986
  var NAME_PATTERN = /^[a-z0-9-]+$/;
@@ -2840,7 +4010,7 @@ function parseSkillFile(filePath, dirName, scope) {
2840
4010
  const name = parsed?.meta.name || dirName;
2841
4011
  const description = parsed?.meta.description;
2842
4012
  if (parsed && NAME_PATTERN.test(name) && description) {
2843
- skill = { name, description, filePath, dir: path13.dirname(filePath), scope };
4013
+ skill = { name, description, filePath, dir: path17.dirname(filePath), scope };
2844
4014
  }
2845
4015
  } catch {
2846
4016
  skill = null;
@@ -2857,14 +4027,14 @@ function scanDir(base, scope, into) {
2857
4027
  }
2858
4028
  for (const entry of entries.sort()) {
2859
4029
  if (!NAME_PATTERN.test(entry)) continue;
2860
- const parsed = parseSkillFile(path13.join(base, entry, SKILL_FILE), entry, scope);
4030
+ const parsed = parseSkillFile(path17.join(base, entry, SKILL_FILE), entry, scope);
2861
4031
  if (parsed) into.set(parsed.name, parsed);
2862
4032
  }
2863
4033
  }
2864
4034
  function discoverSkills(cwd, home = starHome()) {
2865
4035
  const byName = /* @__PURE__ */ new Map();
2866
- scanDir(path13.join(home, "skills"), "user", byName);
2867
- scanDir(path13.join(cwd, ".star", "skills"), "project", byName);
4036
+ scanDir(path17.join(home, "skills"), "user", byName);
4037
+ scanDir(path17.join(cwd, ".star", "skills"), "project", byName);
2868
4038
  return [...byName.values()];
2869
4039
  }
2870
4040
  function formatSkillsBlock(skills) {
@@ -2895,8 +4065,8 @@ function createSkillTool(deps) {
2895
4065
  name: "skill",
2896
4066
  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.",
2897
4067
  permission: "read",
2898
- parameters: z12.object({
2899
- name: z12.string().describe("Name of the skill to load, as listed in the system prompt.")
4068
+ parameters: z13.object({
4069
+ name: z13.string().describe("Name of the skill to load, as listed in the system prompt.")
2900
4070
  }),
2901
4071
  async execute(args) {
2902
4072
  const skills = discoverSkills(deps.cwd, deps.home);
@@ -2926,7 +4096,7 @@ function createSkillTool(deps) {
2926
4096
  }
2927
4097
 
2928
4098
  // src/agent/subagent.ts
2929
- import { z as z13 } from "zod";
4099
+ import { z as z14 } from "zod";
2930
4100
 
2931
4101
  // src/tools/index.ts
2932
4102
  function createDefaultRegistry(todoStore) {
@@ -2937,8 +4107,37 @@ function createDefaultRegistry(todoStore) {
2937
4107
  var MAX_SUBAGENT_DEPTH = 1;
2938
4108
  var MAX_REPORT_CHARS = 2e4;
2939
4109
  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.";
4110
+ function convertUsagePricing(usage, from, to, config) {
4111
+ if (from === to) return usage;
4112
+ const fromCfg = config.models.find((m) => m.model === from.modelId);
4113
+ const toCfg = config.models.find((m) => m.model === to.modelId);
4114
+ if (fromCfg?.promptPrice === void 0 || fromCfg.completionPrice === void 0 || toCfg?.promptPrice === void 0 || toCfg.completionPrice === void 0 || toCfg.promptPrice <= 0 || toCfg.completionPrice <= 0) {
4115
+ return usage;
4116
+ }
4117
+ const cached = Math.min(Math.max(usage.cachedPromptTokens ?? 0, 0), usage.promptTokens);
4118
+ const promptUsd = (usage.promptTokens - cached) * fromCfg.promptPrice;
4119
+ const cachedUsd = cached * (fromCfg.cacheReadPrice ?? fromCfg.promptPrice);
4120
+ const cacheReadUsd = (usage.cacheReadInputTokens ?? 0) * (fromCfg.cacheReadPrice ?? 0);
4121
+ const completionUsd = usage.completionTokens * fromCfg.completionPrice;
4122
+ const scaledCached = cachedUsd / (toCfg.cacheReadPrice ?? toCfg.promptPrice);
4123
+ const promptTokens = Math.round(promptUsd / toCfg.promptPrice + scaledCached);
4124
+ const cachedPromptTokens = Math.round(scaledCached);
4125
+ const toCacheReadPrice = toCfg.cacheReadPrice !== void 0 && toCfg.cacheReadPrice > 0 ? toCfg.cacheReadPrice : void 0;
4126
+ const completionTokens = Math.round(
4127
+ completionUsd / toCfg.completionPrice + (toCacheReadPrice === void 0 ? cacheReadUsd / toCfg.completionPrice : 0)
4128
+ );
4129
+ const cacheReadInputTokens = toCacheReadPrice === void 0 ? 0 : Math.round(cacheReadUsd / toCacheReadPrice);
4130
+ const result = {
4131
+ promptTokens,
4132
+ completionTokens,
4133
+ totalTokens: promptTokens + completionTokens
4134
+ };
4135
+ if (cachedPromptTokens > 0) result.cachedPromptTokens = cachedPromptTokens;
4136
+ if (cacheReadInputTokens > 0) result.cacheReadInputTokens = cacheReadInputTokens;
4137
+ return result;
4138
+ }
2940
4139
  async function runSubagent(deps, args, cwd, signal) {
2941
- const { AgentLoop: AgentLoop2 } = await import("./loop-XY3TE5NN.js");
4140
+ const { AgentLoop: AgentLoop2 } = await import("./loop-DVVPIOS7.js");
2942
4141
  const child = new AgentLoop2({
2943
4142
  model: deps.model,
2944
4143
  registry: createDefaultRegistry(),
@@ -2948,12 +4147,16 @@ async function runSubagent(deps, args, cwd, signal) {
2948
4147
 
2949
4148
  ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2950
4149
  providerMetadata: deps.providerMetadata,
4150
+ temperature: deps.temperature,
4151
+ streamIdleTimeoutSec: deps.streamIdleTimeoutSec,
4152
+ streamFirstChunkTimeoutSec: deps.streamFirstChunkTimeoutSec,
2951
4153
  subagentDepth: deps.depth + 1
2952
4154
  });
2953
4155
  const confirmHandler = deps.getConfirmHandler?.();
2954
4156
  if (confirmHandler) child.confirmHandler = confirmHandler;
2955
4157
  let toolCalls = 0;
2956
4158
  let error = null;
4159
+ const usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
2957
4160
  try {
2958
4161
  for await (const event of child.stream(args.prompt, signal)) {
2959
4162
  if (event.type === "tool-result") {
@@ -2961,10 +4164,18 @@ ${SUBAGENT_PROMPT}` : SUBAGENT_PROMPT,
2961
4164
  } else if (event.type === "error") {
2962
4165
  error = event.error.message;
2963
4166
  break;
4167
+ } else if (event.type === "finish" && event.usage) {
4168
+ usage.promptTokens += event.usage.promptTokens;
4169
+ usage.completionTokens += event.usage.completionTokens;
4170
+ usage.totalTokens += event.usage.totalTokens;
4171
+ usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + (event.usage.cachedPromptTokens ?? 0);
4172
+ usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + (event.usage.cacheReadInputTokens ?? 0);
2964
4173
  }
2965
4174
  }
2966
4175
  } catch (e) {
2967
4176
  error = e instanceof Error ? e.message : String(e);
4177
+ } finally {
4178
+ if (usage.totalTokens > 0) deps.onUsage?.(usage, deps.model);
2968
4179
  }
2969
4180
  if (signal.aborted) {
2970
4181
  throw new Error("Tool execution aborted.");
@@ -2997,10 +4208,10 @@ function createSubagentTool(deps) {
2997
4208
  name: "subagent",
2998
4209
  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).",
2999
4210
  permission: "exec",
3000
- parameters: z13.object({
3001
- prompt: z13.string().describe("Complete, self-contained instructions for the subagent."),
3002
- description: z13.string().optional().describe("Short label for the subtask, shown in the result header."),
3003
- run_in_background: z13.boolean().optional().describe(
4211
+ parameters: z14.object({
4212
+ prompt: z14.string().describe("Complete, self-contained instructions for the subagent."),
4213
+ description: z14.string().optional().describe("Short label for the subtask, shown in the result header."),
4214
+ run_in_background: z14.boolean().optional().describe(
3004
4215
  "Run concurrently in the background and return a task id immediately (default false). Use for independent subtasks that can run in parallel."
3005
4216
  )
3006
4217
  }),
@@ -3026,14 +4237,14 @@ function createSubagentTool(deps) {
3026
4237
  }
3027
4238
 
3028
4239
  // src/agent/user-memory.ts
3029
- import { appendFileSync, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
3030
- import path14 from "path";
3031
- import { z as z14 } from "zod";
4240
+ import { appendFileSync as appendFileSync2, mkdirSync, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
4241
+ import path18 from "path";
4242
+ import { z as z15 } from "zod";
3032
4243
  var USER_MEMORY_MAX_CHARS = 32 * 1024;
3033
4244
  var REMEMBER_TEXT_MAX_CHARS = 500;
3034
4245
  var cache3 = /* @__PURE__ */ new Map();
3035
4246
  function readUserMemory(home = starHome()) {
3036
- const file = path14.join(home, "MEMORY.md");
4247
+ const file = path18.join(home, "MEMORY.md");
3037
4248
  let mtimeMs;
3038
4249
  try {
3039
4250
  mtimeMs = statSync4(file).mtimeMs;
@@ -3065,7 +4276,7 @@ function readUserMemory(home = starHome()) {
3065
4276
  }
3066
4277
  function appendUserMemory(text, home = starHome()) {
3067
4278
  mkdirSync(home, { recursive: true });
3068
- appendFileSync(path14.join(home, "MEMORY.md"), `- ${text}
4279
+ appendFileSync2(path18.join(home, "MEMORY.md"), `- ${text}
3069
4280
  `, "utf8");
3070
4281
  }
3071
4282
  function createRememberTool(deps = {}) {
@@ -3073,8 +4284,8 @@ function createRememberTool(deps = {}) {
3073
4284
  name: "remember",
3074
4285
  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.`,
3075
4286
  permission: "write",
3076
- parameters: z14.object({
3077
- text: z14.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
4287
+ parameters: z15.object({
4288
+ text: z15.string().max(REMEMBER_TEXT_MAX_CHARS).describe("The note to save, as a single concise sentence.")
3078
4289
  }),
3079
4290
  async execute(args) {
3080
4291
  appendUserMemory(args.text, deps.home);
@@ -3085,9 +4296,10 @@ function createRememberTool(deps = {}) {
3085
4296
 
3086
4297
  // src/agent/loop.ts
3087
4298
  var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task using only the read-only tools available to you, then present a concrete, step-by-step implementation plan as your final answer. You must not modify files, run shell commands, or otherwise change the system \u2014 write/exec tools are unavailable in this mode. Do not ask the user to run commands for you; note manual steps in the plan instead. The user will review your plan and approve it before any execution begins.";
3088
- var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?|I will|I'll|I am going to|I'm going to|I shall|let me|next,? I|I will now)/i;
4299
+ var PENDING_WORK_PATTERN = /(?:我将|我会|我现在|接下来|下一步|下面(?:我|将)|稍后|随后|准备开始|现在开始|马上|即将|这就|待会|开始(?:执行|进行|处理|转换|动手|生成|写入|运行|创建)|(?:完成后|然后|接着)会|让我(?:们)?(?:来)?(?!知道)|I will|I'll|I am going to|I'm going to|I shall|\blet me(?!\s+know\b)|next,? I|I will now)/i;
3089
4300
  var AUTO_CONTINUE_NUDGE = "[auto-continue] You ended your turn with words instead of actions. Do not describe or restate the plan \u2014 continue the task NOW by calling tools. Reply with text only if the task is already fully complete.";
3090
4301
  var EMPTY_REPLY_NUDGE = "[auto-continue] Your previous reply was empty \u2014 no text and no tool calls. If the task is fully complete, reply with one short confirmation; otherwise continue NOW by calling tools.";
4302
+ var TRUNCATED_CONTINUE_NUDGE = "[auto-continue] Your previous reply was cut off mid-stream by a timeout \u2014 it is incomplete. Resume EXACTLY where you stopped: do not repeat or restate what you already wrote; continue with the next tool call or the remainder of the text.";
3091
4303
  function isFilteredEmptyFinish(finishReason) {
3092
4304
  return finishReason === "content-filter";
3093
4305
  }
@@ -3098,7 +4310,7 @@ function finalNudgeWarning(autoContinues, maxAutoContinues) {
3098
4310
  return autoContinues >= maxAutoContinues ? " This is the final automatic continuation \u2014 if your next reply again has no tool calls, nudging stops and the turn is handed back to the user. Finish the work now or summarize what remains." : "";
3099
4311
  }
3100
4312
  var COMPLETION_CHECK_TIMEOUT_MS = 3e4;
3101
- async function checkTaskComplete(model, request, finalReply, signal) {
4313
+ async function checkTaskComplete(model, request, finalReply, signal, temperature) {
3102
4314
  const controller = new AbortController();
3103
4315
  const timer = setTimeout(() => controller.abort(), COMPLETION_CHECK_TIMEOUT_MS);
3104
4316
  const onAbort = () => controller.abort();
@@ -3107,6 +4319,7 @@ async function checkTaskComplete(model, request, finalReply, signal) {
3107
4319
  const { text } = await generateText3({
3108
4320
  model,
3109
4321
  maxTokens: 8,
4322
+ temperature,
3110
4323
  abortSignal: controller.signal,
3111
4324
  prompt: [
3112
4325
  "An AI coding agent was given this task by the user:",
@@ -3184,9 +4397,19 @@ var AgentLoop = class {
3184
4397
  // consumes it as the redo label right after retractLastTurn sets it.
3185
4398
  lastUndoneLabel = "";
3186
4399
  // Doom-loop guard: signature of the most recent tool call and how many
3187
- // times in a row it has repeated. Any different call resets the count.
4400
+ // times in a row it has repeated. Any different call resets the count, and
4401
+ // each new user turn restarts the streak.
3188
4402
  lastToolSignature = null;
3189
4403
  repeatedToolCalls = 0;
4404
+ // Token usage subagents consumed, waiting to fold into this loop's next
4405
+ // finish event with usage: sync children report when their tool call
4406
+ // settles, background ones whenever they finish (drained or not).
4407
+ // Consumers meter only root-loop finish events, so folding here bills
4408
+ // subagent spend with zero consumer changes — /cost, /usage and
4409
+ // sessionBudgetUsd all cover it. A background child still running when the
4410
+ // session's last finish has passed never reports (best effort, noted in
4411
+ // agent-tasks cleanup paths).
4412
+ subagentUsage = null;
3190
4413
  // Turn context of the in-flight stream() turn, stamped onto every tool
3191
4414
  // execution so file snapshots attribute root changes to a turn and mark
3192
4415
  // subagent changes as excluded from turn-level retraction.
@@ -3226,8 +4449,12 @@ var AgentLoop = class {
3226
4449
  cwd: opts.cwd,
3227
4450
  system: opts.system,
3228
4451
  providerMetadata: opts.providerMetadata,
4452
+ temperature: opts.temperature,
4453
+ streamIdleTimeoutSec: opts.streamIdleTimeoutSec,
4454
+ streamFirstChunkTimeoutSec: opts.streamFirstChunkTimeoutSec,
3229
4455
  depth,
3230
- getConfirmHandler: () => this.confirmHandler
4456
+ getConfirmHandler: () => this.confirmHandler,
4457
+ onUsage: (usage, childModel) => this.addSubagentUsage(usage, childModel)
3231
4458
  })
3232
4459
  );
3233
4460
  }
@@ -3377,7 +4604,10 @@ var AgentLoop = class {
3377
4604
  }
3378
4605
  // Truncates the history back to the given message index (the user message
3379
4606
  // at that index and everything after it is dropped) and persists the
3380
- // trimmed history, so /rewind stays consistent across resume.
4607
+ // trimmed history, so /rewind stays consistent across resume. The redo
4608
+ // stack dies with the rewound context: its entries describe pre-/undo
4609
+ // working-tree states that no longer line up with this conversation, so
4610
+ // restoring one would clobber the rewound tree.
3381
4611
  async retractFromIndex(index) {
3382
4612
  const cut = this.retractionCut(index);
3383
4613
  const removed = this.messages.length - cut;
@@ -3385,6 +4615,7 @@ var AgentLoop = class {
3385
4615
  this.messages = this.messages.slice(0, cut);
3386
4616
  await this.opts.sessionStore?.replaceMessages([...this.messages]);
3387
4617
  this.turnMarkers = this.turnMarkers.filter((m) => m.userIndex < cut);
4618
+ this.redoStack = [];
3388
4619
  return removed;
3389
4620
  }
3390
4621
  async appendContextMessage(text, role = "user") {
@@ -3492,9 +4723,12 @@ var AgentLoop = class {
3492
4723
  this.maybeScheduleTitle();
3493
4724
  const { config, registry, cwd } = this.opts;
3494
4725
  const aiTools = this.buildAiTools();
4726
+ this.lastToolSignature = null;
4727
+ this.repeatedToolCalls = 0;
3495
4728
  let autoContinues = 0;
3496
4729
  let lastWasNudge = false;
3497
4730
  let usedToolsThisTurn = false;
4731
+ let streamCutContinues = 0;
3498
4732
  let openTodos = [];
3499
4733
  const maxSteps = config.maxSteps;
3500
4734
  let step = 0;
@@ -3508,6 +4742,7 @@ var AgentLoop = class {
3508
4742
  `Max steps (${maxSteps}) reached with no tool execution since the last checkpoint, stopping.`
3509
4743
  )
3510
4744
  };
4745
+ await this.runStopHooks("max-steps");
3511
4746
  return;
3512
4747
  }
3513
4748
  toolExecutedSinceCheckpoint = false;
@@ -3536,6 +4771,7 @@ ${finished.result}`
3536
4771
  );
3537
4772
  if (compacted.compacted) {
3538
4773
  this.messages = await this.applyCompactionSummary(compacted, signal);
4774
+ await this.opts.sessionStore?.replaceMessages([...this.messages]);
3539
4775
  this.turnMarkers = [];
3540
4776
  }
3541
4777
  const maxRetries = Math.max(0, config.streamMaxRetries);
@@ -3546,7 +4782,9 @@ ${finished.result}`
3546
4782
  let lastFinishReason;
3547
4783
  let lastUsage;
3548
4784
  let sawReasoning = false;
4785
+ let wasTruncated = false;
3549
4786
  let oversizedImagesStripped = false;
4787
+ const hasOutput = () => text.trim().length > 0 || toolCalls.length > 0;
3550
4788
  for (let attempt = 0; ; attempt++) {
3551
4789
  text = "";
3552
4790
  toolCalls.length = 0;
@@ -3554,6 +4792,7 @@ ${finished.result}`
3554
4792
  lastFinishReason = void 0;
3555
4793
  lastUsage = void 0;
3556
4794
  sawReasoning = false;
4795
+ wasTruncated = false;
3557
4796
  const timeoutScale = Math.min(attempt + 1, 3);
3558
4797
  try {
3559
4798
  for await (const event of this.streamOnce(aiTools, signal, timeoutScale)) {
@@ -3568,8 +4807,9 @@ ${finished.result}`
3568
4807
  yield event;
3569
4808
  } else if (event.type === "finish") {
3570
4809
  lastFinishReason = event.finishReason;
4810
+ if (event.truncated) wasTruncated = true;
3571
4811
  lastUsage = event.usage;
3572
- yield event;
4812
+ yield this.foldSubagentUsage(event);
3573
4813
  } else if (event.type === "error") {
3574
4814
  failure = event.error;
3575
4815
  } else {
@@ -3583,9 +4823,10 @@ ${finished.result}`
3583
4823
  }
3584
4824
  if (signal.aborted) {
3585
4825
  yield* this.persistInterrupted(text, toolCalls);
4826
+ await this.runStopHooks("aborted");
3586
4827
  return;
3587
4828
  }
3588
- if (!failure && (text.length > 0 || toolCalls.length > 0)) break;
4829
+ if (!failure && hasOutput()) break;
3589
4830
  if (failure && !oversizedImagesStripped && isOversizedImageError(failure)) {
3590
4831
  oversizedImagesStripped = true;
3591
4832
  const removed = await this.stripOversizedImages();
@@ -3599,7 +4840,7 @@ ${finished.result}`
3599
4840
  }
3600
4841
  if (failure && !isRetryableStreamError(failure)) break;
3601
4842
  if (attempt >= maxRetries) break;
3602
- if (!failure && text.length === 0 && toolCalls.length === 0 && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
4843
+ if (!failure && !hasOutput() && attempt >= 1 && (isFilteredEmptyFinish(lastFinishReason) || sawReasoning || lastUsage !== void 0 && lastUsage.completionTokens > 0))
3603
4844
  break;
3604
4845
  const delayMs = computeRetryDelayMs(attempt, failure ? baseDelay : baseDelay * 2, failure);
3605
4846
  yield {
@@ -3611,14 +4852,16 @@ ${finished.result}`
3611
4852
  };
3612
4853
  if (!await sleep(delayMs, signal)) {
3613
4854
  yield* this.persistInterrupted(text, toolCalls);
4855
+ await this.runStopHooks("aborted");
3614
4856
  return;
3615
4857
  }
3616
4858
  }
3617
4859
  if (failure) {
3618
4860
  yield { type: "error", error: failure };
4861
+ await this.runStopHooks("error");
3619
4862
  return;
3620
4863
  }
3621
- if (text.length === 0 && toolCalls.length === 0) {
4864
+ if (!hasOutput()) {
3622
4865
  if (isSteerableEmptyFinish(lastFinishReason) && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
3623
4866
  autoContinues++;
3624
4867
  lastWasNudge = true;
@@ -3638,6 +4881,7 @@ ${finished.result}`
3638
4881
  type: "error",
3639
4882
  error: new Error("The model returned an empty response after retries; ending the turn.")
3640
4883
  };
4884
+ await this.runStopHooks("empty");
3641
4885
  return;
3642
4886
  }
3643
4887
  const assistantMessage = {
@@ -3655,6 +4899,27 @@ ${finished.result}`
3655
4899
  this.messages.push(assistantMessage);
3656
4900
  await this.persist(assistantMessage);
3657
4901
  if (toolCalls.length === 0) {
4902
+ if (wasTruncated) {
4903
+ if (config.permissionMode !== "plan" && streamCutContinues < config.streamMaxRetries) {
4904
+ streamCutContinues++;
4905
+ yield {
4906
+ type: "notice",
4907
+ message: `reply cut short by a stream timeout; asking the model to resume where it stopped (${streamCutContinues}/${config.streamMaxRetries}).`
4908
+ };
4909
+ const nudge = { role: "user", content: TRUNCATED_CONTINUE_NUDGE };
4910
+ this.messages.push(nudge);
4911
+ await this.persist(nudge);
4912
+ continue;
4913
+ }
4914
+ if (config.permissionMode !== "plan") {
4915
+ yield {
4916
+ type: "notice",
4917
+ message: 'reply was cut short by a stream timeout and resume attempts are exhausted, handing the turn back \u2014 reply "continue" to keep going.'
4918
+ };
4919
+ }
4920
+ await this.runStopHooks("completed");
4921
+ return;
4922
+ }
3658
4923
  let reason = null;
3659
4924
  let nudgeText = AUTO_CONTINUE_NUDGE;
3660
4925
  if (openTodos.length > 0) {
@@ -3662,13 +4927,27 @@ ${finished.result}`
3662
4927
  nudgeText = `[auto-continue] You ended your turn with unfinished todo item(s): ${openTodos.map((t) => `"${t}"`).join(
3663
4928
  ", "
3664
4929
  )}. Complete them now with tool calls, or update the list with todo_write if they are no longer needed.`;
4930
+ } else if (usedToolsThisTurn && !signal.aborted) {
4931
+ const complete = await checkTaskComplete(
4932
+ this.opts.model,
4933
+ inputText,
4934
+ text,
4935
+ signal,
4936
+ this.opts.temperature
4937
+ );
4938
+ if (complete === false) {
4939
+ reason = "completion check reports the task unfinished";
4940
+ } else if (complete === null) {
4941
+ if (lastWasNudge) {
4942
+ reason = "reply still had no tool calls";
4943
+ } else if (PENDING_WORK_PATTERN.test(text)) {
4944
+ reason = "reply announced unfinished work";
4945
+ }
4946
+ }
3665
4947
  } else if (lastWasNudge) {
3666
4948
  reason = "reply still had no tool calls";
3667
4949
  } else if (PENDING_WORK_PATTERN.test(text)) {
3668
4950
  reason = "reply announced unfinished work";
3669
- } else if (usedToolsThisTurn && !signal.aborted) {
3670
- const complete = await checkTaskComplete(this.opts.model, inputText, text, signal);
3671
- if (complete === false) reason = "completion check reports the task unfinished";
3672
4951
  }
3673
4952
  if (reason !== null && config.permissionMode !== "plan" && autoContinues < config.maxAutoContinues) {
3674
4953
  autoContinues++;
@@ -3691,7 +4970,7 @@ ${finished.result}`
3691
4970
  message: `${reason}; auto-continue limit reached, handing the turn back \u2014 reply "continue" to keep going.`
3692
4971
  };
3693
4972
  }
3694
- await this.runEventHooks("Stop");
4973
+ await this.runStopHooks("completed");
3695
4974
  return;
3696
4975
  }
3697
4976
  lastWasNudge = false;
@@ -3722,6 +5001,24 @@ ${finished.result}`
3722
5001
  this.messages.push(toolMessage);
3723
5002
  await this.persist(toolMessage);
3724
5003
  answered.add(call.id);
5004
+ if (result.images && result.images.length > 0) {
5005
+ const imageMessage = {
5006
+ role: "user",
5007
+ content: [
5008
+ ...result.images.map((img) => ({
5009
+ type: "image",
5010
+ image: img.data,
5011
+ mimeType: img.mimeType
5012
+ })),
5013
+ ...result.images.map((img) => ({
5014
+ type: "text",
5015
+ text: `[image from ${call.name}: ${img.path}]`
5016
+ }))
5017
+ ]
5018
+ };
5019
+ this.messages.push(imageMessage);
5020
+ await this.persist(imageMessage);
5021
+ }
3725
5022
  if (call.name === "todo_write" && !result.isError) {
3726
5023
  openTodos = pendingTodoTitles(call.args);
3727
5024
  }
@@ -3754,7 +5051,10 @@ ${finished.result}`
3754
5051
  yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
3755
5052
  }
3756
5053
  }
3757
- if (signal.aborted) return;
5054
+ if (signal.aborted) {
5055
+ await this.runStopHooks("aborted");
5056
+ return;
5057
+ }
3758
5058
  step++;
3759
5059
  }
3760
5060
  }
@@ -3787,7 +5087,7 @@ ${finished.result}`
3787
5087
  const content = parts.join("\n\n");
3788
5088
  const head = this.messages[0];
3789
5089
  if (head?.role === "system" && typeof head.content === "string") {
3790
- if (head.content !== content) head.content = content;
5090
+ if (head.content !== content) this.messages[0] = { role: "system", content };
3791
5091
  } else if (head?.role !== "system") {
3792
5092
  this.messages.unshift({ role: "system", content });
3793
5093
  }
@@ -3800,7 +5100,7 @@ ${finished.result}`
3800
5100
  const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
3801
5101
  const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
3802
5102
  try {
3803
- const summary = await summarizeMessages(dropped, model, signal);
5103
+ const summary = await summarizeMessages(dropped, model, signal, this.opts.temperature);
3804
5104
  const messages = compacted.messages.slice();
3805
5105
  messages[headCount] = {
3806
5106
  role: "user",
@@ -3813,15 +5113,15 @@ ${summary}`
3813
5113
  }
3814
5114
  }
3815
5115
  async *streamOnce(aiTools, signal, timeoutScale = 1) {
3816
- const { streamChat } = await import("./stream-EFQPULQQ.js");
3817
5116
  for await (const event of streamChat({
3818
5117
  model: this.opts.model,
3819
5118
  messages: this.messages,
3820
5119
  tools: aiTools,
3821
5120
  abortSignal: signal,
3822
5121
  providerMetadata: this.opts.providerMetadata,
3823
- idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3 * timeoutScale,
3824
- firstPartTimeoutMs: this.opts.config.streamFirstChunkTimeoutSec * 1e3 * timeoutScale
5122
+ idleTimeoutMs: (this.opts.streamIdleTimeoutSec ?? this.opts.config.streamIdleTimeoutSec) * 1e3 * timeoutScale,
5123
+ firstPartTimeoutMs: (this.opts.streamFirstChunkTimeoutSec ?? this.opts.config.streamFirstChunkTimeoutSec) * 1e3 * timeoutScale,
5124
+ temperature: this.opts.temperature
3825
5125
  })) {
3826
5126
  if (event.type === "finish" && event.truncated) {
3827
5127
  yield {
@@ -3844,6 +5144,14 @@ ${summary}`
3844
5144
  }
3845
5145
  return tools;
3846
5146
  }
5147
+ // Fires the Stop hooks for a turn's end, on every termination path (normal
5148
+ // completion, Esc abort, exhausted stream retries, terminal empty replies,
5149
+ // the maxSteps no-progress stop). The reason rides in STAR_TOOL_INPUT as
5150
+ // {"reason": ...}: Stop hooks ignore tool matchers and the variable was
5151
+ // never set for Stop before, so existing hooks are unaffected.
5152
+ async runStopHooks(reason) {
5153
+ await this.runEventHooks("Stop", void 0, { reason });
5154
+ }
3847
5155
  async runEventHooks(event, toolName, toolInput) {
3848
5156
  const empty = { blocked: false, warnings: [] };
3849
5157
  if (this.opts.config.hooks.length === 0) return empty;
@@ -3860,6 +5168,47 @@ ${summary}`
3860
5168
  return empty;
3861
5169
  }
3862
5170
  }
5171
+ // Accumulates a settled subagent's usage. When the child provably ran on a
5172
+ // different, fully priced model, the counts are first converted into
5173
+ // this-model-priced equivalents (convertUsagePricing): session cost is
5174
+ // token totals × the active model's price, so the conversion keeps the $
5175
+ // estimate accurate where raw sums would misprice.
5176
+ addSubagentUsage(usage, childModel) {
5177
+ const converted = convertUsagePricing(usage, childModel, this.opts.model, this.opts.config);
5178
+ const pending = this.subagentUsage ?? {
5179
+ promptTokens: 0,
5180
+ completionTokens: 0,
5181
+ totalTokens: 0
5182
+ };
5183
+ pending.promptTokens += converted.promptTokens;
5184
+ pending.completionTokens += converted.completionTokens;
5185
+ pending.totalTokens += converted.totalTokens;
5186
+ if (converted.cachedPromptTokens) {
5187
+ pending.cachedPromptTokens = (pending.cachedPromptTokens ?? 0) + converted.cachedPromptTokens;
5188
+ }
5189
+ if (converted.cacheReadInputTokens) {
5190
+ pending.cacheReadInputTokens = (pending.cacheReadInputTokens ?? 0) + converted.cacheReadInputTokens;
5191
+ }
5192
+ this.subagentUsage = pending;
5193
+ }
5194
+ // Folds pending subagent usage into an outgoing finish event (and clears
5195
+ // it). A finish without usage reports nothing to consumers, so the pending
5196
+ // amount is kept for the next finish that carries usage instead.
5197
+ foldSubagentUsage(event) {
5198
+ const pending = this.subagentUsage;
5199
+ if (!pending || !event.usage) return event;
5200
+ this.subagentUsage = null;
5201
+ const usage = {
5202
+ promptTokens: event.usage.promptTokens + pending.promptTokens,
5203
+ completionTokens: event.usage.completionTokens + pending.completionTokens,
5204
+ totalTokens: event.usage.totalTokens + pending.totalTokens
5205
+ };
5206
+ const cachedPromptTokens = (event.usage.cachedPromptTokens ?? 0) + (pending.cachedPromptTokens ?? 0);
5207
+ const cacheReadInputTokens = (event.usage.cacheReadInputTokens ?? 0) + (pending.cacheReadInputTokens ?? 0);
5208
+ if (cachedPromptTokens > 0) usage.cachedPromptTokens = cachedPromptTokens;
5209
+ if (cacheReadInputTokens > 0) usage.cacheReadInputTokens = cacheReadInputTokens;
5210
+ return { ...event, usage };
5211
+ }
3863
5212
  // opencode's doom_loop guard: returns a refusal once the identical tool
3864
5213
  // call (name + stable-serialized args) has repeated config.doomLoopThreshold
3865
5214
  // times in a row; 0 disables. Any different signature resets the streak.
@@ -3956,17 +5305,25 @@ export {
3956
5305
  compactMessages,
3957
5306
  WORKING_DIFF_MAX_LINES,
3958
5307
  isGitRepo,
3959
- getGitSummary,
5308
+ getGitSummaryCached,
3960
5309
  collectCommitContext,
3961
5310
  buildCommitPrompt,
3962
5311
  collectWorkingDiff,
3963
5312
  MAX_IMAGE_DIMENSION,
3964
- probeImageDimensions,
3965
5313
  reconcileToolCalls,
5314
+ wrapFetchWithActivity,
5315
+ starHome,
5316
+ globalConfigPath,
5317
+ sessionsDir,
5318
+ userMemoryPath,
5319
+ envFilePath,
5320
+ projectConfigPath,
3966
5321
  isSensitivePath,
3967
5322
  isAllowedByRules,
3968
5323
  buildAllowRule,
3969
5324
  isDangerousCommand,
5325
+ removeTreeRepo,
5326
+ pruneTreeReposExcept,
3970
5327
  diffTreeNames,
3971
5328
  listSnapshots,
3972
5329
  clearSnapshots,
@@ -3979,6 +5336,7 @@ export {
3979
5336
  bashTool,
3980
5337
  SKIP_DIRS,
3981
5338
  createIgnorePredicate,
5339
+ downsampleImageIfNeeded,
3982
5340
  formatTaskList,
3983
5341
  formatTaskStarted,
3984
5342
  formatTaskFinished,
@@ -3993,4 +5351,4 @@ export {
3993
5351
  PLAN_MODE_PROMPT,
3994
5352
  AgentLoop
3995
5353
  };
3996
- //# sourceMappingURL=chunk-I6FDXAWX.js.map
5354
+ //# sourceMappingURL=chunk-OOOW7FC4.js.map