@cryer/star-cli 0.3.2 → 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,6 +17,7 @@ import {
17
17
  createIgnorePredicate,
18
18
  defaultAgentTasks,
19
19
  defaultTaskManager,
20
+ diffTreeNames,
20
21
  discoverSkills,
21
22
  estimateTokens,
22
23
  formatTaskFinished,
@@ -39,9 +40,8 @@ import {
39
40
  resolveShell,
40
41
  resolveSnapshotContent,
41
42
  rewindToSnapshot,
42
- summarizeMessages,
43
- undoTurnSnapshots
44
- } from "./chunk-BV4PLM6X.js";
43
+ summarizeMessages
44
+ } from "./chunk-3L6QKJM4.js";
45
45
  import {
46
46
  envFilePath,
47
47
  globalConfigPath,
@@ -49,11 +49,20 @@ import {
49
49
  sessionsDir,
50
50
  starHome,
51
51
  userMemoryPath
52
- } from "./chunk-LADQCYKL.js";
52
+ } from "./chunk-B6P4VUBE.js";
53
53
 
54
54
  // src/main.tsx
55
55
  import { Command } from "commander";
56
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
+
57
66
  // src/cli/format.ts
58
67
  function summarizeArgs(args, maxLength = 120) {
59
68
  let json;
@@ -98,7 +107,7 @@ function buildDisplayMessages(messages) {
98
107
  if (message.role === "user" || message.role === "assistant") {
99
108
  const text = coreMessageText(message);
100
109
  if (text) {
101
- display.push({ id: display.length, role: message.role, text });
110
+ display.push({ id: display.length, role: message.role, text: toTerminalSafe(text) });
102
111
  } else {
103
112
  collapsed++;
104
113
  }
@@ -110,7 +119,7 @@ function buildDisplayMessages(messages) {
110
119
  display.push({
111
120
  id: display.length,
112
121
  role: "system",
113
- text: `\u5DF2\u6062\u590D ${collapsed} \u6761\u5386\u53F2\u6D88\u606F`
122
+ text: `Restored ${collapsed} history message(s) not shown here.`
114
123
  });
115
124
  }
116
125
  return display;
@@ -168,7 +177,15 @@ function resizeImagePlan(platform, inputFile, outputFile, maxDimension, tools =
168
177
  if (tools.hasConvert) {
169
178
  return {
170
179
  command: "convert",
171
- 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
+ ]
172
189
  };
173
190
  }
174
191
  if (tools.hasFfmpeg) {
@@ -236,6 +253,7 @@ var MAX_MENTION_BYTES = 100 * 1024;
236
253
  var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
237
254
  var MAX_DIR_MENTION_ENTRIES = 200;
238
255
  var MAX_DIR_MENTION_DEPTH = 10;
256
+ var MAX_DIR_MENTION_TOTAL = 5e3;
239
257
  var IMAGE_MIME_TYPES = {
240
258
  ".png": "image/png",
241
259
  ".jpg": "image/jpeg",
@@ -261,7 +279,7 @@ async function readImageInput(filePath, cwd) {
261
279
  });
262
280
  return result.image;
263
281
  }
264
- 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;
265
283
  function parseMentions(text) {
266
284
  const mentions = [];
267
285
  const seen = /* @__PURE__ */ new Set();
@@ -294,13 +312,18 @@ function isBinaryContent(buf) {
294
312
  return probe.includes(0);
295
313
  }
296
314
  async function listDirEntries(dir, prefix, depth, state, ignore) {
315
+ if (state.capped) return;
297
316
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
298
317
  if (!entries) return;
299
- 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) => {
300
319
  const dirDiff = Number(b.isDirectory()) - Number(a.isDirectory());
301
320
  return dirDiff !== 0 ? dirDiff : a.name.localeCompare(b.name);
302
321
  });
303
322
  for (let i = 0; i < visible.length; i++) {
323
+ if (state.total >= MAX_DIR_MENTION_TOTAL) {
324
+ state.capped = true;
325
+ return;
326
+ }
304
327
  const entry = visible[i];
305
328
  if (!entry) continue;
306
329
  state.total += 1;
@@ -325,11 +348,13 @@ async function listDirEntries(dir, prefix, depth, state, ignore) {
325
348
  }
326
349
  async function directoryBlock(abs, mention, ignore) {
327
350
  const display = `${mention.replace(/\\/g, "/").replace(/\/+$/, "")}/`;
328
- const state = { lines: [`${display}`], shown: 0, total: 0 };
351
+ const state = { lines: [`${display}`], shown: 0, total: 0, capped: false };
329
352
  const probe = await readdir(abs).catch(() => null);
330
353
  if (!probe) return null;
331
354
  await listDirEntries(abs, "", 1, state, ignore);
332
- if (state.total > state.shown) {
355
+ if (state.capped) {
356
+ state.lines.push("... (truncated, many more entries)");
357
+ } else if (state.total > state.shown) {
333
358
  state.lines.push(
334
359
  `... (truncated, ${state.total - state.shown} more entries \u2014 use glob/grep tools or mention a subdirectory to see more)`
335
360
  );
@@ -473,11 +498,56 @@ import { parse, stringify } from "smol-toml";
473
498
  async function addAllowRule(rule) {
474
499
  return addPermissionRule("allow", rule);
475
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
+ }
476
545
  async function addPermissionRule(kind, rule) {
477
546
  const filePath = globalConfigPath();
547
+ let content = "";
478
548
  let raw = {};
479
549
  try {
480
- const content = await fs.promises.readFile(filePath, "utf8");
550
+ content = await fs.promises.readFile(filePath, "utf8");
481
551
  raw = parse(content);
482
552
  } catch (error) {
483
553
  if (error.code !== "ENOENT") throw error;
@@ -485,24 +555,47 @@ async function addPermissionRule(kind, rule) {
485
555
  const permissions = typeof raw.permissions === "object" && raw.permissions !== null ? raw.permissions : {};
486
556
  const rules = Array.isArray(permissions[kind]) ? permissions[kind] : [];
487
557
  if (rules.includes(rule)) return false;
488
- permissions[kind] = [...rules, rule];
489
- raw.permissions = permissions;
558
+ const nextRules = [...rules, rule];
490
559
  await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
491
- 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));
492
568
  return true;
493
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
+ }
494
589
  async function savePermissionMode(mode) {
495
590
  const filePath = globalConfigPath();
496
- let raw = {};
591
+ let content = "";
497
592
  try {
498
- const content = await fs.promises.readFile(filePath, "utf8");
499
- raw = parse(content);
593
+ content = await fs.promises.readFile(filePath, "utf8");
500
594
  } catch (error) {
501
595
  if (error.code !== "ENOENT") throw error;
502
596
  }
503
- raw.permissionMode = mode;
504
597
  await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
505
- await fs.promises.writeFile(filePath, stringify(raw), "utf8");
598
+ await writeFileAtomic(filePath, upsertTopLevelKeyText(content, "permissionMode", mode));
506
599
  }
507
600
 
508
601
  // src/config/schema.ts
@@ -530,7 +623,11 @@ var ModelConfigSchema = z.object({
530
623
  // Cost estimation needs BOTH prices (USD per 1M tokens); with only one set
531
624
  // the model is treated as unpriced.
532
625
  promptPrice: z.number().nonnegative().optional(),
533
- 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()
534
631
  });
535
632
  var PermissionsConfigSchema = z.object({
536
633
  allow: z.array(z.string()).default([]),
@@ -592,7 +689,17 @@ var ConfigSchema = z.object({
592
689
  notifyBell: z.boolean().default(true),
593
690
  notifyBellThresholdSec: z.number().positive().default(10),
594
691
  permissions: PermissionsConfigSchema.default({ allow: [], deny: [] }),
595
- 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)
596
703
  });
597
704
  function contextWindowTokens(config, modelName) {
598
705
  return config.models.find((m) => m.name === modelName)?.contextMaxTokens ?? config.contextMaxTokens;
@@ -674,10 +781,13 @@ function createSseNormalizingFetch(baseFetch = fetch) {
674
781
  return response;
675
782
  }
676
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");
677
787
  return new Response(body, {
678
788
  status: response.status,
679
789
  statusText: response.statusText,
680
- headers: response.headers
790
+ headers
681
791
  });
682
792
  };
683
793
  }
@@ -715,7 +825,7 @@ function createModel(config, modelName) {
715
825
  baseURL: provider.baseURL,
716
826
  apiKey,
717
827
  headers: provider.headers
718
- })(modelConfig.model);
828
+ })(modelConfig.model, {}, { includeUsage: true });
719
829
  case "openai-responses":
720
830
  return createOpenAI({
721
831
  baseURL: provider.baseURL,
@@ -738,6 +848,21 @@ function checkpointsDir(sessionDir) {
738
848
  function indexPath(sessionDir) {
739
849
  return path4.join(checkpointsDir(sessionDir), "index.json");
740
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
+ }
741
866
  function checkpointContentPath(sessionDir, id) {
742
867
  return path4.join(checkpointsDir(sessionDir), `${id}.snapshot`);
743
868
  }
@@ -756,32 +881,36 @@ async function listCheckpointRecords(sessionDir) {
756
881
  }
757
882
  }
758
883
  async function appendCheckpointRecord(sessionDir, record, content) {
759
- await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true });
760
- if (record.existed && content !== null) {
761
- await fs2.writeFile(checkpointContentPath(sessionDir, record.id), content, "utf8");
762
- }
763
- const records = await listCheckpointRecords(sessionDir);
764
- records.push(record);
765
- 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
+ });
766
893
  }
767
894
  async function removeCheckpointRecords(sessionDir, ids) {
768
895
  if (ids.length === 0) return;
769
- const drop = new Set(ids);
770
- const records = await listCheckpointRecords(sessionDir);
771
- const kept = records.filter((record) => !drop.has(record.id));
772
- if (kept.length === records.length) return;
773
- await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true });
774
- await fs2.writeFile(indexPath(sessionDir), JSON.stringify(kept, null, 2));
775
- for (const id of ids) {
776
- await fs2.rm(checkpointContentPath(sessionDir, id), { force: true });
777
- }
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
+ });
778
907
  }
