@cryer/star-cli 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -17,15 +17,14 @@ import {
17
17
  createIgnorePredicate,
18
18
  defaultAgentTasks,
19
19
  defaultTaskManager,
20
+ diffTreeNames,
20
21
  discoverSkills,
21
- envFilePath,
22
22
  estimateTokens,
23
23
  formatTaskFinished,
24
24
  formatTaskList,
25
25
  formatTaskStarted,
26
26
  formatTodos,
27
27
  getGitSummary,
28
- globalConfigPath,
29
28
  hydrateSnapshots,
30
29
  isAllowedByRules,
31
30
  isDangerousCommand,
@@ -36,22 +35,34 @@ import {
36
35
  loadTodos,
37
36
  parseTodoArgs,
38
37
  probeImageDimensions,
39
- projectConfigPath,
40
38
  reconcileToolCalls,
41
39
  resetTodos,
42
40
  resolveShell,
43
41
  resolveSnapshotContent,
44
42
  rewindToSnapshot,
43
+ summarizeMessages
44
+ } from "./chunk-3L6QKJM4.js";
45
+ import {
46
+ envFilePath,
47
+ globalConfigPath,
48
+ projectConfigPath,
45
49
  sessionsDir,
46
50
  starHome,
47
- summarizeMessages,
48
- undoTurnSnapshots,
49
51
  userMemoryPath
50
- } from "./chunk-K4SIXWCI.js";
52
+ } from "./chunk-B6P4VUBE.js";
51
53
 
52
54
  // src/main.tsx
53
55
  import { Command } from "commander";
54
56
 
57
+ // src/cli/terminal-text.ts
58
+ var UNSAFE_CHARS = /[\x00-\x09\x0b-\x1f\x7f]/;
59
+ var CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f]/g;
60
+ var TABS = /\t/g;
61
+ function toTerminalSafe(text) {
62
+ if (!UNSAFE_CHARS.test(text)) return text;
63
+ return text.replace(TABS, " ").replace(CONTROL_CHARS, "");
64
+ }
65
+
55
66
  // src/cli/format.ts