779
908
  async function loadSessionSnapshots(sessionDir) {
780
909
  const records = await listCheckpointRecords(sessionDir);
781
910
  return records.map((record) => ({
782
911
  ...record,
783
912
  content: null,
784
- contentFile: record.existed ? checkpointContentPath(sessionDir, record.id) : void 0
913
+ contentFile: record.existed && !record.contentTooLarge ? checkpointContentPath(sessionDir, record.id) : void 0
785
914
  }));
786
915
  }
787
916
 
@@ -802,6 +931,10 @@ function debugWarn(message) {
802
931
  if (process.env.STAR_DEBUG === "1") process.stderr.write(`[star-cli] ${message}
803
932
  `);
804
933
  }
934
+ var FALLBACK_META = /* @__PURE__ */ Symbol("star.fallbackMeta");
935
+ function isFallbackMeta(meta) {
936
+ return meta[FALLBACK_META] === true;
937
+ }
805
938
  function dayKey(date) {
806
939
  const pad = (n) => String(n).padStart(2, "0");
807
940
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
@@ -811,6 +944,9 @@ var SessionStore = class _SessionStore {
811
944
  dir;
812
945
  pendingMeta = null;
813
946
  initialized = false;
947
+ cachedMeta = null;
948
+ metaTmpSeq = 0;
949
+ metaWriteQueue = Promise.resolve();
814
950
  constructor(id) {
815
951
  this.id = id;
816
952
  this.dir = path5.join(sessionsDir(), id);
@@ -840,6 +976,7 @@ var SessionStore = class _SessionStore {
840
976
  } catch {
841
977
  debugWarn(`session ${id}: corrupt meta.json, using defaults`);
842
978
  }
979
+ await store.sweepTmpFiles();
843
980
  store.initialized = true;
844
981
  return store;
845
982
  }
@@ -850,16 +987,40 @@ var SessionStore = class _SessionStore {
850
987
  } catch {
851
988
  return [];
852
989
  }
853
- const metas = [];
854
- 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 () => {
855
1012
  try {
856
- const raw = await fs3.readFile(path5.join(sessionsDir(), entry, "meta.json"), "utf8");
857
- const meta = JSON.parse(raw);
858
- if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
859
- } 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;
860
1019
  }
861
- }
862
- return metas.sort((a, b) => b.updatedAt - a.updatedAt);
1020
+ });
1021
+ this.metaWriteQueue = run.catch(() => {
1022
+ });
1023
+ return run;
863
1024
  }
864
1025
  async ensureInitialized() {
865
1026
  if (this.initialized) return;
@@ -868,7 +1029,8 @@ var SessionStore = class _SessionStore {
868
1029
  this.initialized = true;
869
1030
  return;
870
1031
  }
871
- await fs3.mkdir(this.dir, { recursive: true });
1032
+ await fs3.mkdir(this.dir, { recursive: true, mode: 448 });
1033
+ await this.sweepTmpFiles();
872
1034
  const meta = {
873
1035
  id: this.id,
874
1036
  title: "",
@@ -877,16 +1039,31 @@ var SessionStore = class _SessionStore {
877
1039
  createdAt: pending.createdAt,
878
1040
  updatedAt: pending.createdAt
879
1041
  };
880
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1042
+ await this.writeMeta(meta);
1043
+ this.cachedMeta = meta;
881
1044
  this.initialized = true;
882
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
+ }
883
1060
  async append(message) {
884
1061
  await this.ensureInitialized();
885
1062
  await fs3.appendFile(this.messagesPath(), `${JSON.stringify(message)}
886
1063
  `);
887
1064
  const meta = await this.meta();
888
1065
  meta.updatedAt = Date.now();
889
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1066
+ await this.writeMeta(meta);
890
1067
  }
891
1068
  async replaceMessages(messages) {
892
1069
  await this.ensureInitialized();
@@ -895,7 +1072,7 @@ var SessionStore = class _SessionStore {
895
1072
  ` : "");
896
1073
  const meta = await this.meta();
897
1074
  meta.updatedAt = Date.now();
898
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1075
+ await this.writeMeta(meta);
899
1076
  }
900
1077
  async messages() {
901
1078
  let raw;
@@ -918,13 +1095,24 @@ var SessionStore = class _SessionStore {
918
1095
  }
919
1096
  fallbackMeta() {
920
1097
  const now = Date.now();
921
- 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;
922
1108
  }
923
1109
  async meta() {
924
1110
  await this.ensureInitialized();
1111
+ if (this.cachedMeta) return this.cachedMeta;
925
1112
  try {
926
1113
  const raw = await fs3.readFile(this.metaPath(), "utf8");
927
- return JSON.parse(raw);
1114
+ this.cachedMeta = JSON.parse(raw);
1115
+ return this.cachedMeta;
928
1116
  } catch {
929
1117
  debugWarn(`session ${this.id}: unreadable meta.json, using defaults`);
930
1118
  return this.fallbackMeta();
@@ -935,7 +1123,7 @@ var SessionStore = class _SessionStore {
935
1123
  const meta = await this.meta();
936
1124
  meta.title = title;
937
1125
  meta.updatedAt = Date.now();
938
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1126
+ await this.writeMeta(meta);
939
1127
  }
940
1128
  async appendCheckpoint(record, content) {
941
1129
  await this.ensureInitialized();
@@ -960,6 +1148,12 @@ var SessionStore = class _SessionStore {
960
1148
  usage.promptTokens += delta.promptTokens;
961
1149
  usage.completionTokens += delta.completionTokens;
962
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
+ }
963
1157
  meta.usage = usage;
964
1158
  const day = dayKey(/* @__PURE__ */ new Date());
965
1159
  const byDay = meta.usageByDay ?? {};
@@ -969,7 +1163,7 @@ var SessionStore = class _SessionStore {
969
1163
  bucket.totalTokens += delta.totalTokens;
970
1164
  byDay[day] = bucket;
971
1165
  meta.usageByDay = byDay;
972
- await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1166
+ await this.writeMeta(meta);
973
1167
  }
974
1168
  };
975
1169
 
@@ -1026,6 +1220,33 @@ async function findLatestSession(cwd) {
1026
1220
  const [latest] = await SessionStore.list(cwd);
1027
1221
  return latest ?? null;
1028
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
+ }
1029
1250
  function shortSessionId(id) {
1030
1251
  const dash = id.lastIndexOf("-");
1031
1252
  return dash === -1 ? id : id.slice(dash + 1);
@@ -1042,20 +1263,20 @@ async function resolveSessionId(query) {
1042
1263
  function relativeTime(timestamp) {
1043
1264
  const diff = Date.now() - timestamp;
1044
1265
  const minutes = Math.floor(diff / 6e4);
1045
- if (minutes < 1) return "\u521A\u521A";
1046
- if (minutes < 60) return `${minutes} \u5206\u949F\u524D`;
1266
+ if (minutes < 1) return "just now";
1267
+ if (minutes < 60) return `${minutes}m ago`;
1047
1268
  const hours = Math.floor(minutes / 60);
1048
- if (hours < 24) return `${hours} \u5C0F\u65F6\u524D`;
1269
+ if (hours < 24) return `${hours}h ago`;
1049
1270
  const days = Math.floor(hours / 24);
1050
- if (days < 30) return `${days} \u5929\u524D`;
1271
+ if (days < 30) return `${days}d ago`;
1051
1272
  const months = Math.floor(days / 30);
1052
- if (months < 12) return `${months} \u4E2A\u6708\u524D`;
1053
- return `${Math.floor(months / 12)} \u5E74\u524D`;
1273
+ if (months < 12) return `${months}mo ago`;
1274
+ return `${Math.floor(months / 12)}y ago`;
1054
1275
  }
1055
1276
  function formatSessionEntries(entries, options = {}) {
1056
1277
  return entries.map(({ meta, messageCount }) => {
1057
- const title = meta.title || "(\u65E0\u6807\u9898)";
1058
- 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)}`;
1059
1280
  return options.showCwd ? `${base} [${meta.cwd}]` : base;
1060
1281
  }).join("\n");
1061
1282
  }
@@ -1086,7 +1307,9 @@ function computeCostUsd(usage, model) {
1086
1307
  if (model?.promptPrice === void 0 || model.completionPrice === void 0) {
1087
1308
  return null;
1088
1309
  }
1089
- 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;
1090
1313
  }
1091
1314
  function estimateCost(usage, modelName, model) {
1092
1315
  const cost = computeCostUsd(usage, model);
@@ -1102,7 +1325,8 @@ function estimateCost(usage, modelName, model) {
1102
1325
  }
1103
1326
  return `Estimated cost: unknown (no price configured for ${modelName})`;
1104
1327
  }