56
67
  function summarizeArgs(args, maxLength = 120) {
57
68
  let json;
@@ -96,7 +107,7 @@ function buildDisplayMessages(messages) {
96
107
  if (message.role === "user" || message.role === "assistant") {
97
108
  const text = coreMessageText(message);
98
109
  if (text) {
99
- display.push({ id: display.length, role: message.role, text });
110
+ display.push({ id: display.length, role: message.role, text: toTerminalSafe(text) });
100
111
  } else {
101
112
  collapsed++;
102
113
  }
@@ -108,7 +119,7 @@ function buildDisplayMessages(messages) {
108
119
  display.push({
109
120
  id: display.length,
110
121
  role: "system",
111
- text: `\u5DF2\u6062\u590D ${collapsed} \u6761\u5386\u53F2\u6D88\u606F`
122
+ text: `Restored ${collapsed} history message(s) not shown here.`
112
123
  });
113
124
  }
114
125
  return display;
@@ -166,7 +177,15 @@ function resizeImagePlan(platform, inputFile, outputFile, maxDimension, tools =
166
177
  if (tools.hasConvert) {
167
178
  return {
168
179
  command: "convert",
169
- args: [inputFile, "-resize", `${maxDimension}x${maxDimension}>`, outputFile]
180
+ // -auto-orient keeps EXIF-rotated photos upright after the resize
181
+ // strips their orientation metadata.
182
+ args: [
183
+ inputFile,
184
+ "-auto-orient",
185
+ "-resize",
186
+ `${maxDimension}x${maxDimension}>`,
187
+ outputFile
188
+ ]
170
189
  };
171
190
  }
172
191
  if (tools.hasFfmpeg) {
@@ -234,6 +253,7 @@ var MAX_MENTION_BYTES = 100 * 1024;
234
253
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
235
254
  var MAX_DIR_MENTION_ENTRIES = 200;
236
255
  var MAX_DIR_MENTION_DEPTH = 10;
256
+ var MAX_DIR_MENTION_TOTAL = 5e3;
237
257
  var IMAGE_MIME_TYPES = {
238
258
  ".png": "image/png",
239
259
  ".jpg": "image/jpeg",
@@ -259,7 +279,7 @@ async function readImageInput(filePath, cwd) {
259
279
  });
260
280
  return result.image;
261
281
  }
262
- var MENTION_RE = /(?<=^|\s)@([A-Za-z]:[\\/][A-Za-z0-9._\-/\\]*|[A-Za-z0-9._\-/\\]+)/g;
282
+ var MENTION_RE = /(?<=^|\s)@([A-Za-z]:[\\/][A-Za-z0-9._\-/\\\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]*|[A-Za-z0-9._\-/\\\u2e80-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]+)/g;
263
283
  function parseMentions(text) {
264
284
  const mentions = [];
265
285
  const seen = /* @__PURE__ */ new Set();
@@ -292,13 +312,18 @@ function isBinaryContent(buf) {
292
312
  return probe.includes(0);
293
313
  }
294
314
  async function listDirEntries(dir, prefix, depth, state, ignore) {
315
+ if (state.capped) return;
295
316
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
296
317
  if (!entries) return;
297
- const visible = entries.filter((entry) => entry.isDirectory() || entry.isFile()).filter((entry) => !isSensitivePath(entry.name)).filter((entry) => !ignore?.(path2.join(dir, entry.name), entry.isDirectory())).sort((a, b) => {
318
+ const visible = entries.filter((entry) => entry.isDirectory() || entry.isFile()).filter((entry) => !(entry.isDirectory() && SKIP_DIRS.has(entry.name))).filter((entry) => !isSensitivePath(entry.name)).filter((entry) => !ignore?.(path2.join(dir, entry.name), entry.isDirectory())).sort((a, b) => {
298
319
  const dirDiff = Number(b.isDirectory()) - Number(a.isDirectory());
299
320
  return dirDiff !== 0 ? dirDiff : a.name.localeCompare(b.name);
300
321
  });
301
322
  for (let i = 0; i < visible.length; i++) {
323
+ if (state.total >= MAX_DIR_MENTION_TOTAL) {
324
+ state.capped = true;
325
+ return;
326
+ }
302
327
  const entry = visible[i];
303
328
  if (!entry) continue;
304
329
  state.total += 1;
@@ -323,11 +348,13 @@ async function listDirEntries(dir, prefix, depth, state, ignore) {
323
348
  }
324
349
  async function directoryBlock(abs, mention, ignore) {
325
350
  const display = `${mention.replace(/\\/g, "/").replace(/\/+$/, "")}/`;
326
- const state = { lines: [`${display}`], shown: 0, total: 0 };
351
+ const state = { lines: [`${display}`], shown: 0, total: 0, capped: false };
327
352
  const probe = await readdir(abs).catch(() => null);
328
353
  if (!probe) return null;
329
354
  await listDirEntries(abs, "", 1, state, ignore);
330
- if (state.total > state.shown) {
355
+ if (state.capped) {
356
+ state.lines.push("... (truncated, many more entries)");
357
+ } else if (state.total > state.shown) {
331
358
  state.lines.push(
332
359
  `... (truncated, ${state.total - state.shown} more entries \u2014 use glob/grep tools or mention a subdirectory to see more)`
333
360
  );
@@ -471,11 +498,56 @@ import { parse, stringify } from "smol-toml";
471
498
  async function addAllowRule(rule) {
472
499
  return addPermissionRule("allow", rule);
473
500
  }
501
+ function tomlString(value) {
502
+ return JSON.stringify(value);
503
+ }
504
+ async function writeFileAtomic(filePath, content) {
505
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
506
+ await fs.promises.writeFile(tmpPath, content, "utf8");
507
+ await fs.promises.rename(tmpPath, filePath);
508
+ }
509
+ function upsertPermissionRuleText(content, kind, rules, hadPermissionsTable) {
510
+ const newLine = `${kind} = [${rules.map(tomlString).join(", ")}]`;
511
+ const lines = content.split("\n");
512
+ let sectionStart = -1;
513
+ let sectionEnd = lines.length;
514
+ for (let i = 0; i < lines.length; i++) {
515
+ const line = lines[i] ?? "";
516
+ if (!/^\s*\[/.test(line)) continue;
517
+ if (sectionStart === -1) {
518
+ if (/^\s*\[\s*permissions\s*\]\s*(?:#.*)?$/.test(line)) sectionStart = i;
519
+ } else {
520
+ sectionEnd = i;
521
+ break;
522
+ }
523
+ }
524
+ if (sectionStart === -1) {
525
+ if (hadPermissionsTable) return null;
526
+ const separator = content === "" ? "" : content.endsWith("\n") ? "\n" : "\n\n";
527
+ return `${content}${separator}[permissions]
528
+ ${newLine}
529
+ `;
530
+ }
531
+ const inlineArray = new RegExp(`^(\\s*)${kind}\\s*=\\s*\\[.*\\](\\s*(?:#.*)?)$`);
532
+ const anyKey = new RegExp(`^\\s*${kind}\\s*=`);
533
+ for (let i = sectionStart + 1; i < sectionEnd; i++) {
534
+ const line = lines[i] ?? "";
535
+ const match = inlineArray.exec(line);
536
+ if (match) {
537
+ lines[i] = `${match[1]}${newLine}${match[2]}`;
538
+ return lines.join("\n");
539
+ }
540
+ if (anyKey.test(line)) return null;
541
+ }
542
+ lines.splice(sectionStart + 1, 0, newLine);
543
+ return lines.join("\n");
544
+ }
474
545
  async function addPermissionRule(kind, rule) {
475
546
  const filePath = globalConfigPath();
547
+ let content = "";
476
548
  let raw = {};
477
549
  try {
478
- const content = await fs.promises.readFile(filePath, "utf8");
550
+ content = await fs.promises.readFile(filePath, "utf8");
479
551
  raw = parse(content);
480
552
  } catch (error) {
481
553
  if (error.code !== "ENOENT") throw error;
@@ -483,30 +555,57 @@ async function addPermissionRule(kind, rule) {
483
555
  const permissions = typeof raw.permissions === "object" && raw.permissions !== null ? raw.permissions : {};
484
556
  const rules = Array.isArray(permissions[kind]) ? permissions[kind] : [];
485
557
  if (rules.includes(rule)) return false;
486
- permissions[kind] = [...rules, rule];
487
- raw.permissions = permissions;
558
+ const nextRules = [...rules, rule];
488
559
  await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
489
- await fs.promises.writeFile(filePath, stringify(raw), "utf8");
560
+ const updated = upsertPermissionRuleText(content, kind, nextRules, raw.permissions !== void 0);
561
+ if (updated !== null) {
562
+ await writeFileAtomic(filePath, updated);
563
+ return true;
564
+ }
565
+ permissions[kind] = nextRules;
566
+ raw.permissions = permissions;
567
+ await writeFileAtomic(filePath, stringify(raw));
490
568
  return true;
491
569
  }
570
+ function upsertTopLevelKeyText(content, key, value) {
571
+ const newLine = `${key} = ${tomlString(value)}`;
572
+ if (content.trim() === "") return `${newLine}
573
+ `;
574
+ const lines = content.split("\n");
575
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
576
+ const end = firstTable === -1 ? lines.length : firstTable;
577
+ const keyLine = new RegExp(`^\\s*${key}\\s*=`);
578
+ for (let i = 0; i < end; i++) {
579
+ const line = lines[i] ?? "";
580
+ if (keyLine.test(line)) {
581
+ const comment = /\s+#.*$/.exec(line);
582
+ lines[i] = `${newLine}${comment?.[0] ?? ""}`;
583
+ return lines.join("\n");
584
+ }
585
+ }
586
+ lines.splice(end, 0, newLine);
587
+ return lines.join("\n");
588
+ }
492
589
  async function savePermissionMode(mode) {
493
590
  const filePath = globalConfigPath();
494
- let raw = {};
591
+ let content = "";
495
592
  try {
496
- const content = await fs.promises.readFile(filePath, "utf8");
497
- raw = parse(content);
593
+ content = await fs.promises.readFile(filePath, "utf8");
498
594
  } catch (error) {
499
595
  if (error.code !== "ENOENT") throw error;
500
596
  }
501
- raw.permissionMode = mode;
502
597
  await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
503
- await fs.promises.writeFile(filePath, stringify(raw), "utf8");
598
+ await writeFileAtomic(filePath, upsertTopLevelKeyText(content, "permissionMode", mode));
504
599
  }
505
600
 
506
601
  // src/config/schema.ts
507
602
  import { z } from "zod";
508
603
  var ProviderConfigSchema = z.object({
509
604
  name: z.string(),
605
+ // openai-responses talks to the Responses API (/responses) instead of chat
606
+ // completions — the wire format codex uses, and the native API of
607
+ // reasoning-model relays that otherwise hide reasoning behind empty chat
608
+ // completions. streamChat sends strictSchemas:false + store:false with it.
510
609
  protocol: z.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
511
610
  baseURL: z.string(),
512
611
  apiKeyEnv: z.string().optional(),
@@ -524,7 +623,11 @@ var ModelConfigSchema = z.object({
524
623
  // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
525
624
  // the model is treated as unpriced.
526
625
  promptPrice: z.number().nonnegative().optional(),
527
- completionPrice: z.number().nonnegative().optional()
626
+ completionPrice: z.number().nonnegative().optional(),
627
+ // Optional price (USD per 1M tokens) for cache-read prompt tokens. When
628
+ // unset, OpenAI-style cached tokens bill at promptPrice and Anthropic-style
629
+ // cache reads are not billed — set this for accurate cache pricing.
630
+ cacheReadPrice: z.number().nonnegative().optional()
528
631
  });
529
632
  var PermissionsConfigSchema = z.object({
530
633
  allow: z.array(z.string()).default([]),
@@ -563,14 +666,16 @@ var ConfigSchema = z.object({
563
666
  // Extra attempts per model request when a stream fails transiently
564
667
  // (network error, 429/5xx, idle watchdog cutoff) or comes back empty, so a
565
668
  // relay hiccup does not silently end a half-finished turn. 0 disables.
566
- // Retries after a timeout/empty failure scale both stream timeouts up
567
- // (attempt number ×, capped at 3x) since the relay is likely overloaded.
669
+ // The wait between attempts honors Retry-After headers and otherwise backs
670
+ // off exponentially with jitter (llm/retry.ts); retries after a
671
+ // timeout/empty failure also scale both stream timeouts up (attempt number
672
+ // ×, capped at 3x) since the relay is likely overloaded.
568
673
  streamMaxRetries: z.number().int().min(0).default(3),
569
674
  // How many consecutive unproductive replies may be nudged before the turn
570
675
  // is handed back: a text-only reply that announces pending work ("我将…",
571
- // "I will…") or answers a nudge with more words, or an empty reply the
572
- // model finished deliberately (steered with a nudge after one identical
573
- // resend instead of burning the stream retry budget). Tool calls reset the
676
+ // "I will…") or answers a nudge with more words, or an empty reply that
677
+ // survived the stream retry budget (steered with a nudge — a changed
678
+ // request — since identical resends already failed). Tool calls reset the
574
679
  // count, so a productive task keeps going; exhaustion surfaces a notice
575
680
  // instead of stopping silently. Never fires in plan mode. 0 disables.
576
681
  maxAutoContinues: z.number().int().min(0).default(2),
@@ -584,7 +689,17 @@ var ConfigSchema = z.object({
584
689
  notifyBell: z.boolean().default(true),
585
690
  notifyBellThresholdSec: z.number().positive().default(10),
586
691
  permissions: PermissionsConfigSchema.default({ allow: [], deny: [] }),
587
- hooks: z.array(HookConfigSchema).default([])
692
+ hooks: z.array(HookConfigSchema).default([]),
693
+ // Consecutive identical tool calls (same tool + same arguments) allowed
694
+ // before the loop refuses to execute the repeat and tells the model to
695
+ // change approach — a guard against agents stuck retrying a failing call
696
+ // (opencode's doom_loop). 0 disables.
697
+ doomLoopThreshold: z.number().int().min(0).default(3),
698
+ // Track the whole working tree with an internal git repo (outside the
699
+ // user's .git) at every turn boundary, so /undo and /redo can restore
700
+ // changes made by ANY tool (bash included), not just write_file/edit_file.
701
+ // Falls back silently to per-file snapshots when git is unavailable.
702
+ gitSnapshots: z.boolean().default(true)
588
703
  });
589
704
  function contextWindowTokens(config, modelName) {
590
705
  return config.models.find((m) => m.name === modelName)?.contextMaxTokens ?? config.contextMaxTokens;
@@ -609,6 +724,74 @@ function resolveModelConfig(config, modelName) {
609
724
  return model;
610
725
  }
611
726
 
727
+ // src/llm/sse-normalize.ts
728
+ var SseNormalizeTransform = class extends TransformStream {
729
+ constructor() {
730
+ let buffer = "";
731
+ let pending = null;
732
+ const flushPending = (controller) => {
733
+ if (pending === null) return;
734
+ controller.enqueue(`data: ${pending}
735
+
736
+ `);
737
+ pending = null;
738
+ };
739
+ const handleLine = (raw, controller) => {
740
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
741
+ if (line === "") {
742
+ flushPending(controller);
743
+ return;
744
+ }
745
+ if (line.startsWith("data:")) {
746
+ flushPending(controller);
747
+ pending = line.slice(5).replace(/^ /, "");
748
+ return;
749
+ }
750
+ if (line.startsWith("{") && pending !== null) {
751
+ flushPending(controller);
752
+ pending = line;
753
+ return;
754
+ }
755
+ flushPending(controller);
756
+ controller.enqueue(`${line}
757
+ `);
758
+ };
759
+ super({
760
+ transform(chunk, controller) {
761
+ buffer += chunk;
762
+ let index = buffer.indexOf("\n");
763
+ while (index !== -1) {
764
+ handleLine(buffer.slice(0, index), controller);
765
+ buffer = buffer.slice(index + 1);
766
+ index = buffer.indexOf("\n");
767
+ }
768
+ },
769
+ flush(controller) {
770
+ if (buffer.length > 0) handleLine(buffer, controller);
771
+ flushPending(controller);
772
+ }
773
+ });
774
+ }
775
+ };
776
+ function createSseNormalizingFetch(baseFetch = fetch) {
777
+ return async (input, init) => {
778
+ const response = await baseFetch(input, init);
779
+ const contentType = response.headers.get("content-type") ?? "";
780
+ if (!response.body || !contentType.includes("text/event-stream")) {
781
+ return response;
782
+ }
783
+ const body = response.body.pipeThrough(new TextDecoderStream()).pipeThrough(new SseNormalizeTransform()).pipeThrough(new TextEncoderStream());
784
+ const headers = new Headers(response.headers);
785
+ headers.delete("content-length");
786
+ headers.delete("content-encoding");
787
+ return new Response(body, {
788
+ status: response.status,
789
+ statusText: response.statusText,
790
+ headers
791
+ });
792
+ };
793
+ }
794
+
612
795
  // src/llm/provider.ts
613
796
  function resolveApiKey(provider) {
614
797
  const fromEnv = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : void 0;
@@ -642,12 +825,16 @@ function createModel(config, modelName) {
642
825
  baseURL: provider.baseURL,
643
826
  apiKey,
644
827
  headers: provider.headers
645
- })(modelConfig.model);
828
+ })(modelConfig.model, {}, { includeUsage: true });
646
829
  case "openai-responses":
647
830
  return createOpenAI({
648
831
  baseURL: provider.baseURL,
649
832
  apiKey,
650
- headers: provider.headers
833
+ headers: provider.headers,
834
+ // Relays fronting the Responses API sometimes batch several JSON
835
+ // events into one SSE data payload, which the SDK's parser cannot
836
+ // read; normalize the wire format back to one event per data line.
837
+ fetch: createSseNormalizingFetch()
651
838
  }).responses(modelConfig.model);
652
839
  }
653
840
  }
@@ -661,6 +848,21 @@ function checkpointsDir(sessionDir) {
661
848
  function indexPath(sessionDir) {
662
849
  return path4.join(checkpointsDir(sessionDir), "index.json");
663
850
  }
851
+ var indexWriteQueues = /* @__PURE__ */ new Map();
852
+ function enqueueIndexWrite(sessionDir, op) {
853
+ const queued = indexWriteQueues.get(sessionDir) ?? Promise.resolve();
854
+ const run = queued.then(op);
855
+ indexWriteQueues.set(
856
+ sessionDir,
857
+ run.then(
858
+ () => {
859
+ },
860
+ () => {
861
+ }
862
+ )
863
+ );
864
+ return run;
865
+ }
664
866
  function checkpointContentPath(sessionDir, id) {
665
867
  return path4.join(checkpointsDir(sessionDir), `${id}.snapshot`);
666
868
  }
@@ -679,32 +881,36 @@ async function listCheckpointRecords(sessionDir) {
679
881
  }
680
882
  }
681
883
  async function appendCheckpointRecord(sessionDir, record, content) {
682
- await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true });
683
- if (record.existed && content !== null) {
684
- await fs2.writeFile(checkpointContentPath(sessionDir, record.id), content, "utf8");
685
- }
686
- const records = await listCheckpointRecords(sessionDir);
687
- records.push(record);
688
- await fs2.writeFile(indexPath(sessionDir), JSON.stringify(records, null, 2));
884
+ await enqueueIndexWrite(sessionDir, async () => {
885
+ await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true, mode: 448 });
886
+ if (record.existed && content !== null) {
887
+ await fs2.writeFile(checkpointContentPath(sessionDir, record.id), content, "utf8");
888
+ }
889
+ const records = await listCheckpointRecords(sessionDir);
890
+ records.push(record);
891
+ await fs2.writeFile(indexPath(sessionDir), JSON.stringify(records, null, 2));
892
+ });
689
893
  }
690
894
  async function removeCheckpointRecords(sessionDir, ids) {
691
895
  if (ids.length === 0) return;
692
- const drop = new Set(ids);
693
- const records = await listCheckpointRecords(sessionDir);
694
- const kept = records.filter((record) => !drop.has(record.id));
695
- if (kept.length === records.length) return;
696
- await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true });
697
- await fs2.writeFile(indexPath(sessionDir), JSON.stringify(kept, null, 2));
698
- for (const id of ids) {
699
- await fs2.rm(checkpointContentPath(sessionDir, id), { force: true });
700
- }
896
+ await enqueueIndexWrite(sessionDir, async () => {
897
+ const drop = new Set(ids);
898
+ const records = await listCheckpointRecords(sessionDir);
899
+ const kept = records.filter((record) => !drop.has(record.id));
900
+ if (kept.length === records.length) return;
901
+ await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true, mode: 448 });
902
+ await fs2.writeFile(indexPath(sessionDir), JSON.stringify(kept, null, 2));
903
+ for (const id of ids) {
904
+ await fs2.rm(checkpointContentPath(sessionDir, id), { force: true });
905
+ }
906
+ });
701
907
  }
702
908
  async function loadSessionSnapshots(sessionDir) {
703
909
  const records = await listCheckpointRecords(sessionDir);
704
910
  return records.map((record) => ({
705
911
  ...record,
706
912
  content: null,
707
- contentFile: record.existed ? checkpointContentPath(sessionDir, record.id) : void 0
913
+ contentFile: record.existed && !record.contentTooLarge ? checkpointContentPath(sessionDir, record.id) : void 0
708
914
  }));
709
915
  }
710
916
 
@@ -725,6 +931,10 @@ function debugWarn(message) {
725
931
  if (process.env.STAR_DEBUG === "1") process.stderr.write(`[star-cli] ${message}
726
932
  `);
727
933
  }
934
+ var FALLBACK_META = /* @__PURE__ */ Symbol("star.fallbackMeta");
935
+ function isFallbackMeta(meta) {
936
+ return meta[FALLBACK_META] === true;
937
+ }
728
938
  function dayKey(date) {
729
939
  const pad = (n) => String(n).padStart(2, "0");
730
940
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
@@ -734,6 +944,9 @@ var SessionStore = class _SessionStore {
734
944
  dir;
735
945
  pendingMeta = null;
736
946
  initialized = false;
947
+ cachedMeta = null;
948
+ metaTmpSeq = 0;
949
+ metaWriteQueue = Promise.resolve();
737
950
  constructor(id) {
738
951
  this.id = id;
739
952
  this.dir = path5.join(sessionsDir(), id);
@@ -763,6 +976,7 @@ var SessionStore = class _SessionStore {
763
976
  } catch {
764
977
  debugWarn(`session ${id}: corrupt meta.json, using defaults`);
765
978
  }
979
+ await store.sweepTmpFiles();
766
980
  store.initialized = true;
767
981
  return store;
768
982
  }
@@ -773,16 +987,40 @@ var SessionStore = class _SessionStore {
773
987
  } catch {
774
988
  return [];
775
989
  }
776
- const metas = [];
777
- for (const entry of entries) {
990
+ const metas = await Promise.all(
991
+ entries.map(async (entry) => {
992
+ try {
993
+ const raw = await fs3.readFile(path5.join(sessionsDir(), entry, "meta.json"), "utf8");
994
+ const meta = JSON.parse(raw);
995
+ if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) return meta;
996
+ } catch {
997
+ }
998
+ return null;
999
+ })
1000
+ );
1001
+ return metas.filter((meta) => meta !== null).sort((a, b) => b.updatedAt - a.updatedAt);
1002
+ }
1003
+ // Meta writes are serialized per instance and land atomically (tmp file +
1004
+ // rename) over a cached in-memory meta: the fire-and-forget title/usage
1005
+ // writers race the loop's own appends, and a truncated read once turned
1006
+ // fallbackMeta's empty defaults into a persisted wipe of model/cwd/usage.
1007
+ // A branded fallback meta is never written back for the same reason.
1008
+ writeMeta(meta) {
1009
+ if (isFallbackMeta(meta)) return Promise.resolve();
1010
+ const tmp = `${this.metaPath()}.tmp-${process.pid}-${this.metaTmpSeq++}`;
1011
+ const run = this.metaWriteQueue.then(async () => {
778
1012
  try {
779
- const raw = await fs3.readFile(path5.join(sessionsDir(), entry, "meta.json"), "utf8");
780
- const meta = JSON.parse(raw);
781
- if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
782
- } catch {
1013
+ await fs3.writeFile(tmp, JSON.stringify(meta, null, 2));
1014
+ await fs3.rename(tmp, this.metaPath());
1015
+ } catch (error) {
1016
+ await fs3.unlink(tmp).catch(() => {
1017
+ });
1018
+ throw error;
783
1019
  }
784
- }
785
- return metas.sort((a, b) => b.updatedAt - a.updatedAt);
1020
+ });
1021
+ this.metaWriteQueue = run.catch(() => {
1022
+ });
1023
+ return run;
786
1024
  }
787
1025
  async ensureInitialized() {
788
1026
  if (this.initialized) return;
@@ -791,7 +1029,8 @@ var SessionStore = class _SessionStore {
791
1029
  this.initialized = true;
792
1030
  return;
793
1031
  }
794
- await fs3.mkdir(this.dir, { recursive: true });
1032
+ await fs3.mkdir(this.dir, { recursive: true, mode: 448 });
1033
+ await this.sweepTmpFiles();
795
1034
  const meta = {
796
1035
  id: this.id,
797
1036
  title: "",
@@ -800,16 +1039,31 @@ var SessionStore = class _SessionStore {
800
1039
  createdAt: pending.createdAt,
801
1040
  updatedAt: pending.createdAt
802
1041
  };
803
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1042
+ await this.writeMeta(meta);
1043
+ this.cachedMeta = meta;
804
1044
  this.initialized = true;
805
1045
  }
1046
+ // Removes meta.json.tmp-* orphans left behind by a crashed meta write
1047
+ // (tmp file + rename); best-effort, the directory may not even exist yet.
1048
+ async sweepTmpFiles() {
1049
+ let entries;
1050
+ try {
1051
+ entries = await fs3.readdir(this.dir);
1052
+ } catch {
1053
+ return;
1054
+ }
1055
+ await Promise.all(
1056
+ entries.filter((entry) => entry.startsWith("meta.json.tmp-")).map((entry) => fs3.unlink(path5.join(this.dir, entry)).catch(() => {
1057
+ }))
1058
+ );
1059
+ }
806
1060
  async append(message) {
807
1061
  await this.ensureInitialized();
808
1062
  await fs3.appendFile(this.messagesPath(), `${JSON.stringify(message)}
809
1063
  `);
810
1064
  const meta = await this.meta();
811
1065
  meta.updatedAt = Date.now();
812
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1066
+ await this.writeMeta(meta);
813
1067
  }
814
1068
  async replaceMessages(messages) {
815
1069
  await this.ensureInitialized();
@@ -818,7 +1072,7 @@ var SessionStore = class _SessionStore {
818
1072
  ` : "");
819
1073
  const meta = await this.meta();
820
1074
  meta.updatedAt = Date.now();
821
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1075
+ await this.writeMeta(meta);
822
1076
  }
823
1077
  async messages() {
824
1078
  let raw;
@@ -841,13 +1095,24 @@ var SessionStore = class _SessionStore {
841
1095
  }
842
1096
  fallbackMeta() {
843
1097
  const now = Date.now();
844
- return { id: this.id, title: "", model: "", cwd: "", createdAt: now, updatedAt: now };
1098
+ const meta = {
1099
+ id: this.id,
1100
+ title: "",
1101
+ model: "",
1102
+ cwd: "",
1103
+ createdAt: now,
1104
+ updatedAt: now
1105
+ };
1106
+ Object.defineProperty(meta, FALLBACK_META, { value: true, enumerable: false });
1107
+ return meta;
845
1108
  }
846
1109
  async meta() {
847
1110
  await this.ensureInitialized();
1111
+ if (this.cachedMeta) return this.cachedMeta;
848
1112
  try {
849
1113
  const raw = await fs3.readFile(this.metaPath(), "utf8");
850
- return JSON.parse(raw);
1114
+ this.cachedMeta = JSON.parse(raw);
1115
+ return this.cachedMeta;
851
1116
  } catch {
852
1117
  debugWarn(`session ${this.id}: unreadable meta.json, using defaults`);
853
1118
  return this.fallbackMeta();
@@ -858,7 +1123,7 @@ var SessionStore = class _SessionStore {
858
1123
  const meta = await this.meta();
859
1124
  meta.title = title;
860
1125
  meta.updatedAt = Date.now();
861
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1126
+ await this.writeMeta(meta);
862
1127
  }
863
1128
  async appendCheckpoint(record, content) {
864
1129
  await this.ensureInitialized();
@@ -883,6 +1148,12 @@ var SessionStore = class _SessionStore {
883
1148
  usage.promptTokens += delta.promptTokens;
884
1149
  usage.completionTokens += delta.completionTokens;
885
1150
  usage.totalTokens += delta.totalTokens;
1151
+ if (delta.cachedPromptTokens) {
1152
+ usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + delta.cachedPromptTokens;
1153
+ }
1154
+ if (delta.cacheReadInputTokens) {
1155
+ usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + delta.cacheReadInputTokens;
1156
+ }
886
1157
  meta.usage = usage;
887
1158
  const day = dayKey(/* @__PURE__ */ new Date());
888
1159
  const byDay = meta.usageByDay ?? {};
@@ -892,7 +1163,7 @@ var SessionStore = class _SessionStore {
892
1163
  bucket.totalTokens += delta.totalTokens;
893
1164
  byDay[day] = bucket;
894
1165
  meta.usageByDay = byDay;
895
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1166
+ await this.writeMeta(meta);
896
1167
  }
897
1168
  };
898
1169
 
@@ -949,6 +1220,33 @@ async function findLatestSession(cwd) {
949
1220
  const [latest] = await SessionStore.list(cwd);
950
1221
  return latest ?? null;
951
1222
  }
1223
+ async function sessionPreview(id, maxBytes = 4096) {
1224
+ let handle = null;
1225
+ try {
1226
+ handle = await fs5.open(path7.join(sessionsDir(), id, "messages.jsonl"), "r");
1227
+ const buffer = Buffer.alloc(maxBytes);
1228
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
1229
+ const head = buffer.toString("utf8", 0, bytesRead);
1230
+ for (const line of head.split("\n")) {
1231
+ const trimmed = line.trim();
1232
+ if (!trimmed) continue;
1233
+ try {
1234
+ const message = JSON.parse(trimmed);
1235
+ if (message.role !== "user") continue;
1236
+ const text = typeof message.content === "string" ? message.content : message.content.filter((part) => part.type === "text").map((part) => part.type === "text" ? part.text : "").join(" ");
1237
+ const collapsed = text.replace(/\s+/g, " ").trim();
1238
+ if (collapsed) return collapsed;
1239
+ } catch {
1240
+ }
1241
+ }
1242
+ return null;
1243
+ } catch {
1244
+ return null;
1245
+ } finally {
1246
+ await handle?.close().catch(() => {
1247
+ });
1248
+ }
1249
+ }
952
1250
  function shortSessionId(id) {
953
1251
  const dash = id.lastIndexOf("-");
954
1252
  return dash === -1 ? id : id.slice(dash + 1);
@@ -965,20 +1263,20 @@ async function resolveSessionId(query) {
965
1263
  function relativeTime(timestamp) {
966
1264
  const diff = Date.now() - timestamp;
967
1265
  const minutes = Math.floor(diff / 6e4);
968
- if (minutes < 1) return "\u521A\u521A";
969
- if (minutes < 60) return `${minutes} \u5206\u949F\u524D`;
1266
+ if (minutes < 1) return "just now";
1267
+ if (minutes < 60) return `${minutes}m ago`;
970
1268
  const hours = Math.floor(minutes / 60);
971
- if (hours < 24) return `${hours} \u5C0F\u65F6\u524D`;
1269
+ if (hours < 24) return `${hours}h ago`;
972
1270
  const days = Math.floor(hours / 24);
973
- if (days < 30) return `${days} \u5929\u524D`;
1271
+ if (days < 30) return `${days}d ago`;
974
1272
  const months = Math.floor(days / 30);
975
- if (months < 12) return `${months} \u4E2A\u6708\u524D`;
976
- return `${Math.floor(months / 12)} \u5E74\u524D`;
1273
+ if (months < 12) return `${months}mo ago`;
1274
+ return `${Math.floor(months / 12)}y ago`;
977
1275
  }
978
1276
  function formatSessionEntries(entries, options = {}) {
979
1277
  return entries.map(({ meta, messageCount }) => {
980
- const title = meta.title || "(\u65E0\u6807\u9898)";
981
- const base = `${shortSessionId(meta.id)} ${title} ${messageCount} \u6761\u6D88\u606F \u66F4\u65B0\u4E8E ${relativeTime(meta.updatedAt)}`;
1278
+ const title = meta.title || "(untitled)";
1279
+ const base = `${shortSessionId(meta.id)} ${title} ${messageCount} messages updated ${relativeTime(meta.updatedAt)}`;
982
1280
  return options.showCwd ? `${base} [${meta.cwd}]` : base;
983
1281
  }).join("\n");
984
1282
  }
@@ -1009,7 +1307,9 @@ function computeCostUsd(usage, model) {
1009
1307
  if (model?.promptPrice === void 0 || model.completionPrice === void 0) {
1010
1308
  return null;
1011
1309
  }
1012
- return (usage.promptTokens * model.promptPrice + usage.completionTokens * model.completionPrice) / 1e6;
1310
+ const cached = Math.min(Math.max(usage.cachedPromptTokens ?? 0, 0), usage.promptTokens);
1311
+ const promptUsd = (usage.promptTokens - cached) * model.promptPrice + cached * (model.cacheReadPrice ?? model.promptPrice) + (usage.cacheReadInputTokens ?? 0) * (model.cacheReadPrice ?? 0);
1312
+ return (promptUsd + usage.completionTokens * model.completionPrice) / 1e6;
1013
1313
  }
1014
1314
  function estimateCost(usage, modelName, model) {
1015
1315
  const cost = computeCostUsd(usage, model);
@@ -1025,7 +1325,8 @@ function estimateCost(usage, modelName, model) {
1025
1325
  }
1026
1326
  return `Estimated cost: unknown (no price configured for ${modelName})`;
1027
1327
  }
1028
- return `Estimated cost: $${formatDollars(cost)} (${model?.name} @ $${model?.promptPrice}/M prompt, $${model?.completionPrice}/M completion)`;
1328
+ const cachePrice = model?.cacheReadPrice !== void 0 ? `, $${model.cacheReadPrice}/M cache read` : "";
1329
+ return `Estimated cost: $${formatDollars(cost)} (${model?.name} @ $${model?.promptPrice}/M prompt, $${model?.completionPrice}/M completion${cachePrice})`;
1029
1330
  }
1030
1331
 
1031
1332
  // src/session/usage.ts
@@ -1101,6 +1402,12 @@ function aggregateUsage(metas) {
1101
1402
  entry.sessions += 1;
1102
1403
  entry.requests += requestCount;
1103
1404
  addTo(entry, totals);
1405
+ if (hasTotals && meta.usage) {
1406
+ const cached = tokens(meta.usage.cachedPromptTokens);
1407
+ const cacheRead = tokens(meta.usage.cacheReadInputTokens);
1408
+ if (cached > 0) entry.cachedPromptTokens = (entry.cachedPromptTokens ?? 0) + cached;
1409
+ if (cacheRead > 0) entry.cacheReadInputTokens = (entry.cacheReadInputTokens ?? 0) + cacheRead;
1410
+ }
1104
1411
  models.set(name, entry);
1105
1412
  }
1106
1413
  return {
@@ -1155,7 +1462,7 @@ function formatUsageDashboard(stats, models, options = {}) {
1155
1462
  const configured = new Map(models.map((m) => [m.name, m]));
1156
1463
  const rows = stats.models.map((entry) => {
1157
1464
  const cfg = configured.get(entry.model);
1158
- const cost = cfg?.promptPrice !== void 0 && cfg.completionPrice !== void 0 ? (entry.promptTokens * cfg.promptPrice + entry.completionTokens * cfg.completionPrice) / 1e6 : null;
1465
+ const cost = computeCostUsd(entry, cfg);
1159
1466
  return { entry, cost };
1160
1467
  });
1161
1468
  const header = ["model", "requests", "prompt", "completion", "total", "cost"];
@@ -1229,7 +1536,7 @@ function cacheHitPercent(totals) {
1229
1536
  // src/cli/clipboard.ts
1230
1537
  import { execFile as execFile2 } from "child_process";
1231
1538
  import { randomUUID as randomUUID2 } from "crypto";
1232
- import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
1539
+ import { readFile as readFile3, stat as stat2, unlink as unlink2 } from "fs/promises";
1233
1540
  import os2 from "os";
1234
1541
  import path9 from "path";
1235
1542
  import { promisify as promisify2 } from "util";
@@ -1308,6 +1615,13 @@ async function finalizeClipboardImage(image) {
1308
1615
  const result = await downsampleImageIfNeeded(image);
1309
1616
  return { image: result.image, oversized: result.oversized && !result.resized };
1310
1617
  }
1618
+ async function readClipboardTempFile(tempFile) {
1619
+ const st = await stat2(tempFile).catch(() => null);
1620
+ if (!st || st.size === 0 || st.size > MAX_CLIPBOARD_IMAGE_BYTES) return null;
1621
+ const buf = await readFile3(tempFile).catch(() => null);
1622
+ if (!buf || buf.length === 0) return null;
1623
+ return { path: "clipboard.png", mimeType: "image/png", data: buf.toString("base64") };
1624
+ }
1311
1625
  async function readClipboardImage() {
1312
1626
  const tempFile = path9.join(os2.tmpdir(), `star-clipboard-${randomUUID2()}.png`);
1313
1627
  const hasPngpaste = process.platform === "darwin" ? await commandExists2("pngpaste") : false;
@@ -1320,22 +1634,18 @@ async function readClipboardImage() {
1320
1634
  maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES,
1321
1635
  encoding: "buffer"
1322
1636
  });
1323
- const buf2 = stdout;
1324
- if (buf2.length === 0) return null;
1637
+ const buf = stdout;
1638
+ if (buf.length === 0) return null;
1325
1639
  return finalizeClipboardImage({
1326
1640
  path: "clipboard.png",
1327
1641
  mimeType: "image/png",
1328
- data: buf2.toString("base64")
1642
+ data: buf.toString("base64")
1329
1643
  });
1330
1644
  }
1331
1645
  await execFileAsync2(plan.command, plan.args, { timeout: CLIPBOARD_TIMEOUT_MS });
1332
- const buf = await readFile3(tempFile).catch(() => null);
1333
- if (!buf || buf.length === 0) return null;
1334
- return finalizeClipboardImage({
1335
- path: "clipboard.png",
1336
- mimeType: "image/png",
1337
- data: buf.toString("base64")
1338
- });
1646
+ const image = await readClipboardTempFile(tempFile);
1647
+ if (!image) return null;
1648
+ return finalizeClipboardImage(image);
1339
1649
  } catch {
1340
1650
  return null;
1341
1651
  } finally {
@@ -1695,7 +2005,7 @@ function applyEdit(content, oldString, newString, replaceAll) {
1695
2005
  }
1696
2006
  if (count === 0) return null;
1697
2007
  if (count > 1 && !replaceAll) return null;
1698
- return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
2008
+ return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
1699
2009
  }
1700
2010
  function newFilePreview(content, label) {
1701
2011
  const all = content.split("\n");
@@ -1994,13 +2304,22 @@ ${content}`);
1994
2304
  });
1995
2305
  register({
1996
2306
  name: "undo",
1997
- description: "Undo the last conversation turn: revert its file changes (write_file/edit_file) and retract its messages",
2307
+ description: "Undo the last conversation turn: revert its file changes (any tool, bash included, when git snapshots are on) and retract its messages",
1998
2308
  usage: "/undo",
1999
2309
  category: "Changes",
2000
2310
  async run(_args, ctx) {
2001
2311
  ctx.addSystemMessage(await ctx.undo());
2002
2312
  }
2003
2313
  });
2314
+ register({
2315
+ name: "redo",
2316
+ description: "Redo the last git-snapshot undo: restore the working tree to its pre-undo state",
2317
+ usage: "/redo",
2318
+ category: "Changes",
2319
+ async run(_args, ctx) {
2320
+ ctx.addSystemMessage(await ctx.redo());
2321
+ }
2322
+ });
2004
2323
  register({
2005
2324
  name: "rewind",
2006
2325
  description: "List file-change checkpoints, or rewind to just before one: restore files and retract the conversation",
@@ -2272,19 +2591,19 @@ function maskApiKey(key) {
2272
2591
  if (key.length <= 8) return "\u2022\u2022\u2022\u2022\u2022\u2022";
2273
2592
  return `${key.slice(0, 3)}\u2026${key.slice(-4)}`;
2274
2593
  }
2275
- function tomlString(value) {
2594
+ function tomlString2(value) {
2276
2595
  return JSON.stringify(value);
2277
2596
  }
2278
2597
  function buildProviderTomlBlock(provider) {
2279
2598
  return [
2280
2599
  "[[providers]]",
2281
- `name = ${tomlString(provider.name)}`,
2600
+ `name = ${tomlString2(provider.name)}`,
2282
2601
  "# wire format: openai-compatible | anthropic | openai-responses",
2283
- `protocol = ${tomlString(provider.protocol)}`,
2284
- `baseURL = ${tomlString(provider.baseURL)}`,
2602
+ `protocol = ${tomlString2(provider.protocol)}`,
2603
+ `baseURL = ${tomlString2(provider.baseURL)}`,
2285
2604
  "# environment variable holding the API key; the key itself lives in",
2286
2605
  "# ~/.star-cli/.env (written by /connect), never in this file",
2287
- `apiKeyEnv = ${tomlString(provider.apiKeyEnv ?? "")}`,
2606
+ `apiKeyEnv = ${tomlString2(provider.apiKeyEnv ?? "")}`,
2288
2607
  "# inline key instead of apiKeyEnv (not recommended):",
2289
2608
  '# apiKey = "sk-..."',
2290
2609
  "# extra HTTP headers sent to the provider:",
@@ -2296,9 +2615,9 @@ function buildProviderTomlBlock(provider) {
2296
2615
  function buildModelTomlBlock(model) {
2297
2616
  return [
2298
2617
  "[[models]]",
2299
- `name = ${tomlString(model.name)}`,
2300
- `provider = ${tomlString(model.provider)}`,
2301
- `model = ${tomlString(model.model)}`,
2618
+ `name = ${tomlString2(model.name)}`,
2619
+ `provider = ${tomlString2(model.provider)}`,
2620
+ `model = ${tomlString2(model.model)}`,
2302
2621
  "# per-model context window in tokens \u2014 overrides the top-level",
2303
2622
  "# contextMaxTokens for compaction and the ctx % in the status bar",
2304
2623
  `contextMaxTokens = ${model.contextMaxTokens ?? CONNECT_DEFAULT_CONTEXT_TOKENS}`,
@@ -2366,7 +2685,7 @@ function saveConnection(answers, configPath = globalConfigPath(), envPath = envF
2366
2685
  return { provider, model, configPath, envPath };
2367
2686
  }
2368
2687
  function setDefaultModelInToml(content, modelName) {
2369
- const line = `defaultModel = ${tomlString(modelName)}`;
2688
+ const line = `defaultModel = ${tomlString2(modelName)}`;
2370
2689
  const lines = content.split("\n");
2371
2690
  let inTopLevel = true;
2372
2691
  for (let i = 0; i < lines.length; i++) {
@@ -2806,6 +3125,20 @@ async function initProject(opts) {
2806
3125
  };
2807
3126
  }
2808
3127
 
3128
+ // src/cli/commands/redo.ts
3129
+ function formatRedoSummary(entry) {
3130
+ const label = entry.label !== "" ? ` of "${entry.label}"` : "";
3131
+ return `Restore the working tree to its state before the undo${label} (tree ${entry.tree.slice(0, 8)}). Conversation messages are not restored.`;
3132
+ }
3133
+ function formatRedoResult(result) {
3134
+ if (!result.ok) {
3135
+ return `Redo failed \u2014 git could not restore the snapshot (tree ${result.tree.slice(0, 8)}). The redo entry was kept; try again or check that git is working.`;
3136
+ }
3137
+ const files = result.files === null ? "unknown number of" : `${result.files}`;
3138
+ const label = result.label !== "" ? ` \u2014 "${result.label}"` : "";
3139
+ return `Redone: restored ${files} file(s) to the pre-undo state (tree ${result.tree.slice(0, 8)})${label}.`;
3140
+ }
3141
+
2809
3142
  // src/cli/commands/registry.ts
2810
3143
  var CommandRegistry = class {
2811
3144
  commands = /* @__PURE__ */ new Map();
@@ -2921,6 +3254,16 @@ async function buildUndoDiffs(snapshots, cwd) {
2921
3254
  }
2922
3255
  return diffs;
2923
3256
  }
3257
+ async function buildUndoTreeDiffs(cwd, tree) {
3258
+ const names = await diffTreeNames(cwd, tree);
3259
+ if (!names || names.length === 0) return [];
3260
+ return [
3261
+ {
3262
+ label: "Working-tree files restoring the turn's start will touch:",
3263
+ lines: names.map((name) => ({ kind: "marker", text: name }))
3264
+ }
3265
+ ];
3266
+ }
2924
3267
 
2925
3268
  // src/cli/components/ConnectWizard.tsx
2926
3269
  import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
@@ -3855,18 +4198,19 @@ import { memo } from "react";
3855
4198
  import { Text as Text6 } from "ink";
3856
4199
  import { Fragment, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
3857
4200
  function DiffLineView({ line }) {
4201
+ const text = toTerminalSafe(line.text);
3858
4202
  if (line.kind === "add") return /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
3859
4203
  "+ ",
3860
- line.text
4204
+ text
3861
4205
  ] });
3862
4206
  if (line.kind === "del") return /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
3863
4207
  "- ",
3864
- line.text
4208
+ text
3865
4209
  ] });
3866
- if (line.kind === "marker") return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: line.text });
4210
+ if (line.kind === "marker") return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: text });
3867
4211
  return /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
3868
4212
  " ",
3869
- line.text
4213
+ text
3870
4214
  ] });
3871
4215
  }
3872
4216
  function DiffLines({ lines }) {
@@ -3910,6 +4254,17 @@ var MessageList = memo(function MessageList2({ messages }) {
3910
4254
  // src/cli/components/PermissionPrompt.tsx
3911
4255
  import { Box as Box7, Text as Text8, useInput as useInput6 } from "ink";
3912
4256
  import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
4257
+ var BASH_COMMAND_MAX = 1e3;
4258
+ function middleTruncate(text, max) {
4259
+ if (text.length <= max) return text;
4260
+ const half = Math.floor((max - 1) / 2);
4261
+ return `${text.slice(0, half)}\u2026${text.slice(text.length - half)}`;
4262
+ }
4263
+ function bashCommand(args) {
4264
+ if (typeof args !== "object" || args === null) return null;
4265
+ const command = args.command;
4266
+ return typeof command === "string" ? command : null;
4267
+ }
3913
4268
  function PermissionPrompt({ request, preview, onDecision }) {
3914
4269
  useInput6((input) => {
3915
4270
  const ch = input.toLowerCase();
@@ -3917,6 +4272,7 @@ function PermissionPrompt({ request, preview, onDecision }) {
3917
4272
  else if (ch === "n") onDecision("no");
3918
4273
  else if (ch === "a") onDecision("always");
3919
4274
  });
4275
+ const command = request.toolName === "bash" ? bashCommand(request.args) : null;
3920
4276
  return /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
3921
4277
  /* @__PURE__ */ jsxs8(Text8, { bold: true, color: "yellow", children: [
3922
4278
  "Permission required: ",
@@ -3928,10 +4284,10 @@ function PermissionPrompt({ request, preview, onDecision }) {
3928
4284
  preview ? /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", children: [
3929
4285
  /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
3930
4286
  preview.type === "new-file" ? "New file: " : "",
3931
- preview.label
4287
+ toTerminalSafe(preview.label)
3932
4288
  ] }),
3933
4289
  /* @__PURE__ */ jsx8(DiffLines, { lines: preview.lines })
3934
- ] }) : /* @__PURE__ */ jsx8(Text8, { children: summarizeArgs(request.args) }),
4290
+ ] }) : command !== null ? /* @__PURE__ */ jsx8(Text8, { children: toTerminalSafe(middleTruncate(command, BASH_COMMAND_MAX)) }) : /* @__PURE__ */ jsx8(Text8, { children: toTerminalSafe(summarizeArgs(request.args)) }),
3935
4291
  /* @__PURE__ */ jsx8(Text8, { children: "[y] allow [n] deny [a] always (saved to config)" })
3936
4292
  ] });
3937
4293
  }
@@ -3990,7 +4346,7 @@ var StatusBar = memo2(function StatusBar2({
3990
4346
  }) {
3991
4347
  const { stdout } = useStdout2();
3992
4348
  const wide = (stdout?.columns ?? 80) >= WIDE_COLUMNS;
3993
- const joined = backgroundTasks.join(", ");
4349
+ const joined = backgroundTasks.map((label) => toTerminalSafe(label)).join(", ");
3994
4350
  const labels = joined.length > BG_LABEL_MAX ? `${joined.slice(0, BG_LABEL_MAX)}\u2026` : joined;
3995
4351
  return /* @__PURE__ */ jsxs10(Box9, { justifyContent: "space-between", children: [
3996
4352
  /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
@@ -4079,34 +4435,59 @@ function ThinkingIndicator({
4079
4435
 
4080
4436
  // src/cli/components/TodoPanel.tsx
4081
4437
  import { Box as Box12, Text as Text13 } from "ink";
4082
- import { jsx as jsx11, jsxs as jsxs13 } from "react/jsx-runtime";
4438
+ import { jsxs as jsxs13 } from "react/jsx-runtime";
4083
4439
  var SYMBOLS = {
4084
4440
  pending: "[ ]",
4085
4441
  in_progress: "[~]",
4086
4442
  done: "[x]"
4087
4443
  };
4444
+ var MAX_VISIBLE_TODOS = 10;
4445
+ function visibleTodoWindow(todos, max = MAX_VISIBLE_TODOS) {
4446
+ if (todos.length <= max) return { items: [...todos], hiddenBefore: 0, hiddenAfter: 0 };
4447
+ const active = todos.findIndex((todo) => todo.status === "in_progress");
4448
+ const start = active > 0 ? Math.min(active, todos.length - max) : 0;
4449
+ const items = todos.slice(start, start + max);
4450
+ return {
4451
+ items,
4452
+ hiddenBefore: start,
4453
+ hiddenAfter: todos.length - start - items.length
4454
+ };
4455
+ }
4088
4456
  function TodoPanel({ todos }) {
4089
4457
  if (todos.length === 0) return null;
4090
- return /* @__PURE__ */ jsx11(Box12, { flexDirection: "column", children: todos.map((todo) => /* @__PURE__ */ jsxs13(
4091
- Text13,
4092
- {
4093
- dimColor: todo.status === "done",
4094
- color: todo.status === "in_progress" ? "cyan" : void 0,
4095
- children: [
4096
- SYMBOLS[todo.status],
4097
- " ",
4098
- todo.title
4099
- ]
4100
- },
4101
- todo.id
4102
- )) });
4458
+ const { items, hiddenBefore, hiddenAfter } = visibleTodoWindow(todos);
4459
+ return /* @__PURE__ */ jsxs13(Box12, { flexDirection: "column", children: [
4460
+ hiddenBefore > 0 && /* @__PURE__ */ jsxs13(Text13, { dimColor: true, children: [
4461
+ " \u2026 ",
4462
+ hiddenBefore,
4463
+ " more above"
4464
+ ] }),
4465
+ items.map((todo) => /* @__PURE__ */ jsxs13(
4466
+ Text13,
4467
+ {
4468
+ dimColor: todo.status === "done",
4469
+ color: todo.status === "in_progress" ? "cyan" : void 0,
4470
+ children: [
4471
+ SYMBOLS[todo.status],
4472
+ " ",
4473
+ toTerminalSafe(todo.title)
4474
+ ]
4475
+ },
4476
+ todo.id
4477
+ )),
4478
+ hiddenAfter > 0 && /* @__PURE__ */ jsxs13(Text13, { dimColor: true, children: [
4479
+ " \u2026 ",
4480
+ hiddenAfter,
4481
+ " more"
4482
+ ] })
4483
+ ] });
4103
4484
  }
4104
4485
 
4105
4486
  // src/cli/components/ToolCallCard.tsx
4106
4487
  function formatToolCard(card) {
4107
4488
  const header = `${card.name} ${card.argsSummary}${card.isError ? " [error]" : ""}`;
4108
4489
  if (card.result === void 0) return header;
4109
- const preview = previewLines(card.result, 10);
4490
+ const preview = previewLines(toTerminalSafe(card.result), 10);
4110
4491
  return `${header}
4111
4492
  ${preview.text}${preview.truncated ? "\n... (truncated)" : ""}`;
4112
4493
  }
@@ -4243,6 +4624,18 @@ async function executeShellBang(raw, cwd, signal) {
4243
4624
  };
4244
4625
  }
4245
4626
 
4627
+ // src/cli/system-prompt.ts
4628
+ var SYSTEM_PROMPT = `You are Star CLI, an AI coding agent running in the user's terminal.
4629
+ You help with software engineering tasks: reading, writing and editing code, running shell commands, and managing todos.
4630
+ Be concise and direct. Use tools when they help accomplish the task.
4631
+ For any task with two or more steps, start by creating a todo list with todo_write and keep it updated as you progress; only mark an item completed after you have verified its result.
4632
+ The working directory is the user's project root; never touch files outside it without explicit instruction.
4633
+ For conversion, batch-processing, or other scriptable tasks, write a script file into the working directory and run it instead of pasting long inline code into the shell; once the script's output is verified, delete the script unless the user asked to keep it.
4634
+ When a tool call or command fails, read the error output, work out the cause, and try again with a corrected or alternative approach \u2014 never repeat an identical failing call without changing something.
4635
+ Never consider the task finished until you have verified the result yourself: run the tests, the build, or a check command that proves the output is correct, and if verification fails or reveals gaps, keep fixing until it passes.
4636
+ Do not end your turn while the task is still incomplete; keep going until it is done or you are genuinely blocked, and if you are blocked, state exactly what is missing.
4637
+ Never end a reply by announcing what you will do next \u2014 either do it now with tool calls, or do not mention it. Narrating future actions is not progress.`;
4638
+
4246
4639
  // src/cli/ticker.ts
4247
4640
  var TICK_MS = 100;
4248
4641
  function nextFlush(prev, next) {
@@ -4290,7 +4683,7 @@ async function checkForUpdate(currentVersion, fetcher = defaultFetcher2) {
4290
4683
  }
4291
4684
 
4292
4685
  // src/cli/repl.tsx
4293
- import { jsx as jsx12, jsxs as jsxs14 } from "react/jsx-runtime";
4686
+ import { jsx as jsx11, jsxs as jsxs14 } from "react/jsx-runtime";
4294
4687
  var CLEAR_TERMINAL = process.platform === "win32" ? "\x1B[2J\x1B[0f" : "\x1B[2J\x1B[3J\x1B[H";
4295
4688
  function emptyUsage() {
4296
4689
  return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -4301,19 +4694,25 @@ function runningTaskLabels() {
4301
4694
  return [...shells, ...agents];
4302
4695
  }
4303
4696
  var SESSION_MODE_CYCLE = ["ask", "auto", "readonly", "plan"];
4697
+ var BUSY_BLOCKED_COMMANDS = /* @__PURE__ */ new Set([
4698
+ "new",
4699
+ "resume",
4700
+ "undo",
4701
+ "redo",
4702
+ "rewind",
4703
+ "compact",
4704
+ "fork",
4705
+ "clear-sessions",
4706
+ "model",
4707
+ "connect",
4708
+ "memory"
4709
+ ]);
4304
4710
  function formatUsage(usage) {
4305
4711
  return `API usage this session: ${usage.requests} requests, ${usage.promptTokens} prompt + ${usage.completionTokens} completion = ${usage.totalTokens} tokens`;
4306
4712
  }
4307
4713
  var SESSION_PICKER_CAP = 20;
4308
- async function sessionPreview(id) {
4309
- const store = await SessionStore.open(id);
4310
- if (!store) return null;
4311
- const first = (await store.messages()).find((m) => m.role === "user");
4312
- if (!first) return null;
4313
- const text = coreMessageText(first).replace(/\s+/g, " ").trim();
4314
- if (!text) return null;
4315
- return text.length > 60 ? `${text.slice(0, 60)}\u2026` : text;
4316
- }
4714
+ var SESSION_PREVIEW_MAX = 60;
4715
+ var MAX_VISIBLE_QUEUED = 5;
4317
4716
  function Repl({
4318
4717
  backend,
4319
4718
  model,
@@ -4349,12 +4748,14 @@ function Repl({
4349
4748
  const nextIdRef = useRef5(initialDisplay.length);
4350
4749
  const abortRef = useRef5(null);
4351
4750
  const streamedRef = useRef5("");
4751
+ const lastCommittedLenRef = useRef5(0);
4352
4752
  const thinkingRef = useRef5(false);
4353
4753
  const reasoningRef = useRef5("");
4354
4754
  const tickerStopRef = useRef5(null);
4355
4755
  const flushedRef = useRef5({ streamed: "", reasoning: "" });
4356
4756
  const toolCardsRef = useRef5(/* @__PURE__ */ new Map());
4357
4757
  const pendingRef = useRef5(null);
4758
+ const permissionChainRef = useRef5(Promise.resolve());
4358
4759
  const alwaysAllowedRef = useRef5(/* @__PURE__ */ new Set());
4359
4760
  const prevModeRef = useRef5(null);
4360
4761
  const planApprovalRef = useRef5(false);
@@ -4363,6 +4764,10 @@ function Repl({
4363
4764
  const pendingConnectRef = useRef5(null);
4364
4765
  const modelNameRef = useRef5(model);
4365
4766
  const sessionStoreRef = useRef5(sessionStore);
4767
+ const configRef = useRef5(config);
4768
+ useEffect2(() => {
4769
+ configRef.current = config;
4770
+ }, [config]);
4366
4771
  const usageRef = useRef5(initialUsage ? { ...initialUsage } : emptyUsage());
4367
4772
  const turnChunksRef = useRef5(0);
4368
4773
  const messagesRef = useRef5(initialDisplay);
@@ -4538,14 +4943,22 @@ function Repl({
4538
4943
  );
4539
4944
  const attachConfirmHandler = useCallback(
4540
4945
  (target) => {
4541
- target.confirmHandler = async (req) => {
4946
+ target.confirmHandler = (req) => {
4542
4947
  if (isAllowedByRules([...alwaysAllowedRef.current], req)) {
4543
- return true;
4948
+ return Promise.resolve(true);
4544
4949
  }
4545
- const preview = await generateDiffPreview(req.toolName, req.args, cwd).catch(() => null);
4546
- return new Promise((resolve) => {
4547
- setPendingPermission({ request: req, preview, resolve });
4548
- });
4950
+ const ask = async () => {
4951
+ const preview = await generateDiffPreview(req.toolName, req.args, cwd).catch(() => null);
4952
+ return new Promise((resolve) => {
4953
+ setPendingPermission({ request: req, preview, resolve });
4954
+ });
4955
+ };
4956
+ const decision = permissionChainRef.current.then(ask);
4957
+ permissionChainRef.current = decision.then(
4958
+ () => void 0,
4959
+ () => void 0
4960
+ );
4961
+ return decision;
4549
4962
  };
4550
4963
  target.onHookWarning = (message) => pushMessage("system", message);
4551
4964
  },
@@ -4576,27 +4989,29 @@ function Repl({
4576
4989
  setBgLabels(runningTaskLabels());
4577
4990
  pushMessage(
4578
4991
  "system",
4579
- task.status === "running" ? formatTaskStarted(task) : formatTaskFinished(task)
4992
+ toTerminalSafe(
4993
+ task.status === "running" ? formatTaskStarted(task) : formatTaskFinished(task)
4994
+ )
4580
4995
  );
4581
4996
  if (task.status !== "running") {
4582
4997
  notifyBell({
4583
- enabled: config.notifyBell,
4584
- thresholdSec: config.notifyBellThresholdSec,
4998
+ enabled: configRef.current.notifyBell,
4999
+ thresholdSec: configRef.current.notifyBellThresholdSec,
4585
5000
  noNotifyEnv: process.env.STAR_NO_NOTIFY === "1"
4586
5001
  });
4587
5002
  }
4588
5003
  };
4589
5004
  const onAgentUpdate = (task) => {
4590
5005
  setBgLabels(runningTaskLabels());
4591
- const label = task.description ? `: ${task.description}` : "";
5006
+ const label = task.description ? `: ${toTerminalSafe(task.description)}` : "";
4592
5007
  pushMessage(
4593
5008
  "system",
4594
5009
  task.status === "running" ? `Background subagent ${task.id} started${label}` : `Background subagent ${task.id} ${task.status}${label}`
4595
5010
  );
4596
5011
  if (task.status !== "running") {
4597
5012
  notifyBell({
4598
- enabled: config.notifyBell,
4599
- thresholdSec: config.notifyBellThresholdSec,
5013
+ enabled: configRef.current.notifyBell,
5014
+ thresholdSec: configRef.current.notifyBellThresholdSec,
4600
5015
  noNotifyEnv: process.env.STAR_NO_NOTIFY === "1"
4601
5016
  });
4602
5017
  }
@@ -4609,7 +5024,7 @@ function Repl({
4609
5024
  defaultTaskManager.cleanup();
4610
5025
  defaultAgentTasks.cleanup();
4611
5026
  };
4612
- }, [pushMessage, config]);
5027
+ }, [pushMessage]);
4613
5028
  const interrupt = useCallback(() => {
4614
5029
  abortRef.current?.abort();
4615
5030
  const p = pendingRef.current;
@@ -4648,9 +5063,11 @@ function Repl({
4648
5063
  const handleExit = useCallback(() => {
4649
5064
  const killed = defaultTaskManager.cleanup();
4650
5065
  const killedAgents = defaultAgentTasks.cleanup();
4651
- const stopped = [...killed.map((t) => t.id), ...killedAgents.map((t) => t.id)];
4652
- if (stopped.length > 0) {
4653
- pushMessage("system", `Stopped ${stopped.length} background task(s): ${stopped.join(", ")}`);
5066
+ const stopped = killed.length + killedAgents.length;
5067
+ if (stopped > 0) {
5068
+ pushMessage("system", `${stopped} background task(s) still running; reports will be lost.`);
5069
+ setTimeout(exit, 50);
5070
+ return;
4654
5071
  }
4655
5072
  exit();
4656
5073
  }, [exit, pushMessage]);
@@ -4711,6 +5128,7 @@ function Repl({
4711
5128
  registry: createDefaultRegistry(),
4712
5129
  config,
4713
5130
  cwd,
5131
+ system: SYSTEM_PROMPT,
4714
5132
  sessionStore: sessionStoreRef.current,
4715
5133
  contextMaxTokens: contextWindowTokens(config, name)
4716
5134
  });
@@ -4752,12 +5170,15 @@ function Repl({
4752
5170
  if (!resumed) {
4753
5171
  return `Session not found: ${id}`;
4754
5172
  }
4755
- await current.loadMessages(resumed.messages);
4756
- setTodos(await loadTodos(cwd));
4757
5173
  const store = await SessionStore.open(resolvedId);
4758
- if (store) {
4759
- hydrateSnapshots(await loadSessionSnapshots(store.dir));
5174
+ if (!store) {
5175
+ return `Session not found: ${id}`;
4760
5176
  }
5177
+ await current.loadMessages(resumed.messages);
5178
+ current.setSessionStore(store);
5179
+ sessionStoreRef.current = store;
5180
+ setTodos(await loadTodos(cwd));
5181
+ hydrateSnapshots(await loadSessionSnapshots(store.dir));
4761
5182
  const display = buildDisplayMessages(resumed.messages);
4762
5183
  nextIdRef.current = display.length;
4763
5184
  applyMessages(display);
@@ -4783,6 +5204,7 @@ function Repl({
4783
5204
  abortRef.current = controller;
4784
5205
  const turnStartedAt = Date.now();
4785
5206
  streamedRef.current = "";
5207
+ lastCommittedLenRef.current = 0;
4786
5208
  thinkingRef.current = true;
4787
5209
  reasoningRef.current = "";
4788
5210
  turnChunksRef.current = 0;
@@ -4793,22 +5215,25 @@ function Repl({
4793
5215
  setIsStreaming(true);
4794
5216
  flushedRef.current = { streamed: "", reasoning: "" };
4795
5217
  const commitStreamed = (tight) => {
4796
- if (streamedRef.current.length === 0) return;
4797
- pushAssistantChunk(streamedRef.current, tight);
5218
+ const uncommitted = streamedRef.current.slice(lastCommittedLenRef.current);
5219
+ if (uncommitted.length > 0) {
5220
+ pushAssistantChunk(uncommitted, tight);
5221
+ }
4798
5222
  streamedRef.current = "";
5223
+ lastCommittedLenRef.current = 0;
4799
5224
  flushedRef.current = { ...flushedRef.current, streamed: "" };
4800
5225
  setStreamingText("");
4801
5226
  };
4802
5227
  tickerStopRef.current = startTicker((tick) => {
4803
5228
  setSpinnerTick(tick);
4804
- const split = splitCommittableLines(streamedRef.current, 8);
5229
+ const uncommitted = streamedRef.current.slice(lastCommittedLenRef.current);
5230
+ const split = splitCommittableLines(uncommitted, 8);
4805
5231
  if (split) {
4806
5232
  pushAssistantChunk(split.committed, true);
4807
- streamedRef.current = split.rest;
4808
- flushedRef.current = { ...flushedRef.current, streamed: "" };
5233
+ lastCommittedLenRef.current += split.committed.length + 1;
4809
5234
  }
4810
5235
  const next = {
4811
- streamed: streamedRef.current,
5236
+ streamed: streamedRef.current.slice(lastCommittedLenRef.current),
4812
5237
  reasoning: reasoningRef.current
4813
5238
  };
4814
5239
  if (nextFlush(flushedRef.current, next) !== null) {
@@ -4835,9 +5260,9 @@ function Repl({
4835
5260
  );
4836
5261
  }
4837
5262
  }
4838
- streamedRef.current += event.text;
5263
+ streamedRef.current += toTerminalSafe(event.text);
4839
5264
  } else if (event.type === "reasoning") {
4840
- reasoningRef.current += event.text;
5265
+ reasoningRef.current += toTerminalSafe(event.text);
4841
5266
  } else if (event.type === "tool-call") {
4842
5267
  commitStreamed(true);
4843
5268
  toolCardsRef.current.set(event.id, {
@@ -4877,6 +5302,12 @@ function Repl({
4877
5302
  usage.promptTokens += event.usage.promptTokens;
4878
5303
  usage.completionTokens += event.usage.completionTokens;
4879
5304
  usage.totalTokens += event.usage.totalTokens;
5305
+ if (event.usage.cachedPromptTokens) {
5306
+ usage.cachedPromptTokens = (usage.cachedPromptTokens ?? 0) + event.usage.cachedPromptTokens;
5307
+ }
5308
+ if (event.usage.cacheReadInputTokens) {
5309
+ usage.cacheReadInputTokens = (usage.cacheReadInputTokens ?? 0) + event.usage.cacheReadInputTokens;
5310
+ }
4880
5311
  if (cacheUsageReported(event.usage)) {
4881
5312
  if (!usage.cache) usage.cache = { cachedTokens: 0, promptTokens: 0 };
4882
5313
  addToCacheTotals(usage.cache, event.usage);
@@ -4886,9 +5317,13 @@ function Repl({
4886
5317
  });
4887
5318
  }
4888
5319
  } else if (event.type === "retry") {
5320
+ streamedRef.current = streamedRef.current.slice(0, lastCommittedLenRef.current);
5321
+ flushedRef.current = { ...flushedRef.current, streamed: "" };
5322
+ setStreamingText("");
5323
+ const wait = event.delayMs !== void 0 && event.delayMs >= 1e3 ? ` in ${Math.round(event.delayMs / 1e3)}s` : "";
4889
5324
  pushMessage(
4890
5325
  "system",
4891
- `Request failed, retrying (${event.attempt}/${event.maxAttempts}): ${event.reason}`
5326
+ `Request failed, retrying (${event.attempt}/${event.maxAttempts})${wait}: ${event.reason}`
4892
5327
  );
4893
5328
  } else if (event.type === "notice") {
4894
5329
  pushMessage("system", event.message);
@@ -4915,8 +5350,9 @@ function Repl({
4915
5350
  setThinkingText("");
4916
5351
  setThoughtSummary(null);
4917
5352
  setActivity(null);
4918
- const finalText = streamedRef.current;
5353
+ const finalText = streamedRef.current.slice(lastCommittedLenRef.current);
4919
5354
  streamedRef.current = "";
5355
+ lastCommittedLenRef.current = 0;
4920
5356
  setStreamingText(null);
4921
5357
  const interrupted = controller.signal.aborted;
4922
5358
  notifyBell({
@@ -5099,10 +5535,8 @@ ${lines.join("\n")}`;
5099
5535
  return ctx.permissionMode(picked);
5100
5536
  },
5101
5537
  pickSession: async (all) => {
5102
- const entries = (await listSessionEntries(all ? void 0 : cwd)).slice(
5103
- 0,
5104
- SESSION_PICKER_CAP
5105
- );
5538
+ const currentId = sessionStoreRef.current?.id;
5539
+ const entries = (await listSessionEntries(all ? void 0 : cwd)).filter((entry) => entry.meta.id !== currentId).slice(0, SESSION_PICKER_CAP);
5106
5540
  if (entries.length === 0) {
5107
5541
  return all ? "No sessions found." : "No sessions found for this directory.";
5108
5542
  }
@@ -5110,13 +5544,16 @@ ${lines.join("\n")}`;
5110
5544
  entries.map(async ({ meta, messageCount }) => {
5111
5545
  const parts = [`${messageCount} messages \xB7 ${relativeTime(meta.updatedAt)}`];
5112
5546
  const preview = await sessionPreview(meta.id);
5113
- if (preview) parts.push(preview);
5547
+ if (preview) {
5548
+ parts.push(
5549
+ preview.length > SESSION_PREVIEW_MAX ? `${preview.slice(0, SESSION_PREVIEW_MAX)}\u2026` : preview
5550
+ );
5551
+ }
5114
5552
  if (all) parts.push(`[${meta.cwd}]`);
5115
5553
  return {
5116
5554
  value: meta.id,
5117
5555
  label: meta.title || shortSessionId(meta.id),
5118
- description: parts.join(" \xB7 "),
5119
- hint: meta.id === sessionStoreRef.current?.id ? "current" : void 0
5556
+ description: parts.join(" \xB7 ")
5120
5557
  };
5121
5558
  })
5122
5559
  );
@@ -5229,15 +5666,16 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5229
5666
  if (preview.removed === 0) {
5230
5667
  return "Nothing to undo (no conversation turn to retract).";
5231
5668
  }
5232
- const diffs = await buildUndoDiffs(
5669
+ const diffs = preview.tree ? await buildUndoTreeDiffs(cwd, preview.tree) : await buildUndoDiffs(
5233
5670
  preview.turn !== void 0 ? listTurnSnapshots(preview.turn) : [],
5234
5671
  cwd
5235
5672
  );
5673
+ const summary = preview.tree ? `${preview.removed} message(s) will be retracted, and the working tree restored to the turn's start (git snapshot \u2014 covers changes from any tool, bash included).` : `${preview.removed} message(s) will be retracted, ${diffs.length} file change(s) reverted.`;
5236
5674
  const confirmed = await new Promise((resolve) => {
5237
5675
  setPendingRewind({
5238
5676
  title: "Undo last turn",
5239
5677
  confirmLabel: "undo",
5240
- summary: `${preview.removed} message(s) will be retracted, ${diffs.length} file change(s) reverted.`,
5678
+ summary,
5241
5679
  diffs,
5242
5680
  resolve
5243
5681
  });
@@ -5245,11 +5683,10 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5245
5683
  if (!confirmed) {
5246
5684
  return "Undo cancelled.";
5247
5685
  }
5248
- const { removed, turn } = await current.retractLastTurn();
5249
- if (removed === 0) {
5686
+ const outcome = await current.undoLastTurn();
5687
+ if (outcome.removed === 0) {
5250
5688
  return "Nothing to undo (no conversation turn to retract).";
5251
5689
  }
5252
- const reverted = turn !== void 0 ? await undoTurnSnapshots(turn) : [];
5253
5690
  const visible = messagesRef.current;
5254
5691
  let cut = -1;
5255
5692
  for (let i = visible.length - 1; i >= 0; i--) {
@@ -5259,9 +5696,36 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5259
5696
  }
5260
5697
  }
5261
5698
  redrawMessages(cut >= 0 ? visible.slice(0, cut) : visible);
5262
- return [...reverted, `Retracted the last conversation turn (${removed} messages).`].join(
5263
- "\n"
5264
- );
5699
+ return [
5700
+ ...outcome.reverted,
5701
+ `Retracted the last conversation turn (${outcome.removed} messages).`
5702
+ ].join("\n");
5703
+ },
5704
+ redo: async () => {
5705
+ const current = backendRef.current;
5706
+ if (!(current instanceof AgentLoop)) {
5707
+ return "Nothing to redo.";
5708
+ }
5709
+ const entry = current.peekRedo();
5710
+ if (!entry) {
5711
+ return "Nothing to redo.";
5712
+ }
5713
+ const confirmed = await new Promise((resolve) => {
5714
+ setPendingRewind({
5715
+ title: "Redo last undo",
5716
+ confirmLabel: "redo",
5717
+ summary: formatRedoSummary(entry),
5718
+ resolve
5719
+ });
5720
+ });
5721
+ if (!confirmed) {
5722
+ return "Redo cancelled.";
5723
+ }
5724
+ const result = await current.redoLastUndo();
5725
+ if (!result) {
5726
+ return "Nothing to redo.";
5727
+ }
5728
+ return formatRedoResult(result);
5265
5729
  },
5266
5730
  rewind: async (args) => {
5267
5731
  if (!args) {
@@ -5444,6 +5908,10 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5444
5908
  );
5445
5909
  return;
5446
5910
  }
5911
+ if (abortRef.current && BUSY_BLOCKED_COMMANDS.has(parsed.name)) {
5912
+ pushMessage("system", "Busy \u2014 wait for the turn to finish or press Esc to interrupt it.");
5913
+ return;
5914
+ }
5447
5915
  void command.run(parsed.args, registry.ctx);
5448
5916
  return;
5449
5917
  }
@@ -5487,8 +5955,8 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5487
5955
  }, [cwd, config, isStreaming, epoch]);
5488
5956
  const sessionCost = sessionCostUsd();
5489
5957
  return /* @__PURE__ */ jsxs14(Box13, { flexDirection: "column", children: [
5490
- /* @__PURE__ */ jsx12(MessageList, { messages }, `messages-${epoch}`),
5491
- (thinking || activity !== null) && /* @__PURE__ */ jsx12(
5958
+ /* @__PURE__ */ jsx11(MessageList, { messages }, `messages-${epoch}`),
5959
+ (thinking || activity !== null) && /* @__PURE__ */ jsx11(
5492
5960
  ThinkingIndicator,
5493
5961
  {
5494
5962
  reasoning: thinkingText,
@@ -5496,9 +5964,9 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5496
5964
  activity: activity ?? void 0
5497
5965
  }
5498
5966
  ),
5499
- !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx12(Text14, { dimColor: true, children: thoughtSummary }),
5500
- streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx12(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
5501
- pending && /* @__PURE__ */ jsx12(
5967
+ !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx11(Text14, { dimColor: true, children: thoughtSummary }),
5968
+ streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx11(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
5969
+ pending && /* @__PURE__ */ jsx11(
5502
5970
  PermissionPrompt,
5503
5971
  {
5504
5972
  request: pending.request,
@@ -5506,8 +5974,8 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5506
5974
  onDecision: handleDecision
5507
5975
  }
5508
5976
  ),
5509
- planApproval && /* @__PURE__ */ jsx12(ExitPlanPrompt, { onDecision: handlePlanDecision }),
5510
- pendingRewind && /* @__PURE__ */ jsx12(
5977
+ planApproval && /* @__PURE__ */ jsx11(ExitPlanPrompt, { onDecision: handlePlanDecision }),
5978
+ pendingRewind && /* @__PURE__ */ jsx11(
5511
5979
  RewindConfirmPrompt,
5512
5980
  {
5513
5981
  summary: pendingRewind.summary,
@@ -5517,7 +5985,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5517
5985
  onDecision: handleRewindDecision
5518
5986
  }
5519
5987
  ),
5520
- picker && /* @__PURE__ */ jsx12(
5988
+ picker && /* @__PURE__ */ jsx11(
5521
5989
  SelectPrompt,
5522
5990
  {
5523
5991
  title: picker.title,
@@ -5526,7 +5994,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5526
5994
  onCancel: handlePickerCancel
5527
5995
  }
5528
5996
  ),
5529
- pendingConnect && /* @__PURE__ */ jsx12(
5997
+ pendingConnect && /* @__PURE__ */ jsx11(
5530
5998
  ConnectWizard,
5531
5999
  {
5532
6000
  existingProviderNames: config.providers.map((p) => p.name),
@@ -5538,17 +6006,22 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5538
6006
  onCancel: cancelConnect
5539
6007
  }
5540
6008
  ),
5541
- queueItems.map((item) => /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
6009
+ queueItems.slice(0, MAX_VISIBLE_QUEUED).map((item) => /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
5542
6010
  "queued: ",
5543
6011
  item.text.length > 80 ? `${item.text.slice(0, 80)}\u2026` : item.text
5544
6012
  ] }, item.id)),
6013
+ queueItems.length > MAX_VISIBLE_QUEUED && /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
6014
+ "\u2026 ",
6015
+ queueItems.length - MAX_VISIBLE_QUEUED,
6016
+ " more queued"
6017
+ ] }),
5545
6018
  pendingImages.map((staged) => /* @__PURE__ */ jsxs14(Text14, { dimColor: true, children: [
5546
6019
  "[image attached: ",
5547
6020
  staged.image.path,
5548
6021
  "]"
5549
6022
  ] }, staged.id)),
5550
- /* @__PURE__ */ jsx12(TodoPanel, { todos }),
5551
- /* @__PURE__ */ jsx12(
6023
+ /* @__PURE__ */ jsx11(TodoPanel, { todos }),
6024
+ /* @__PURE__ */ jsx11(
5552
6025
  InputBox,
5553
6026
  {
5554
6027
  isStreaming,
@@ -5563,7 +6036,7 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5563
6036
  onPasteImage: handlePasteImage
5564
6037
  }
5565
6038
  ),
5566
- /* @__PURE__ */ jsx12(
6039
+ /* @__PURE__ */ jsx11(
5567
6040
  StatusBar,
5568
6041
  {
5569
6042
  cwd,
@@ -5584,7 +6057,7 @@ function renderRepl(backend, opts) {
5584
6057
  defaultTaskManager.cleanup();
5585
6058
  });
5586
6059
  return render(
5587
- /* @__PURE__ */ jsx12(
6060
+ /* @__PURE__ */ jsx11(
5588
6061
  Repl,
5589
6062
  {
5590
6063
  backend,
@@ -5621,6 +6094,37 @@ function parseTomlFile(content, filePath) {
5621
6094
  function mergeConfig(base, override) {
5622
6095
  return { ...base, ...override };
5623
6096
  }
6097
+ var PROJECT_ALLOWED_KEYS = /* @__PURE__ */ new Set([
6098
+ "defaultModel",
6099
+ "models",
6100
+ "maxSteps",
6101
+ "contextMaxTokens",
6102
+ "streamIdleTimeoutSec",
6103
+ "streamFirstChunkTimeoutSec",
6104
+ "streamMaxRetries",
6105
+ "maxAutoContinues",
6106
+ "contextCompaction",
6107
+ "sessionBudgetUsd",
6108
+ "notifyBell",
6109
+ "notifyBellThresholdSec",
6110
+ "doomLoopThreshold",
6111
+ "gitSnapshots"
6112
+ ]);
6113
+ function sanitizeProjectConfig(project, filePath) {
6114
+ const sanitized = {};
6115
+ for (const [key, value] of Object.entries(project)) {
6116
+ if (value === void 0) continue;
6117
+ if (PROJECT_ALLOWED_KEYS.has(key)) {
6118
+ sanitized[key] = value;
6119
+ } else {
6120
+ process.stderr.write(
6121
+ `[star] ignoring "${key}" in ${filePath}: project config cannot set this key
6122
+ `
6123
+ );
6124
+ }
6125
+ }
6126
+ return sanitized;
6127
+ }
5624
6128
  function applyOverrides(config, overrides) {
5625
6129
  if (!overrides) return config;
5626
6130
  const merged = { ...config };
@@ -5636,22 +6140,13 @@ function readTomlFileSync(filePath) {
5636
6140
  function loadConfigSync(cwd, overrides) {
5637
6141
  loadEnvFile();
5638
6142
  const global = readTomlFileSync(globalConfigPath());
5639
- const project = readTomlFileSync(projectConfigPath(cwd));
6143
+ const projectPath = projectConfigPath(cwd);
6144
+ const project = sanitizeProjectConfig(readTomlFileSync(projectPath), projectPath);
5640
6145
  const merged = applyOverrides(mergeConfig(global, project), overrides);
5641
6146
  return ConfigSchema.parse(merged);
5642
6147
  }
5643
6148
 
5644
6149
  // src/main.tsx
5645
- var SYSTEM_PROMPT = `You are Star CLI, an AI coding agent running in the user's terminal.
5646
- You help with software engineering tasks: reading, writing and editing code, running shell commands, and managing todos.
5647
- Be concise and direct. Use tools when they help accomplish the task.
5648
- For any task with two or more steps, start by creating a todo list with todo_write and keep it updated as you progress; only mark an item completed after you have verified its result.
5649
- The working directory is the user's project root; never touch files outside it without explicit instruction.
5650
- For conversion, batch-processing, or other scriptable tasks, write a script file into the working directory and run it instead of pasting long inline code into the shell; once the script's output is verified, delete the script unless the user asked to keep it.
5651
- When a tool call or command fails, read the error output, work out the cause, and try again with a corrected or alternative approach \u2014 never repeat an identical failing call without changing something.
5652
- Never consider the task finished until you have verified the result yourself: run the tests, the build, or a check command that proves the output is correct, and if verification fails or reveals gaps, keep fixing until it passes.
5653
- Do not end your turn while the task is still incomplete; keep going until it is done or you are genuinely blocked, and if you are blocked, state exactly what is missing.
5654
- Never end a reply by announcing what you will do next \u2014 either do it now with tool calls, or do not mention it. Narrating future actions is not progress.`;
5655
6150
  async function createLoop(config, modelName, cwd, sessionStore) {
5656
6151
  const model = createModel(config, modelName);
5657
6152
  const registry = createDefaultRegistry();
@@ -5665,7 +6160,7 @@ async function createLoop(config, modelName, cwd, sessionStore) {
5665
6160
  contextMaxTokens: contextWindowTokens(config, modelName)
5666
6161
  });
5667
6162
  }
5668
- async function printMode(loop, prompt, cwd, json, imagePaths) {
6163
+ async function printMode(loop, prompt, cwd, json, imagePaths, sessionStore) {
5669
6164
  const flagImages = [];
5670
6165
  for (const imagePath of imagePaths) {
5671
6166
  if (!imageMimeType(imagePath)) {
@@ -5734,15 +6229,22 @@ async function printMode(loop, prompt, cwd, json, imagePaths) {
5734
6229
  }
5735
6230
  case "finish":
5736
6231
  usage.add(event.usage);
6232
+ if (event.usage) {
6233
+ sessionStore?.addUsage(event.usage).catch(() => {
6234
+ });
6235
+ }
5737
6236
  break;
5738
- case "retry":
5739
- if (!json)
6237
+ case "retry": {
6238
+ if (!json) {
6239
+ const wait = event.delayMs !== void 0 && event.delayMs >= 1e3 ? ` in ${Math.round(event.delayMs / 1e3)}s` : "";
5740
6240
  process.stderr.write(
5741
6241
  `
5742
- [retry ${event.attempt}/${event.maxAttempts}] ${event.reason}
6242
+ [retry ${event.attempt}/${event.maxAttempts}${wait}] ${event.reason}
5743
6243
  `
5744
6244
  );
6245
+ }
5745
6246
  break;
6247
+ }
5746
6248
  case "notice":
5747
6249
  if (!json) process.stderr.write(`
5748
6250
  [notice] ${event.message}
@@ -5780,8 +6282,15 @@ async function printMode(loop, prompt, cwd, json, imagePaths) {
5780
6282
  }
5781
6283
  }
5782
6284
  if (!json) process.stdout.write("\n");
5783
- defaultTaskManager.cleanup();
5784
- defaultAgentTasks.cleanup();
6285
+ const killedShells = defaultTaskManager.cleanup();
6286
+ const killedAgents = defaultAgentTasks.cleanup();
6287
+ const stopped = killedShells.length + killedAgents.length;
6288
+ if (stopped > 0) {
6289
+ process.stderr.write(
6290
+ `[info] ${stopped} background task(s) were still running and have been stopped.
6291
+ `
6292
+ );
6293
+ }
5785
6294
  return exitCode;
5786
6295
  }
5787
6296
  var program = new Command();
@@ -5859,12 +6368,17 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
5859
6368
  }
5860
6369
  }
5861
6370
  if (resumeId) {
5862
- resumed = await resumeSession(resumeId);
5863
- if (!resumed) {
6371
+ sessionStore = await SessionStore.open(resumeId);
6372
+ if (!sessionStore) {
5864
6373
  console.error(`Session not found: ${resumeId}`);
5865
6374
  process.exit(1);
5866
6375
  }
5867
- sessionStore = await SessionStore.open(resumeId);
6376
+ const [meta, loaded] = await Promise.all([sessionStore.meta(), sessionStore.messages()]);
6377
+ const messages = reconcileToolCalls(loaded);
6378
+ if (messages.length !== loaded.length) {
6379
+ await sessionStore.replaceMessages(messages);
6380
+ }
6381
+ resumed = { meta, messages };
5868
6382
  } else if (!opts.print) {
5869
6383
  sessionStore = await SessionStore.create(cwd, modelName);
5870
6384
  }
@@ -5888,7 +6402,8 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
5888
6402
  opts.print,
5889
6403
  cwd,
5890
6404
  Boolean(opts.json),
5891
- opts.image ?? []
6405
+ opts.image ?? [],
6406
+ sessionStore
5892
6407
  );
5893
6408
  return;
5894
6409
  }