1105
- 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})`;
1106
1330
  }
1107
1331
 
1108
1332
  // src/session/usage.ts
@@ -1178,6 +1402,12 @@ function aggregateUsage(metas) {
1178
1402
  entry.sessions += 1;
1179
1403
  entry.requests += requestCount;
1180
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
+ }
1181
1411
  models.set(name, entry);
1182
1412
  }
1183
1413
  return {
@@ -1232,7 +1462,7 @@ function formatUsageDashboard(stats, models, options = {}) {
1232
1462
  const configured = new Map(models.map((m) => [m.name, m]));
1233
1463
  const rows = stats.models.map((entry) => {
1234
1464
  const cfg = configured.get(entry.model);
1235
- 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);
1236
1466
  return { entry, cost };
1237
1467
  });
1238
1468
  const header = ["model", "requests", "prompt", "completion", "total", "cost"];
@@ -1306,7 +1536,7 @@ function cacheHitPercent(totals) {
1306
1536
  // src/cli/clipboard.ts
1307
1537
  import { execFile as execFile2 } from "child_process";
1308
1538
  import { randomUUID as randomUUID2 } from "crypto";
1309
- import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
1539
+ import { readFile as readFile3, stat as stat2, unlink as unlink2 } from "fs/promises";
1310
1540
  import os2 from "os";
1311
1541
  import path9 from "path";
1312
1542
  import { promisify as promisify2 } from "util";
@@ -1385,6 +1615,13 @@ async function finalizeClipboardImage(image) {
1385
1615
  const result = await downsampleImageIfNeeded(image);
1386
1616
  return { image: result.image, oversized: result.oversized && !result.resized };
1387
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
+ }
1388
1625
  async function readClipboardImage() {
1389
1626
  const tempFile = path9.join(os2.tmpdir(), `star-clipboard-${randomUUID2()}.png`);
1390
1627
  const hasPngpaste = process.platform === "darwin" ? await commandExists2("pngpaste") : false;
@@ -1397,22 +1634,18 @@ async function readClipboardImage() {
1397
1634
  maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES,
1398
1635
  encoding: "buffer"
1399
1636
  });
1400
- const buf2 = stdout;
1401
- if (buf2.length === 0) return null;
1637
+ const buf = stdout;
1638
+ if (buf.length === 0) return null;
1402
1639
  return finalizeClipboardImage({
1403
1640
  path: "clipboard.png",
1404
1641
  mimeType: "image/png",
1405
- data: buf2.toString("base64")
1642
+ data: buf.toString("base64")
1406
1643
  });
1407
1644
  }
1408
1645
  await execFileAsync2(plan.command, plan.args, { timeout: CLIPBOARD_TIMEOUT_MS });
1409
- const buf = await readFile3(tempFile).catch(() => null);
1410
- if (!buf || buf.length === 0) return null;
1411
- return finalizeClipboardImage({
1412
- path: "clipboard.png",
1413
- mimeType: "image/png",
1414
- data: buf.toString("base64")
1415
- });
1646
+ const image = await readClipboardTempFile(tempFile);
1647
+ if (!image) return null;
1648
+ return finalizeClipboardImage(image);
1416
1649
  } catch {
1417
1650
  return null;
1418
1651
  } finally {
@@ -1772,7 +2005,7 @@ function applyEdit(content, oldString, newString, replaceAll) {
1772
2005
  }
1773
2006
  if (count === 0) return null;
1774
2007
  if (count > 1 && !replaceAll) return null;
1775
- return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
2008
+ return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString);
1776
2009
  }
1777
2010
  function newFilePreview(content, label) {
1778
2011
  const all = content.split("\n");
@@ -2071,13 +2304,22 @@ ${content}`);
2071
2304
  });
2072
2305
  register({
2073
2306
  name: "undo",
2074
- 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",
2075
2308
  usage: "/undo",
2076
2309
  category: "Changes",
2077
2310
  async run(_args, ctx) {
2078
2311
  ctx.addSystemMessage(await ctx.undo());
2079
2312
  }
2080
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
+ });
2081
2323
  register({
2082
2324
  name: "rewind",
2083
2325
  description: "List file-change checkpoints, or rewind to just before one: restore files and retract the conversation",
@@ -2349,19 +2591,19 @@ function maskApiKey(key) {
2349
2591
  if (key.length <= 8) return "\u2022\u2022\u2022\u2022\u2022\u2022";
2350
2592
  return `${key.slice(0, 3)}\u2026${key.slice(-4)}`;
2351
2593
  }
2352
- function tomlString(value) {
2594
+ function tomlString2(value) {
2353
2595
  return JSON.stringify(value);
2354
2596
  }
2355
2597
  function buildProviderTomlBlock(provider) {
2356
2598
  return [
2357
2599
  "[[providers]]",
2358
- `name = ${tomlString(provider.name)}`,
2600
+ `name = ${tomlString2(provider.name)}`,
2359
2601
  "# wire format: openai-compatible | anthropic | openai-responses",
2360
- `protocol = ${tomlString(provider.protocol)}`,
2361
- `baseURL = ${tomlString(provider.baseURL)}`,
2602
+ `protocol = ${tomlString2(provider.protocol)}`,
2603
+ `baseURL = ${tomlString2(provider.baseURL)}`,
2362
2604
  "# environment variable holding the API key; the key itself lives in",
2363
2605
  "# ~/.star-cli/.env (written by /connect), never in this file",
2364
- `apiKeyEnv = ${tomlString(provider.apiKeyEnv ?? "")}`,
2606
+ `apiKeyEnv = ${tomlString2(provider.apiKeyEnv ?? "")}`,
2365
2607
  "# inline key instead of apiKeyEnv (not recommended):",
2366
2608
  '# apiKey = "sk-..."',
2367
2609
  "# extra HTTP headers sent to the provider:",
@@ -2373,9 +2615,9 @@ function buildProviderTomlBlock(provider) {
2373
2615
  function buildModelTomlBlock(model) {
2374
2616
  return [
2375
2617
  "[[models]]",
2376
- `name = ${tomlString(model.name)}`,
2377
- `provider = ${tomlString(model.provider)}`,
2378
- `model = ${tomlString(model.model)}`,
2618
+ `name = ${tomlString2(model.name)}`,
2619
+ `provider = ${tomlString2(model.provider)}`,
2620
+ `model = ${tomlString2(model.model)}`,
2379
2621
  "# per-model context window in tokens \u2014 overrides the top-level",
2380
2622
  "# contextMaxTokens for compaction and the ctx % in the status bar",
2381
2623
  `contextMaxTokens = ${model.contextMaxTokens ?? CONNECT_DEFAULT_CONTEXT_TOKENS}`,
@@ -2443,7 +2685,7 @@ function saveConnection(answers, configPath = globalConfigPath(), envPath = envF
2443
2685
  return { provider, model, configPath, envPath };
2444
2686
  }
2445
2687
  function setDefaultModelInToml(content, modelName) {
2446
- const line = `defaultModel = ${tomlString(modelName)}`;
2688
+ const line = `defaultModel = ${tomlString2(modelName)}`;
2447
2689
  const lines = content.split("\n");
2448
2690
  let inTopLevel = true;
2449
2691
  for (let i = 0; i < lines.length; i++) {
@@ -2883,6 +3125,20 @@ async function initProject(opts) {
2883
3125
  };
2884
3126
  }
2885
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
+
2886
3142
  // src/cli/commands/registry.ts
2887
3143
  var CommandRegistry = class {
2888
3144
  commands = /* @__PURE__ */ new Map();
@@ -2998,6 +3254,16 @@ async function buildUndoDiffs(snapshots, cwd) {
2998
3254
  }
2999
3255
  return diffs;
3000
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
+ }
3001
3267
 
3002
3268
  // src/cli/components/ConnectWizard.tsx
3003
3269
  import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
@@ -3932,18 +4198,19 @@ import { memo } from "react";
3932
4198
  import { Text as Text6 } from "ink";
3933
4199
  import { Fragment, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
3934
4200
  function DiffLineView({ line }) {
4201
+ const text = toTerminalSafe(line.text);
3935
4202
  if (line.kind === "add") return /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
3936
4203
  "+ ",
3937
- line.text
4204
+ text
3938
4205
  ] });
3939
4206
  if (line.kind === "del") return /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
3940
4207
  "- ",
3941
- line.text
4208
+ text
3942
4209
  ] });
3943
- 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 });
3944
4211
  return /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
3945
4212
  " ",
3946
- line.text
4213
+ text
3947
4214
  ] });
3948
4215
  }
3949
4216
  function DiffLines({ lines }) {
@@ -3987,6 +4254,17 @@ var MessageList = memo(function MessageList2({ messages }) {
3987
4254
  // src/cli/components/PermissionPrompt.tsx
3988
4255
  import { Box as Box7, Text as Text8, useInput as useInput6 } from "ink";
3989
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
+ }
3990
4268
  function PermissionPrompt({ request, preview, onDecision }) {
3991
4269
  useInput6((input) => {
3992
4270
  const ch = input.toLowerCase();
@@ -3994,6 +4272,7 @@ function PermissionPrompt({ request, preview, onDecision }) {
3994
4272
  else if (ch === "n") onDecision("no");
3995
4273
  else if (ch === "a") onDecision("always");
3996
4274
  });
4275
+ const command = request.toolName === "bash" ? bashCommand(request.args) : null;
3997
4276
  return /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
3998
4277
  /* @__PURE__ */ jsxs8(Text8, { bold: true, color: "yellow", children: [
3999
4278
  "Permission required: ",
@@ -4005,10 +4284,10 @@ function PermissionPrompt({ request, preview, onDecision }) {
4005
4284
  preview ? /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", children: [
4006
4285
  /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
4007
4286
  preview.type === "new-file" ? "New file: " : "",
4008
- preview.label
4287
+ toTerminalSafe(preview.label)
4009
4288
  ] }),
4010
4289
  /* @__PURE__ */ jsx8(DiffLines, { lines: preview.lines })
4011
- ] }) : /* @__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)) }),
4012
4291
  /* @__PURE__ */ jsx8(Text8, { children: "[y] allow [n] deny [a] always (saved to config)" })
4013
4292
  ] });
4014
4293
  }
@@ -4067,7 +4346,7 @@ var StatusBar = memo2(function StatusBar2({
4067
4346
  }) {
4068
4347
  const { stdout } = useStdout2();
4069
4348
  const wide = (stdout?.columns ?? 80) >= WIDE_COLUMNS;
4070
- const joined = backgroundTasks.join(", ");
4349
+ const joined = backgroundTasks.map((label) => toTerminalSafe(label)).join(", ");
4071
4350
  const labels = joined.length > BG_LABEL_MAX ? `${joined.slice(0, BG_LABEL_MAX)}\u2026` : joined;
4072
4351
  return /* @__PURE__ */ jsxs10(Box9, { justifyContent: "space-between", children: [
4073
4352
  /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
@@ -4156,17 +4435,6 @@ function ThinkingIndicator({
4156
4435
 
4157
4436
  // src/cli/components/TodoPanel.tsx
4158
4437
  import { Box as Box12, Text as Text13 } from "ink";
4159
-
4160
- // src/cli/terminal-text.ts
4161
- var UNSAFE_CHARS = /[\x00-\x09\x0b-\x1f\x7f]/;
4162
- var CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f]/g;
4163
- var TABS = /\t/g;
4164
- function toTerminalSafe(text) {
4165
- if (!UNSAFE_CHARS.test(text)) return text;
4166
- return text.replace(TABS, " ").replace(CONTROL_CHARS, "");
4167
- }
4168
-
4169
- // src/cli/components/TodoPanel.tsx
4170
4438
  import { jsxs as jsxs13 } from "react/jsx-runtime";
4171
4439
  var SYMBOLS = {
4172
4440
  pending: "[ ]",
@@ -4356,6 +4624,18 @@ async function executeShellBang(raw, cwd, signal) {
4356
4624
  };
4357
4625
  }
4358
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
+
4359
4639
  // src/cli/ticker.ts
4360
4640
  var TICK_MS = 100;
4361
4641
  function nextFlush(prev, next) {
@@ -4414,20 +4694,25 @@ function runningTaskLabels() {
4414
4694
  return [...shells, ...agents];
4415
4695
  }
4416
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
+ ]);
4417
4710
  function formatUsage(usage) {
4418
4711
  return `API usage this session: ${usage.requests} requests, ${usage.promptTokens} prompt + ${usage.completionTokens} completion = ${usage.totalTokens} tokens`;
4419
4712
  }
4420
4713
  var SESSION_PICKER_CAP = 20;
4714
+ var SESSION_PREVIEW_MAX = 60;
4421
4715
  var MAX_VISIBLE_QUEUED = 5;
4422
- async function sessionPreview(id) {
4423
- const store = await SessionStore.open(id);
4424
- if (!store) return null;
4425
- const first = (await store.messages()).find((m) => m.role === "user");
4426
- if (!first) return null;
4427
- const text = coreMessageText(first).replace(/\s+/g, " ").trim();
4428
- if (!text) return null;
4429
- return text.length > 60 ? `${text.slice(0, 60)}\u2026` : text;
4430
- }
4431
4716
  function Repl({
4432
4717
  backend,
4433
4718
  model,
@@ -4463,12 +4748,14 @@ function Repl({
4463
4748
  const nextIdRef = useRef5(initialDisplay.length);
4464
4749
  const abortRef = useRef5(null);
4465
4750
  const streamedRef = useRef5("");
4751
+ const lastCommittedLenRef = useRef5(0);
4466
4752
  const thinkingRef = useRef5(false);
4467
4753
  const reasoningRef = useRef5("");
4468
4754
  const tickerStopRef = useRef5(null);
4469
4755
  const flushedRef = useRef5({ streamed: "", reasoning: "" });
4470
4756
  const toolCardsRef = useRef5(/* @__PURE__ */ new Map());
4471
4757
  const pendingRef = useRef5(null);
4758
+ const permissionChainRef = useRef5(Promise.resolve());
4472
4759
  const alwaysAllowedRef = useRef5(/* @__PURE__ */ new Set());
4473
4760
  const prevModeRef = useRef5(null);
4474
4761
  const planApprovalRef = useRef5(false);
@@ -4477,6 +4764,10 @@ function Repl({
4477
4764
  const pendingConnectRef = useRef5(null);
4478
4765
  const modelNameRef = useRef5(model);
4479
4766
  const sessionStoreRef = useRef5(sessionStore);
4767
+ const configRef = useRef5(config);
4768
+ useEffect2(() => {
4769
+ configRef.current = config;
4770
+ }, [config]);
4480
4771
  const usageRef = useRef5(initialUsage ? { ...initialUsage } : emptyUsage());
4481
4772
  const turnChunksRef = useRef5(0);
4482
4773
  const messagesRef = useRef5(initialDisplay);
@@ -4652,14 +4943,22 @@ function Repl({
4652
4943
  );
4653
4944
  const attachConfirmHandler = useCallback(
4654
4945
  (target) => {
4655
- target.confirmHandler = async (req) => {
4946
+ target.confirmHandler = (req) => {
4656
4947
  if (isAllowedByRules([...alwaysAllowedRef.current], req)) {
4657
- return true;
4948
+ return Promise.resolve(true);
4658
4949
  }
4659
- const preview = await generateDiffPreview(req.toolName, req.args, cwd).catch(() => null);
4660
- return new Promise((resolve) => {
4661
- setPendingPermission({ request: req, preview, resolve });
4662
- });
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;
4663
4962
  };
4664
4963
  target.onHookWarning = (message) => pushMessage("system", message);
4665
4964
  },
@@ -4690,27 +4989,29 @@ function Repl({
4690
4989
  setBgLabels(runningTaskLabels());
4691
4990
  pushMessage(
4692
4991
  "system",
4693
- task.status === "running" ? formatTaskStarted(task) : formatTaskFinished(task)
4992
+ toTerminalSafe(
4993
+ task.status === "running" ? formatTaskStarted(task) : formatTaskFinished(task)
4994
+ )
4694
4995
  );
4695
4996
  if (task.status !== "running") {
4696
4997
  notifyBell({
4697
- enabled: config.notifyBell,
4698
- thresholdSec: config.notifyBellThresholdSec,
4998
+ enabled: configRef.current.notifyBell,
4999
+ thresholdSec: configRef.current.notifyBellThresholdSec,
4699
5000
  noNotifyEnv: process.env.STAR_NO_NOTIFY === "1"
4700
5001
  });
4701
5002
  }
4702
5003
  };
4703
5004
  const onAgentUpdate = (task) => {
4704
5005
  setBgLabels(runningTaskLabels());
4705
- const label = task.description ? `: ${task.description}` : "";
5006
+ const label = task.description ? `: ${toTerminalSafe(task.description)}` : "";
4706
5007
  pushMessage(
4707
5008
  "system",
4708
5009
  task.status === "running" ? `Background subagent ${task.id} started${label}` : `Background subagent ${task.id} ${task.status}${label}`
4709
5010
  );
4710
5011
  if (task.status !== "running") {
4711
5012
  notifyBell({
4712
- enabled: config.notifyBell,
4713
- thresholdSec: config.notifyBellThresholdSec,
5013
+ enabled: configRef.current.notifyBell,
5014
+ thresholdSec: configRef.current.notifyBellThresholdSec,
4714
5015
  noNotifyEnv: process.env.STAR_NO_NOTIFY === "1"
4715
5016
  });
4716
5017
  }
@@ -4723,7 +5024,7 @@ function Repl({
4723
5024
  defaultTaskManager.cleanup();
4724
5025
  defaultAgentTasks.cleanup();
4725
5026
  };
4726
- }, [pushMessage, config]);
5027
+ }, [pushMessage]);
4727
5028
  const interrupt = useCallback(() => {
4728
5029
  abortRef.current?.abort();
4729
5030
  const p = pendingRef.current;
@@ -4762,9 +5063,11 @@ function Repl({
4762
5063
  const handleExit = useCallback(() => {
4763
5064
  const killed = defaultTaskManager.cleanup();
4764
5065
  const killedAgents = defaultAgentTasks.cleanup();
4765
- const stopped = [...killed.map((t) => t.id), ...killedAgents.map((t) => t.id)];
4766
- if (stopped.length > 0) {
4767
- 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;
4768
5071
  }
4769
5072
  exit();
4770
5073
  }, [exit, pushMessage]);
@@ -4825,6 +5128,7 @@ function Repl({
4825
5128
  registry: createDefaultRegistry(),
4826
5129
  config,
4827
5130
  cwd,
5131
+ system: SYSTEM_PROMPT,
4828
5132
  sessionStore: sessionStoreRef.current,
4829
5133
  contextMaxTokens: contextWindowTokens(config, name)
4830
5134
  });
@@ -4866,12 +5170,15 @@ function Repl({
4866
5170
  if (!resumed) {
4867
5171
  return `Session not found: ${id}`;
4868
5172
  }
4869
- await current.loadMessages(resumed.messages);
4870
- setTodos(await loadTodos(cwd));
4871
5173
  const store = await SessionStore.open(resolvedId);
4872
- if (store) {
4873
- hydrateSnapshots(await loadSessionSnapshots(store.dir));
5174
+ if (!store) {
5175
+ return `Session not found: ${id}`;
4874
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));
4875
5182
  const display = buildDisplayMessages(resumed.messages);
4876
5183
  nextIdRef.current = display.length;
4877
5184
  applyMessages(display);
@@ -4897,6 +5204,7 @@ function Repl({
4897
5204
  abortRef.current = controller;
4898
5205
  const turnStartedAt = Date.now();
4899
5206
  streamedRef.current = "";
5207
+ lastCommittedLenRef.current = 0;
4900
5208
  thinkingRef.current = true;
4901
5209
  reasoningRef.current = "";
4902
5210
  turnChunksRef.current = 0;
@@ -4907,22 +5215,25 @@ function Repl({
4907
5215
  setIsStreaming(true);
4908
5216
  flushedRef.current = { streamed: "", reasoning: "" };
4909
5217
  const commitStreamed = (tight) => {
4910
- if (streamedRef.current.length === 0) return;
4911
- pushAssistantChunk(streamedRef.current, tight);
5218
+ const uncommitted = streamedRef.current.slice(lastCommittedLenRef.current);
5219
+ if (uncommitted.length > 0) {
5220
+ pushAssistantChunk(uncommitted, tight);
5221
+ }
4912
5222
  streamedRef.current = "";
5223
+ lastCommittedLenRef.current = 0;
4913
5224
  flushedRef.current = { ...flushedRef.current, streamed: "" };
4914
5225
  setStreamingText("");
4915
5226
  };
4916
5227
  tickerStopRef.current = startTicker((tick) => {
4917
5228
  setSpinnerTick(tick);
4918
- const split = splitCommittableLines(streamedRef.current, 8);
5229
+ const uncommitted = streamedRef.current.slice(lastCommittedLenRef.current);
5230
+ const split = splitCommittableLines(uncommitted, 8);
4919
5231
  if (split) {
4920
5232
  pushAssistantChunk(split.committed, true);
4921
- streamedRef.current = split.rest;
4922
- flushedRef.current = { ...flushedRef.current, streamed: "" };
5233
+ lastCommittedLenRef.current += split.committed.length + 1;
4923
5234
  }
4924
5235
  const next = {
4925
- streamed: streamedRef.current,
5236
+ streamed: streamedRef.current.slice(lastCommittedLenRef.current),
4926
5237
  reasoning: reasoningRef.current
4927
5238
  };
4928
5239
  if (nextFlush(flushedRef.current, next) !== null) {
@@ -4991,6 +5302,12 @@ function Repl({
4991
5302
  usage.promptTokens += event.usage.promptTokens;
4992
5303
  usage.completionTokens += event.usage.completionTokens;
4993
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
+ }
4994
5311
  if (cacheUsageReported(event.usage)) {
4995
5312
  if (!usage.cache) usage.cache = { cachedTokens: 0, promptTokens: 0 };
4996
5313
  addToCacheTotals(usage.cache, event.usage);
@@ -5000,6 +5317,9 @@ function Repl({
5000
5317
  });
5001
5318
  }
5002
5319
  } else if (event.type === "retry") {
5320
+ streamedRef.current = streamedRef.current.slice(0, lastCommittedLenRef.current);
5321
+ flushedRef.current = { ...flushedRef.current, streamed: "" };
5322
+ setStreamingText("");
5003
5323
  const wait = event.delayMs !== void 0 && event.delayMs >= 1e3 ? ` in ${Math.round(event.delayMs / 1e3)}s` : "";
5004
5324
  pushMessage(
5005
5325
  "system",
@@ -5030,8 +5350,9 @@ function Repl({
5030
5350
  setThinkingText("");
5031
5351
  setThoughtSummary(null);
5032
5352
  setActivity(null);
5033
- const finalText = streamedRef.current;
5353
+ const finalText = streamedRef.current.slice(lastCommittedLenRef.current);
5034
5354
  streamedRef.current = "";
5355
+ lastCommittedLenRef.current = 0;
5035
5356
  setStreamingText(null);
5036
5357
  const interrupted = controller.signal.aborted;
5037
5358
  notifyBell({
@@ -5214,10 +5535,8 @@ ${lines.join("\n")}`;
5214
5535
  return ctx.permissionMode(picked);
5215
5536
  },
5216
5537
  pickSession: async (all) => {
5217
- const entries = (await listSessionEntries(all ? void 0 : cwd)).slice(
5218
- 0,
5219
- SESSION_PICKER_CAP
5220
- );
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);
5221
5540
  if (entries.length === 0) {
5222
5541
  return all ? "No sessions found." : "No sessions found for this directory.";
5223
5542
  }
@@ -5225,13 +5544,16 @@ ${lines.join("\n")}`;
5225
5544
  entries.map(async ({ meta, messageCount }) => {
5226
5545
  const parts = [`${messageCount} messages \xB7 ${relativeTime(meta.updatedAt)}`];
5227
5546
  const preview = await sessionPreview(meta.id);
5228
- 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
+ }
5229
5552
  if (all) parts.push(`[${meta.cwd}]`);
5230
5553
  return {
5231
5554
  value: meta.id,
5232
5555
  label: meta.title || shortSessionId(meta.id),
5233
- description: parts.join(" \xB7 "),
5234
- hint: meta.id === sessionStoreRef.current?.id ? "current" : void 0
5556
+ description: parts.join(" \xB7 ")
5235
5557
  };
5236
5558
  })
5237
5559
  );
@@ -5344,15 +5666,16 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5344
5666
  if (preview.removed === 0) {
5345
5667
  return "Nothing to undo (no conversation turn to retract).";
5346
5668
  }
5347
- const diffs = await buildUndoDiffs(
5669
+ const diffs = preview.tree ? await buildUndoTreeDiffs(cwd, preview.tree) : await buildUndoDiffs(
5348
5670
  preview.turn !== void 0 ? listTurnSnapshots(preview.turn) : [],
5349
5671
  cwd
5350
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.`;
5351
5674
  const confirmed = await new Promise((resolve) => {
5352
5675
  setPendingRewind({
5353
5676
  title: "Undo last turn",
5354
5677
  confirmLabel: "undo",
5355
- summary: `${preview.removed} message(s) will be retracted, ${diffs.length} file change(s) reverted.`,
5678
+ summary,
5356
5679
  diffs,
5357
5680
  resolve
5358
5681
  });
@@ -5360,11 +5683,10 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5360
5683
  if (!confirmed) {
5361
5684
  return "Undo cancelled.";
5362
5685
  }
5363
- const { removed, turn } = await current.retractLastTurn();
5364
- if (removed === 0) {
5686
+ const outcome = await current.undoLastTurn();
5687
+ if (outcome.removed === 0) {
5365
5688
  return "Nothing to undo (no conversation turn to retract).";
5366
5689
  }
5367
- const reverted = turn !== void 0 ? await undoTurnSnapshots(turn) : [];
5368
5690
  const visible = messagesRef.current;
5369
5691
  let cut = -1;
5370
5692
  for (let i = visible.length - 1; i >= 0; i--) {
@@ -5374,9 +5696,36 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
5374
5696
  }
5375
5697
  }
5376
5698
  redrawMessages(cut >= 0 ? visible.slice(0, cut) : visible);
5377
- return [...reverted, `Retracted the last conversation turn (${removed} messages).`].join(
5378
- "\n"
5379
- );
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);
5380
5729
  },
5381
5730
  rewind: async (args) => {
5382
5731
  if (!args) {
@@ -5559,6 +5908,10 @@ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message
5559
5908
  );
5560
5909
  return;
5561
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
+ }
5562
5915
  void command.run(parsed.args, registry.ctx);
5563
5916
  return;
5564
5917
  }
@@ -5741,6 +6094,37 @@ function parseTomlFile(content, filePath) {
5741
6094
  function mergeConfig(base, override) {
5742
6095
  return { ...base, ...override };
5743
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
+ }
5744
6128
  function applyOverrides(config, overrides) {
5745
6129
  if (!overrides) return config;
5746
6130
  const merged = { ...config };
@@ -5756,22 +6140,13 @@ function readTomlFileSync(filePath) {
5756
6140
  function loadConfigSync(cwd, overrides) {
5757
6141
  loadEnvFile();
5758
6142
  const global = readTomlFileSync(globalConfigPath());
5759
- const project = readTomlFileSync(projectConfigPath(cwd));
6143
+ const projectPath = projectConfigPath(cwd);
6144
+ const project = sanitizeProjectConfig(readTomlFileSync(projectPath), projectPath);
5760
6145
  const merged = applyOverrides(mergeConfig(global, project), overrides);
5761
6146
  return ConfigSchema.parse(merged);
5762
6147
  }
5763
6148
 
5764
6149
  // src/main.tsx
5765
- var SYSTEM_PROMPT = `You are Star CLI, an AI coding agent running in the user's terminal.
5766
- You help with software engineering tasks: reading, writing and editing code, running shell commands, and managing todos.
5767
- Be concise and direct. Use tools when they help accomplish the task.
5768
- 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.
5769
- The working directory is the user's project root; never touch files outside it without explicit instruction.
5770
- 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.
5771
- 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.
5772
- 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.
5773
- 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.
5774
- 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.`;
5775
6150
  async function createLoop(config, modelName, cwd, sessionStore) {
5776
6151
  const model = createModel(config, modelName);
5777
6152
  const registry = createDefaultRegistry();
@@ -5785,7 +6160,7 @@ async function createLoop(config, modelName, cwd, sessionStore) {
5785
6160
  contextMaxTokens: contextWindowTokens(config, modelName)
5786
6161
  });
5787
6162
  }
5788
- async function printMode(loop, prompt, cwd, json, imagePaths) {
6163
+ async function printMode(loop, prompt, cwd, json, imagePaths, sessionStore) {
5789
6164
  const flagImages = [];
5790
6165
  for (const imagePath of imagePaths) {
5791
6166
  if (!imageMimeType(imagePath)) {
@@ -5854,6 +6229,10 @@ async function printMode(loop, prompt, cwd, json, imagePaths) {
5854
6229
  }
5855
6230
  case "finish":
5856
6231
  usage.add(event.usage);
6232
+ if (event.usage) {
6233
+ sessionStore?.addUsage(event.usage).catch(() => {
6234
+ });
6235
+ }
5857
6236
  break;
5858
6237
  case "retry": {
5859
6238
  if (!json) {
@@ -5903,8 +6282,15 @@ async function printMode(loop, prompt, cwd, json, imagePaths) {
5903
6282
  }
5904
6283
  }
5905
6284
  if (!json) process.stdout.write("\n");
5906
- defaultTaskManager.cleanup();
5907
- 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
+ }
5908
6294
  return exitCode;
5909
6295
  }
5910
6296
  var program = new Command();
@@ -5982,12 +6368,17 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
5982
6368
  }
5983
6369
  }
5984
6370
  if (resumeId) {
5985
- resumed = await resumeSession(resumeId);
5986
- if (!resumed) {
6371
+ sessionStore = await SessionStore.open(resumeId);
6372
+ if (!sessionStore) {
5987
6373
  console.error(`Session not found: ${resumeId}`);
5988
6374
  process.exit(1);
5989
6375
  }
5990
- 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 };
5991
6382
  } else if (!opts.print) {
5992
6383
  sessionStore = await SessionStore.create(cwd, modelName);
5993
6384
  }
@@ -6011,7 +6402,8 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
6011
6402
  opts.print,
6012
6403
  cwd,
6013
6404
  Boolean(opts.json),
6014
- opts.image ?? []
6405
+ opts.image ?? [],
6406
+ sessionStore
6015
6407
  );
6016
6408
  return;
6017
6409
  }