@cryer/star-cli 0.1.3 → 0.2.0

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
@@ -1,608 +1,40 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ AgentLoop,
4
+ SKIP_DIRS,
5
+ TodoStore,
6
+ WORKING_DIFF_MAX_LINES,
7
+ bashTool,
8
+ buildAllowRule,
9
+ buildCommitPrompt,
10
+ clearSnapshots,
11
+ collectCommitContext,
12
+ collectWorkingDiff,
13
+ compactMessages,
14
+ createDefaultRegistry,
15
+ defaultTaskManager,
16
+ estimateTokens,
17
+ formatTaskFinished,
18
+ formatTaskList,
19
+ formatTaskStarted,
20
+ formatTodos,
21
+ getGitSummary,
22
+ hydrateSnapshots,
23
+ isAllowedByRules,
24
+ isDangerousCommand,
25
+ isGitRepo,
26
+ isSensitivePath,
27
+ listSnapshots,
28
+ reconcileToolCalls,
29
+ resolveShell,
30
+ rewindToSnapshot,
31
+ summarizeMessages,
32
+ undoTurnSnapshots
33
+ } from "./chunk-RLQXGX3N.js";
2
34
 
3
35
  // src/main.tsx
4
36
  import { Command } from "commander";
5
37
 
6
- // src/agent/loop.ts
7
- import { tool as aiTool } from "ai";
8
-
9
- // src/context/compaction.ts
10
- import { generateText } from "ai";
11
-
12
- // src/context/tokens.ts
13
- var CHARS_PER_TOKEN = 4;
14
- var MESSAGE_OVERHEAD = 4;
15
- function contentCharLength(message) {
16
- if (message.role === "tool") {
17
- let chars2 = 0;
18
- for (const part of message.content) {
19
- chars2 += JSON.stringify(part.result ?? null).length;
20
- }
21
- return chars2;
22
- }
23
- if (typeof message.content === "string") {
24
- return message.content.length;
25
- }
26
- let chars = 0;
27
- for (const part of message.content) {
28
- if (part.type === "text" || part.type === "reasoning") {
29
- chars += part.text.length;
30
- } else if (part.type === "tool-call") {
31
- chars += part.toolName.length + JSON.stringify(part.args ?? null).length;
32
- }
33
- }
34
- return chars;
35
- }
36
- function estimateMessageTokens(message) {
37
- return Math.ceil(contentCharLength(message) / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD;
38
- }
39
- function estimateTokens(messages) {
40
- return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
41
- }
42
-
43
- // src/context/compaction.ts
44
- var MIN_KEPT_MESSAGES = 4;
45
- function placeholderMessage(droppedCount) {
46
- return {
47
- role: "user",
48
- content: `[context compacted: ${droppedCount} earlier messages dropped]`
49
- };
50
- }
51
- var SUMMARY_SYSTEM_PROMPT = [
52
- "You are summarizing an AI coding agent's conversation for context compaction.",
53
- "Write a concise summary that preserves:",
54
- "- the user's goals and requests",
55
- "- decisions made and their rationale",
56
- "- files and code touched (paths, key changes)",
57
- "- tool results that matter (errors, key outputs)",
58
- "- outstanding TODOs and next steps",
59
- "Output only the summary, no preamble."
60
- ].join("\n");
61
- function serializeMessage(message) {
62
- if (typeof message.content === "string") {
63
- return `${message.role}: ${message.content}`;
64
- }
65
- const parts = message.content.map((part) => {
66
- if (part.type === "text") return part.text;
67
- if (part.type === "tool-call")
68
- return `[tool-call ${part.toolName}] ${JSON.stringify(part.args)}`;
69
- if (part.type === "tool-result")
70
- return `[tool-result ${part.toolName}] ${JSON.stringify(part.result)}`;
71
- return `[${part.type}]`;
72
- });
73
- return `${message.role}: ${parts.join("\n")}`;
74
- }
75
- async function summarizeMessages(messages, model) {
76
- const transcript = messages.map(serializeMessage).join("\n");
77
- const { text } = await generateText({
78
- model,
79
- system: SUMMARY_SYSTEM_PROMPT,
80
- prompt: `Summarize this conversation so far:
81
-
82
- ${transcript}`
83
- });
84
- return text.trim();
85
- }
86
- function compactMessages(messages, maxTokens) {
87
- if (estimateTokens(messages) <= maxTokens) {
88
- return { messages, compacted: false, droppedCount: 0 };
89
- }
90
- const first = messages[0];
91
- const hasSystem = first?.role === "system";
92
- const head = hasSystem && first ? [first] : [];
93
- const rest = hasSystem ? messages.slice(1) : messages.slice();
94
- const turns = [];
95
- for (const message of rest) {
96
- const current = turns[turns.length - 1];
97
- if (message.role === "user" || !current) {
98
- turns.push([message]);
99
- } else {
100
- current.push(message);
101
- }
102
- }
103
- let droppedCount = 0;
104
- let turnIndex = 0;
105
- while (turnIndex < turns.length) {
106
- const turn = turns[turnIndex];
107
- if (rest.length - droppedCount - turn.length < MIN_KEPT_MESSAGES) {
108
- break;
109
- }
110
- droppedCount += turn.length;
111
- turnIndex += 1;
112
- const candidate = [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)];
113
- if (estimateTokens(candidate) <= maxTokens) {
114
- break;
115
- }
116
- }
117
- if (droppedCount === 0) {
118
- return { messages, compacted: false, droppedCount: 0 };
119
- }
120
- return {
121
- messages: [...head, placeholderMessage(droppedCount), ...rest.slice(droppedCount)],
122
- compacted: true,
123
- droppedCount
124
- };
125
- }
126
-
127
- // src/core/messages.ts
128
- var MISSING_TOOL_RESULT_TEXT = "tool result missing (session was interrupted)";
129
- function reconcileToolCalls(messages) {
130
- const result = [];
131
- let pending = [];
132
- const flushPending = () => {
133
- for (const call of pending) {
134
- result.push({
135
- role: "tool",
136
- content: [
137
- {
138
- type: "tool-result",
139
- toolCallId: call.toolCallId,
140
- toolName: call.toolName,
141
- result: MISSING_TOOL_RESULT_TEXT
142
- }
143
- ]
144
- });
145
- }
146
- pending = [];
147
- };
148
- for (const message of messages) {
149
- if (message.role === "assistant" && Array.isArray(message.content)) {
150
- flushPending();
151
- pending = message.content.filter((part) => part.type === "tool-call").map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName }));
152
- } else if (message.role === "tool" && Array.isArray(message.content) && pending.length > 0) {
153
- const answered = new Set(message.content.map((part) => part.toolCallId));
154
- pending = pending.filter((call) => !answered.has(call.toolCallId));
155
- } else {
156
- flushPending();
157
- }
158
- result.push(message);
159
- }
160
- flushPending();
161
- return result;
162
- }
163
- function retractLastTurn(messages) {
164
- for (let i = messages.length - 1; i >= 0; i--) {
165
- if (messages[i]?.role === "user") {
166
- return { messages: messages.slice(0, i), removed: messages.length - i };
167
- }
168
- }
169
- return { messages, removed: 0 };
170
- }
171
-
172
- // src/permissions/gate.ts
173
- import path from "path";
174
-
175
- // src/permissions/allow.ts
176
- function parseAllowRule(rule) {
177
- const match = /^([\w-]+)(?:\((.*)\))?$/.exec(rule.trim());
178
- if (!match) return null;
179
- return { toolName: match[1], pattern: match[2] };
180
- }
181
- function globMatch(pattern, value) {
182
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
183
- return new RegExp(`^${escaped}$`).test(value);
184
- }
185
- function requestTarget(req) {
186
- if (typeof req.args !== "object" || req.args === null) return void 0;
187
- const args = req.args;
188
- const key = req.toolName === "bash" ? "command" : "path";
189
- const value = args[key];
190
- return typeof value === "string" ? value : void 0;
191
- }
192
- function matchAllowRule(rule, req) {
193
- if (rule.toolName !== req.toolName) return false;
194
- if (rule.pattern === void 0) return true;
195
- const target = requestTarget(req);
196
- return target !== void 0 && globMatch(rule.pattern, target);
197
- }
198
- function isAllowedByRules(rules, req) {
199
- return rules.some((raw) => {
200
- const rule = parseAllowRule(raw);
201
- return rule !== null && matchAllowRule(rule, req);
202
- });
203
- }
204
- function buildAllowRule(req) {
205
- const target = requestTarget(req);
206
- return target !== void 0 ? `${req.toolName}(${target})` : req.toolName;
207
- }
208
-
209
- // src/permissions/gate.ts
210
- var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file"]);
211
- var DANGEROUS_COMMAND_PATTERNS = [
212
- /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
213
- /rm\s+-rf\s+~(?:\s|\/|$|;|&)/i,
214
- /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
215
- /\bmkfs\b/i,
216
- /dd\s+[^|;]*\bif=/i,
217
- />\s*\/dev\/sd[a-z]/i,
218
- /\bshutdown\b/i,
219
- /\breboot\b/i,
220
- /\bpoweroff\b/i
221
- ];
222
- function isDangerousCommand(command) {
223
- return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
224
- }
225
- function getStringArg(args, key) {
226
- if (typeof args === "object" && args !== null) {
227
- const value = args[key];
228
- if (typeof value === "string") {
229
- return value;
230
- }
231
- }
232
- return void 0;
233
- }
234
- function normalizeAbsolute(p, cwd) {
235
- const abs = path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
236
- return abs.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
237
- }
238
- function isOutsideCwd(p, cwd) {
239
- const abs = normalizeAbsolute(p, cwd);
240
- const base = normalizeAbsolute(cwd, cwd);
241
- return abs !== base && !abs.startsWith(`${base}/`);
242
- }
243
- function isSensitivePath(p) {
244
- const base = p.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
245
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
246
- return false;
247
- }
248
- return base === ".env" || base.startsWith(".env.") || base === "id_rsa" || base.endsWith(".pem");
249
- }
250
- function checkPermission(mode, req, ctx, allowRules = []) {
251
- if (mode === "yolo") {
252
- return "allow";
253
- }
254
- const command = getStringArg(req.args, "command");
255
- const filePath = getStringArg(req.args, "path");
256
- if (req.toolName === "bash" && command !== void 0 && isDangerousCommand(command)) {
257
- return "deny";
258
- }
259
- if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
260
- if ((req.toolName === "write_file" || req.toolName === "edit_file") && isSensitivePath(filePath)) {
261
- return "deny";
262
- }
263
- if (isOutsideCwd(filePath, ctx.cwd)) {
264
- if (mode === "ask" && req.level === "read") {
265
- return "ask";
266
- }
267
- return "deny";
268
- }
269
- }
270
- if (mode === "auto") {
271
- return "allow";
272
- }
273
- if (mode === "readonly") {
274
- return req.level === "read" ? "allow" : "deny";
275
- }
276
- if (mode === "plan") {
277
- return req.level === "read" ? "allow" : "deny";
278
- }
279
- if (isAllowedByRules(allowRules, req)) {
280
- return "allow";
281
- }
282
- return req.level === "read" ? "allow" : "ask";
283
- }
284
-
285
- // src/tools/fs/snapshots.ts
286
- import { mkdir, readFile, rm, writeFile } from "fs/promises";
287
- import path2 from "path";
288
- var MAX_SNAPSHOTS = 50;
289
- var stack = [];
290
- var activeTurn = 0;
291
- function beginTurn() {
292
- return ++activeTurn;
293
- }
294
- function currentTurnSeq() {
295
- return activeTurn;
296
- }
297
- async function captureSnapshot(filePath, toolName) {
298
- let content = null;
299
- let existed = false;
300
- try {
301
- content = await readFile(filePath, "utf8");
302
- existed = true;
303
- } catch {
304
- }
305
- return { path: filePath, existed, content, toolName, timestamp: Date.now(), turn: activeTurn };
306
- }
307
- function pushSnapshot(snapshot) {
308
- stack.push(snapshot);
309
- if (stack.length > MAX_SNAPSHOTS) {
310
- stack.splice(0, stack.length - MAX_SNAPSHOTS);
311
- }
312
- }
313
- async function revert(snapshot) {
314
- if (snapshot.existed && snapshot.content !== null) {
315
- await mkdir(path2.dirname(snapshot.path), { recursive: true });
316
- await writeFile(snapshot.path, snapshot.content, "utf8");
317
- return `Restored ${snapshot.path} to its state before ${snapshot.toolName}.`;
318
- }
319
- await rm(snapshot.path, { force: true });
320
- return `Deleted ${snapshot.path} (it did not exist before ${snapshot.toolName}).`;
321
- }
322
- async function undoTurnSnapshots(turn) {
323
- const matches = [];
324
- for (let i = stack.length - 1; i >= 0; i--) {
325
- const snapshot = stack[i];
326
- if (snapshot && snapshot.turn === turn) {
327
- matches.push(snapshot);
328
- stack.splice(i, 1);
329
- }
330
- }
331
- const messages = [];
332
- for (const snapshot of matches) {
333
- messages.push(await revert(snapshot));
334
- }
335
- return messages;
336
- }
337
-
338
- // src/agent/loop.ts
339
- var PLAN_MODE_PROMPT = "\n\nYou are currently in PLAN MODE. Research the task using only the read-only tools available to you, then present a concrete, step-by-step implementation plan as your final answer. You must not modify files, run shell commands, or otherwise change the system \u2014 write/exec tools are unavailable in this mode. Do not ask the user to run commands for you; note manual steps in the plan instead. The user will review your plan and approve it before any execution begins.";
340
- var AgentLoop = class {
341
- messages = [];
342
- opts;
343
- // Seq + user-message index of each turn started via stream(); lets /undo
344
- // match a retracted turn to the file snapshots it produced. Cleared when
345
- // history is replaced wholesale (resume/compact), because indices no longer
346
- // line up — in that case /undo only retracts messages, never wrong files.
347
- turnMarkers = [];
348
- confirmHandler;
349
- constructor(opts) {
350
- this.opts = opts;
351
- if (opts.system) {
352
- this.messages.push({ role: "system", content: opts.system });
353
- }
354
- }
355
- getMessages() {
356
- return this.messages;
357
- }
358
- async loadMessages(messages) {
359
- this.messages = reconcileToolCalls(messages);
360
- this.turnMarkers = [];
361
- }
362
- // Drops the final user message and everything after it, and persists the
363
- // trimmed history so a later /resume does not bring the turn back.
364
- // `turn` is the retracted turn's snapshot seq when it could be verified
365
- // against the marker recorded at turn start — undefined means the caller
366
- // must not revert any file snapshots.
367
- async retractLastTurn() {
368
- const result = retractLastTurn([...this.messages]);
369
- if (result.removed === 0) return { removed: 0 };
370
- const userIndex = result.messages.length;
371
- this.messages = result.messages;
372
- await this.opts.sessionStore?.replaceMessages([...this.messages]);
373
- const last = this.turnMarkers[this.turnMarkers.length - 1];
374
- let turn;
375
- if (last && last.userIndex === userIndex) {
376
- turn = last.seq;
377
- this.turnMarkers.pop();
378
- }
379
- return { removed: result.removed, turn };
380
- }
381
- async appendContextMessage(text, role = "user") {
382
- const message = { role, content: text };
383
- this.messages.push(message);
384
- await this.persist(message);
385
- }
386
- async persist(message) {
387
- await this.opts.sessionStore?.append(message);
388
- }
389
- async *stream(input, signal, opts) {
390
- this.syncSystemMessage();
391
- const userMessage = { role: "user", content: input };
392
- this.messages.push(userMessage);
393
- this.turnMarkers.push({ seq: beginTurn(), userIndex: this.messages.length - 1 });
394
- await this.persist(
395
- opts?.persistAs !== void 0 ? { role: "user", content: opts.persistAs } : userMessage
396
- );
397
- const { config, registry, cwd } = this.opts;
398
- const aiTools = this.buildAiTools();
399
- for (let step = 0; step < config.maxSteps; step++) {
400
- const compacted = compactMessages([...this.messages], config.contextMaxTokens);
401
- if (compacted.compacted) {
402
- this.messages = await this.applyCompactionSummary(compacted);
403
- }
404
- let text = "";
405
- const toolCalls = [];
406
- let failed = false;
407
- try {
408
- for await (const event of this.streamOnce(aiTools, signal)) {
409
- if (event.type === "text-delta") {
410
- text += event.text;
411
- yield event;
412
- } else if (event.type === "reasoning") {
413
- yield event;
414
- } else if (event.type === "tool-call") {
415
- toolCalls.push({ id: event.id, name: event.name, args: event.args });
416
- yield event;
417
- } else if (event.type === "error") {
418
- failed = true;
419
- yield event;
420
- } else {
421
- yield event;
422
- }
423
- }
424
- } catch (error) {
425
- if (signal.aborted) return;
426
- yield { type: "error", error: error instanceof Error ? error : new Error(String(error)) };
427
- return;
428
- }
429
- if (failed) return;
430
- const assistantMessage = {
431
- role: "assistant",
432
- content: [
433
- ...text ? [{ type: "text", text }] : [],
434
- ...toolCalls.map((call) => ({
435
- type: "tool-call",
436
- toolCallId: call.id,
437
- toolName: call.name,
438
- args: call.args
439
- }))
440
- ]
441
- };
442
- this.messages.push(assistantMessage);
443
- await this.persist(assistantMessage);
444
- if (toolCalls.length === 0) return;
445
- const answered = /* @__PURE__ */ new Set();
446
- try {
447
- for (const call of toolCalls) {
448
- if (signal.aborted) break;
449
- const result = await this.executeTool(call, signal);
450
- const toolMessage = {
451
- role: "tool",
452
- content: [
453
- {
454
- type: "tool-result",
455
- toolCallId: call.id,
456
- toolName: call.name,
457
- result: result.content
458
- }
459
- ]
460
- };
461
- this.messages.push(toolMessage);
462
- await this.persist(toolMessage);
463
- answered.add(call.id);
464
- yield {
465
- type: "tool-result",
466
- id: call.id,
467
- name: call.name,
468
- content: result.content,
469
- isError: result.isError
470
- };
471
- }
472
- } finally {
473
- for (const call of toolCalls) {
474
- if (answered.has(call.id)) continue;
475
- const content = signal.aborted ? "Tool execution interrupted by user." : "Tool execution interrupted before a result was produced.";
476
- const synthetic = {
477
- role: "tool",
478
- content: [
479
- {
480
- type: "tool-result",
481
- toolCallId: call.id,
482
- toolName: call.name,
483
- result: content
484
- }
485
- ]
486
- };
487
- this.messages.push(synthetic);
488
- await this.persist(synthetic).catch(() => {
489
- });
490
- yield { type: "tool-result", id: call.id, name: call.name, content, isError: true };
491
- }
492
- }
493
- if (signal.aborted) return;
494
- }
495
- yield {
496
- type: "error",
497
- error: new Error(`Max steps (${config.maxSteps}) reached, stopping.`)
498
- };
499
- }
500
- // The permission mode can change at runtime, so the system message is
501
- // recomputed at the start of every turn instead of being frozen by the
502
- // constructor. Also restores a system message after loadMessages() (resume,
503
- // /model switch) replaced the history with one that has none.
504
- syncSystemMessage() {
505
- const base = this.opts.system;
506
- const plan = this.opts.config.permissionMode === "plan";
507
- const content = plan ? base ? base + PLAN_MODE_PROMPT : PLAN_MODE_PROMPT.trim() : base ?? null;
508
- if (content === null) return;
509
- const head = this.messages[0];
510
- if (head?.role === "system" && typeof head.content === "string") {
511
- if (head.content !== content) head.content = content;
512
- } else if (head?.role !== "system") {
513
- this.messages.unshift({ role: "system", content });
514
- }
515
- }
516
- async applyCompactionSummary(compacted) {
517
- const { config, model } = this.opts;
518
- if (config.contextCompaction !== "summary" || !model) {
519
- return compacted.messages;
520
- }
521
- const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
522
- const dropped = this.messages.slice(headCount, headCount + compacted.droppedCount);
523
- try {
524
- const summary = await summarizeMessages(dropped, model);
525
- const messages = compacted.messages.slice();
526
- messages[headCount] = {
527
- role: "user",
528
- content: `[earlier conversation summarized]
529
- ${summary}`
530
- };
531
- return messages;
532
- } catch {
533
- return compacted.messages;
534
- }
535
- }
536
- async *streamOnce(aiTools, signal) {
537
- const { streamChat } = await import("./stream-Y55NRF2S.js");
538
- yield* streamChat({
539
- model: this.opts.model,
540
- messages: this.messages,
541
- tools: aiTools,
542
- abortSignal: signal,
543
- idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3
544
- });
545
- }
546
- buildAiTools() {
547
- const plan = this.opts.config.permissionMode === "plan";
548
- const tools = {};
549
- for (const t of this.opts.registry.list()) {
550
- if (plan && t.permission !== "read") continue;
551
- tools[t.name] = aiTool({
552
- description: t.description,
553
- parameters: t.parameters
554
- });
555
- }
556
- return tools;
557
- }
558
- async executeTool(call, signal) {
559
- const { registry, config, cwd } = this.opts;
560
- const tool = registry.get(call.name);
561
- if (!tool) {
562
- return { content: `Unknown tool: ${call.name}`, isError: true };
563
- }
564
- const decision = checkPermission(
565
- config.permissionMode,
566
- { toolName: call.name, args: call.args, level: tool.permission },
567
- { cwd },
568
- config.permissions.allow
569
- );
570
- if (decision === "deny") {
571
- return { content: `Permission denied for tool "${call.name}".`, isError: true };
572
- }
573
- if (decision === "ask") {
574
- let approved = false;
575
- try {
576
- approved = this.confirmHandler ? await this.confirmHandler({
577
- toolName: call.name,
578
- args: call.args,
579
- level: tool.permission
580
- }) : false;
581
- } catch {
582
- approved = false;
583
- }
584
- if (!approved) {
585
- return { content: `User rejected tool "${call.name}".`, isError: true };
586
- }
587
- }
588
- const parsed = tool.parameters.safeParse(call.args);
589
- if (!parsed.success) {
590
- return { content: `Invalid arguments: ${parsed.error.message}`, isError: true };
591
- }
592
- try {
593
- return await tool.execute(parsed.data, { cwd, abortSignal: signal });
594
- } catch (error) {
595
- if (signal.aborted) {
596
- return { content: "Tool execution aborted.", isError: true };
597
- }
598
- return {
599
- content: `Tool error: ${error instanceof Error ? error.message : String(error)}`,
600
- isError: true
601
- };
602
- }
603
- }
604
- };
605
-
606
38
  // src/cli/format.ts
607
39
  function summarizeArgs(args, maxLength = 120) {
608
40
  let json;
@@ -666,92 +98,30 @@ function buildDisplayMessages(messages) {
666
98
  }
667
99
 
668
100
  // src/cli/mentions.ts
669
- import { readFile as readFile2, stat as stat2 } from "fs/promises";
670
- import path4 from "path";
671
-
672
- // src/tools/fs/util.ts
673
- import { readdir, stat } from "fs/promises";
674
- import path3 from "path";
675
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
676
- async function walkFiles(root, skipDirs = SKIP_DIRS) {
677
- const out = [];
678
- async function walk(dir, relBase) {
679
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
680
- if (!entries) {
681
- return;
682
- }
683
- for (const entry of entries) {
684
- const abs = path3.join(dir, entry.name);
685
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
686
- if (entry.isDirectory()) {
687
- if (!skipDirs.has(entry.name)) {
688
- await walk(abs, rel);
689
- }
690
- } else if (entry.isFile()) {
691
- try {
692
- const st = await stat(abs);
693
- out.push({ abs, rel, mtimeMs: st.mtimeMs });
694
- } catch {
695
- }
696
- }
697
- }
698
- }
699
- await walk(root, "");
700
- return out;
701
- }
702
- var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
703
- function globToRegExp(pattern) {
704
- let re = "";
705
- let i = 0;
706
- while (i < pattern.length) {
707
- const c = pattern.charAt(i);
708
- if (c === "*") {
709
- if (pattern.charAt(i + 1) === "*") {
710
- i += 2;
711
- if (pattern.charAt(i) === "/") {
712
- i += 1;
713
- re += "(?:[^/]+/)*";
714
- } else {
715
- re += ".*";
716
- }
717
- } else {
718
- re += "[^/]*";
719
- i += 1;
720
- }
721
- } else if (c === "?") {
722
- re += "[^/]";
723
- i += 1;
724
- } else {
725
- re += c.replace(REGEX_SPECIALS, "\\$&");
726
- i += 1;
727
- }
728
- }
729
- return new RegExp(`^${re}$`);
730
- }
731
- function matchesGlob(pattern, relPath) {
732
- const normalized = pattern.replace(/\\/g, "/");
733
- if (!normalized.includes("/")) {
734
- const base = relPath.split("/").pop() ?? relPath;
735
- return globToRegExp(normalized).test(base);
736
- }
737
- return globToRegExp(normalized).test(relPath);
738
- }
739
- function isSensitivePath2(filePath) {
740
- const base = path3.basename(filePath);
741
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
742
- return false;
743
- }
744
- if (base === ".env" || base.startsWith(".env.")) {
745
- return true;
746
- }
747
- if (base === "id_rsa" || base.endsWith(".pem")) {
748
- return true;
749
- }
750
- return false;
751
- }
752
-
753
- // src/cli/mentions.ts
101
+ import { readFile, stat } from "fs/promises";
102
+ import path from "path";
754
103
  var MAX_MENTION_BYTES = 100 * 1024;
104
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
105
+ var IMAGE_MIME_TYPES = {
106
+ ".png": "image/png",
107
+ ".jpg": "image/jpeg",
108
+ ".jpeg": "image/jpeg",
109
+ ".gif": "image/gif",
110
+ ".webp": "image/webp"
111
+ };
112
+ function imageMimeType(filePath) {
113
+ return IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? null;
114
+ }
115
+ async function readImageInput(filePath, cwd) {
116
+ const mimeType = imageMimeType(filePath);
117
+ if (!mimeType) return null;
118
+ const abs = path.resolve(cwd, filePath);
119
+ const st = await stat(abs).catch(() => null);
120
+ if (!st || !st.isFile() || st.size > MAX_IMAGE_BYTES) return null;
121
+ const buf = await readFile(abs).catch(() => null);
122
+ if (!buf) return null;
123
+ return { path: filePath, mimeType, data: buf.toString("base64") };
124
+ }
755
125
  var MENTION_RE = /(?<=^|\s)@([A-Za-z]:[\\/][A-Za-z0-9._\-/\\]*|[A-Za-z0-9._\-/\\]+)/g;
756
126
  function parseMentions(text) {
757
127
  const mentions = [];
@@ -787,18 +157,39 @@ function isBinaryContent(buf) {
787
157
  async function resolveMentions(text, cwd) {
788
158
  const { cleanText, mentions } = parseMentions(text);
789
159
  if (mentions.length === 0) {
790
- return { input: text, attached: [], skipped: [] };
160
+ return { input: text, attached: [], skipped: [], images: [] };
791
161
  }
792
162
  const blocks = [];
793
163
  const attached = [];
794
164
  const skipped = [];
165
+ const images = [];
795
166
  for (const mention of mentions) {
796
- const abs = path4.resolve(cwd, mention);
797
- if (isSensitivePath2(abs)) {
167
+ const abs = path.resolve(cwd, mention);
168
+ if (isSensitivePath(abs)) {
798
169
  skipped.push({ path: mention, reason: "sensitive file" });
799
170
  continue;
800
171
  }
801
- const st = await stat2(abs).catch(() => null);
172
+ const mimeType = imageMimeType(mention);
173
+ if (mimeType) {
174
+ const st2 = await stat(abs).catch(() => null);
175
+ if (!st2 || !st2.isFile()) {
176
+ skipped.push({ path: mention, reason: st2 ? "is a directory" : "file not found" });
177
+ continue;
178
+ }
179
+ if (st2.size > MAX_IMAGE_BYTES) {
180
+ skipped.push({ path: mention, reason: "image too large" });
181
+ continue;
182
+ }
183
+ const buf2 = await readFile(abs).catch(() => null);
184
+ if (!buf2) {
185
+ skipped.push({ path: mention, reason: "unreadable" });
186
+ continue;
187
+ }
188
+ attached.push(mention);
189
+ images.push({ path: mention, mimeType, data: buf2.toString("base64") });
190
+ continue;
191
+ }
192
+ const st = await stat(abs).catch(() => null);
802
193
  if (!st) {
803
194
  skipped.push({ path: mention, reason: "file not found" });
804
195
  continue;
@@ -811,7 +202,7 @@ async function resolveMentions(text, cwd) {
811
202
  skipped.push({ path: mention, reason: `exceeds ${MAX_MENTION_BYTES / 1024}KB limit` });
812
203
  continue;
813
204
  }
814
- const buf = await readFile2(abs).catch(() => null);
205
+ const buf = await readFile(abs).catch(() => null);
815
206
  if (!buf) {
816
207
  skipped.push({ path: mention, reason: "unreadable" });
817
208
  continue;
@@ -826,7 +217,7 @@ ${buf.toString("utf8")}
826
217
  --- end ---`);
827
218
  }
828
219
  const input = [cleanText, ...blocks].filter((s) => s.length > 0).join("\n\n") || text;
829
- return { input, attached, skipped };
220
+ return { input, attached, skipped, images };
830
221
  }
831
222
 
832
223
  // src/cli/print-json.ts
@@ -873,28 +264,28 @@ var UsageTracker = class {
873
264
  };
874
265
 
875
266
  // src/cli/repl.tsx
876
- import { Box as Box8, Text as Text8, render, useApp, useInput as useInput4 } from "ink";
877
- import { useCallback, useEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
267
+ import { Box as Box9, Text as Text10, render, useApp, useInput as useInput5 } from "ink";
268
+ import { useCallback, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
878
269
 
879
270
  // src/config/save.ts
880
271
  import fs from "fs";
881
- import path6 from "path";
272
+ import path3 from "path";
882
273
  import { parse, stringify } from "smol-toml";
883
274
 
884
275
  // src/config/paths.ts
885
276
  import os from "os";
886
- import path5 from "path";
277
+ import path2 from "path";
887
278
  function starHome() {
888
- return process.env.STAR_HOME ?? path5.join(os.homedir(), ".star-cli");
279
+ return process.env.STAR_HOME ?? path2.join(os.homedir(), ".star-cli");
889
280
  }
890
281
  function globalConfigPath() {
891
- return path5.join(starHome(), "config.toml");
282
+ return path2.join(starHome(), "config.toml");
892
283
  }
893
284
  function sessionsDir() {
894
- return path5.join(starHome(), "sessions");
285
+ return path2.join(starHome(), "sessions");
895
286
  }
896
287
  function projectConfigPath(cwd) {
897
- return path5.join(cwd, ".star", "config.toml");
288
+ return path2.join(cwd, ".star", "config.toml");
898
289
  }
899
290
 
900
291
  // src/config/save.ts
@@ -912,7 +303,7 @@ async function addAllowRule(rule) {
912
303
  if (allow.includes(rule)) return false;
913
304
  permissions.allow = [...allow, rule];
914
305
  raw.permissions = permissions;
915
- await fs.promises.mkdir(path6.dirname(filePath), { recursive: true });
306
+ await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
916
307
  await fs.promises.writeFile(filePath, stringify(raw), "utf8");
917
308
  return true;
918
309
  }
@@ -926,7 +317,7 @@ async function savePermissionMode(mode) {
926
317
  if (error.code !== "ENOENT") throw error;
927
318
  }
928
319
  raw.permissionMode = mode;
929
- await fs.promises.mkdir(path6.dirname(filePath), { recursive: true });
320
+ await fs.promises.mkdir(path3.dirname(filePath), { recursive: true });
930
321
  await fs.promises.writeFile(filePath, stringify(raw), "utf8");
931
322
  }
932
323
 
@@ -992,9 +383,69 @@ function createModel(config, modelName) {
992
383
  }
993
384
  }
994
385
 
995
- // src/session/store.ts
386
+ // src/session/checkpoints.ts
996
387
  import fs2 from "fs/promises";
997
- import path7 from "path";
388
+ import path4 from "path";
389
+ function checkpointsDir(sessionDir) {
390
+ return path4.join(sessionDir, "checkpoints");
391
+ }
392
+ function indexPath(sessionDir) {
393
+ return path4.join(checkpointsDir(sessionDir), "index.json");
394
+ }
395
+ function checkpointContentPath(sessionDir, id) {
396
+ return path4.join(checkpointsDir(sessionDir), `${id}.snapshot`);
397
+ }
398
+ async function listCheckpointRecords(sessionDir) {
399
+ let raw;
400
+ try {
401
+ raw = await fs2.readFile(indexPath(sessionDir), "utf8");
402
+ } catch {
403
+ return [];
404
+ }
405
+ try {
406
+ const parsed = JSON.parse(raw);
407
+ return Array.isArray(parsed) ? parsed : [];
408
+ } catch {
409
+ return [];
410
+ }
411
+ }
412
+ async function appendCheckpointRecord(sessionDir, record, content) {
413
+ await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true });
414
+ if (record.existed && content !== null) {
415
+ await fs2.writeFile(checkpointContentPath(sessionDir, record.id), content, "utf8");
416
+ }
417
+ const records = await listCheckpointRecords(sessionDir);
418
+ records.push(record);
419
+ await fs2.writeFile(indexPath(sessionDir), JSON.stringify(records, null, 2));
420
+ }
421
+ async function removeCheckpointRecords(sessionDir, ids) {
422
+ if (ids.length === 0) return;
423
+ const drop = new Set(ids);
424
+ const records = await listCheckpointRecords(sessionDir);
425
+ const kept = records.filter((record) => !drop.has(record.id));
426
+ if (kept.length === records.length) return;
427
+ await fs2.mkdir(checkpointsDir(sessionDir), { recursive: true });
428
+ await fs2.writeFile(indexPath(sessionDir), JSON.stringify(kept, null, 2));
429
+ for (const id of ids) {
430
+ await fs2.rm(checkpointContentPath(sessionDir, id), { force: true });
431
+ }
432
+ }
433
+ async function loadSessionSnapshots(sessionDir) {
434
+ const records = await listCheckpointRecords(sessionDir);
435
+ return records.map((record) => ({
436
+ ...record,
437
+ content: null,
438
+ contentFile: record.existed ? checkpointContentPath(sessionDir, record.id) : void 0
439
+ }));
440
+ }
441
+
442
+ // src/session/clear.ts
443
+ import fs4 from "fs/promises";
444
+ import path6 from "path";
445
+
446
+ // src/session/store.ts
447
+ import fs3 from "fs/promises";
448
+ import path5 from "path";
998
449
  function generateId(now) {
999
450
  const pad = (n) => String(n).padStart(2, "0");
1000
451
  const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
@@ -1005,13 +456,9 @@ function debugWarn(message) {
1005
456
  if (process.env.STAR_DEBUG === "1") process.stderr.write(`[star-cli] ${message}
1006
457
  `);
1007
458
  }
1008
- function messageText(message) {
1009
- const content = message.content;
1010
- if (typeof content === "string") return content;
1011
- if (Array.isArray(content)) {
1012
- return content.filter((part) => part.type === "text").map((part) => "text" in part ? part.text : "").join(" ");
1013
- }
1014
- return "";
459
+ function dayKey(date) {
460
+ const pad = (n) => String(n).padStart(2, "0");
461
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
1015
462
  }
1016
463
  var SessionStore = class _SessionStore {
1017
464
  id;
@@ -1020,13 +467,13 @@ var SessionStore = class _SessionStore {
1020
467
  initialized = false;
1021
468
  constructor(id) {
1022
469
  this.id = id;
1023
- this.dir = path7.join(sessionsDir(), id);
470
+ this.dir = path5.join(sessionsDir(), id);
1024
471
  }
1025
472
  metaPath() {
1026
- return path7.join(this.dir, "meta.json");
473
+ return path5.join(this.dir, "meta.json");
1027
474
  }
1028
475
  messagesPath() {
1029
- return path7.join(this.dir, "messages.jsonl");
476
+ return path5.join(this.dir, "messages.jsonl");
1030
477
  }
1031
478
  static async create(cwd, model) {
1032
479
  const store = new _SessionStore(generateId(/* @__PURE__ */ new Date()));
@@ -1037,7 +484,7 @@ var SessionStore = class _SessionStore {
1037
484
  const store = new _SessionStore(id);
1038
485
  let raw;
1039
486
  try {
1040
- raw = await fs2.readFile(store.metaPath(), "utf8");
487
+ raw = await fs3.readFile(store.metaPath(), "utf8");
1041
488
  } catch {
1042
489
  return null;
1043
490
  }
@@ -1053,14 +500,14 @@ var SessionStore = class _SessionStore {
1053
500
  static async list(cwd) {
1054
501
  let entries;
1055
502
  try {
1056
- entries = await fs2.readdir(sessionsDir());
503
+ entries = await fs3.readdir(sessionsDir());
1057
504
  } catch {
1058
505
  return [];
1059
506
  }
1060
507
  const metas = [];
1061
508
  for (const entry of entries) {
1062
509
  try {
1063
- const raw = await fs2.readFile(path7.join(sessionsDir(), entry, "meta.json"), "utf8");
510
+ const raw = await fs3.readFile(path5.join(sessionsDir(), entry, "meta.json"), "utf8");
1064
511
  const meta = JSON.parse(raw);
1065
512
  if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
1066
513
  } catch {
@@ -1075,7 +522,7 @@ var SessionStore = class _SessionStore {
1075
522
  this.initialized = true;
1076
523
  return;
1077
524
  }
1078
- await fs2.mkdir(this.dir, { recursive: true });
525
+ await fs3.mkdir(this.dir, { recursive: true });
1079
526
  const meta = {
1080
527
  id: this.id,
1081
528
  title: "",
@@ -1084,33 +531,30 @@ var SessionStore = class _SessionStore {
1084
531
  createdAt: pending.createdAt,
1085
532
  updatedAt: pending.createdAt
1086
533
  };
1087
- await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
534
+ await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1088
535
  this.initialized = true;
1089
536
  }
1090
537
  async append(message) {
1091
538
  await this.ensureInitialized();
1092
- await fs2.appendFile(this.messagesPath(), `${JSON.stringify(message)}
539
+ await fs3.appendFile(this.messagesPath(), `${JSON.stringify(message)}
1093
540
  `);
1094
541
  const meta = await this.meta();
1095
542
  meta.updatedAt = Date.now();
1096
- if (!meta.title && message.role === "user") {
1097
- meta.title = messageText(message).slice(0, 60);
1098
- }
1099
- await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
543
+ await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1100
544
  }
1101
545
  async replaceMessages(messages) {
1102
546
  await this.ensureInitialized();
1103
547
  const content = messages.map((message) => JSON.stringify(message)).join("\n");
1104
- await fs2.writeFile(this.messagesPath(), content ? `${content}
548
+ await fs3.writeFile(this.messagesPath(), content ? `${content}
1105
549
  ` : "");
1106
550
  const meta = await this.meta();
1107
551
  meta.updatedAt = Date.now();
1108
- await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
552
+ await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1109
553
  }
1110
554
  async messages() {
1111
555
  let raw;
1112
556
  try {
1113
- raw = await fs2.readFile(this.messagesPath(), "utf8");
557
+ raw = await fs3.readFile(this.messagesPath(), "utf8");
1114
558
  } catch {
1115
559
  return [];
1116
560
  }
@@ -1133,7 +577,7 @@ var SessionStore = class _SessionStore {
1133
577
  async meta() {
1134
578
  await this.ensureInitialized();
1135
579
  try {
1136
- const raw = await fs2.readFile(this.metaPath(), "utf8");
580
+ const raw = await fs3.readFile(this.metaPath(), "utf8");
1137
581
  return JSON.parse(raw);
1138
582
  } catch {
1139
583
  debugWarn(`session ${this.id}: unreadable meta.json, using defaults`);
@@ -1145,7 +589,17 @@ var SessionStore = class _SessionStore {
1145
589
  const meta = await this.meta();
1146
590
  meta.title = title;
1147
591
  meta.updatedAt = Date.now();
1148
- await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
592
+ await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
593
+ }
594
+ async appendCheckpoint(record, content) {
595
+ await this.ensureInitialized();
596
+ await appendCheckpointRecord(this.dir, record, content);
597
+ }
598
+ async listCheckpoints() {
599
+ return listCheckpointRecords(this.dir);
600
+ }
601
+ async removeCheckpoints(ids) {
602
+ await removeCheckpointRecords(this.dir, ids);
1149
603
  }
1150
604
  async addUsage(delta) {
1151
605
  await this.ensureInitialized();
@@ -1161,24 +615,87 @@ var SessionStore = class _SessionStore {
1161
615
  usage.completionTokens += delta.completionTokens;
1162
616
  usage.totalTokens += delta.totalTokens;
1163
617
  meta.usage = usage;
1164
- await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
618
+ const day = dayKey(/* @__PURE__ */ new Date());
619
+ const byDay = meta.usageByDay ?? {};
620
+ const bucket = byDay[day] ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
621
+ bucket.promptTokens += delta.promptTokens;
622
+ bucket.completionTokens += delta.completionTokens;
623
+ bucket.totalTokens += delta.totalTokens;
624
+ byDay[day] = bucket;
625
+ meta.usageByDay = byDay;
626
+ await fs3.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1165
627
  }
1166
628
  };
1167
629
 
1168
- // src/session/resume.ts
1169
- async function resumeSession(id) {
1170
- const store = await SessionStore.open(id);
1171
- if (!store) return null;
1172
- const [meta, loaded] = await Promise.all([store.meta(), store.messages()]);
1173
- const messages = reconcileToolCalls(loaded);
1174
- if (messages.length !== loaded.length) {
1175
- await store.replaceMessages(messages);
630
+ // src/session/clear.ts
631
+ async function clearSessions(cwd, excludeId) {
632
+ if (cwd !== void 0) {
633
+ const metas = await SessionStore.list(cwd);
634
+ let removed2 = 0;
635
+ for (const meta of metas) {
636
+ if (meta.id === excludeId) continue;
637
+ await fs4.rm(path6.join(sessionsDir(), meta.id), { recursive: true, force: true });
638
+ removed2 += 1;
639
+ }
640
+ return removed2;
1176
641
  }
1177
- return { meta, messages };
1178
- }
1179
- function relativeTime(timestamp) {
1180
- const diff = Date.now() - timestamp;
1181
- const minutes = Math.floor(diff / 6e4);
642
+ let entries;
643
+ try {
644
+ entries = await fs4.readdir(sessionsDir());
645
+ } catch {
646
+ return 0;
647
+ }
648
+ let removed = 0;
649
+ for (const entry of entries) {
650
+ if (entry === excludeId) continue;
651
+ await fs4.rm(path6.join(sessionsDir(), entry), { recursive: true, force: true });
652
+ removed += 1;
653
+ }
654
+ return removed;
655
+ }
656
+
657
+ // src/session/list.ts
658
+ import fs5 from "fs/promises";
659
+ import path7 from "path";
660
+ async function countMessages(id) {
661
+ let raw;
662
+ try {
663
+ raw = await fs5.readFile(path7.join(sessionsDir(), id, "messages.jsonl"), "utf8");
664
+ } catch {
665
+ return 0;
666
+ }
667
+ let count = 0;
668
+ for (const line of raw.split("\n")) {
669
+ if (line.trim()) count += 1;
670
+ }
671
+ return count;
672
+ }
673
+ async function listSessionEntries(cwd) {
674
+ const metas = await SessionStore.list(cwd);
675
+ return Promise.all(
676
+ metas.map(async (meta) => ({ meta, messageCount: await countMessages(meta.id) }))
677
+ );
678
+ }
679
+ async function findLatestSession(cwd) {
680
+ const [latest] = await SessionStore.list(cwd);
681
+ return latest ?? null;
682
+ }
683
+ function shortSessionId(id) {
684
+ const dash = id.lastIndexOf("-");
685
+ return dash === -1 ? id : id.slice(dash + 1);
686
+ }
687
+ async function resolveSessionId(query) {
688
+ const metas = await SessionStore.list();
689
+ const exact = metas.find((meta) => meta.id === query);
690
+ if (exact) return exact.id;
691
+ const matches = metas.filter(
692
+ (meta) => shortSessionId(meta.id) === query || meta.id.startsWith(query)
693
+ );
694
+ return matches.length === 1 ? matches[0]?.id ?? null : null;
695
+ }
696
+ function relativeTime(timestamp) {
697
+ const diff = Date.now() - timestamp;
698
+ const minutes = Math.floor(diff / 6e4);
1182
699
  if (minutes < 1) return "\u521A\u521A";
1183
700
  if (minutes < 60) return `${minutes} \u5206\u949F\u524D`;
1184
701
  const hours = Math.floor(minutes / 60);
@@ -1189,1012 +706,215 @@ function relativeTime(timestamp) {
1189
706
  if (months < 12) return `${months} \u4E2A\u6708\u524D`;
1190
707
  return `${Math.floor(months / 12)} \u5E74\u524D`;
1191
708
  }
1192
- function formatSessionList(metas) {
1193
- return metas.map((meta) => {
709
+ function formatSessionEntries(entries, options = {}) {
710
+ return entries.map(({ meta, messageCount }) => {
1194
711
  const title = meta.title || "(\u65E0\u6807\u9898)";
1195
- return `${meta.id} ${title} (\u66F4\u65B0\u4E8E ${relativeTime(meta.updatedAt)})`;
712
+ const base = `${shortSessionId(meta.id)} ${title} ${messageCount} \u6761\u6D88\u606F \u66F4\u65B0\u4E8E ${relativeTime(meta.updatedAt)}`;
713
+ return options.showCwd ? `${base} [${meta.cwd}]` : base;
1196
714
  }).join("\n");
1197
715
  }
1198
716
 
1199
- // src/tasks/format.ts
1200
- function formatDuration(startedAt, endedAt) {
1201
- const seconds = Math.max(0, Math.round(((endedAt ?? Date.now()) - startedAt) / 1e3));
1202
- if (seconds < 60) return `${seconds}s`;
1203
- const minutes = Math.floor(seconds / 60);
1204
- if (minutes < 60) return `${minutes}m${seconds % 60}s`;
1205
- return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
1206
- }
1207
- function formatStatus(task) {
1208
- const duration = formatDuration(task.startedAt, task.endedAt);
1209
- if (task.status === "running") return `running ${duration}`;
1210
- const exit = task.exitCode === null || task.exitCode === void 0 ? "" : `, exit ${task.exitCode}`;
1211
- return `${task.status}${exit} after ${duration}`;
1212
- }
1213
- function formatTaskLine(task) {
1214
- const label = task.description ? `${task.description} (${task.command})` : task.command;
1215
- return `${task.id} [${formatStatus(task)}] ${label}`;
1216
- }
1217
- function formatTaskList(tasks) {
1218
- if (tasks.length === 0) return "No background tasks.";
1219
- return `Background tasks:
1220
- ${tasks.map(formatTaskLine).join("\n")}`;
1221
- }
1222
- function formatTaskStarted(task) {
1223
- const label = task.description ? `${task.description} (${task.command})` : task.command;
1224
- return `Background task ${task.id} started: ${label}`;
1225
- }
1226
- function formatTaskFinished(task) {
1227
- const exit = task.exitCode === null || task.exitCode === void 0 ? "" : ` (exit ${task.exitCode})`;
1228
- switch (task.status) {
1229
- case "completed":
1230
- return `Background task ${task.id} finished${exit}: ${task.command}`;
1231
- case "failed":
1232
- return `Background task ${task.id} failed${exit}: ${task.command}`;
1233
- case "timed_out":
1234
- return `Background task ${task.id} timed out: ${task.command}`;
1235
- case "stopped":
1236
- return `Background task ${task.id} stopped: ${task.command}`;
1237
- default:
1238
- return `Background task ${task.id}: ${task.command}`;
717
+ // src/session/resume.ts
718
+ async function resumeSession(id) {
719
+ const store = await SessionStore.open(id);
720
+ if (!store) return null;
721
+ const [meta, loaded] = await Promise.all([store.meta(), store.messages()]);
722
+ const messages = reconcileToolCalls(loaded);
723
+ if (messages.length !== loaded.length) {
724
+ await store.replaceMessages(messages);
1239
725
  }
726
+ return { meta, messages };
1240
727
  }
1241
728
 
1242
- // src/tasks/manager.ts
1243
- import { spawn as spawn2 } from "child_process";
1244
- import { EventEmitter } from "events";
1245
-
1246
- // src/tools/bash.ts
1247
- import { spawn } from "child_process";
1248
- import { existsSync } from "fs";
1249
- import path8 from "path";
1250
- import { z } from "zod";
1251
- var MAX_OUTPUT = 3e4;
1252
- var DEFAULT_TIMEOUT = 120;
1253
- var MAX_TIMEOUT = 600;
1254
- function findOnPath(exe, exclude) {
1255
- for (const dir of (process.env.PATH ?? "").split(path8.delimiter)) {
1256
- if (!dir || exclude?.(dir)) {
1257
- continue;
1258
- }
1259
- const full = path8.join(dir, exe);
1260
- if (existsSync(full)) {
1261
- return full;
1262
- }
1263
- }
1264
- return null;
1265
- }
1266
- function resolveShell() {
1267
- if (process.platform !== "win32") {
1268
- return { shell: "sh", wrap: (c) => ["-c", c], label: "sh" };
1269
- }
1270
- const isWslStub = (dir) => /\\(system32|windowsapps)\\?$/i.test(dir.trim());
1271
- const fromPath = findOnPath("bash.exe", isWslStub);
1272
- if (fromPath) {
1273
- return { shell: fromPath, wrap: (c) => ["-c", c], label: "bash" };
1274
- }
1275
- const gitExe = findOnPath("git.exe");
1276
- if (gitExe) {
1277
- const sibling = path8.join(path8.dirname(path8.dirname(gitExe)), "bin", "bash.exe");
1278
- if (existsSync(sibling)) {
1279
- return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
1280
- }
1281
- }
1282
- for (const candidate of [
1283
- "C:\\Program Files\\Git\\bin\\bash.exe",
1284
- "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
1285
- "D:\\Git\\bin\\bash.exe"
1286
- ]) {
1287
- if (existsSync(candidate)) {
1288
- return { shell: candidate, wrap: (c) => ["-c", c], label: "bash" };
1289
- }
729
+ // src/cli/cost.ts
730
+ function formatDollars(cost) {
731
+ let s = cost.toFixed(4).replace(/0+$/, "");
732
+ if (s.endsWith(".")) {
733
+ s += "00";
734
+ } else if (s.split(".")[1]?.length === 1) {
735
+ s += "0";
1290
736
  }
1291
- const comspec = process.env.ComSpec ?? "cmd.exe";
1292
- return { shell: comspec, wrap: (c) => ["/d", "/s", "/c", c], label: "cmd" };
737
+ return s;
1293
738
  }
1294
- var schema = z.object({
1295
- command: z.string().describe("Shell command to execute"),
1296
- timeout: z.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
1297
- description: z.string().optional().describe("Short description of what the command does"),
1298
- run_in_background: z.boolean().optional().describe(
1299
- "Run the command in the background and return a task id immediately (default false). Use task_output/task_kill to inspect or stop it."
1300
- )
1301
- });
1302
- function killTree(child) {
1303
- if (process.platform === "win32" && child.pid) {
1304
- spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
1305
- windowsHide: true,
1306
- stdio: "ignore"
1307
- }).unref();
1308
- return;
1309
- }
1310
- child.kill("SIGKILL");
1311
- }
1312
- function truncateMiddle(s) {
1313
- if (s.length <= MAX_OUTPUT) {
1314
- return s;
1315
- }
1316
- const half = Math.floor(MAX_OUTPUT / 2);
1317
- const head = s.slice(0, half);
1318
- const tail = s.slice(-half);
1319
- return `${head}
1320
- ... [${s.length - MAX_OUTPUT} characters truncated] ...
1321
- ${tail}`;
1322
- }
1323
- var bashTool = {
1324
- name: "bash",
1325
- description: "Execute a shell command (Git Bash on Windows when available, otherwise cmd; sh elsewhere). Stdout and stderr are merged. Output is truncated to 30000 characters. Set run_in_background for long-running commands; it returns a task id for task_list/task_output/task_kill.",
1326
- permission: "exec",
1327
- parameters: schema,
1328
- execute(args, ctx) {
1329
- if (args.run_in_background) {
1330
- const task = defaultTaskManager.start({
1331
- command: args.command,
1332
- description: args.description,
1333
- cwd: ctx.cwd,
1334
- timeoutSeconds: args.timeout
1335
- });
1336
- return Promise.resolve({
1337
- content: `Background task started: ${task.id}
1338
- command: ${args.command}
1339
- description: ${args.description ?? "(none)"}`
1340
- });
1341
- }
1342
- const timeoutSeconds = Math.min(args.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
1343
- const spec = resolveShell();
1344
- return new Promise((resolve) => {
1345
- const child = spawn(spec.shell, spec.wrap(args.command), {
1346
- cwd: ctx.cwd,
1347
- windowsHide: true
1348
- });
1349
- let output = "";
1350
- let settled = false;
1351
- const finish = (result) => {
1352
- if (settled) {
1353
- return;
1354
- }
1355
- settled = true;
1356
- clearTimeout(timer);
1357
- ctx.abortSignal?.removeEventListener("abort", onAbort);
1358
- resolve(result);
1359
- };
1360
- const onAbort = () => {
1361
- killTree(child);
1362
- finish({ content: `${truncateMiddle(output)}
1363
- Command aborted`, isError: true });
1364
- };
1365
- const timer = setTimeout(() => {
1366
- killTree(child);
1367
- finish({
1368
- content: `${truncateMiddle(output)}
1369
- Command timed out after ${timeoutSeconds}s`,
1370
- isError: true
1371
- });
1372
- }, timeoutSeconds * 1e3);
1373
- child.stdout.on("data", (d) => {
1374
- output += d.toString("utf8");
1375
- });
1376
- child.stderr.on("data", (d) => {
1377
- output += d.toString("utf8");
1378
- });
1379
- child.on("error", (err) => {
1380
- finish({ content: `Failed to start shell '${spec.label}': ${err.message}`, isError: true });
1381
- });
1382
- ctx.abortSignal?.addEventListener("abort", onAbort);
1383
- child.on("close", (code) => {
1384
- const trimmed = output.replace(/\s+$/, "");
1385
- if (code === 0) {
1386
- finish({ content: truncateMiddle(trimmed) || "(no output)" });
1387
- return;
1388
- }
1389
- const body = truncateMiddle(trimmed);
1390
- finish({
1391
- content: `${body}${body ? "\n" : ""}Exit code: ${code ?? "unknown"}`,
1392
- isError: true
1393
- });
1394
- });
1395
- });
1396
- }
1397
- };
1398
-
1399
- // src/tasks/manager.ts
1400
- var MAX_TASK_OUTPUT = 1e5;
1401
- var DEFAULT_TASK_TIMEOUT = 3600;
1402
- var TRUNCATED_PREFIX = "[... earlier output truncated ...]\n";
1403
- var TaskManager = class extends EventEmitter {
1404
- records = /* @__PURE__ */ new Map();
1405
- seq = 0;
1406
- start(opts) {
1407
- const id = `task-${++this.seq}`;
1408
- const spec = resolveShell();
1409
- const child = spawn2(spec.shell, spec.wrap(opts.command), {
1410
- cwd: opts.cwd,
1411
- windowsHide: true
1412
- });
1413
- const rec = {
1414
- id,
1415
- command: opts.command,
1416
- description: opts.description,
1417
- status: "running",
1418
- startedAt: Date.now(),
1419
- output: "",
1420
- truncated: false,
1421
- child
1422
- };
1423
- this.records.set(id, rec);
1424
- const append = (d) => {
1425
- rec.output += d.toString("utf8");
1426
- if (rec.output.length > MAX_TASK_OUTPUT) {
1427
- rec.output = rec.output.slice(-MAX_TASK_OUTPUT);
1428
- rec.truncated = true;
1429
- }
1430
- };
1431
- child.stdout.on("data", append);
1432
- child.stderr.on("data", append);
1433
- child.on("error", (err) => {
1434
- append(Buffer.from(`Failed to start shell '${spec.label}': ${err.message}
1435
- `));
1436
- if (rec.status === "running") {
1437
- this.finish(rec, "failed", null);
1438
- }
1439
- });
1440
- child.on("close", (code) => {
1441
- if (rec.status !== "running") return;
1442
- this.finish(rec, code === 0 ? "completed" : "failed", code);
1443
- });
1444
- const timeoutSeconds = opts.timeoutSeconds ?? DEFAULT_TASK_TIMEOUT;
1445
- rec.timer = setTimeout(() => this.terminate(rec, "timed_out"), timeoutSeconds * 1e3);
1446
- rec.timer.unref();
1447
- this.emitUpdate(rec);
1448
- return this.snapshot(rec);
1449
- }
1450
- get(id) {
1451
- const rec = this.records.get(id);
1452
- return rec ? this.snapshot(rec) : void 0;
1453
- }
1454
- list() {
1455
- return [...this.records.values()].map((rec) => this.snapshot(rec));
1456
- }
1457
- runningCount() {
1458
- let count = 0;
1459
- for (const rec of this.records.values()) {
1460
- if (rec.status === "running") count += 1;
1461
- }
1462
- return count;
1463
- }
1464
- kill(id) {
1465
- const rec = this.records.get(id);
1466
- if (!rec || rec.status !== "running") return void 0;
1467
- this.terminate(rec, "stopped");
1468
- return this.snapshot(rec);
1469
- }
1470
- cleanup() {
1471
- const killed = [];
1472
- for (const rec of this.records.values()) {
1473
- if (rec.status === "running") {
1474
- this.terminate(rec, "stopped");
1475
- killed.push(this.snapshot(rec));
1476
- }
1477
- }
1478
- return killed;
1479
- }
1480
- terminate(rec, status) {
1481
- killTree(rec.child);
1482
- this.finish(rec, status, null);
1483
- }
1484
- finish(rec, status, exitCode) {
1485
- if (rec.timer) clearTimeout(rec.timer);
1486
- rec.status = status;
1487
- rec.exitCode = exitCode;
1488
- rec.endedAt = Date.now();
1489
- this.emitUpdate(rec);
1490
- }
1491
- emitUpdate(rec) {
1492
- this.emit("update", this.snapshot(rec));
1493
- }
1494
- snapshot(rec) {
1495
- return {
1496
- id: rec.id,
1497
- command: rec.command,
1498
- description: rec.description,
1499
- status: rec.status,
1500
- startedAt: rec.startedAt,
1501
- endedAt: rec.endedAt,
1502
- exitCode: rec.exitCode,
1503
- output: rec.truncated ? TRUNCATED_PREFIX + rec.output : rec.output
1504
- };
1505
- }
1506
- };
1507
- var defaultTaskManager = new TaskManager();
1508
-
1509
- // src/tools/fs/edit.ts
1510
- import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1511
- import path9 from "path";
1512
- import { z as z2 } from "zod";
1513
- var schema2 = z2.object({
1514
- path: z2.string().describe("File path, absolute or relative to the working directory"),
1515
- old_string: z2.string().min(1).describe("Exact text to replace"),
1516
- new_string: z2.string().describe("Replacement text"),
1517
- replace_all: z2.boolean().optional().describe("Replace every occurrence instead of requiring a unique match")
1518
- });
1519
- var editFileTool = {
1520
- name: "edit_file",
1521
- description: "Replace an exact string in a file. Fails if old_string is not found or matches multiple locations unless replace_all is set.",
1522
- permission: "write",
1523
- parameters: schema2,
1524
- async execute(args, ctx) {
1525
- const filePath = path9.resolve(ctx.cwd, args.path);
1526
- let content;
1527
- try {
1528
- content = await readFile3(filePath, "utf8");
1529
- } catch (err) {
1530
- return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
1531
- }
1532
- let count = 0;
1533
- let idx = content.indexOf(args.old_string);
1534
- while (idx !== -1) {
1535
- count += 1;
1536
- idx = content.indexOf(args.old_string, idx + args.old_string.length);
1537
- }
1538
- if (count === 0) {
1539
- return { content: `old_string not found in ${args.path}`, isError: true };
1540
- }
1541
- if (count > 1 && !args.replace_all) {
1542
- return {
1543
- content: `old_string matches ${count} locations in ${args.path}; provide more surrounding context or set replace_all`,
1544
- isError: true
1545
- };
1546
- }
1547
- const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, args.new_string);
1548
- try {
1549
- await writeFile2(filePath, updated, "utf8");
1550
- } catch (err) {
1551
- return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1552
- }
1553
- pushSnapshot({
1554
- path: filePath,
1555
- existed: true,
1556
- content,
1557
- toolName: "edit_file",
1558
- timestamp: Date.now(),
1559
- turn: currentTurnSeq()
1560
- });
1561
- return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
1562
- }
1563
- };
1564
-
1565
- // src/tools/fs/glob.ts
1566
- import path10 from "path";
1567
- import { z as z3 } from "zod";
1568
- var MAX_RESULTS = 100;
1569
- var schema3 = z3.object({
1570
- pattern: z3.string().describe(
1571
- "Glob pattern, e.g. 'src/**/*.ts'. Supports **, * and ?. A bare pattern like '*.ts' matches basenames recursively."
1572
- ),
1573
- path: z3.string().optional().describe("Directory to search, defaults to the working directory")
1574
- });
1575
- var globTool = {
1576
- name: "glob",
1577
- description: "Find files by glob pattern. Skips node_modules, .git and dist. Returns paths sorted by modification time, newest first, up to 100 results.",
1578
- permission: "read",
1579
- parameters: schema3,
1580
- async execute(args, ctx) {
1581
- const root = path10.resolve(ctx.cwd, args.path ?? ".");
1582
- const files = await walkFiles(root);
1583
- const matched = files.filter((f) => matchesGlob(args.pattern, f.rel)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
1584
- if (matched.length === 0) {
1585
- return { content: `No files matched pattern: ${args.pattern}` };
1586
- }
1587
- return { content: matched.map((f) => f.rel).join("\n") };
1588
- }
1589
- };
1590
-
1591
- // src/tools/fs/grep.ts
1592
- import { readFile as readFile4, stat as stat3 } from "fs/promises";
1593
- import path11 from "path";
1594
- import { z as z4 } from "zod";
1595
- var MAX_MATCHES = 250;
1596
- var schema4 = z4.object({
1597
- pattern: z4.string().describe("Regular expression to search for"),
1598
- path: z4.string().optional().describe("File or directory to search, defaults to the working directory"),
1599
- glob: z4.string().optional().describe("Glob pattern to filter which files are searched, e.g. '*.ts'"),
1600
- ignoreCase: z4.boolean().optional().describe("Case-insensitive matching")
1601
- });
1602
- var grepTool = {
1603
- name: "grep",
1604
- description: "Search file contents with a regular expression. Outputs 'file:line:content', up to 250 matches. Skips binary files, node_modules and .git.",
1605
- permission: "read",
1606
- parameters: schema4,
1607
- async execute(args, ctx) {
1608
- let re;
1609
- try {
1610
- re = new RegExp(args.pattern, args.ignoreCase ? "i" : "");
1611
- } catch (err) {
1612
- return { content: `Invalid regular expression: ${err.message}`, isError: true };
1613
- }
1614
- const root = path11.resolve(ctx.cwd, args.path ?? ".");
1615
- let files;
1616
- try {
1617
- const st = await stat3(root);
1618
- if (st.isFile()) {
1619
- files = [{ abs: root, rel: path11.basename(root), mtimeMs: st.mtimeMs }];
1620
- } else {
1621
- files = await walkFiles(root);
1622
- }
1623
- } catch {
1624
- return { content: `Path not found: ${args.path ?? "."}`, isError: true };
1625
- }
1626
- if (args.glob) {
1627
- const glob = args.glob;
1628
- files = files.filter((f) => matchesGlob(glob, f.rel));
1629
- }
1630
- const out = [];
1631
- let truncated = false;
1632
- outer: for (const file of files) {
1633
- let buf;
1634
- try {
1635
- buf = await readFile4(file.abs);
1636
- } catch {
1637
- continue;
1638
- }
1639
- if (buf.includes(0)) {
1640
- continue;
1641
- }
1642
- const lines = buf.toString("utf8").split("\n");
1643
- for (let i = 0; i < lines.length; i++) {
1644
- const line = lines[i] ?? "";
1645
- if (re.test(line)) {
1646
- out.push(`${file.rel}:${i + 1}:${line}`);
1647
- if (out.length >= MAX_MATCHES) {
1648
- truncated = true;
1649
- break outer;
1650
- }
1651
- }
1652
- }
1653
- }
1654
- if (out.length === 0) {
1655
- return { content: `No matches for pattern: ${args.pattern}` };
1656
- }
1657
- if (truncated) {
1658
- out.push(`... (truncated: more than ${MAX_MATCHES} matches)`);
1659
- }
1660
- return { content: out.join("\n") };
1661
- }
1662
- };
1663
-
1664
- // src/tools/fs/read.ts
1665
- import { readFile as readFile5, stat as stat4 } from "fs/promises";
1666
- import path12 from "path";
1667
- import { z as z5 } from "zod";
1668
- var DEFAULT_LIMIT = 2e3;
1669
- var MAX_CHARS = 100 * 1024;
1670
- var schema5 = z5.object({
1671
- path: z5.string().describe("File path, absolute or relative to the working directory"),
1672
- offset: z5.number().int().positive().optional().describe("1-based line number to start reading from"),
1673
- limit: z5.number().int().positive().optional().describe("Maximum number of lines to read (default 2000)")
1674
- });
1675
- var readFileTool = {
1676
- name: "read_file",
1677
- description: "Read a text file and return its contents with line numbers. Output is truncated to 2000 lines or 100KB by default.",
1678
- permission: "read",
1679
- parameters: schema5,
1680
- async execute(args, ctx) {
1681
- const filePath = path12.resolve(ctx.cwd, args.path);
1682
- if (isSensitivePath2(filePath)) {
1683
- return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
1684
- }
1685
- const st = await stat4(filePath).catch(() => null);
1686
- if (!st) {
1687
- return { content: `File not found: ${args.path}`, isError: true };
1688
- }
1689
- if (st.isDirectory()) {
1690
- return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
1691
- }
1692
- const raw = await readFile5(filePath, "utf8");
1693
- const lines = raw.split("\n");
1694
- const start = args.offset ?? 1;
1695
- const limit = args.limit ?? DEFAULT_LIMIT;
1696
- const end = Math.min(lines.length, start - 1 + limit);
1697
- const out = [];
1698
- let size = 0;
1699
- let truncatedBySize = false;
1700
- for (let i = start - 1; i < end; i++) {
1701
- const line = lines[i];
1702
- if (line === void 0) {
1703
- break;
1704
- }
1705
- const numbered = `${i + 1} ${line}`;
1706
- if (size + numbered.length > MAX_CHARS) {
1707
- truncatedBySize = true;
1708
- break;
1709
- }
1710
- out.push(numbered);
1711
- size += numbered.length;
1712
- }
1713
- const lastShown = start - 1 + out.length;
1714
- if (truncatedBySize) {
1715
- out.push(
1716
- `... (truncated: output exceeds 100KB, showing lines ${start}-${lastShown} of ${lines.length})`
1717
- );
1718
- } else if (end < lines.length) {
1719
- out.push(`... (truncated: showing lines ${start}-${end} of ${lines.length})`);
1720
- }
1721
- return { content: out.join("\n") };
1722
- }
1723
- };
1724
-
1725
- // src/tools/fs/write.ts
1726
- import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
1727
- import path13 from "path";
1728
- import { z as z6 } from "zod";
1729
- var schema6 = z6.object({
1730
- path: z6.string().describe("File path, absolute or relative to the working directory"),
1731
- content: z6.string().describe("Full content to write to the file")
1732
- });
1733
- var writeFileTool = {
1734
- name: "write_file",
1735
- description: "Write content to a file, creating parent directories as needed. Overwrites existing files.",
1736
- permission: "write",
1737
- parameters: schema6,
1738
- async execute(args, ctx) {
1739
- const filePath = path13.resolve(ctx.cwd, args.path);
1740
- const snapshot = await captureSnapshot(filePath, "write_file");
1741
- try {
1742
- await mkdir2(path13.dirname(filePath), { recursive: true });
1743
- await writeFile3(filePath, args.content, "utf8");
1744
- } catch (err) {
1745
- return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1746
- }
1747
- pushSnapshot(snapshot);
1748
- return { content: `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}` };
1749
- }
1750
- };
1751
-
1752
- // src/tools/tasks.ts
1753
- import { z as z7 } from "zod";
1754
- var idSchema = z7.object({
1755
- id: z7.string().describe("Task id, e.g. task-1")
1756
- });
1757
- var taskListTool = {
1758
- name: "task_list",
1759
- description: "List background shell tasks with status, runtime, and exit code.",
1760
- permission: "read",
1761
- parameters: z7.object({}),
1762
- execute() {
1763
- return Promise.resolve({ content: formatTaskList(defaultTaskManager.list()) });
1764
- }
1765
- };
1766
- var taskOutputTool = {
1767
- name: "task_output",
1768
- description: "Read the current output of a background shell task (works while still running).",
1769
- permission: "read",
1770
- parameters: idSchema,
1771
- execute(args) {
1772
- const task = defaultTaskManager.get(args.id);
1773
- if (!task) {
1774
- return Promise.resolve({ content: `Unknown task: ${args.id}`, isError: true });
1775
- }
1776
- const body = task.output.replace(/\s+$/, "") || "(no output yet)";
1777
- return Promise.resolve({ content: `${formatTaskLine(task)}
1778
- ${body}` });
1779
- }
1780
- };
1781
- var taskKillTool = {
1782
- name: "task_kill",
1783
- description: "Stop a running background shell task (kills the whole process tree).",
1784
- permission: "exec",
1785
- parameters: idSchema,
1786
- execute(args) {
1787
- const task = defaultTaskManager.get(args.id);
1788
- if (!task) {
1789
- return Promise.resolve({ content: `Unknown task: ${args.id}`, isError: true });
1790
- }
1791
- if (task.status !== "running") {
1792
- return Promise.resolve({
1793
- content: `Task ${args.id} is not running (status: ${task.status}).`,
1794
- isError: true
1795
- });
1796
- }
1797
- defaultTaskManager.kill(args.id);
1798
- return Promise.resolve({ content: `Task ${args.id} stopped.` });
739
+ function computeCostUsd(usage, model) {
740
+ if (model?.promptPrice === void 0 || model.completionPrice === void 0) {
741
+ return null;
1799
742
  }
1800
- };
1801
-
1802
- // src/tools/todo.ts
1803
- import { mkdir as mkdir3, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
1804
- import path14 from "path";
1805
- import { z as z8 } from "zod";
1806
- var todoItemSchema = z8.object({
1807
- id: z8.number().int(),
1808
- title: z8.string(),
1809
- status: z8.enum(["pending", "in_progress", "done"])
1810
- });
1811
- var writeSchema = z8.object({
1812
- todos: z8.array(todoItemSchema).describe("The full todo list, replacing the current one")
1813
- });
1814
- var readSchema = z8.object({});
1815
- function fileFor(cwd) {
1816
- return path14.join(cwd, ".star", "todos.json");
743
+ return (usage.promptTokens * model.promptPrice + usage.completionTokens * model.completionPrice) / 1e6;
1817
744
  }
1818
- var TodoStore = class {
1819
- items = /* @__PURE__ */ new Map();
1820
- list() {
1821
- return [...this.items.values()];
1822
- }
1823
- replace(items) {
1824
- this.items.clear();
1825
- for (const item of items) {
1826
- this.items.set(item.id, item);
1827
- }
1828
- }
1829
- async load(cwd) {
1830
- let raw;
1831
- try {
1832
- raw = await readFile6(fileFor(cwd), "utf8");
1833
- } catch {
1834
- return;
1835
- }
1836
- try {
1837
- const parsed = JSON.parse(raw);
1838
- if (Array.isArray(parsed)) {
1839
- this.replace(parsed.filter((it) => todoItemSchema.safeParse(it).success));
1840
- }
1841
- } catch {
1842
- }
1843
- }
1844
- async save(cwd) {
1845
- await mkdir3(path14.join(cwd, ".star"), { recursive: true });
1846
- await writeFile4(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
1847
- `);
745
+ function estimateCost(usage, modelName, model) {
746
+ const cost = computeCostUsd(usage, model);
747
+ if (cost === null || model === void 0) {
748
+ return `Estimated cost: unknown (no price configured for ${modelName})`;
1848
749
  }
1849
- };
1850
- var SYMBOLS = {
1851
- pending: "[ ]",
1852
- in_progress: "[~]",
1853
- done: "[x]"
1854
- };
1855
- function formatTodos(items) {
1856
- if (items.length === 0) {
1857
- return "No todos.";
1858
- }
1859
- const lines = items.map((it) => `${SYMBOLS[it.status]} ${it.id}. ${it.title}`);
1860
- const width = Math.max(...lines.map((l) => l.length));
1861
- return lines.map((line, i) => {
1862
- const status = items[i]?.status;
1863
- return status === "pending" ? line : `${line.padEnd(width + 4)}(${status})`;
1864
- }).join("\n");
1865
- }
1866
- var defaultStore = new TodoStore();
1867
- function createTodoTools(store = defaultStore) {
1868
- const todoWrite = {
1869
- name: "todo_write",
1870
- description: "Replace the current todo list with the given items. Each item has an id, title, and status (pending, in_progress, done).",
1871
- permission: "read",
1872
- parameters: writeSchema,
1873
- async execute(args, ctx) {
1874
- const parsed = writeSchema.safeParse(args);
1875
- if (!parsed.success) {
1876
- return { content: `Invalid todos: ${parsed.error.message}`, isError: true };
1877
- }
1878
- await store.load(ctx.cwd);
1879
- store.replace(parsed.data.todos);
1880
- await store.save(ctx.cwd);
1881
- return { content: formatTodos(store.list()) };
1882
- }
1883
- };
1884
- const todoRead = {
1885
- name: "todo_read",
1886
- description: "Read the current todo list.",
1887
- permission: "read",
1888
- parameters: readSchema,
1889
- async execute(_args, ctx) {
1890
- await store.load(ctx.cwd);
1891
- return { content: formatTodos(store.list()) };
1892
- }
1893
- };
1894
- return [todoWrite, todoRead];
750
+ return `Estimated cost: $${formatDollars(cost)} (${model.name} @ $${model.promptPrice}/M prompt, $${model.completionPrice}/M completion)`;
1895
751
  }
1896
752
 
1897
- // src/tools/web/fetch.ts
1898
- import { z as z9 } from "zod";
1899
- var DEFAULT_MAX_CHARS = 2e4;
1900
- var MAX_BYTES = 2 * 1024 * 1024;
1901
- var TIMEOUT_MS = 3e4;
1902
- var schema7 = z9.object({
1903
- url: z9.string().describe("The http(s) URL to fetch"),
1904
- maxChars: z9.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
1905
- });
1906
- function decodeEntities(s) {
1907
- return s.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-fA-F]+);/gi, (_, n) => String.fromCodePoint(Number.parseInt(n, 16))).replace(/&(quot|lt|gt|nbsp);/g, (m) => {
1908
- switch (m) {
1909
- case "&quot;":
1910
- return '"';
1911
- case "&lt;":
1912
- return "<";
1913
- case "&gt;":
1914
- return ">";
1915
- case "&nbsp;":
1916
- return " ";
1917
- default:
1918
- return m;
1919
- }
1920
- }).replace(/&amp;/g, "&");
1921
- }
1922
- function htmlToText(html) {
1923
- const noBlocks = html.replace(/<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
1924
- const withBreaks = noBlocks.replace(
1925
- /<br\s*\/?>|<\/(p|div|li|tr|h[1-6]|section|article|header|footer|blockquote|pre)>/gi,
1926
- "\n"
1927
- );
1928
- const stripped = decodeEntities(withBreaks.replace(/<[^>]+>/g, " "));
1929
- return stripped.split("\n").map((line) => line.replace(/\s+/g, " ").trim()).join("\n").replace(/\n{3,}/g, "\n\n").trim();
1930
- }
1931
- async function readBody(res, maxChars) {
1932
- if (!res.body) {
1933
- return "";
1934
- }
1935
- const reader = res.body.getReader();
1936
- const decoder = new TextDecoder("utf-8");
1937
- let bytes = 0;
1938
- let text = "";
1939
- try {
1940
- for (; ; ) {
1941
- const { done, value } = await reader.read();
1942
- if (done) {
1943
- break;
1944
- }
1945
- bytes += value.byteLength;
1946
- text += decoder.decode(value, { stream: true });
1947
- if (bytes >= MAX_BYTES || text.length >= maxChars * 2) {
1948
- break;
1949
- }
1950
- }
1951
- text += decoder.decode();
1952
- } finally {
1953
- reader.cancel().catch(() => {
1954
- });
1955
- }
1956
- return text;
1957
- }
1958
- function truncate(s, maxChars) {
1959
- if (s.length <= maxChars) {
1960
- return s;
1961
- }
1962
- return `${s.slice(0, maxChars)}
1963
- [truncated, showing first ${maxChars} of ${s.length} chars]`;
753
+ // src/session/usage.ts
754
+ var USAGE_MAX_DAYS = 14;
755
+ function tokens(value) {
756
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
757
+ }
758
+ function bucketOf(value) {
759
+ const record = typeof value === "object" && value !== null ? value : {};
760
+ const promptTokens = tokens(record.promptTokens);
761
+ const completionTokens = tokens(record.completionTokens);
762
+ return {
763
+ promptTokens,
764
+ completionTokens,
765
+ totalTokens: tokens(record.totalTokens) || promptTokens + completionTokens
766
+ };
1964
767
  }
1965
- var webFetchTool = {
1966
- name: "web_fetch",
1967
- description: "Fetch a URL over http(s) and return its contents as readable text. HTML pages are converted to plain text (tags, scripts and styles removed). Output is truncated to maxChars (default 20000).",
1968
- permission: "read",
1969
- parameters: schema7,
1970
- async execute(args, ctx) {
1971
- let url;
1972
- try {
1973
- url = new URL(args.url);
1974
- } catch {
1975
- return { content: `ERROR: invalid URL: ${args.url}`, isError: true };
1976
- }
1977
- if (url.protocol !== "http:" && url.protocol !== "https:") {
1978
- return {
1979
- content: `ERROR: only http(s) URLs are supported, got: ${url.protocol}`,
1980
- isError: true
1981
- };
1982
- }
1983
- const maxChars = args.maxChars ?? DEFAULT_MAX_CHARS;
1984
- const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS);
1985
- const signal = ctx.abortSignal ? AbortSignal.any([timeoutSignal, ctx.abortSignal]) : timeoutSignal;
1986
- let res;
1987
- try {
1988
- res = await fetch(url, { signal, redirect: "follow" });
1989
- } catch (err) {
1990
- if (ctx.abortSignal?.aborted) {
1991
- return { content: "ERROR: request aborted", isError: true };
1992
- }
1993
- if (timeoutSignal.aborted) {
1994
- return { content: `ERROR: request timed out after ${TIMEOUT_MS / 1e3}s`, isError: true };
768
+ function addTo(target, source) {
769
+ target.promptTokens += source.promptTokens;
770
+ target.completionTokens += source.completionTokens;
771
+ target.totalTokens += source.totalTokens;
772
+ }
773
+ function aggregateUsage(metas) {
774
+ const days = /* @__PURE__ */ new Map();
775
+ const models = /* @__PURE__ */ new Map();
776
+ const legacy = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
777
+ let hasLegacy = false;
778
+ let sessionsWithUsage = 0;
779
+ let requests = 0;
780
+ const grand = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
781
+ for (const meta of metas) {
782
+ const dayBuckets = [];
783
+ if (meta.usageByDay !== null && typeof meta.usageByDay === "object") {
784
+ for (const [date, value] of Object.entries(meta.usageByDay)) {
785
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
786
+ dayBuckets.push([date, bucketOf(value)]);
1995
787
  }
1996
- const message = err instanceof Error ? err.message : String(err);
1997
- return { content: `ERROR: failed to fetch ${args.url}: ${message}`, isError: true };
1998
788
  }
1999
- if (!res.ok) {
2000
- await res.body?.cancel().catch(() => {
2001
- });
2002
- return {
2003
- content: `ERROR: HTTP ${res.status} ${res.statusText} for ${args.url}`,
2004
- isError: true
2005
- };
2006
- }
2007
- const [rawType = ""] = (res.headers.get("content-type") ?? "text/plain").split(";");
2008
- const contentType = rawType.trim().toLowerCase();
2009
- const isHtml = contentType === "text/html" || contentType === "application/xhtml+xml";
2010
- const isText = contentType.startsWith("text/") || contentType === "application/json" || contentType === "application/xml" || contentType.endsWith("+json") || contentType.endsWith("+xml");
2011
- if (!isHtml && !isText) {
2012
- await res.body?.cancel().catch(() => {
2013
- });
2014
- return {
2015
- content: `ERROR: unsupported content type: ${contentType} (${args.url})`,
2016
- isError: true
789
+ const daySum = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
790
+ for (const [, bucket] of dayBuckets) addTo(daySum, bucket);
791
+ const hasTotals = meta.usage !== null && typeof meta.usage === "object";
792
+ const totals = hasTotals ? bucketOf(meta.usage) : daySum;
793
+ const requestCount = hasTotals && meta.usage ? tokens(meta.usage.requests) : 0;
794
+ if (!hasTotals && dayBuckets.length === 0) continue;
795
+ sessionsWithUsage += 1;
796
+ addTo(grand, totals);
797
+ requests += requestCount;
798
+ for (const [date, bucket] of dayBuckets) {
799
+ const acc = days.get(date) ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
800
+ addTo(acc, bucket);
801
+ days.set(date, acc);
802
+ }
803
+ if (hasTotals) {
804
+ const rest = {
805
+ promptTokens: Math.max(0, totals.promptTokens - daySum.promptTokens),
806
+ completionTokens: Math.max(0, totals.completionTokens - daySum.completionTokens),
807
+ totalTokens: Math.max(0, totals.totalTokens - daySum.totalTokens)
2017
808
  };
2018
- }
2019
- let body;
2020
- try {
2021
- body = await readBody(res, maxChars);
2022
- } catch (err) {
2023
- const message = err instanceof Error ? err.message : String(err);
2024
- return { content: `ERROR: failed to read response body: ${message}`, isError: true };
2025
- }
2026
- const text = isHtml ? htmlToText(body) : body;
2027
- return { content: truncate(text, maxChars) };
2028
- }
2029
- };
2030
-
2031
- // src/tools/web/search.ts
2032
- import { z as z10 } from "zod";
2033
- var TIMEOUT_MS2 = 15e3;
2034
- var DEFAULT_MAX_RESULTS = 5;
2035
- var MAX_RESULTS_LIMIT = 10;
2036
- var SEARCH_URL = "https://html.duckduckgo.com/html/?q=";
2037
- var USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
2038
- var schema8 = z10.object({
2039
- query: z10.string().min(1).describe("The search query"),
2040
- maxResults: z10.number().int().min(1).max(MAX_RESULTS_LIMIT).optional().describe(
2041
- `Maximum number of results to return (default ${DEFAULT_MAX_RESULTS}, max ${MAX_RESULTS_LIMIT})`
2042
- )
2043
- });
2044
- function stripTags(s) {
2045
- return decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
2046
- }
2047
- function normalizeUrl(href) {
2048
- const decoded = decodeEntities(href);
2049
- const uddg = decoded.match(/[?&]uddg=([^&]+)/);
2050
- if (uddg?.[1]) {
2051
- try {
2052
- return decodeURIComponent(uddg[1]);
2053
- } catch {
2054
- return decoded;
2055
- }
2056
- }
2057
- if (decoded.startsWith("//")) {
2058
- return `https:${decoded}`;
2059
- }
2060
- return decoded;
2061
- }
2062
- function parseResults(html) {
2063
- const results = [];
2064
- const anchorRe = /<a\b[^>]*\bclass\s*=\s*"[^"]*\bresult__a\b[^"]*"[^>]*>[\s\S]*?<\/a>/gi;
2065
- const anchors = [...html.matchAll(anchorRe)];
2066
- for (const [i, anchor] of anchors.entries()) {
2067
- const full = anchor[0];
2068
- const hrefMatch = full.match(/href\s*=\s*"([^"]*)"/i);
2069
- if (!hrefMatch?.[1]) {
2070
- continue;
2071
- }
2072
- const url = normalizeUrl(hrefMatch[1]);
2073
- const title = stripTags(full.replace(/^<a\b[^>]*>/i, "").replace(/<\/a>\s*$/i, ""));
2074
- const blockStart = (anchor.index ?? 0) + full.length;
2075
- const blockEnd = i + 1 < anchors.length ? anchors[i + 1]?.index ?? html.length : html.length;
2076
- const block = html.slice(blockStart, blockEnd);
2077
- const snippetMatch = block.match(
2078
- /<(?:a|div)\b[^>]*\bclass\s*=\s*"[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div)>/i
2079
- );
2080
- const snippet = snippetMatch?.[1] ? stripTags(snippetMatch[1]) : "";
2081
- if (title && url) {
2082
- results.push({ title, url, snippet });
2083
- }
2084
- }
2085
- return results;
2086
- }
2087
- var webSearchTool = {
2088
- name: "web_search",
2089
- description: "Search the web using DuckDuckGo and return a numbered list of results with title, URL and snippet. No API key required. Use web_fetch to read a result's full page.",
2090
- permission: "read",
2091
- parameters: schema8,
2092
- async execute(args, ctx) {
2093
- const maxResults = Math.min(args.maxResults ?? DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT);
2094
- const url = `${SEARCH_URL}${encodeURIComponent(args.query)}`;
2095
- const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS2);
2096
- const signal = ctx.abortSignal ? AbortSignal.any([timeoutSignal, ctx.abortSignal]) : timeoutSignal;
2097
- let res;
2098
- try {
2099
- res = await fetch(url, {
2100
- signal,
2101
- redirect: "follow",
2102
- headers: { "user-agent": USER_AGENT, accept: "text/html" }
2103
- });
2104
- } catch (err) {
2105
- if (ctx.abortSignal?.aborted) {
2106
- return { content: "ERROR: request aborted", isError: true };
809
+ if (rest.promptTokens > 0 || rest.completionTokens > 0 || rest.totalTokens > 0) {
810
+ addTo(legacy, rest);
811
+ hasLegacy = true;
2107
812
  }
2108
- const name = err instanceof Error ? err.name : "";
2109
- if (timeoutSignal.aborted || name === "TimeoutError") {
2110
- return { content: `ERROR: search timed out after ${TIMEOUT_MS2 / 1e3}s`, isError: true };
2111
- }
2112
- const message = err instanceof Error ? err.message : String(err);
2113
- return { content: `ERROR: search request failed: ${message}`, isError: true };
2114
- }
2115
- if (!res.ok) {
2116
- await res.body?.cancel().catch(() => {
2117
- });
2118
- return {
2119
- content: `ERROR: HTTP ${res.status} ${res.statusText} from DuckDuckGo`,
2120
- isError: true
2121
- };
2122
- }
2123
- let html;
2124
- try {
2125
- html = await res.text();
2126
- } catch (err) {
2127
- const message = err instanceof Error ? err.message : String(err);
2128
- return { content: `ERROR: failed to read search response: ${message}`, isError: true };
2129
813
  }
2130
- const results = parseResults(html).slice(0, maxResults);
2131
- if (results.length === 0) {
2132
- return {
2133
- content: `ERROR: no results found for "${args.query}" (the response may have been blocked or the page layout changed)`,
2134
- isError: true
2135
- };
2136
- }
2137
- const lines = results.map(
2138
- (r, i) => `${i + 1}. ${r.title}
2139
- ${r.url}${r.snippet ? `
2140
- ${r.snippet}` : ""}`
2141
- );
2142
- return { content: lines.join("\n") };
814
+ const name = typeof meta.model === "string" && meta.model.trim() ? meta.model : "unknown";
815
+ const entry = models.get(name) ?? {
816
+ model: name,
817
+ sessions: 0,
818
+ requests: 0,
819
+ promptTokens: 0,
820
+ completionTokens: 0,
821
+ totalTokens: 0
822
+ };
823
+ entry.sessions += 1;
824
+ entry.requests += requestCount;
825
+ addTo(entry, totals);
826
+ models.set(name, entry);
2143
827
  }
2144
- };
2145
-
2146
- // src/tools/registry.ts
2147
- var ToolRegistry = class {
2148
- tools = /* @__PURE__ */ new Map();
2149
- constructor() {
2150
- for (const tool of [
2151
- readFileTool,
2152
- writeFileTool,
2153
- editFileTool,
2154
- globTool,
2155
- grepTool,
2156
- bashTool,
2157
- webFetchTool,
2158
- webSearchTool,
2159
- taskListTool,
2160
- taskOutputTool,
2161
- taskKillTool,
2162
- ...createTodoTools()
2163
- ]) {
2164
- this.register(tool);
828
+ return {
829
+ sessions: metas.length,
830
+ sessionsWithUsage,
831
+ requests,
832
+ promptTokens: grand.promptTokens,
833
+ completionTokens: grand.completionTokens,
834
+ totalTokens: grand.totalTokens,
835
+ days: [...days.entries()].map(([date, bucket]) => ({ date, ...bucket })).sort((a, b) => a.date.localeCompare(b.date)),
836
+ legacy: hasLegacy ? legacy : null,
837
+ models: [...models.values()].sort((a, b) => b.totalTokens - a.totalTokens)
838
+ };
839
+ }
840
+ async function collectUsageStats() {
841
+ return aggregateUsage(await SessionStore.list());
842
+ }
843
+ function fmt(n) {
844
+ return n.toLocaleString("en-US");
845
+ }
846
+ var BAR_WIDTH = 30;
847
+ function formatUsageDashboard(stats, models, options = {}) {
848
+ if (stats.sessions === 0) return "No sessions found.";
849
+ const lines = [
850
+ "Token usage across all sessions",
851
+ "",
852
+ `Total: ${stats.sessions} sessions (${stats.sessionsWithUsage} with usage) \xB7 ${fmt(stats.requests)} requests \xB7 ${fmt(stats.promptTokens)} prompt + ${fmt(stats.completionTokens)} completion = ${fmt(stats.totalTokens)} tokens`
853
+ ];
854
+ if (stats.sessionsWithUsage === 0) {
855
+ lines.push("", "No token usage recorded yet.");
856
+ return lines.join("\n");
857
+ }
858
+ const maxDays = options.maxDays ?? USAGE_MAX_DAYS;
859
+ const recent = stats.days.slice(-maxDays);
860
+ if (recent.length > 0) {
861
+ lines.push("");
862
+ lines.push(
863
+ stats.days.length > maxDays ? `By day (last ${maxDays} of ${stats.days.length} days with usage):` : "By day:"
864
+ );
865
+ const maxTotal = Math.max(...recent.map((day) => day.totalTokens));
866
+ for (const day of recent) {
867
+ const barLen = maxTotal > 0 ? Math.max(1, Math.round(day.totalTokens / maxTotal * BAR_WIDTH)) : 0;
868
+ lines.push(`${day.date} ${fmt(day.totalTokens).padStart(12)} ${"\u2588".repeat(barLen)}`);
2165
869
  }
2166
870
  }
2167
- register(tool) {
2168
- this.tools.set(tool.name, tool);
2169
- }
2170
- get(name) {
2171
- return this.tools.get(name);
2172
- }
2173
- list() {
2174
- return [...this.tools.values()];
2175
- }
2176
- names() {
2177
- return [...this.tools.keys()];
871
+ if (stats.legacy) {
872
+ lines.push(
873
+ `Earlier usage without per-day records: ${fmt(stats.legacy.totalTokens)} tokens (${fmt(stats.legacy.promptTokens)} prompt + ${fmt(stats.legacy.completionTokens)} completion)`
874
+ );
2178
875
  }
2179
- };
2180
-
2181
- // src/tools/index.ts
2182
- function createDefaultRegistry() {
2183
- const registry = new ToolRegistry();
2184
- for (const tool of createTodoTools()) {
2185
- registry.register(tool);
876
+ lines.push("", "By model:");
877
+ const configured = new Map(models.map((m) => [m.name, m]));
878
+ const rows = stats.models.map((entry) => {
879
+ const cfg = configured.get(entry.model);
880
+ const cost = cfg?.promptPrice !== void 0 && cfg.completionPrice !== void 0 ? (entry.promptTokens * cfg.promptPrice + entry.completionTokens * cfg.completionPrice) / 1e6 : null;
881
+ return { entry, cost };
882
+ });
883
+ const header = ["model", "requests", "prompt", "completion", "total", "cost"];
884
+ const cells = rows.map(({ entry, cost }) => [
885
+ entry.model,
886
+ fmt(entry.requests),
887
+ fmt(entry.promptTokens),
888
+ fmt(entry.completionTokens),
889
+ fmt(entry.totalTokens),
890
+ cost === null ? "\u2014" : `$${formatDollars(cost)}`
891
+ ]);
892
+ const widths = header.map(
893
+ (h, i) => Math.max(h.length, ...cells.map((row) => row[i]?.length ?? 0))
894
+ );
895
+ const renderRow = (row) => row.map((cell, i) => i === 0 ? cell.padEnd(widths[0] ?? 0) : cell.padStart(widths[i] ?? 0)).join(" ");
896
+ lines.push(renderRow(header));
897
+ for (const row of cells) lines.push(renderRow(row));
898
+ const pricedCosts = rows.filter((row) => row.cost !== null).map((row) => row.cost);
899
+ const unpriced = rows.filter((row) => row.cost === null).map((row) => row.entry.model);
900
+ if (pricedCosts.length > 0) {
901
+ const total = pricedCosts.reduce((sum, cost) => sum + cost, 0);
902
+ const note = unpriced.length > 0 ? ` (excludes ${unpriced.join(", ")} \u2014 no price configured)` : "";
903
+ lines.push("", `Estimated cost: $${formatDollars(total)}${note}`);
904
+ } else {
905
+ lines.push("", "Estimated cost: unknown (no per-model pricing configured)");
2186
906
  }
2187
- return registry;
907
+ return lines.join("\n");
2188
908
  }
2189
909
 
2190
910
  // src/version.ts
2191
- import fs3 from "fs";
2192
- import path15 from "path";
911
+ import fs6 from "fs";
912
+ import path8 from "path";
2193
913
  import { fileURLToPath } from "url";
2194
914
  function readPackageVersion() {
2195
915
  try {
2196
- const pkgPath = path15.join(path15.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
2197
- const pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
916
+ const pkgPath = path8.join(path8.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
917
+ const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf8"));
2198
918
  if (typeof pkg === "object" && pkg !== null) {
2199
919
  const version = pkg.version;
2200
920
  if (typeof version === "string") return version;
@@ -2205,9 +925,141 @@ function readPackageVersion() {
2205
925
  }
2206
926
  var VERSION = readPackageVersion();
2207
927
 
928
+ // src/cli/budget.ts
929
+ var BUDGET_WARN_RATIO = 0.8;
930
+ function budgetState(costUsd, budgetUsd) {
931
+ if (budgetUsd === void 0 || costUsd === null) return "ok";
932
+ if (costUsd >= budgetUsd) return "exceeded";
933
+ if (costUsd >= budgetUsd * BUDGET_WARN_RATIO) return "warn";
934
+ return "ok";
935
+ }
936
+
937
+ // src/cli/clipboard.ts
938
+ import { execFile } from "child_process";
939
+ import { randomUUID } from "crypto";
940
+ import { readFile as readFile2, unlink } from "fs/promises";
941
+ import os2 from "os";
942
+ import path9 from "path";
943
+ import { promisify } from "util";
944
+ var CLIPBOARD_TIMEOUT_MS = 5e3;
945
+ var MAX_CLIPBOARD_IMAGE_BYTES = 10 * 1024 * 1024;
946
+ var execFileAsync = promisify(execFile);
947
+ function windowsClipboardImageScript(tempFile) {
948
+ const escaped = tempFile.replace(/'/g, "''");
949
+ return [
950
+ "Add-Type -AssemblyName System.Windows.Forms",
951
+ "$img = [Windows.Forms.Clipboard]::GetImage()",
952
+ "if ($null -ne $img) {",
953
+ ` $img.Save('${escaped}', [System.Drawing.Imaging.ImageFormat]::Png)`,
954
+ " $img.Dispose()",
955
+ "}"
956
+ ].join("; ");
957
+ }
958
+ function macOsClipboardImageLines(tempFile) {
959
+ const posix = tempFile.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
960
+ return [
961
+ "set pngData to the clipboard as \xABclass PNGf\xBB",
962
+ `set theFile to open for access (POSIX file "${posix}") with write permission`,
963
+ "write pngData to theFile",
964
+ "close access theFile"
965
+ ];
966
+ }
967
+ function clipboardImagePlan(platform, tempFile, hasPngpaste) {
968
+ if (platform === "win32") {
969
+ return {
970
+ kind: "temp-file",
971
+ command: "powershell.exe",
972
+ args: [
973
+ "-NoProfile",
974
+ "-NonInteractive",
975
+ "-STA",
976
+ "-Command",
977
+ windowsClipboardImageScript(tempFile)
978
+ ]
979
+ };
980
+ }
981
+ if (platform === "darwin") {
982
+ if (hasPngpaste) {
983
+ return { kind: "temp-file", command: "pngpaste", args: [tempFile] };
984
+ }
985
+ return {
986
+ kind: "temp-file",
987
+ command: "osascript",
988
+ args: macOsClipboardImageLines(tempFile).flatMap((line) => ["-e", line])
989
+ };
990
+ }
991
+ if (platform === "linux") {
992
+ return {
993
+ kind: "stdout",
994
+ command: "xclip",
995
+ args: ["-selection", "clipboard", "-t", "image/png", "-o"]
996
+ };
997
+ }
998
+ return null;
999
+ }
1000
+ function clipboardCopyCommand(platform) {
1001
+ if (platform === "win32") return { command: "clip.exe", args: [] };
1002
+ if (platform === "darwin") return { command: "pbcopy", args: [] };
1003
+ if (platform === "linux") return { command: "xclip", args: ["-selection", "clipboard"] };
1004
+ return null;
1005
+ }
1006
+ async function commandExists(command) {
1007
+ const checker = process.platform === "win32" ? "where" : "which";
1008
+ try {
1009
+ await execFileAsync(checker, [command], { timeout: CLIPBOARD_TIMEOUT_MS });
1010
+ return true;
1011
+ } catch {
1012
+ return false;
1013
+ }
1014
+ }
1015
+ async function readClipboardImage() {
1016
+ const tempFile = path9.join(os2.tmpdir(), `star-clipboard-${randomUUID()}.png`);
1017
+ const hasPngpaste = process.platform === "darwin" ? await commandExists("pngpaste") : false;
1018
+ const plan = clipboardImagePlan(process.platform, tempFile, hasPngpaste);
1019
+ if (!plan) return null;
1020
+ try {
1021
+ if (plan.kind === "stdout") {
1022
+ const { stdout } = await execFileAsync(plan.command, plan.args, {
1023
+ timeout: CLIPBOARD_TIMEOUT_MS,
1024
+ maxBuffer: MAX_CLIPBOARD_IMAGE_BYTES,
1025
+ encoding: "buffer"
1026
+ });
1027
+ const buf2 = stdout;
1028
+ if (buf2.length === 0) return null;
1029
+ return { path: "clipboard.png", mimeType: "image/png", data: buf2.toString("base64") };
1030
+ }
1031
+ await execFileAsync(plan.command, plan.args, { timeout: CLIPBOARD_TIMEOUT_MS });
1032
+ const buf = await readFile2(tempFile).catch(() => null);
1033
+ if (!buf || buf.length === 0) return null;
1034
+ return { path: "clipboard.png", mimeType: "image/png", data: buf.toString("base64") };
1035
+ } catch {
1036
+ return null;
1037
+ } finally {
1038
+ if (plan.kind === "temp-file") {
1039
+ await unlink(tempFile).catch(() => {
1040
+ });
1041
+ }
1042
+ }
1043
+ }
1044
+ async function copyText(text) {
1045
+ const plan = clipboardCopyCommand(process.platform);
1046
+ if (!plan) return false;
1047
+ return new Promise((resolve) => {
1048
+ const child = execFile(
1049
+ plan.command,
1050
+ plan.args,
1051
+ { timeout: CLIPBOARD_TIMEOUT_MS },
1052
+ (error) => resolve(error === null)
1053
+ );
1054
+ child.stdin?.on("error", () => {
1055
+ });
1056
+ child.stdin?.end(text);
1057
+ });
1058
+ }
1059
+
2208
1060
  // src/cli/commands/actions.ts
2209
- import fs4 from "fs/promises";
2210
- import path16 from "path";
1061
+ import fs7 from "fs/promises";
1062
+ import path10 from "path";
2211
1063
  var MIN_COMPACT_MESSAGES = 4;
2212
1064
  async function compactSession(opts) {
2213
1065
  const { backend, sessionStore, config, model } = opts;
@@ -2268,15 +1120,15 @@ async function exportSession(opts) {
2268
1120
  return "Nothing to export: the session has no messages.";
2269
1121
  }
2270
1122
  const sessionId = sessionStore?.id ?? `unknown-${Date.now()}`;
2271
- const target = arg ? path16.resolve(cwd, arg) : path16.resolve(cwd, `star-session-${sessionId}.md`);
1123
+ const target = arg ? path10.resolve(cwd, arg) : path10.resolve(cwd, `star-session-${sessionId}.md`);
2272
1124
  const existed = await fileExists(target);
2273
- await fs4.mkdir(path16.dirname(target), { recursive: true });
2274
- await fs4.writeFile(target, renderSessionMarkdown(sessionId, messages));
1125
+ await fs7.mkdir(path10.dirname(target), { recursive: true });
1126
+ await fs7.writeFile(target, renderSessionMarkdown(sessionId, messages));
2275
1127
  return existed ? `Exported ${messages.length} messages to ${target} (overwrote existing file).` : `Exported ${messages.length} messages to ${target}.`;
2276
1128
  }
2277
1129
  async function fileExists(target) {
2278
1130
  try {
2279
- await fs4.access(target);
1131
+ await fs7.access(target);
2280
1132
  return true;
2281
1133
  } catch {
2282
1134
  return false;
@@ -2336,6 +1188,191 @@ function stringifyPart(value) {
2336
1188
  return typeof value === "string" ? value : JSON.stringify(value, null, 2);
2337
1189
  }
2338
1190
 
1191
+ // src/cli/diff-preview.ts
1192
+ import { readFile as readFile3 } from "fs/promises";
1193
+ import path11 from "path";
1194
+ var CONTEXT_LINES = 2;
1195
+ var MAX_HUNK_LINES = 15;
1196
+ var MAX_TOTAL_LINES = 12;
1197
+ var NEW_FILE_PREVIEW_LINES = 10;
1198
+ var MAX_DP_CELLS = 25e4;
1199
+ function diffOps(oldText, newText) {
1200
+ const a = oldText.split("\n");
1201
+ const b = newText.split("\n");
1202
+ let start = 0;
1203
+ while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
1204
+ let endA = a.length;
1205
+ let endB = b.length;
1206
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
1207
+ endA -= 1;
1208
+ endB -= 1;
1209
+ }
1210
+ const prefix = a.slice(0, start).map((text) => ({ kind: "context", text }));
1211
+ const suffix = a.slice(endA).map((text) => ({ kind: "context", text }));
1212
+ const midA = a.slice(start, endA);
1213
+ const midB = b.slice(start, endB);
1214
+ const prefixLen = prefix.length;
1215
+ const ops = [...prefix];
1216
+ if (midA.length * midB.length > MAX_DP_CELLS) {
1217
+ for (const text of midA) ops.push({ kind: "del", text });
1218
+ for (const text of midB) ops.push({ kind: "add", text });
1219
+ } else if (midA.length > 0 || midB.length > 0) {
1220
+ const rows = midA.length + 1;
1221
+ const cols = midB.length + 1;
1222
+ const dp = new Uint32Array(rows * cols);
1223
+ const cell = (r, c) => dp[r * cols + c] ?? 0;
1224
+ for (let i2 = 1; i2 < rows; i2++) {
1225
+ for (let j2 = 1; j2 < cols; j2++) {
1226
+ dp[i2 * cols + j2] = midA[i2 - 1] === midB[j2 - 1] ? cell(i2 - 1, j2 - 1) + 1 : Math.max(cell(i2 - 1, j2), cell(i2, j2 - 1));
1227
+ }
1228
+ }
1229
+ const lineAt = (lines, index) => lines[index] ?? "";
1230
+ const middle = [];
1231
+ let i = midA.length;
1232
+ let j = midB.length;
1233
+ while (i > 0 && j > 0) {
1234
+ if (midA[i - 1] === midB[j - 1]) {
1235
+ middle.push({ kind: "context", text: lineAt(midA, i - 1) });
1236
+ i -= 1;
1237
+ j -= 1;
1238
+ } else if (cell(i, j - 1) >= cell(i - 1, j)) {
1239
+ middle.push({ kind: "add", text: lineAt(midB, j - 1) });
1240
+ j -= 1;
1241
+ } else {
1242
+ middle.push({ kind: "del", text: lineAt(midA, i - 1) });
1243
+ i -= 1;
1244
+ }
1245
+ }
1246
+ while (i > 0) {
1247
+ middle.push({ kind: "del", text: lineAt(midA, i - 1) });
1248
+ i -= 1;
1249
+ }
1250
+ while (j > 0) {
1251
+ middle.push({ kind: "add", text: lineAt(midB, j - 1) });
1252
+ j -= 1;
1253
+ }
1254
+ middle.reverse();
1255
+ ops.push(...middle);
1256
+ }
1257
+ ops.push(...suffix);
1258
+ return ops.length === prefixLen + suffix.length ? [] : ops;
1259
+ }
1260
+ function hunkRanges(ops) {
1261
+ const changes = [];
1262
+ for (let i = 0; i < ops.length; i++) {
1263
+ const op = ops[i];
1264
+ if (op && op.kind !== "context") changes.push(i);
1265
+ }
1266
+ const ranges = [];
1267
+ let groupStart = 0;
1268
+ for (let k = 1; k <= changes.length; k++) {
1269
+ const cur = changes[k];
1270
+ const prev = changes[k - 1];
1271
+ const first = changes[groupStart];
1272
+ if (prev === void 0 || first === void 0) break;
1273
+ if (cur === void 0 || cur - prev > CONTEXT_LINES * 2 + 1) {
1274
+ ranges.push([
1275
+ Math.max(0, first - CONTEXT_LINES),
1276
+ Math.min(ops.length, prev + CONTEXT_LINES + 1)
1277
+ ]);
1278
+ groupStart = k;
1279
+ }
1280
+ }
1281
+ return ranges;
1282
+ }
1283
+ function buildDiffLines(oldText, newText) {
1284
+ const ops = diffOps(oldText, newText);
1285
+ if (ops.length === 0) return [];
1286
+ const ranges = hunkRanges(ops);
1287
+ const lines = [];
1288
+ let omitted = 0;
1289
+ for (const [rangeIndex, [start, end]] of ranges.entries()) {
1290
+ if (rangeIndex > 0) lines.push({ kind: "marker", text: "\xB7\xB7\xB7" });
1291
+ const hunk = ops.slice(start, end);
1292
+ let shown = hunk;
1293
+ if (hunk.length > MAX_HUNK_LINES) {
1294
+ const headCount = Math.ceil(MAX_HUNK_LINES / 2);
1295
+ shown = [...hunk.slice(0, headCount), ...hunk.slice(headCount - MAX_HUNK_LINES)];
1296
+ }
1297
+ if (hunk.length > shown.length) {
1298
+ omitted += hunk.length - shown.length;
1299
+ }
1300
+ for (const op of shown) lines.push(op);
1301
+ }
1302
+ if (lines.length > MAX_TOTAL_LINES) {
1303
+ omitted += lines.length - MAX_TOTAL_LINES;
1304
+ lines.length = MAX_TOTAL_LINES;
1305
+ }
1306
+ if (omitted > 0) lines.push({ kind: "marker", text: `... (${omitted} more lines)` });
1307
+ return lines;
1308
+ }
1309
+ function parseGitDiffLines(diffText) {
1310
+ return diffText.split("\n").map((raw) => {
1311
+ if (raw.startsWith("+") && !raw.startsWith("+++")) {
1312
+ return { kind: "add", text: raw.slice(1) };
1313
+ }
1314
+ if (raw.startsWith("-") && !raw.startsWith("---")) {
1315
+ return { kind: "del", text: raw.slice(1) };
1316
+ }
1317
+ if (raw.startsWith("@@") || raw.startsWith("diff ") || raw.startsWith("index ") || raw.startsWith("#") || raw.startsWith("+++") || raw.startsWith("---")) {
1318
+ return { kind: "marker", text: raw };
1319
+ }
1320
+ return { kind: "context", text: raw.startsWith(" ") ? raw.slice(1) : raw };
1321
+ });
1322
+ }
1323
+ function getStringArg(args, key) {
1324
+ if (typeof args === "object" && args !== null) {
1325
+ const value = args[key];
1326
+ if (typeof value === "string") return value;
1327
+ }
1328
+ return void 0;
1329
+ }
1330
+ function applyEdit(content, oldString, newString, replaceAll) {
1331
+ let count = 0;
1332
+ let idx = content.indexOf(oldString);
1333
+ while (idx !== -1) {
1334
+ count += 1;
1335
+ idx = content.indexOf(oldString, idx + oldString.length);
1336
+ }
1337
+ if (count === 0) return null;
1338
+ if (count > 1 && !replaceAll) return null;
1339
+ return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
1340
+ }
1341
+ function newFilePreview(content, label) {
1342
+ const all = content.split("\n");
1343
+ const shown = all.slice(0, NEW_FILE_PREVIEW_LINES);
1344
+ const lines = shown.map((text) => ({ kind: "add", text }));
1345
+ if (all.length > shown.length) {
1346
+ lines.push({ kind: "marker", text: `... (${all.length - shown.length} more lines)` });
1347
+ }
1348
+ return { type: "new-file", label, lines };
1349
+ }
1350
+ async function generateDiffPreview(toolName, args, cwd) {
1351
+ if (toolName !== "edit_file" && toolName !== "write_file") return null;
1352
+ const filePath = getStringArg(args, "path");
1353
+ if (filePath === void 0) return null;
1354
+ const absolute = path11.resolve(cwd, filePath);
1355
+ let existing;
1356
+ try {
1357
+ existing = await readFile3(absolute, "utf8");
1358
+ } catch {
1359
+ existing = null;
1360
+ }
1361
+ if (toolName === "edit_file") {
1362
+ const oldString = getStringArg(args, "old_string");
1363
+ const newString = getStringArg(args, "new_string");
1364
+ if (existing === null || oldString === void 0 || newString === void 0) return null;
1365
+ const replaceAll = typeof args === "object" && args !== null && args.replace_all === true;
1366
+ const updated = applyEdit(existing, oldString, newString, replaceAll);
1367
+ if (updated === null) return null;
1368
+ return { type: "diff", label: filePath, lines: buildDiffLines(existing, updated) };
1369
+ }
1370
+ const content = getStringArg(args, "content");
1371
+ if (content === void 0) return null;
1372
+ if (existing === null) return newFilePreview(content, filePath);
1373
+ return { type: "diff", label: filePath, lines: buildDiffLines(existing, content) };
1374
+ }
1375
+
2339
1376
  // src/cli/commands/builtin.ts
2340
1377
  function registerBuiltinCommands(registry) {
2341
1378
  registry.register({
@@ -2359,6 +1396,37 @@ ${lines.join("\n")}`);
2359
1396
  ctx.clearMessages();
2360
1397
  }
2361
1398
  });
1399
+ registry.register({
1400
+ name: "new",
1401
+ description: "Start a new session with a clean context",
1402
+ usage: "/new",
1403
+ async run(_args, ctx) {
1404
+ if (!ctx.newSession) {
1405
+ ctx.addSystemMessage("/new: this context cannot start a new session.");
1406
+ return;
1407
+ }
1408
+ ctx.addSystemMessage(await ctx.newSession());
1409
+ }
1410
+ });
1411
+ registry.register({
1412
+ name: "clear-sessions",
1413
+ description: "Delete stored sessions: this directory by default, every session with --all (the current session is kept)",
1414
+ usage: "/clear-sessions [--all]",
1415
+ async run(args, ctx) {
1416
+ const arg = args.trim();
1417
+ if (arg !== "" && arg !== "--all") {
1418
+ ctx.addSystemMessage(
1419
+ `/clear-sessions: unknown argument "${arg}". Usage: /clear-sessions [--all]`
1420
+ );
1421
+ return;
1422
+ }
1423
+ if (!ctx.clearSessions) {
1424
+ ctx.addSystemMessage("/clear-sessions: this context cannot delete sessions.");
1425
+ return;
1426
+ }
1427
+ ctx.addSystemMessage(await ctx.clearSessions(arg === "--all"));
1428
+ }
1429
+ });
2362
1430
  registry.register({
2363
1431
  name: "exit",
2364
1432
  description: "Exit the application",
@@ -2390,12 +1458,15 @@ ${lines.join("\n")}`);
2390
1458
  registry.register({
2391
1459
  name: "resume",
2392
1460
  description: "List sessions or resume a session by id",
2393
- usage: "/resume [sessionId]",
1461
+ usage: "/resume [sessionId | --all]",
2394
1462
  async run(args, ctx) {
2395
- if (!args) {
1463
+ const arg = args.trim();
1464
+ if (arg === "--all") {
1465
+ ctx.addSystemMessage(await ctx.listSessions(true));
1466
+ } else if (!arg) {
2396
1467
  ctx.addSystemMessage(await ctx.listSessions());
2397
1468
  } else {
2398
- ctx.addSystemMessage(await ctx.resumeSession(args));
1469
+ ctx.addSystemMessage(await ctx.resumeSession(arg));
2399
1470
  }
2400
1471
  }
2401
1472
  });
@@ -2423,6 +1494,14 @@ ${lines.join("\n")}`);
2423
1494
  ctx.addSystemMessage(ctx.showUsage());
2424
1495
  }
2425
1496
  });
1497
+ registry.register({
1498
+ name: "usage",
1499
+ description: "Show token usage aggregated across all sessions",
1500
+ usage: "/usage",
1501
+ async run(_args, ctx) {
1502
+ ctx.addSystemMessage(await ctx.showGlobalUsage());
1503
+ }
1504
+ });
2426
1505
  registry.register({
2427
1506
  name: "config",
2428
1507
  description: "Show the current configuration",
@@ -2471,6 +1550,14 @@ ${lines.join("\n")}`);
2471
1550
  ctx.addSystemMessage(await ctx.undo());
2472
1551
  }
2473
1552
  });
1553
+ registry.register({
1554
+ name: "rewind",
1555
+ description: "List file-change checkpoints, or rewind to just before one: restore files and retract the conversation",
1556
+ usage: "/rewind [n]",
1557
+ async run(args, ctx) {
1558
+ ctx.addSystemMessage(await ctx.rewind(args.trim()));
1559
+ }
1560
+ });
2474
1561
  registry.register({
2475
1562
  name: "init",
2476
1563
  description: "Generate an AGENTS.md for the current project",
@@ -2487,11 +1574,97 @@ ${lines.join("\n")}`);
2487
1574
  ctx.addSystemMessage(await ctx.runDoctor());
2488
1575
  }
2489
1576
  });
1577
+ registry.register({
1578
+ name: "commit",
1579
+ description: "Analyze uncommitted changes and create a git commit (Conventional Commits)",
1580
+ usage: "/commit [instructions]",
1581
+ async run(args, ctx) {
1582
+ const cwd = ctx.cwd ?? process.cwd();
1583
+ if (!isGitRepo(cwd)) {
1584
+ ctx.addSystemMessage("/commit: not a git repository \u2014 nothing to do.");
1585
+ return;
1586
+ }
1587
+ const context = collectCommitContext(cwd);
1588
+ if (!context) {
1589
+ ctx.addSystemMessage("/commit: working tree clean \u2014 nothing to commit.");
1590
+ return;
1591
+ }
1592
+ const prompt = buildCommitPrompt(context, args);
1593
+ if (ctx.submitPrompt) {
1594
+ return ctx.submitPrompt(prompt);
1595
+ }
1596
+ ctx.addSystemMessage("/commit: this context cannot submit prompts to the model.");
1597
+ }
1598
+ });
1599
+ registry.register({
1600
+ name: "copy",
1601
+ description: "Copy the last assistant reply to the clipboard (`all` for the whole conversation)",
1602
+ usage: "/copy [all]",
1603
+ async run(args, ctx) {
1604
+ const arg = args.trim();
1605
+ if (arg !== "" && arg !== "all") {
1606
+ ctx.addSystemMessage(`/copy: unknown argument "${arg}". Usage: /copy [all]`);
1607
+ return;
1608
+ }
1609
+ if (!ctx.conversationText || !ctx.copyToClipboard) {
1610
+ ctx.addSystemMessage("/copy: this context cannot access the clipboard.");
1611
+ return;
1612
+ }
1613
+ const scope = arg === "all" ? "all" : "last";
1614
+ const text = ctx.conversationText(scope);
1615
+ if (!text) {
1616
+ ctx.addSystemMessage(
1617
+ scope === "all" ? "Nothing to copy yet." : "No assistant reply yet \u2014 nothing to copy."
1618
+ );
1619
+ return;
1620
+ }
1621
+ const ok = await ctx.copyToClipboard(text);
1622
+ ctx.addSystemMessage(
1623
+ ok ? `Copied ${text.length} chars to clipboard.` : "Copy failed \u2014 no clipboard command available on this platform."
1624
+ );
1625
+ }
1626
+ });
1627
+ registry.register({
1628
+ name: "diff",
1629
+ description: "Show uncommitted changes (git status + colored diff)",
1630
+ usage: "/diff",
1631
+ run(_args, ctx) {
1632
+ const cwd = ctx.cwd ?? process.cwd();
1633
+ if (!isGitRepo(cwd)) {
1634
+ ctx.addSystemMessage("/diff: not a git repository \u2014 nothing to show.");
1635
+ return;
1636
+ }
1637
+ const result = collectWorkingDiff(cwd);
1638
+ if (!result || result.status === "") {
1639
+ ctx.addSystemMessage("Working tree clean \u2014 no uncommitted changes.");
1640
+ return;
1641
+ }
1642
+ const header = `git status --short:
1643
+ ${result.status}`;
1644
+ if (result.diff === "") {
1645
+ ctx.addSystemMessage(
1646
+ `${header}
1647
+
1648
+ Only untracked files \u2014 no tracked modifications to diff.`
1649
+ );
1650
+ return;
1651
+ }
1652
+ const note = result.truncated ? `diff truncated at ${WORKING_DIFF_MAX_LINES} lines` : void 0;
1653
+ if (ctx.showDiff) {
1654
+ ctx.showDiff(header, parseGitDiffLines(result.diff), note);
1655
+ return;
1656
+ }
1657
+ ctx.addSystemMessage(`${header}
1658
+
1659
+ ${result.diff}${note ? `
1660
+ ... (${note})` : ""}`);
1661
+ }
1662
+ });
2490
1663
  }
2491
1664
 
2492
1665
  // src/cli/commands/custom.ts
2493
1666
  import { readFileSync, readdirSync } from "fs";
2494
- import path17 from "path";
1667
+ import path12 from "path";
2495
1668
  var NAME_PATTERN = /^[a-z0-9-]+$/;
2496
1669
  var DESCRIPTION_PATTERN = /^<!--\s*description:\s*(.+?)\s*-->\s*$/;
2497
1670
  var DEFAULT_DESCRIPTION = "Custom prompt command";
@@ -2526,14 +1699,14 @@ function scanDir(dir, into) {
2526
1699
  if (!entry.endsWith(".md")) continue;
2527
1700
  const name = entry.slice(0, -".md".length);
2528
1701
  if (!NAME_PATTERN.test(name)) continue;
2529
- const parsed = parseCommandFile(path17.join(dir, entry), name);
1702
+ const parsed = parseCommandFile(path12.join(dir, entry), name);
2530
1703
  if (parsed) into.set(name, parsed);
2531
1704
  }
2532
1705
  }
2533
1706
  function loadCustomCommands(cwd, home = starHome()) {
2534
1707
  const byName = /* @__PURE__ */ new Map();
2535
- scanDir(path17.join(home, "commands"), byName);
2536
- scanDir(path17.join(cwd, ".star", "commands"), byName);
1708
+ scanDir(path12.join(home, "commands"), byName);
1709
+ scanDir(path12.join(cwd, ".star", "commands"), byName);
2537
1710
  return [...byName.values()];
2538
1711
  }
2539
1712
  function registerCustomCommands(registry, cwd, home) {
@@ -2558,8 +1731,8 @@ function registerCustomCommands(registry, cwd, home) {
2558
1731
  }
2559
1732
 
2560
1733
  // src/cli/commands/doctor.ts
2561
- import fs5 from "fs/promises";
2562
- import path18 from "path";
1734
+ import fs8 from "fs/promises";
1735
+ import path13 from "path";
2563
1736
  var MIN_NODE_MAJOR = 20;
2564
1737
  var REGISTRY_URL = "https://registry.npmjs.org/";
2565
1738
  var REGISTRY_TIMEOUT_MS = 5e3;
@@ -2573,7 +1746,7 @@ function nodeMajor(version) {
2573
1746
  }
2574
1747
  async function pathExists(target) {
2575
1748
  try {
2576
- await fs5.access(target);
1749
+ await fs8.access(target);
2577
1750
  return true;
2578
1751
  } catch {
2579
1752
  return false;
@@ -2611,7 +1784,7 @@ async function runDoctor(opts) {
2611
1784
  detail: `${spec.label} (${spec.shell})`
2612
1785
  });
2613
1786
  }
2614
- const configPath = path18.join(home, "config.toml");
1787
+ const configPath = path13.join(home, "config.toml");
2615
1788
  const configExists = await pathExists(configPath);
2616
1789
  checks.push({
2617
1790
  name: "STAR_HOME / config",
@@ -2648,14 +1821,14 @@ async function runDoctor(opts) {
2648
1821
  });
2649
1822
  }
2650
1823
  }
2651
- const sessions = path18.join(home, "sessions");
1824
+ const sessions = path13.join(home, "sessions");
2652
1825
  let writable = false;
2653
1826
  let writeError = "";
2654
1827
  try {
2655
- await fs5.mkdir(sessions, { recursive: true });
2656
- const probe = path18.join(sessions, `.doctor-probe-${process.pid}`);
2657
- await fs5.writeFile(probe, "ok");
2658
- await fs5.unlink(probe);
1828
+ await fs8.mkdir(sessions, { recursive: true });
1829
+ const probe = path13.join(sessions, `.doctor-probe-${process.pid}`);
1830
+ await fs8.writeFile(probe, "ok");
1831
+ await fs8.unlink(probe);
2659
1832
  writable = true;
2660
1833
  } catch (error) {
2661
1834
  writeError = error instanceof Error ? error.message : String(error);
@@ -2690,15 +1863,15 @@ function formatDoctorReport(report) {
2690
1863
  }
2691
1864
 
2692
1865
  // src/cli/commands/init-project.ts
2693
- import fs6 from "fs/promises";
2694
- import path19 from "path";
2695
- import { generateText as generateText2 } from "ai";
1866
+ import fs9 from "fs/promises";
1867
+ import path14 from "path";
1868
+ import { generateText } from "ai";
2696
1869
  var IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", "coverage"]);
2697
1870
  var MAX_ENTRIES = 20;
2698
1871
  var README_HEAD_LINES = 5;
2699
1872
  async function fileExists2(target) {
2700
1873
  try {
2701
- await fs6.access(target);
1874
+ await fs9.access(target);
2702
1875
  return true;
2703
1876
  } catch {
2704
1877
  return false;
@@ -2706,7 +1879,7 @@ async function fileExists2(target) {
2706
1879
  }
2707
1880
  async function readPackageJson(cwd) {
2708
1881
  try {
2709
- const raw = await fs6.readFile(path19.join(cwd, "package.json"), "utf8");
1882
+ const raw = await fs9.readFile(path14.join(cwd, "package.json"), "utf8");
2710
1883
  const parsed = JSON.parse(raw);
2711
1884
  if (typeof parsed !== "object" || parsed === null) return null;
2712
1885
  return parsed;
@@ -2723,10 +1896,10 @@ function stringRecord(value) {
2723
1896
  return out;
2724
1897
  }
2725
1898
  async function detectPackageManager(cwd) {
2726
- if (await fileExists2(path19.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
2727
- if (await fileExists2(path19.join(cwd, "yarn.lock"))) return "yarn";
2728
- if (await fileExists2(path19.join(cwd, "package-lock.json"))) return "npm";
2729
- if (await fileExists2(path19.join(cwd, "bun.lockb"))) return "bun";
1899
+ if (await fileExists2(path14.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
1900
+ if (await fileExists2(path14.join(cwd, "yarn.lock"))) return "yarn";
1901
+ if (await fileExists2(path14.join(cwd, "package-lock.json"))) return "npm";
1902
+ if (await fileExists2(path14.join(cwd, "bun.lockb"))) return "bun";
2730
1903
  return null;
2731
1904
  }
2732
1905
  function inferCommands(pm, scripts) {
@@ -2741,7 +1914,7 @@ function inferCommands(pm, scripts) {
2741
1914
  }
2742
1915
  async function listEntries(cwd) {
2743
1916
  try {
2744
- const dirents = await fs6.readdir(cwd, { withFileTypes: true });
1917
+ const dirents = await fs9.readdir(cwd, { withFileTypes: true });
2745
1918
  const entries = [];
2746
1919
  for (const dirent of dirents) {
2747
1920
  if (dirent.isDirectory()) {
@@ -2760,7 +1933,7 @@ async function listEntries(cwd) {
2760
1933
  async function readReadmeHead(cwd) {
2761
1934
  for (const name of ["README.md", "readme.md", "README"]) {
2762
1935
  try {
2763
- const raw = await fs6.readFile(path19.join(cwd, name), "utf8");
1936
+ const raw = await fs9.readFile(path14.join(cwd, name), "utf8");
2764
1937
  return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).slice(0, README_HEAD_LINES);
2765
1938
  } catch {
2766
1939
  }
@@ -2773,14 +1946,14 @@ async function scanProject(cwd) {
2773
1946
  const scripts = stringRecord(pkg?.scripts);
2774
1947
  return {
2775
1948
  name: typeof pkg?.name === "string" ? pkg.name : null,
2776
- dirName: path19.basename(cwd),
1949
+ dirName: path14.basename(cwd),
2777
1950
  packageManager,
2778
1951
  scripts,
2779
1952
  dependencies: Object.keys(stringRecord(pkg?.dependencies)),
2780
1953
  devDependencies: Object.keys(stringRecord(pkg?.devDependencies)),
2781
1954
  commands: inferCommands(packageManager, scripts),
2782
- hasTsconfig: await fileExists2(path19.join(cwd, "tsconfig.json")),
2783
- isGitRepo: await fileExists2(path19.join(cwd, ".git")),
1955
+ hasTsconfig: await fileExists2(path14.join(cwd, "tsconfig.json")),
1956
+ isGitRepo: await fileExists2(path14.join(cwd, ".git")),
2784
1957
  readmeHead: await readReadmeHead(cwd),
2785
1958
  entries: await listEntries(cwd)
2786
1959
  };
@@ -2833,7 +2006,7 @@ var INIT_SYSTEM_PROMPT = [
2833
2006
  "Output only the Markdown, no preamble, no code fences."
2834
2007
  ].join("\n");
2835
2008
  async function polishWithModel(facts, model) {
2836
- const { text } = await generateText2({
2009
+ const { text } = await generateText({
2837
2010
  model,
2838
2011
  system: INIT_SYSTEM_PROMPT,
2839
2012
  prompt: `Project facts:
@@ -2847,7 +2020,7 @@ Write the AGENTS.md.`
2847
2020
  }
2848
2021
  async function initProject(opts) {
2849
2022
  const { cwd, args, model } = opts;
2850
- const target = path19.join(cwd, "AGENTS.md");
2023
+ const target = path14.join(cwd, "AGENTS.md");
2851
2024
  const force = args.split(/\s+/).includes("force");
2852
2025
  if (!force && await fileExists2(target)) {
2853
2026
  return {
@@ -2881,7 +2054,7 @@ async function initProject(opts) {
2881
2054
  content = renderAgentsMd(facts);
2882
2055
  note = " No model available; used the template \u2014 review and refine manually.";
2883
2056
  }
2884
- await fs6.writeFile(target, content);
2057
+ await fs9.writeFile(target, content);
2885
2058
  const lines = content.split("\n").length;
2886
2059
  const how = usedLlm ? "generated with model refinement" : "generated from template";
2887
2060
  return {
@@ -2922,6 +2095,44 @@ function parseSlashCommand(input) {
2922
2095
  };
2923
2096
  }
2924
2097
 
2098
+ // src/cli/commands/rewind.ts
2099
+ import path15 from "path";
2100
+ function planRewind(snapshots, id) {
2101
+ const target = snapshots.find((snapshot) => snapshot.id === id);
2102
+ if (!target) return null;
2103
+ const affected = snapshots.filter((snapshot) => snapshot.id >= id);
2104
+ let messageIndex = null;
2105
+ for (const snapshot of affected) {
2106
+ if (snapshot.messageIndex >= 0) {
2107
+ messageIndex = messageIndex === null ? snapshot.messageIndex : Math.min(messageIndex, snapshot.messageIndex);
2108
+ }
2109
+ }
2110
+ return { target, affected, messageIndex };
2111
+ }
2112
+ function displayPath(filePath, cwd) {
2113
+ const relative = path15.relative(cwd, filePath);
2114
+ return relative !== "" && !relative.startsWith("..") ? relative : filePath;
2115
+ }
2116
+ function formatTime(timestamp) {
2117
+ const pad = (n) => String(n).padStart(2, "0");
2118
+ const date = new Date(timestamp);
2119
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
2120
+ }
2121
+ function formatCheckpointList(snapshots, cwd) {
2122
+ if (snapshots.length === 0) {
2123
+ return "No checkpoints yet \u2014 checkpoints are recorded on every successful write_file/edit_file.";
2124
+ }
2125
+ const lines = snapshots.map((snapshot) => {
2126
+ const label = snapshot.existed ? snapshot.toolName : `${snapshot.toolName} (new file)`;
2127
+ return `#${snapshot.id} ${formatTime(snapshot.timestamp)} ${label} ${displayPath(snapshot.path, cwd)}`;
2128
+ });
2129
+ return [
2130
+ `Checkpoints (${snapshots.length}):`,
2131
+ ...lines,
2132
+ "Use /rewind <n> to restore files and conversation to just before checkpoint n."
2133
+ ].join("\n");
2134
+ }
2135
+
2925
2136
  // src/cli/components/ExitPlanPrompt.tsx
2926
2137
  import { Box, Text, useInput } from "ink";
2927
2138
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -2940,7 +2151,7 @@ function ExitPlanPrompt({ onDecision }) {
2940
2151
 
2941
2152
  // src/cli/components/InputBox.tsx
2942
2153
  import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
2943
- import { useRef, useState } from "react";
2154
+ import { useEffect, useRef, useState } from "react";
2944
2155
 
2945
2156
  // src/cli/commands/suggest.ts
2946
2157
  var MAX_SUGGESTIONS = 5;
@@ -2951,36 +2162,116 @@ function filterCommands(input, commands) {
2951
2162
  return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(prefix)).sort((a, b) => a.name.localeCompare(b.name)).slice(0, MAX_SUGGESTIONS);
2952
2163
  }
2953
2164
 
2165
+ // src/cli/path-suggest.ts
2166
+ import { readdir } from "fs/promises";
2167
+ import path16 from "path";
2168
+ var MAX_SCAN_ENTRIES = 2e3;
2169
+ function extractAtToken(value, cursor) {
2170
+ const before = value.slice(0, cursor);
2171
+ const match = /(?:^|\s)@(\S*)$/.exec(before);
2172
+ if (!match) return null;
2173
+ const token = match[1] ?? "";
2174
+ return { token, start: cursor - token.length - 1, end: cursor };
2175
+ }
2176
+ async function suggestPaths(token, cwd, maxScanEntries = MAX_SCAN_ENTRIES, maxSuggestions = MAX_SUGGESTIONS) {
2177
+ const normalized = token.replace(/\\/g, "/");
2178
+ const slash = normalized.lastIndexOf("/");
2179
+ const dirPart = slash >= 0 ? normalized.slice(0, slash + 1) : "";
2180
+ const basePrefix = slash >= 0 ? normalized.slice(slash + 1) : normalized;
2181
+ if (dirPart.split("/").some((segment) => SKIP_DIRS.has(segment))) return [];
2182
+ const dirAbs = path16.resolve(cwd, dirPart === "" ? "." : dirPart);
2183
+ const entries = await readdir(dirAbs, { withFileTypes: true }).catch(() => null);
2184
+ if (!entries) return [];
2185
+ const lower = basePrefix.toLowerCase();
2186
+ const matches = [];
2187
+ let scanned = 0;
2188
+ for (const entry of entries) {
2189
+ if (scanned >= maxScanEntries) break;
2190
+ scanned += 1;
2191
+ const isDir = entry.isDirectory();
2192
+ if (!isDir && !entry.isFile()) continue;
2193
+ if (isDir && SKIP_DIRS.has(entry.name)) continue;
2194
+ if (entry.name.startsWith(".") && !basePrefix.startsWith(".")) continue;
2195
+ if (!entry.name.toLowerCase().startsWith(lower)) continue;
2196
+ matches.push({ path: `${dirPart}${entry.name}${isDir ? "/" : ""}`, isDir });
2197
+ }
2198
+ matches.sort((a, b) => a.path.localeCompare(b.path));
2199
+ return matches.slice(0, maxSuggestions);
2200
+ }
2201
+
2954
2202
  // src/cli/components/InputBox.tsx
2955
2203
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2956
2204
  function InputBox({
2957
2205
  isStreaming,
2958
2206
  disabled,
2959
2207
  commands,
2208
+ cwd,
2209
+ refill,
2960
2210
  onSubmit,
2961
2211
  onInterrupt,
2962
- onExit
2212
+ onExit,
2213
+ onPasteImage
2963
2214
  }) {
2964
2215
  const [value, setValue] = useState("");
2965
2216
  const [cursor, setCursor] = useState(0);
2966
2217
  const [history, setHistory] = useState([]);
2967
2218
  const [highlight, setHighlight] = useState(0);
2968
2219
  const [suggestionsDismissed, setSuggestionsDismissed] = useState(false);
2220
+ const [pathSuggestions, setPathSuggestions] = useState([]);
2969
2221
  const historyIndexRef = useRef(null);
2970
2222
  const draftRef = useRef("");
2223
+ const refillSeqRef = useRef(0);
2971
2224
  const edit = (next, nextCursor) => {
2972
2225
  setValue(next);
2973
2226
  setCursor(Math.max(0, Math.min(nextCursor, next.length)));
2974
2227
  setHighlight(0);
2975
2228
  setSuggestionsDismissed(false);
2976
2229
  };
2977
- const suggestions = isStreaming || disabled || suggestionsDismissed || !commands ? [] : filterCommands(value, commands);
2978
- const activeIndex = suggestions.length === 0 ? 0 : Math.min(highlight, suggestions.length - 1);
2230
+ useEffect(() => {
2231
+ if (refill && refill.seq !== refillSeqRef.current) {
2232
+ refillSeqRef.current = refill.seq;
2233
+ edit(refill.text, refill.text.length);
2234
+ }
2235
+ });
2236
+ const suggestionsEnabled = !isStreaming && !disabled && !suggestionsDismissed;
2237
+ const suggestions = suggestionsEnabled && commands ? filterCommands(value, commands) : [];
2238
+ useEffect(() => {
2239
+ if (!suggestionsEnabled || !cwd) {
2240
+ setPathSuggestions([]);
2241
+ return;
2242
+ }
2243
+ const token = extractAtToken(value, cursor);
2244
+ if (!token) {
2245
+ setPathSuggestions([]);
2246
+ return;
2247
+ }
2248
+ let cancelled = false;
2249
+ void suggestPaths(token.token, cwd).then((result) => {
2250
+ if (!cancelled) setPathSuggestions(result);
2251
+ });
2252
+ return () => {
2253
+ cancelled = true;
2254
+ };
2255
+ }, [value, cursor, cwd, suggestionsEnabled]);
2256
+ const showPathSuggestions = suggestions.length === 0 && pathSuggestions.length > 0;
2257
+ const suggestionCount = suggestions.length > 0 ? suggestions.length : pathSuggestions.length;
2258
+ const activeIndex = suggestionCount === 0 ? 0 : Math.min(highlight, suggestionCount - 1);
2979
2259
  const completeHighlighted = () => {
2980
- const cmd = suggestions[activeIndex];
2981
- if (!cmd) return;
2982
- const text = `/${cmd.name} `;
2983
- edit(text, text.length);
2260
+ if (suggestions.length > 0) {
2261
+ const cmd = suggestions[activeIndex];
2262
+ if (!cmd) return;
2263
+ const text = `/${cmd.name} `;
2264
+ edit(text, text.length);
2265
+ return;
2266
+ }
2267
+ const sugg = pathSuggestions[activeIndex];
2268
+ const token = extractAtToken(value, cursor);
2269
+ if (!sugg || !token) return;
2270
+ const replacement = `@${sugg.path}${sugg.isDir ? "" : " "}`;
2271
+ edit(
2272
+ value.slice(0, token.start) + replacement + value.slice(token.end),
2273
+ token.start + replacement.length
2274
+ );
2984
2275
  };
2985
2276
  useInput2((input, key) => {
2986
2277
  if (key.ctrl && input === "c") {
@@ -2996,9 +2287,13 @@ function InputBox({
2996
2287
  onExit();
2997
2288
  return;
2998
2289
  }
2999
- if (isStreaming || disabled) return;
2290
+ if ((key.meta || key.ctrl) && input === "v") {
2291
+ onPasteImage?.();
2292
+ return;
2293
+ }
2294
+ if (disabled) return;
3000
2295
  if (key.escape) {
3001
- if (suggestions.length > 0) setSuggestionsDismissed(true);
2296
+ if (!isStreaming && suggestionCount > 0) setSuggestionsDismissed(true);
3002
2297
  return;
3003
2298
  }
3004
2299
  if (key.return) {
@@ -3014,13 +2309,13 @@ function InputBox({
3014
2309
  }
3015
2310
  if (key.tab) {
3016
2311
  if (key.shift) return;
3017
- if (suggestions.length > 0) {
2312
+ if (suggestionCount > 0) {
3018
2313
  completeHighlighted();
3019
2314
  return;
3020
2315
  }
3021
2316
  } else if (key.upArrow) {
3022
- if (suggestions.length > 0) {
3023
- setHighlight((prev) => (prev - 1 + suggestions.length) % suggestions.length);
2317
+ if (suggestionCount > 0) {
2318
+ setHighlight((prev) => (prev - 1 + suggestionCount) % suggestionCount);
3024
2319
  return;
3025
2320
  }
3026
2321
  if (history.length === 0) return;
@@ -3034,8 +2329,8 @@ function InputBox({
3034
2329
  edit(entry, entry.length);
3035
2330
  return;
3036
2331
  } else if (key.downArrow) {
3037
- if (suggestions.length > 0) {
3038
- setHighlight((prev) => (prev + 1) % suggestions.length);
2332
+ if (suggestionCount > 0) {
2333
+ setHighlight((prev) => (prev + 1) % suggestionCount);
3039
2334
  return;
3040
2335
  }
3041
2336
  if (historyIndexRef.current === null) return;
@@ -3052,7 +2347,7 @@ function InputBox({
3052
2347
  setCursor((prev) => Math.max(0, prev - 1));
3053
2348
  return;
3054
2349
  } else if (key.rightArrow) {
3055
- if (suggestions.length > 0 && cursor === value.length) {
2350
+ if (suggestionCount > 0 && cursor === value.length) {
3056
2351
  completeHighlighted();
3057
2352
  } else {
3058
2353
  setCursor((prev) => Math.min(value.length, prev + 1));
@@ -3106,14 +2401,47 @@ function InputBox({
3106
2401
  /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: `/${cmd.name}` }),
3107
2402
  /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: ` - ${cmd.description}` })
3108
2403
  ] }, cmd.name)
2404
+ ) }),
2405
+ showPathSuggestions && /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", paddingLeft: 2, children: pathSuggestions.map(
2406
+ (sugg, index) => index === activeIndex ? /* @__PURE__ */ jsx2(Text2, { bold: true, inverse: true, children: `@${sugg.path}` }, sugg.path) : /* @__PURE__ */ jsxs2(Text2, { children: [
2407
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: `@${sugg.path}` }),
2408
+ sugg.isDir && /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " - directory" })
2409
+ ] }, sugg.path)
3109
2410
  ) })
3110
2411
  ] });
3111
2412
  }
3112
2413
 
3113
2414
  // src/cli/components/MessageList.tsx
3114
- import { Box as Box3, Static, Text as Text3 } from "ink";
2415
+ import { Box as Box3, Static, Text as Text4 } from "ink";
3115
2416
  import { memo } from "react";
3116
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
2417
+
2418
+ // src/cli/components/DiffLines.tsx
2419
+ import { Text as Text3 } from "ink";
2420
+ import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
2421
+ function DiffLineView({ line }) {
2422
+ if (line.kind === "add") return /* @__PURE__ */ jsxs3(Text3, { color: "green", children: [
2423
+ "+ ",
2424
+ line.text
2425
+ ] });
2426
+ if (line.kind === "del") return /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
2427
+ "- ",
2428
+ line.text
2429
+ ] });
2430
+ if (line.kind === "marker") return /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: line.text });
2431
+ return /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
2432
+ " ",
2433
+ line.text
2434
+ ] });
2435
+ }
2436
+ function DiffLines({ lines }) {
2437
+ return /* @__PURE__ */ jsx3(Fragment, { children: lines.map((line, index) => (
2438
+ // biome-ignore lint/suspicious/noArrayIndexKey: diff lines have no stable identity
2439
+ /* @__PURE__ */ jsx3(DiffLineView, { line }, index)
2440
+ )) });
2441
+ }
2442
+
2443
+ // src/cli/components/MessageList.tsx
2444
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3117
2445
  var roleStyles = {
3118
2446
  user: { label: "you", color: "cyan" },
3119
2447
  assistant: { label: "star", color: "green" },
@@ -3121,38 +2449,24 @@ var roleStyles = {
3121
2449
  tool: { label: "tool", color: "magenta" }
3122
2450
  };
3123
2451
  var MessageList = memo(function MessageList2({ messages }) {
3124
- return /* @__PURE__ */ jsx3(Static, { items: messages, children: (message) => {
2452
+ return /* @__PURE__ */ jsx4(Static, { items: messages, children: (message) => {
3125
2453
  const marginBottom = message.tight ? 0 : 1;
3126
2454
  if (message.role === "assistant-cont") {
3127
- return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", marginBottom, children: /* @__PURE__ */ jsx3(Text3, { color: "green", children: message.text }) }, message.id);
2455
+ return /* @__PURE__ */ jsx4(Box3, { flexDirection: "column", marginBottom, children: /* @__PURE__ */ jsx4(Text4, { color: "green", children: message.text }) }, message.id);
3128
2456
  }
3129
2457
  const style = roleStyles[message.role];
3130
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginBottom, children: [
3131
- /* @__PURE__ */ jsx3(Text3, { bold: true, color: style.color, children: style.label }),
3132
- /* @__PURE__ */ jsx3(Text3, { color: style.color, children: message.text }),
3133
- message.note && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: message.note })
2458
+ return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", marginBottom, children: [
2459
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: style.color, children: style.label }),
2460
+ /* @__PURE__ */ jsx4(Text4, { color: style.color, children: message.text }),
2461
+ message.diff && /* @__PURE__ */ jsx4(DiffLines, { lines: message.diff }),
2462
+ message.note && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: message.note })
3134
2463
  ] }, message.id);
3135
2464
  } });
3136
2465
  });
3137
2466
 
3138
2467
  // src/cli/components/PermissionPrompt.tsx
3139
- import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
3140
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
3141
- function DiffLineView({ line }) {
3142
- if (line.kind === "add") return /* @__PURE__ */ jsxs4(Text4, { color: "green", children: [
3143
- "+ ",
3144
- line.text
3145
- ] });
3146
- if (line.kind === "del") return /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
3147
- "- ",
3148
- line.text
3149
- ] });
3150
- if (line.kind === "marker") return /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: line.text });
3151
- return /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
3152
- " ",
3153
- line.text
3154
- ] });
3155
- }
2468
+ import { Box as Box4, Text as Text5, useInput as useInput3 } from "ink";
2469
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
3156
2470
  function PermissionPrompt({ request, preview, onDecision }) {
3157
2471
  useInput3((input) => {
3158
2472
  const ch = input.toLowerCase();
@@ -3160,58 +2474,96 @@ function PermissionPrompt({ request, preview, onDecision }) {
3160
2474
  else if (ch === "n") onDecision("no");
3161
2475
  else if (ch === "a") onDecision("always");
3162
2476
  });
3163
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
3164
- /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "yellow", children: [
2477
+ return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2478
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, color: "yellow", children: [
3165
2479
  "Permission required: ",
3166
2480
  request.toolName,
3167
2481
  " (",
3168
2482
  request.level,
3169
2483
  ")"
3170
2484
  ] }),
3171
- preview ? /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
3172
- /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
2485
+ preview ? /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
2486
+ /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3173
2487
  preview.type === "new-file" ? "New file: " : "",
3174
2488
  preview.label
3175
2489
  ] }),
3176
- preview.lines.map((line, index) => (
3177
- // biome-ignore lint/suspicious/noArrayIndexKey: diff lines have no stable identity
3178
- /* @__PURE__ */ jsx4(DiffLineView, { line }, index)
3179
- ))
3180
- ] }) : /* @__PURE__ */ jsx4(Text4, { children: summarizeArgs(request.args) }),
3181
- /* @__PURE__ */ jsx4(Text4, { children: "[y] allow [n] deny [a] always (saved to config)" })
2490
+ /* @__PURE__ */ jsx5(DiffLines, { lines: preview.lines })
2491
+ ] }) : /* @__PURE__ */ jsx5(Text5, { children: summarizeArgs(request.args) }),
2492
+ /* @__PURE__ */ jsx5(Text5, { children: "[y] allow [n] deny [a] always (saved to config)" })
2493
+ ] });
2494
+ }
2495
+
2496
+ // src/cli/components/RewindConfirmPrompt.tsx
2497
+ import { Box as Box5, Text as Text6, useInput as useInput4 } from "ink";
2498
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
2499
+ function RewindConfirmPrompt({ summary, onDecision }) {
2500
+ useInput4((input, key) => {
2501
+ const ch = input.toLowerCase();
2502
+ if (ch === "y") onDecision("yes");
2503
+ else if (ch === "n" || key.escape) onDecision("no");
2504
+ });
2505
+ return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
2506
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "yellow", children: "Rewind checkpoints" }),
2507
+ /* @__PURE__ */ jsx6(Text6, { children: summary }),
2508
+ /* @__PURE__ */ jsx6(Text6, { children: "[y] rewind [n] cancel (Esc = n)" })
3182
2509
  ] });
3183
2510
  }
3184
2511
 
3185
2512
  // src/cli/components/StatusBar.tsx
3186
- import { Box as Box5, Text as Text5 } from "ink";
2513
+ import { Box as Box6, Text as Text7, useStdout } from "ink";
3187
2514
  import { memo as memo2 } from "react";
3188
- import { jsxs as jsxs5 } from "react/jsx-runtime";
2515
+ import { jsxs as jsxs7 } from "react/jsx-runtime";
3189
2516
  var BG_LABEL_MAX = 40;
2517
+ var WIDE_COLUMNS = 120;
2518
+ function cwdLabel(cwd, wide) {
2519
+ if (wide) return cwd;
2520
+ const base = cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop();
2521
+ return base && base.length > 0 ? base : cwd;
2522
+ }
3190
2523
  var StatusBar = memo2(function StatusBar2({
2524
+ cwd,
3191
2525
  model,
3192
2526
  permissionMode,
3193
- tokens,
2527
+ tokens: tokens2,
2528
+ gitBranch,
2529
+ contextPercent,
2530
+ sessionCostUsd,
3194
2531
  backgroundTasks = []
3195
2532
  }) {
2533
+ const { stdout } = useStdout();
2534
+ const wide = (stdout?.columns ?? 80) >= WIDE_COLUMNS;
3196
2535
  const joined = backgroundTasks.join(", ");
3197
2536
  const labels = joined.length > BG_LABEL_MAX ? `${joined.slice(0, BG_LABEL_MAX)}\u2026` : joined;
3198
- return /* @__PURE__ */ jsxs5(Box5, { justifyContent: "space-between", children: [
3199
- /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3200
- "model: ",
3201
- model
2537
+ return /* @__PURE__ */ jsxs7(Box6, { justifyContent: "space-between", children: [
2538
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2539
+ cwdLabel(cwd, wide),
2540
+ gitBranch ? ` [${gitBranch}]` : ""
3202
2541
  ] }),
3203
- backgroundTasks.length > 0 && /* @__PURE__ */ jsxs5(Text5, { color: "yellow", children: [
2542
+ backgroundTasks.length > 0 && /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
3204
2543
  "bg: ",
3205
2544
  backgroundTasks.length,
3206
2545
  " (",
3207
2546
  labels,
3208
2547
  ")"
3209
2548
  ] }),
3210
- /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3211
- tokens,
2549
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2550
+ "model: ",
2551
+ model
2552
+ ] }),
2553
+ contextPercent != null && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2554
+ "ctx: ",
2555
+ contextPercent,
2556
+ "%"
2557
+ ] }),
2558
+ sessionCostUsd != null && sessionCostUsd > 0 && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2559
+ "$",
2560
+ formatDollars(sessionCostUsd)
2561
+ ] }),
2562
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
2563
+ tokens2,
3212
2564
  " tokens"
3213
2565
  ] }),
3214
- /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
2566
+ /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
3215
2567
  "mode: ",
3216
2568
  permissionMode
3217
2569
  ] })
@@ -3219,247 +2571,104 @@ var StatusBar = memo2(function StatusBar2({
3219
2571
  });
3220
2572
 
3221
2573
  // src/cli/components/StreamingMessage.tsx
3222
- import { Box as Box6, Text as Text6 } from "ink";
2574
+ import { Box as Box7, Text as Text8 } from "ink";
3223
2575
  import { memo as memo3 } from "react";
3224
- import { jsx as jsx5, jsxs as jsxs6 } from "react/jsx-runtime";
2576
+ import { jsx as jsx7, jsxs as jsxs8 } from "react/jsx-runtime";
3225
2577
  var StreamingMessage = memo3(function StreamingMessage2({
3226
- text,
3227
- continuation
3228
- }) {
3229
- return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginBottom: 1, children: [
3230
- !continuation && /* @__PURE__ */ jsx5(Text6, { bold: true, color: "green", children: "star" }),
3231
- /* @__PURE__ */ jsx5(Text6, { color: "green", children: text })
3232
- ] });
3233
- });
3234
-
3235
- // src/cli/components/ThinkingIndicator.tsx
3236
- import { Box as Box7, Text as Text7 } from "ink";
3237
- import { jsxs as jsxs7 } from "react/jsx-runtime";
3238
- var SPINNER_FRAMES = ["-", "\\", "|", "/"];
3239
- var REASONING_TAIL_LENGTH = 200;
3240
- var THOUGHT_SUMMARY_LENGTH = 100;
3241
- function truncateTail(text, max) {
3242
- const flat = text.replace(/\s+/g, " ").trim();
3243
- if (flat.length <= max) return flat;
3244
- return `\u2026${flat.slice(flat.length - max)}`;
3245
- }
3246
- function ThinkingIndicator({
3247
- reasoning,
3248
- frame = 0,
3249
- activity
3250
- }) {
3251
- const tail = reasoning ? truncateTail(reasoning, REASONING_TAIL_LENGTH) : "";
3252
- const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
3253
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
3254
- /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
3255
- spinner,
3256
- " ",
3257
- activity ?? "star is thinking\u2026"
3258
- ] }),
3259
- tail !== "" && /* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
3260
- " ",
3261
- tail
3262
- ] })
3263
- ] });
3264
- }
3265
-
3266
- // src/cli/components/ToolCallCard.tsx
3267
- function formatToolCard(card) {
3268
- const header = `${card.name} ${card.argsSummary}${card.isError ? " [error]" : ""}`;
3269
- if (card.result === void 0) return header;
3270
- const preview = previewLines(card.result, 10);
3271
- return `${header}
3272
- ${preview.text}${preview.truncated ? "\n... (truncated)" : ""}`;
3273
- }
3274
-
3275
- // src/cli/cost.ts
3276
- function formatDollars(cost) {
3277
- let s = cost.toFixed(4).replace(/0+$/, "");
3278
- if (s.endsWith(".")) {
3279
- s += "00";
3280
- } else if (s.split(".")[1]?.length === 1) {
3281
- s += "0";
3282
- }
3283
- return s;
3284
- }
3285
- function estimateCost(usage, modelName, model) {
3286
- if (model?.promptPrice === void 0 || model.completionPrice === void 0) {
3287
- return `Estimated cost: unknown (no price configured for ${modelName})`;
3288
- }
3289
- const cost = (usage.promptTokens * model.promptPrice + usage.completionTokens * model.completionPrice) / 1e6;
3290
- return `Estimated cost: $${formatDollars(cost)} (${model.name} @ $${model.promptPrice}/M prompt, $${model.completionPrice}/M completion)`;
3291
- }
3292
-
3293
- // src/cli/diff-preview.ts
3294
- import { readFile as readFile7 } from "fs/promises";
3295
- import path20 from "path";
3296
- var CONTEXT_LINES = 2;
3297
- var MAX_HUNK_LINES = 15;
3298
- var MAX_TOTAL_LINES = 12;
3299
- var NEW_FILE_PREVIEW_LINES = 10;
3300
- var MAX_DP_CELLS = 25e4;
3301
- function diffOps(oldText, newText) {
3302
- const a = oldText.split("\n");
3303
- const b = newText.split("\n");
3304
- let start = 0;
3305
- while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
3306
- let endA = a.length;
3307
- let endB = b.length;
3308
- while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
3309
- endA -= 1;
3310
- endB -= 1;
3311
- }
3312
- const prefix = a.slice(0, start).map((text) => ({ kind: "context", text }));
3313
- const suffix = a.slice(endA).map((text) => ({ kind: "context", text }));
3314
- const midA = a.slice(start, endA);
3315
- const midB = b.slice(start, endB);
3316
- const prefixLen = prefix.length;
3317
- const ops = [...prefix];
3318
- if (midA.length * midB.length > MAX_DP_CELLS) {
3319
- for (const text of midA) ops.push({ kind: "del", text });
3320
- for (const text of midB) ops.push({ kind: "add", text });
3321
- } else if (midA.length > 0 || midB.length > 0) {
3322
- const rows = midA.length + 1;
3323
- const cols = midB.length + 1;
3324
- const dp = new Uint32Array(rows * cols);
3325
- const cell = (r, c) => dp[r * cols + c] ?? 0;
3326
- for (let i2 = 1; i2 < rows; i2++) {
3327
- for (let j2 = 1; j2 < cols; j2++) {
3328
- dp[i2 * cols + j2] = midA[i2 - 1] === midB[j2 - 1] ? cell(i2 - 1, j2 - 1) + 1 : Math.max(cell(i2 - 1, j2), cell(i2, j2 - 1));
3329
- }
3330
- }
3331
- const lineAt = (lines, index) => lines[index] ?? "";
3332
- const middle = [];
3333
- let i = midA.length;
3334
- let j = midB.length;
3335
- while (i > 0 && j > 0) {
3336
- if (midA[i - 1] === midB[j - 1]) {
3337
- middle.push({ kind: "context", text: lineAt(midA, i - 1) });
3338
- i -= 1;
3339
- j -= 1;
3340
- } else if (cell(i, j - 1) >= cell(i - 1, j)) {
3341
- middle.push({ kind: "add", text: lineAt(midB, j - 1) });
3342
- j -= 1;
3343
- } else {
3344
- middle.push({ kind: "del", text: lineAt(midA, i - 1) });
3345
- i -= 1;
3346
- }
3347
- }
3348
- while (i > 0) {
3349
- middle.push({ kind: "del", text: lineAt(midA, i - 1) });
3350
- i -= 1;
3351
- }
3352
- while (j > 0) {
3353
- middle.push({ kind: "add", text: lineAt(midB, j - 1) });
3354
- j -= 1;
3355
- }
3356
- middle.reverse();
3357
- ops.push(...middle);
3358
- }
3359
- ops.push(...suffix);
3360
- return ops.length === prefixLen + suffix.length ? [] : ops;
2578
+ text,
2579
+ continuation
2580
+ }) {
2581
+ return /* @__PURE__ */ jsxs8(Box7, { flexDirection: "column", marginBottom: 1, children: [
2582
+ !continuation && /* @__PURE__ */ jsx7(Text8, { bold: true, color: "green", children: "star" }),
2583
+ /* @__PURE__ */ jsx7(Text8, { color: "green", children: text })
2584
+ ] });
2585
+ });
2586
+
2587
+ // src/cli/components/ThinkingIndicator.tsx
2588
+ import { Box as Box8, Text as Text9 } from "ink";
2589
+ import { jsxs as jsxs9 } from "react/jsx-runtime";
2590
+ var SPINNER_FRAMES = ["-", "\\", "|", "/"];
2591
+ var REASONING_TAIL_LENGTH = 200;
2592
+ var THOUGHT_SUMMARY_LENGTH = 100;
2593
+ function truncateTail(text, max) {
2594
+ const flat = text.replace(/\s+/g, " ").trim();
2595
+ if (flat.length <= max) return flat;
2596
+ return `\u2026${flat.slice(flat.length - max)}`;
3361
2597
  }
3362
- function hunkRanges(ops) {
3363
- const changes = [];
3364
- for (let i = 0; i < ops.length; i++) {
3365
- const op = ops[i];
3366
- if (op && op.kind !== "context") changes.push(i);
3367
- }
3368
- const ranges = [];
3369
- let groupStart = 0;
3370
- for (let k = 1; k <= changes.length; k++) {
3371
- const cur = changes[k];
3372
- const prev = changes[k - 1];
3373
- const first = changes[groupStart];
3374
- if (prev === void 0 || first === void 0) break;
3375
- if (cur === void 0 || cur - prev > CONTEXT_LINES * 2 + 1) {
3376
- ranges.push([
3377
- Math.max(0, first - CONTEXT_LINES),
3378
- Math.min(ops.length, prev + CONTEXT_LINES + 1)
3379
- ]);
3380
- groupStart = k;
3381
- }
3382
- }
3383
- return ranges;
2598
+ function ThinkingIndicator({
2599
+ reasoning,
2600
+ frame = 0,
2601
+ activity
2602
+ }) {
2603
+ const tail = reasoning ? truncateTail(reasoning, REASONING_TAIL_LENGTH) : "";
2604
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
2605
+ return /* @__PURE__ */ jsxs9(Box8, { flexDirection: "column", children: [
2606
+ /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2607
+ spinner,
2608
+ " ",
2609
+ activity ?? "star is thinking\u2026"
2610
+ ] }),
2611
+ tail !== "" && /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2612
+ " ",
2613
+ tail
2614
+ ] })
2615
+ ] });
3384
2616
  }
3385
- function buildDiffLines(oldText, newText) {
3386
- const ops = diffOps(oldText, newText);
3387
- if (ops.length === 0) return [];
3388
- const ranges = hunkRanges(ops);
3389
- const lines = [];
3390
- let omitted = 0;
3391
- for (const [rangeIndex, [start, end]] of ranges.entries()) {
3392
- if (rangeIndex > 0) lines.push({ kind: "marker", text: "\xB7\xB7\xB7" });
3393
- const hunk = ops.slice(start, end);
3394
- let shown = hunk;
3395
- if (hunk.length > MAX_HUNK_LINES) {
3396
- const headCount = Math.ceil(MAX_HUNK_LINES / 2);
3397
- shown = [...hunk.slice(0, headCount), ...hunk.slice(headCount - MAX_HUNK_LINES)];
3398
- }
3399
- if (hunk.length > shown.length) {
3400
- omitted += hunk.length - shown.length;
3401
- }
3402
- for (const op of shown) lines.push(op);
3403
- }
3404
- if (lines.length > MAX_TOTAL_LINES) {
3405
- omitted += lines.length - MAX_TOTAL_LINES;
3406
- lines.length = MAX_TOTAL_LINES;
3407
- }
3408
- if (omitted > 0) lines.push({ kind: "marker", text: `... (${omitted} more lines)` });
3409
- return lines;
2617
+
2618
+ // src/cli/components/ToolCallCard.tsx
2619
+ function formatToolCard(card) {
2620
+ const header = `${card.name} ${card.argsSummary}${card.isError ? " [error]" : ""}`;
2621
+ if (card.result === void 0) return header;
2622
+ const preview = previewLines(card.result, 10);
2623
+ return `${header}
2624
+ ${preview.text}${preview.truncated ? "\n... (truncated)" : ""}`;
3410
2625
  }
3411
- function getStringArg2(args, key) {
3412
- if (typeof args === "object" && args !== null) {
3413
- const value = args[key];
3414
- if (typeof value === "string") return value;
3415
- }
3416
- return void 0;
2626
+
2627
+ // src/cli/double-esc.ts
2628
+ var DOUBLE_ESC_WINDOW_MS = 600;
2629
+ function isDoubleEscape(previous, now, windowMs = DOUBLE_ESC_WINDOW_MS) {
2630
+ return previous !== null && now - previous <= windowMs;
3417
2631
  }
3418
- function applyEdit(content, oldString, newString, replaceAll) {
3419
- let count = 0;
3420
- let idx = content.indexOf(oldString);
3421
- while (idx !== -1) {
3422
- count += 1;
3423
- idx = content.indexOf(oldString, idx + oldString.length);
3424
- }
3425
- if (count === 0) return null;
3426
- if (count > 1 && !replaceAll) return null;
3427
- return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
2632
+
2633
+ // src/cli/notify.ts
2634
+ var BELL = "\x07";
2635
+ function shouldNotifyBell(input, isTTY) {
2636
+ if (!input.enabled || input.noNotifyEnv || !isTTY) return false;
2637
+ if (input.interrupted) return false;
2638
+ if (input.elapsedMs !== void 0 && input.elapsedMs < input.thresholdSec * 1e3) return false;
2639
+ return true;
3428
2640
  }
3429
- function newFilePreview(content, label) {
3430
- const all = content.split("\n");
3431
- const shown = all.slice(0, NEW_FILE_PREVIEW_LINES);
3432
- const lines = shown.map((text) => ({ kind: "add", text }));
3433
- if (all.length > shown.length) {
3434
- lines.push({ kind: "marker", text: `... (${all.length - shown.length} more lines)` });
3435
- }
3436
- return { type: "new-file", label, lines };
2641
+ function notifyBell(input, deps = {}) {
2642
+ const isTTY = deps.isTTY ?? Boolean(process.stdout.isTTY);
2643
+ if (!shouldNotifyBell(input, isTTY)) return false;
2644
+ (deps.write ?? ((text) => process.stdout.write(text)))(BELL);
2645
+ return true;
3437
2646
  }
3438
- async function generateDiffPreview(toolName, args, cwd) {
3439
- if (toolName !== "edit_file" && toolName !== "write_file") return null;
3440
- const filePath = getStringArg2(args, "path");
3441
- if (filePath === void 0) return null;
3442
- const absolute = path20.resolve(cwd, filePath);
3443
- let existing;
3444
- try {
3445
- existing = await readFile7(absolute, "utf8");
3446
- } catch {
3447
- existing = null;
2647
+
2648
+ // src/cli/queue.ts
2649
+ var PromptQueue = class {
2650
+ items = [];
2651
+ nextId = 1;
2652
+ enqueue(prompt) {
2653
+ this.items.push({ id: this.nextId++, ...prompt });
2654
+ return this.items.length;
2655
+ }
2656
+ dequeue() {
2657
+ return this.items.shift();
2658
+ }
2659
+ // Empties the queue and returns what was dropped (for the "cleared N" note).
2660
+ clear() {
2661
+ const removed = this.items;
2662
+ this.items = [];
2663
+ return removed;
2664
+ }
2665
+ get size() {
2666
+ return this.items.length;
3448
2667
  }
3449
- if (toolName === "edit_file") {
3450
- const oldString = getStringArg2(args, "old_string");
3451
- const newString = getStringArg2(args, "new_string");
3452
- if (existing === null || oldString === void 0 || newString === void 0) return null;
3453
- const replaceAll = typeof args === "object" && args !== null && args.replace_all === true;
3454
- const updated = applyEdit(existing, oldString, newString, replaceAll);
3455
- if (updated === null) return null;
3456
- return { type: "diff", label: filePath, lines: buildDiffLines(existing, updated) };
2668
+ list() {
2669
+ return [...this.items];
3457
2670
  }
3458
- const content = getStringArg2(args, "content");
3459
- if (content === void 0) return null;
3460
- if (existing === null) return newFilePreview(content, filePath);
3461
- return { type: "diff", label: filePath, lines: buildDiffLines(existing, content) };
3462
- }
2671
+ };
3463
2672
 
3464
2673
  // src/cli/shell-bang.ts
3465
2674
  var SHELL_BANG_DISPLAY_MAX_CHARS = 3e3;
@@ -3506,9 +2715,9 @@ function startTicker(onTick, intervalMs = TICK_MS) {
3506
2715
 
3507
2716
  // src/cli/update-check.ts
3508
2717
  var REGISTRY_URL2 = "https://registry.npmjs.org/@cryer%2fstar-cli/latest";
3509
- var TIMEOUT_MS3 = 5e3;
2718
+ var TIMEOUT_MS = 5e3;
3510
2719
  async function defaultFetcher2(url) {
3511
- return fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS3) });
2720
+ return fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
3512
2721
  }
3513
2722
  function isNewerVersion(latest, current) {
3514
2723
  const parse3 = (v) => v.split(".").slice(0, 3).map((part) => Number.parseInt(part, 10));
@@ -3536,7 +2745,7 @@ async function checkForUpdate(currentVersion, fetcher = defaultFetcher2) {
3536
2745
  }
3537
2746
 
3538
2747
  // src/cli/repl.tsx
3539
- import { jsx as jsx6, jsxs as jsxs8 } from "react/jsx-runtime";
2748
+ import { jsx as jsx8, jsxs as jsxs10 } from "react/jsx-runtime";
3540
2749
  var CLEAR_TERMINAL = process.platform === "win32" ? "\x1B[2J\x1B[0f" : "\x1B[2J\x1B[3J\x1B[H";
3541
2750
  function emptyUsage() {
3542
2751
  return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -3575,6 +2784,7 @@ function Repl({
3575
2784
  const [bgLabels, setBgLabels] = useState2(() => runningTaskLabels());
3576
2785
  const [permissionModeState, setPermissionModeState] = useState2(permissionMode);
3577
2786
  const [planApproval, setPlanApprovalState] = useState2(false);
2787
+ const [pendingRewind, setPendingRewindState] = useState2(null);
3578
2788
  const backendRef = useRef2(backend);
3579
2789
  const nextIdRef = useRef2(initialDisplay.length);
3580
2790
  const abortRef = useRef2(null);
@@ -3588,10 +2798,27 @@ function Repl({
3588
2798
  const alwaysAllowedRef = useRef2(/* @__PURE__ */ new Set());
3589
2799
  const prevModeRef = useRef2(null);
3590
2800
  const planApprovalRef = useRef2(false);
2801
+ const pendingRewindRef = useRef2(null);
3591
2802
  const modelNameRef = useRef2(model);
2803
+ const sessionStoreRef = useRef2(sessionStore);
3592
2804
  const usageRef = useRef2(initialUsage ? { ...initialUsage } : emptyUsage());
3593
2805
  const turnChunksRef = useRef2(0);
3594
2806
  const messagesRef = useRef2(initialDisplay);
2807
+ const [queueItems, setQueueItems] = useState2([]);
2808
+ const queueRef = useRef2(new PromptQueue());
2809
+ const [pendingImages, setPendingImages] = useState2([]);
2810
+ const pendingImagesRef = useRef2([]);
2811
+ const imageSeqRef = useRef2(1);
2812
+ const [inputRefill, setInputRefill] = useState2();
2813
+ const refillSeqRef = useRef2(0);
2814
+ const lastEscRef = useRef2(null);
2815
+ const [gitBranch, setGitBranch] = useState2(null);
2816
+ const [contextPercent, setContextPercent] = useState2(null);
2817
+ const budgetWarnedRef = useRef2(false);
2818
+ const budgetExceededRef = useRef2(false);
2819
+ const runStreamRef = useRef2(
2820
+ null
2821
+ );
3595
2822
  const pushMessage = useCallback(
3596
2823
  (role, text, note, tight) => {
3597
2824
  setMessages((prev) => {
@@ -3632,6 +2859,42 @@ function Repl({
3632
2859
  planApprovalRef.current = v;
3633
2860
  setPlanApprovalState(v);
3634
2861
  }, []);
2862
+ const setPendingRewind = useCallback((p) => {
2863
+ pendingRewindRef.current = p;
2864
+ setPendingRewindState(p);
2865
+ }, []);
2866
+ const stageClipboardImage = useCallback((img) => {
2867
+ pendingImagesRef.current = [
2868
+ ...pendingImagesRef.current,
2869
+ { id: imageSeqRef.current++, image: img }
2870
+ ];
2871
+ setPendingImages(pendingImagesRef.current);
2872
+ }, []);
2873
+ const clearPendingImages = useCallback(() => {
2874
+ pendingImagesRef.current = [];
2875
+ setPendingImages([]);
2876
+ }, []);
2877
+ const sessionCostUsd = useCallback(
2878
+ () => computeCostUsd(
2879
+ usageRef.current,
2880
+ config.models.find((m) => m.name === modelNameRef.current)
2881
+ ),
2882
+ [config]
2883
+ );
2884
+ const handlePasteImage = useCallback(() => {
2885
+ void readClipboardImage().then((img) => {
2886
+ if (img) stageClipboardImage(img);
2887
+ });
2888
+ }, [stageClipboardImage]);
2889
+ const handleRewindDecision = useCallback(
2890
+ (decision) => {
2891
+ const p = pendingRewindRef.current;
2892
+ if (!p) return;
2893
+ setPendingRewind(null);
2894
+ p.resolve(decision === "yes");
2895
+ },
2896
+ [setPendingRewind]
2897
+ );
3635
2898
  const applySessionMode = useCallback(
3636
2899
  (mode) => {
3637
2900
  if (mode === config.permissionMode) return;
@@ -3656,10 +2919,11 @@ function Repl({
3656
2919
  setPendingPermission({ request: req, preview, resolve });
3657
2920
  });
3658
2921
  };
2922
+ target.onHookWarning = (message) => pushMessage("system", message);
3659
2923
  },
3660
- [setPendingPermission, cwd]
2924
+ [setPendingPermission, cwd, pushMessage]
3661
2925
  );
3662
- useEffect(() => {
2926
+ useEffect2(() => {
3663
2927
  attachConfirmHandler(backendRef.current);
3664
2928
  return () => {
3665
2929
  tickerStopRef.current?.();
@@ -3667,26 +2931,33 @@ function Repl({
3667
2931
  pendingRef.current?.resolve(false);
3668
2932
  };
3669
2933
  }, [attachConfirmHandler]);
3670
- useEffect(() => {
2934
+ useEffect2(() => {
3671
2935
  if (process.env.STAR_NO_UPDATE_CHECK === "1") return;
3672
2936
  void checkForUpdate(VERSION).then((message) => {
3673
2937
  if (message) pushMessage("system", message);
3674
2938
  });
3675
2939
  }, [pushMessage]);
3676
- useEffect(() => {
2940
+ useEffect2(() => {
3677
2941
  const onUpdate = (task) => {
3678
2942
  setBgLabels(runningTaskLabels());
3679
2943
  pushMessage(
3680
2944
  "system",
3681
2945
  task.status === "running" ? formatTaskStarted(task) : formatTaskFinished(task)
3682
2946
  );
2947
+ if (task.status !== "running") {
2948
+ notifyBell({
2949
+ enabled: config.notifyBell,
2950
+ thresholdSec: config.notifyBellThresholdSec,
2951
+ noNotifyEnv: process.env.STAR_NO_NOTIFY === "1"
2952
+ });
2953
+ }
3683
2954
  };
3684
2955
  defaultTaskManager.on("update", onUpdate);
3685
2956
  return () => {
3686
2957
  defaultTaskManager.off("update", onUpdate);
3687
2958
  defaultTaskManager.cleanup();
3688
2959
  };
3689
- }, [pushMessage]);
2960
+ }, [pushMessage, config]);
3690
2961
  const interrupt = useCallback(() => {
3691
2962
  abortRef.current?.abort();
3692
2963
  const p = pendingRef.current;
@@ -3694,7 +2965,34 @@ function Repl({
3694
2965
  setPendingPermission(null);
3695
2966
  p.resolve(false);
3696
2967
  }
3697
- }, [setPendingPermission]);
2968
+ const cleared = queueRef.current.clear();
2969
+ if (cleared.length > 0) {
2970
+ setQueueItems([]);
2971
+ pushMessage("system", `cleared ${cleared.length} queued message(s)`);
2972
+ }
2973
+ }, [setPendingPermission, pushMessage]);
2974
+ const editLastMessage = useCallback(async () => {
2975
+ const current = backendRef.current;
2976
+ if (typeof current.retractLastTurn !== "function") return;
2977
+ const visible = messagesRef.current;
2978
+ let cut = -1;
2979
+ let text = "";
2980
+ for (let i = visible.length - 1; i >= 0; i--) {
2981
+ const msg = visible[i];
2982
+ if (msg?.role === "user") {
2983
+ cut = i;
2984
+ text = msg.text;
2985
+ break;
2986
+ }
2987
+ }
2988
+ if (cut < 0) return;
2989
+ const { removed } = await current.retractLastTurn();
2990
+ if (removed === 0) return;
2991
+ redrawMessages(visible.slice(0, cut));
2992
+ refillSeqRef.current += 1;
2993
+ setInputRefill({ text, seq: refillSeqRef.current });
2994
+ pushMessage("system", "last message restored for editing");
2995
+ }, [pushMessage, redrawMessages]);
3698
2996
  const handleExit = useCallback(() => {
3699
2997
  const killed = defaultTaskManager.cleanup();
3700
2998
  if (killed.length > 0) {
@@ -3705,10 +3003,27 @@ function Repl({
3705
3003
  }
3706
3004
  exit();
3707
3005
  }, [exit, pushMessage]);
3708
- useInput4((_input, key) => {
3709
- if (key.escape) interrupt();
3006
+ useInput5((_input, key) => {
3007
+ if (key.escape) {
3008
+ const busy = abortRef.current !== null || pendingRef.current !== null || planApprovalRef.current || pendingRewindRef.current !== null;
3009
+ if (busy) {
3010
+ lastEscRef.current = null;
3011
+ interrupt();
3012
+ return;
3013
+ }
3014
+ const now = Date.now();
3015
+ if (isDoubleEscape(lastEscRef.current, now)) {
3016
+ lastEscRef.current = null;
3017
+ void editLastMessage();
3018
+ } else {
3019
+ lastEscRef.current = now;
3020
+ }
3021
+ return;
3022
+ }
3023
+ lastEscRef.current = null;
3710
3024
  if (key.shift && key.tab) {
3711
- if (abortRef.current || pendingRef.current || planApprovalRef.current) return;
3025
+ if (abortRef.current || pendingRef.current || planApprovalRef.current || pendingRewindRef.current)
3026
+ return;
3712
3027
  const current = config.permissionMode;
3713
3028
  const index = SESSION_MODE_CYCLE.indexOf(current);
3714
3029
  const next = SESSION_MODE_CYCLE[(index + 1) % SESSION_MODE_CYCLE.length] ?? "ask";
@@ -3745,7 +3060,7 @@ function Repl({
3745
3060
  registry: createDefaultRegistry(),
3746
3061
  config,
3747
3062
  cwd,
3748
- sessionStore
3063
+ sessionStore: sessionStoreRef.current
3749
3064
  });
3750
3065
  const prev = backendRef.current;
3751
3066
  if (prev instanceof AgentLoop) {
@@ -3760,7 +3075,7 @@ function Repl({
3760
3075
  return `Failed to switch model: ${error instanceof Error ? error.message : String(error)}`;
3761
3076
  }
3762
3077
  },
3763
- [config, cwd, sessionStore, attachConfirmHandler]
3078
+ [config, cwd, attachConfirmHandler]
3764
3079
  );
3765
3080
  const resume = useCallback(
3766
3081
  async (id) => {
@@ -3768,33 +3083,43 @@ function Repl({
3768
3083
  if (!(current instanceof AgentLoop)) {
3769
3084
  return "Current backend does not support resuming sessions.";
3770
3085
  }
3771
- const resumed = await resumeSession(id);
3086
+ const resolvedId = await resolveSessionId(id);
3087
+ if (!resolvedId) {
3088
+ return `Session not found: ${id}`;
3089
+ }
3090
+ const resumed = await resumeSession(resolvedId);
3772
3091
  if (!resumed) {
3773
3092
  return `Session not found: ${id}`;
3774
3093
  }
3775
3094
  await current.loadMessages(resumed.messages);
3095
+ const store = await SessionStore.open(resolvedId);
3096
+ if (store) {
3097
+ hydrateSnapshots(await loadSessionSnapshots(store.dir));
3098
+ }
3776
3099
  const display = buildDisplayMessages(resumed.messages);
3777
3100
  nextIdRef.current = display.length;
3778
3101
  applyMessages(display);
3779
3102
  usageRef.current = resumed.meta.usage ? { ...resumed.meta.usage } : emptyUsage();
3780
3103
  setUsageVersion((v) => v + 1);
3781
- return `Resumed session ${id} (${resumed.messages.length} messages).`;
3104
+ return `Resumed session ${resolvedId} (${resumed.messages.length} messages).`;
3782
3105
  },
3783
3106
  [applyMessages]
3784
3107
  );
3785
3108
  const runStream = useCallback(
3786
- async (input) => {
3109
+ async (input, extraImages = []) => {
3787
3110
  const resolved = await resolveMentions(input, cwd);
3111
+ const images = [...resolved.images, ...extraImages];
3788
3112
  pushMessage(
3789
3113
  "user",
3790
3114
  input,
3791
- resolved.attached.length > 0 ? `attached: ${resolved.attached.join(", ")}` : void 0
3115
+ resolved.attached.length + extraImages.length > 0 ? `attached: ${[...resolved.attached, ...extraImages.map((img) => img.path)].join(", ")}` : void 0
3792
3116
  );
3793
3117
  for (const skip of resolved.skipped) {
3794
3118
  pushMessage("system", `Skipped @${skip.path}: ${skip.reason}`);
3795
3119
  }
3796
3120
  const controller = new AbortController();
3797
3121
  abortRef.current = controller;
3122
+ const turnStartedAt = Date.now();
3798
3123
  streamedRef.current = "";
3799
3124
  thinkingRef.current = true;
3800
3125
  reasoningRef.current = "";
@@ -3831,9 +3156,13 @@ function Repl({
3831
3156
  }
3832
3157
  });
3833
3158
  try {
3834
- for await (const event of backendRef.current.stream(resolved.input, controller.signal, {
3835
- persistAs: input
3836
- })) {
3159
+ for await (const event of backendRef.current.stream(
3160
+ images.length > 0 ? { text: resolved.input, images } : resolved.input,
3161
+ controller.signal,
3162
+ {
3163
+ persistAs: extraImages.length > 0 ? `${input}${" [clipboard image]".repeat(extraImages.length)}` : input
3164
+ }
3165
+ )) {
3837
3166
  if (event.type === "text-delta") {
3838
3167
  if (thinkingRef.current) {
3839
3168
  thinkingRef.current = false;
@@ -3883,7 +3212,7 @@ function Repl({
3883
3212
  usage.completionTokens += event.usage.completionTokens;
3884
3213
  usage.totalTokens += event.usage.totalTokens;
3885
3214
  setUsageVersion((v) => v + 1);
3886
- sessionStore?.addUsage(event.usage).catch(() => {
3215
+ sessionStoreRef.current?.addUsage(event.usage).catch(() => {
3887
3216
  });
3888
3217
  }
3889
3218
  } else if (event.type === "error") {
@@ -3913,6 +3242,13 @@ function Repl({
3913
3242
  streamedRef.current = "";
3914
3243
  setStreamingText(null);
3915
3244
  const interrupted = controller.signal.aborted;
3245
+ notifyBell({
3246
+ enabled: config.notifyBell,
3247
+ thresholdSec: config.notifyBellThresholdSec,
3248
+ noNotifyEnv: process.env.STAR_NO_NOTIFY === "1",
3249
+ interrupted,
3250
+ elapsedMs: Date.now() - turnStartedAt
3251
+ });
3916
3252
  if (finalText.length > 0) {
3917
3253
  pushAssistantChunk(`${finalText}${interrupted ? " [interrupted]" : ""}`, false);
3918
3254
  } else if (interrupted && turnChunksRef.current > 0) {
@@ -3931,6 +3267,37 @@ function Repl({
3931
3267
  if (!interrupted && config.permissionMode === "plan") {
3932
3268
  setPlanApproval(true);
3933
3269
  }
3270
+ const cost = sessionCostUsd();
3271
+ const budget = config.sessionBudgetUsd;
3272
+ const status = budgetState(cost, budget);
3273
+ if (status === "warn" && !budgetWarnedRef.current) {
3274
+ budgetWarnedRef.current = true;
3275
+ pushMessage(
3276
+ "system",
3277
+ `Session cost $${formatDollars(cost ?? 0)} has passed 80% of the $${formatDollars(budget ?? 0)} session budget (sessionBudgetUsd).`
3278
+ );
3279
+ }
3280
+ if (status === "exceeded" && !budgetExceededRef.current) {
3281
+ budgetExceededRef.current = true;
3282
+ pushMessage(
3283
+ "system",
3284
+ `Session budget exceeded: $${formatDollars(cost ?? 0)} of $${formatDollars(budget ?? 0)} (sessionBudgetUsd). New prompts are blocked \u2014 raise sessionBudgetUsd in the config or start a /new session.`
3285
+ );
3286
+ }
3287
+ const nextPrompt = queueRef.current.dequeue();
3288
+ if (nextPrompt) {
3289
+ if (status === "exceeded") {
3290
+ const dropped = queueRef.current.clear().length + 1;
3291
+ setQueueItems([]);
3292
+ pushMessage(
3293
+ "system",
3294
+ `Dropped ${dropped} queued message(s) \u2014 session budget exceeded.`
3295
+ );
3296
+ } else {
3297
+ setQueueItems(queueRef.current.list());
3298
+ void runStreamRef.current?.(nextPrompt.text, nextPrompt.images);
3299
+ }
3300
+ }
3934
3301
  }
3935
3302
  },
3936
3303
  [
@@ -3938,11 +3305,14 @@ function Repl({
3938
3305
  pushAssistantChunk,
3939
3306
  setPendingPermission,
3940
3307
  setPlanApproval,
3941
- sessionStore,
3942
3308
  cwd,
3943
- config
3309
+ config,
3310
+ sessionCostUsd
3944
3311
  ]
3945
3312
  );
3313
+ useEffect2(() => {
3314
+ runStreamRef.current = runStream;
3315
+ }, [runStream]);
3946
3316
  const handlePlanDecision = useCallback(
3947
3317
  (decision) => {
3948
3318
  setPlanApproval(false);
@@ -3956,10 +3326,39 @@ function Repl({
3956
3326
  );
3957
3327
  const registry = useMemo(() => {
3958
3328
  const ctx = {
3329
+ cwd,
3959
3330
  addSystemMessage: (text) => pushMessage("system", text),
3331
+ showDiff: (text, lines, note) => {
3332
+ setMessages((prev) => {
3333
+ const next = [
3334
+ ...prev,
3335
+ { id: nextIdRef.current++, role: "system", text, note, diff: lines }
3336
+ ];
3337
+ messagesRef.current = next;
3338
+ return next;
3339
+ });
3340
+ },
3960
3341
  clearMessages: () => {
3961
3342
  redrawMessages([]);
3962
3343
  },
3344
+ conversationText: (scope) => {
3345
+ const current = backendRef.current;
3346
+ if (!(current instanceof AgentLoop)) return null;
3347
+ const messages2 = current.getMessages();
3348
+ if (scope === "all") {
3349
+ const parts = messages2.filter((m) => m.role === "user" || m.role === "assistant").map((m) => coreMessageText(m)).filter((text) => text.length > 0);
3350
+ return parts.length > 0 ? parts.join("\n\n") : null;
3351
+ }
3352
+ for (let i = messages2.length - 1; i >= 0; i--) {
3353
+ const message = messages2[i];
3354
+ if (message?.role === "assistant") {
3355
+ const text = coreMessageText(message);
3356
+ if (text) return text;
3357
+ }
3358
+ }
3359
+ return null;
3360
+ },
3361
+ copyToClipboard: (text) => copyText(text),
3963
3362
  exit: () => handleExit(),
3964
3363
  listModels: () => {
3965
3364
  const models = listModels(config);
@@ -3971,11 +3370,42 @@ function Repl({
3971
3370
  ${lines.join("\n")}`;
3972
3371
  },
3973
3372
  switchModel,
3974
- listSessions: async () => {
3975
- const metas = await SessionStore.list(cwd);
3976
- return metas.length === 0 ? "No sessions found for this directory." : formatSessionList(metas);
3373
+ listSessions: async (all) => {
3374
+ const entries = await listSessionEntries(all ? void 0 : cwd);
3375
+ if (entries.length === 0) {
3376
+ return all ? "No sessions found." : "No sessions found for this directory.";
3377
+ }
3378
+ return formatSessionEntries(entries, { showCwd: all });
3977
3379
  },
3978
3380
  resumeSession: resume,
3381
+ newSession: async () => {
3382
+ const current = backendRef.current;
3383
+ if (!(current instanceof AgentLoop)) {
3384
+ return "Current backend does not support starting a new session.";
3385
+ }
3386
+ const store = await SessionStore.create(cwd, modelNameRef.current);
3387
+ current.setSessionStore(store);
3388
+ sessionStoreRef.current = store;
3389
+ const [first] = current.getMessages();
3390
+ await current.loadMessages(first?.role === "system" ? [first] : []);
3391
+ clearSnapshots();
3392
+ redrawMessages([]);
3393
+ usageRef.current = emptyUsage();
3394
+ budgetWarnedRef.current = false;
3395
+ budgetExceededRef.current = false;
3396
+ setUsageVersion((v) => v + 1);
3397
+ return `Started a new session (${store.id}) with a clean context.`;
3398
+ },
3399
+ clearSessions: async (all) => {
3400
+ const currentId = sessionStoreRef.current?.id;
3401
+ const removed = await clearSessions(all ? void 0 : cwd, currentId);
3402
+ if (removed === 0) {
3403
+ return all ? "No stored sessions to delete." : "No stored sessions for this directory.";
3404
+ }
3405
+ const scope = all ? "across all directories" : "for this directory";
3406
+ const kept = currentId ? " The current session is still active." : "";
3407
+ return `Deleted ${removed} session(s) ${scope}.${kept}`;
3408
+ },
3979
3409
  showTodos: async () => {
3980
3410
  const store = new TodoStore();
3981
3411
  await store.load(cwd);
@@ -3987,6 +3417,7 @@ ${lines.join("\n")}`;
3987
3417
  return `${formatUsage(usageRef.current)}
3988
3418
  ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
3989
3419
  },
3420
+ showGlobalUsage: async () => formatUsageDashboard(await collectUsageStats(), config.models),
3990
3421
  compactContext: async () => {
3991
3422
  let summaryModel = null;
3992
3423
  try {
@@ -3996,7 +3427,7 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
3996
3427
  }
3997
3428
  const result = await compactSession({
3998
3429
  backend: backendRef.current,
3999
- sessionStore,
3430
+ sessionStore: sessionStoreRef.current,
4000
3431
  config,
4001
3432
  model: summaryModel
4002
3433
  });
@@ -4007,7 +3438,12 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4007
3438
  }
4008
3439
  return result.message;
4009
3440
  },
4010
- exportSession: (arg) => exportSession({ backend: backendRef.current, sessionStore, cwd, arg }),
3441
+ exportSession: (arg) => exportSession({
3442
+ backend: backendRef.current,
3443
+ sessionStore: sessionStoreRef.current,
3444
+ cwd,
3445
+ arg
3446
+ }),
4011
3447
  undo: async () => {
4012
3448
  const current = backendRef.current;
4013
3449
  if (!(current instanceof AgentLoop)) {
@@ -4031,6 +3467,48 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4031
3467
  "\n"
4032
3468
  );
4033
3469
  },
3470
+ rewind: async (args) => {
3471
+ if (!args) {
3472
+ return formatCheckpointList(listSnapshots(), cwd);
3473
+ }
3474
+ const id = Number.parseInt(args, 10);
3475
+ if (!Number.isFinite(id) || String(id) !== args) {
3476
+ return `Usage: /rewind <n> \u2014 "${args}" is not a checkpoint number.`;
3477
+ }
3478
+ const plan = planRewind(listSnapshots(), id);
3479
+ if (!plan) {
3480
+ return `Checkpoint #${id} not found (it may already have been rewound). /rewind lists the current checkpoints.`;
3481
+ }
3482
+ const current = backendRef.current;
3483
+ const messageCount = plan.messageIndex !== null && current instanceof AgentLoop ? current.countRetraction(plan.messageIndex) : 0;
3484
+ const confirmed = await new Promise((resolve) => {
3485
+ setPendingRewind({
3486
+ summary: `Rewind to just before checkpoint #${id} (${plan.target.toolName} ${plan.target.path}):
3487
+ ${plan.affected.length} file change(s) will be reverted, ${messageCount} message(s) retracted.`,
3488
+ resolve
3489
+ });
3490
+ });
3491
+ if (!confirmed) {
3492
+ return "Rewind cancelled.";
3493
+ }
3494
+ const result = await rewindToSnapshot(id);
3495
+ if (!result) {
3496
+ return `Checkpoint #${id} is no longer available.`;
3497
+ }
3498
+ let retracted = 0;
3499
+ if (result.messageIndex !== null && current instanceof AgentLoop) {
3500
+ retracted = await current.retractFromIndex(result.messageIndex);
3501
+ if (retracted > 0) {
3502
+ const display = buildDisplayMessages([...current.getMessages()]);
3503
+ nextIdRef.current = display.length;
3504
+ redrawMessages(display);
3505
+ }
3506
+ }
3507
+ return [
3508
+ ...result.reverted,
3509
+ `Rewound to before checkpoint #${id}: ${result.reverted.length} file change(s) reverted, ${retracted} message(s) retracted.`
3510
+ ].join("\n");
3511
+ },
4034
3512
  permissionMode: async (args) => {
4035
3513
  const current = config.permissionMode;
4036
3514
  if (!args) {
@@ -4084,7 +3562,8 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4084
3562
  `maxSteps: ${config.maxSteps}`,
4085
3563
  `contextMaxTokens: ${config.contextMaxTokens}`,
4086
3564
  `streamIdleTimeoutSec: ${config.streamIdleTimeoutSec}`,
4087
- `permissions.allow (${config.permissions.allow.length}): ${config.permissions.allow.join(", ") || "(none)"}`
3565
+ `permissions.allow (${config.permissions.allow.length}): ${config.permissions.allow.join(", ") || "(none)"}`,
3566
+ `hooks (${config.hooks.length}): ${config.hooks.map((h) => h.event).join(", ") || "(none)"}`
4088
3567
  ].join("\n")
4089
3568
  };
4090
3569
  const reg = new CommandRegistry();
@@ -4098,11 +3577,11 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4098
3577
  cwd,
4099
3578
  switchModel,
4100
3579
  resume,
4101
- sessionStore,
4102
3580
  runStream,
4103
3581
  applyMessages,
4104
3582
  redrawMessages,
4105
- applySessionMode
3583
+ applySessionMode,
3584
+ setPendingRewind
4106
3585
  ]);
4107
3586
  const runShellBang = useCallback(
4108
3587
  async (raw) => {
@@ -4143,6 +3622,11 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4143
3622
  const handleSubmit = useCallback(
4144
3623
  (text) => {
4145
3624
  if (text.startsWith("!")) {
3625
+ if (abortRef.current) {
3626
+ queueRef.current.enqueue({ text, images: [] });
3627
+ setQueueItems(queueRef.current.list());
3628
+ return;
3629
+ }
4146
3630
  void runShellBang(text.slice(1));
4147
3631
  return;
4148
3632
  }
@@ -4157,17 +3641,44 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4157
3641
  void command.run(parsed.args, registry.ctx);
4158
3642
  return;
4159
3643
  }
4160
- void runStream(text);
3644
+ const budget = config.sessionBudgetUsd;
3645
+ if (budgetState(sessionCostUsd(), budget) === "exceeded") {
3646
+ pushMessage(
3647
+ "system",
3648
+ `Session budget of $${formatDollars(budget ?? 0)} (sessionBudgetUsd) is exceeded \u2014 prompts are blocked. Slash commands still work; raise sessionBudgetUsd in the config or start a /new session.`
3649
+ );
3650
+ return;
3651
+ }
3652
+ const images = pendingImagesRef.current.map((staged) => staged.image);
3653
+ if (images.length > 0) clearPendingImages();
3654
+ if (abortRef.current) {
3655
+ queueRef.current.enqueue({ text, images });
3656
+ setQueueItems(queueRef.current.list());
3657
+ return;
3658
+ }
3659
+ void runStream(text, images);
4161
3660
  },
4162
- [registry, pushMessage, runStream, runShellBang]
3661
+ [registry, pushMessage, runStream, runShellBang, config, clearPendingImages, sessionCostUsd]
4163
3662
  );
4164
3663
  const commandHints = useMemo(
4165
3664
  () => registry.list().map((cmd) => ({ name: cmd.name, description: cmd.description })),
4166
3665
  [registry]
4167
3666
  );
4168
- return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
4169
- /* @__PURE__ */ jsx6(MessageList, { messages }, `messages-${epoch}`),
4170
- (thinking || activity !== null) && /* @__PURE__ */ jsx6(
3667
+ useEffect2(() => {
3668
+ setGitBranch(getGitSummary(cwd)?.branch ?? null);
3669
+ const current = backendRef.current;
3670
+ if (current instanceof AgentLoop) {
3671
+ setContextPercent(
3672
+ Math.round(estimateTokens([...current.getMessages()]) / config.contextMaxTokens * 100)
3673
+ );
3674
+ } else {
3675
+ setContextPercent(null);
3676
+ }
3677
+ }, [cwd, config, isStreaming, epoch]);
3678
+ const sessionCost = sessionCostUsd();
3679
+ return /* @__PURE__ */ jsxs10(Box9, { flexDirection: "column", children: [
3680
+ /* @__PURE__ */ jsx8(MessageList, { messages }, `messages-${epoch}`),
3681
+ (thinking || activity !== null) && /* @__PURE__ */ jsx8(
4171
3682
  ThinkingIndicator,
4172
3683
  {
4173
3684
  reasoning: thinkingText,
@@ -4175,9 +3686,9 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4175
3686
  activity: activity ?? void 0
4176
3687
  }
4177
3688
  ),
4178
- !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx6(Text8, { dimColor: true, children: thoughtSummary }),
4179
- streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx6(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
4180
- pending && /* @__PURE__ */ jsx6(
3689
+ !thinking && activity === null && thoughtSummary !== null && /* @__PURE__ */ jsx8(Text10, { dimColor: true, children: thoughtSummary }),
3690
+ streamingText !== null && (streamingText !== "" || turnChunksRef.current === 0) && /* @__PURE__ */ jsx8(StreamingMessage, { text: streamingText, continuation: turnChunksRef.current > 0 }),
3691
+ pending && /* @__PURE__ */ jsx8(
4181
3692
  PermissionPrompt,
4182
3693
  {
4183
3694
  request: pending.request,
@@ -4185,24 +3696,41 @@ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
4185
3696
  onDecision: handleDecision
4186
3697
  }
4187
3698
  ),
4188
- planApproval && /* @__PURE__ */ jsx6(ExitPlanPrompt, { onDecision: handlePlanDecision }),
4189
- /* @__PURE__ */ jsx6(
3699
+ planApproval && /* @__PURE__ */ jsx8(ExitPlanPrompt, { onDecision: handlePlanDecision }),
3700
+ pendingRewind && /* @__PURE__ */ jsx8(RewindConfirmPrompt, { summary: pendingRewind.summary, onDecision: handleRewindDecision }),
3701
+ queueItems.map((item) => /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3702
+ "queued: ",
3703
+ item.text.length > 80 ? `${item.text.slice(0, 80)}\u2026` : item.text
3704
+ ] }, item.id)),
3705
+ pendingImages.map((staged) => /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3706
+ "[image attached: ",
3707
+ staged.image.path,
3708
+ "]"
3709
+ ] }, staged.id)),
3710
+ /* @__PURE__ */ jsx8(
4190
3711
  InputBox,
4191
3712
  {
4192
3713
  isStreaming,
4193
- disabled: pending !== null || planApproval,
3714
+ disabled: pending !== null || planApproval || pendingRewind !== null,
4194
3715
  commands: commandHints,
3716
+ cwd,
3717
+ refill: inputRefill,
4195
3718
  onSubmit: handleSubmit,
4196
3719
  onInterrupt: interrupt,
4197
- onExit: handleExit
3720
+ onExit: handleExit,
3721
+ onPasteImage: handlePasteImage
4198
3722
  }
4199
3723
  ),
4200
- /* @__PURE__ */ jsx6(
3724
+ /* @__PURE__ */ jsx8(
4201
3725
  StatusBar,
4202
3726
  {
3727
+ cwd,
4203
3728
  model: modelName,
4204
3729
  permissionMode: permissionModeState,
4205
3730
  tokens: usageRef.current.totalTokens,
3731
+ gitBranch,
3732
+ contextPercent,
3733
+ sessionCostUsd: sessionCost,
4206
3734
  backgroundTasks: bgLabels
4207
3735
  }
4208
3736
  )
@@ -4213,7 +3741,7 @@ function renderRepl(backend, opts) {
4213
3741
  defaultTaskManager.cleanup();
4214
3742
  });
4215
3743
  return render(
4216
- /* @__PURE__ */ jsx6(
3744
+ /* @__PURE__ */ jsx8(
4217
3745
  Repl,
4218
3746
  {
4219
3747
  backend,
@@ -4230,43 +3758,68 @@ function renderRepl(backend, opts) {
4230
3758
  }
4231
3759
 
4232
3760
  // src/config/loader.ts
4233
- import fs7 from "fs";
3761
+ import fs10 from "fs";
4234
3762
  import { parse as parse2 } from "smol-toml";
4235
3763
 
4236
3764
  // src/config/schema.ts
4237
- import { z as z11 } from "zod";
4238
- var ProviderConfigSchema = z11.object({
4239
- name: z11.string(),
4240
- protocol: z11.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
4241
- baseURL: z11.string(),
4242
- apiKeyEnv: z11.string().optional(),
4243
- apiKey: z11.string().optional(),
4244
- headers: z11.record(z11.string()).optional()
3765
+ import { z } from "zod";
3766
+ var ProviderConfigSchema = z.object({
3767
+ name: z.string(),
3768
+ protocol: z.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
3769
+ baseURL: z.string(),
3770
+ apiKeyEnv: z.string().optional(),
3771
+ apiKey: z.string().optional(),
3772
+ headers: z.record(z.string()).optional()
3773
+ });
3774
+ var ModelConfigSchema = z.object({
3775
+ name: z.string(),
3776
+ provider: z.string(),
3777
+ model: z.string(),
3778
+ maxTokens: z.number().int().positive().optional(),
3779
+ promptPrice: z.number().nonnegative().optional(),
3780
+ completionPrice: z.number().nonnegative().optional()
4245
3781
  });
4246
- var ModelConfigSchema = z11.object({
4247
- name: z11.string(),
4248
- provider: z11.string(),
4249
- model: z11.string(),
4250
- maxTokens: z11.number().int().positive().optional(),
4251
- promptPrice: z11.number().nonnegative().optional(),
4252
- completionPrice: z11.number().nonnegative().optional()
3782
+ var PermissionsConfigSchema = z.object({
3783
+ allow: z.array(z.string()).default([])
4253
3784
  });
4254
- var PermissionsConfigSchema = z11.object({
4255
- allow: z11.array(z11.string()).default([])
3785
+ var HookConfigSchema = z.object({
3786
+ event: z.enum(["PreToolUse", "PostToolUse", "Stop"]),
3787
+ matcher: z.string().refine(
3788
+ (pattern) => {
3789
+ try {
3790
+ new RegExp(pattern);
3791
+ return true;
3792
+ } catch {
3793
+ return false;
3794
+ }
3795
+ },
3796
+ { message: "invalid regular expression" }
3797
+ ).optional(),
3798
+ command: z.string().min(1),
3799
+ timeoutSec: z.number().positive().default(30)
4256
3800
  });
4257
- var ConfigSchema = z11.object({
4258
- defaultModel: z11.string().default(""),
4259
- permissionMode: z11.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
4260
- providers: z11.array(ProviderConfigSchema).default([]),
4261
- models: z11.array(ModelConfigSchema).default([]),
4262
- maxSteps: z11.number().int().positive().default(50),
4263
- contextMaxTokens: z11.number().int().positive().default(1e5),
3801
+ var ConfigSchema = z.object({
3802
+ defaultModel: z.string().default(""),
3803
+ permissionMode: z.enum(["auto", "ask", "readonly", "yolo", "plan"]).default("ask"),
3804
+ providers: z.array(ProviderConfigSchema).default([]),
3805
+ models: z.array(ModelConfigSchema).default([]),
3806
+ maxSteps: z.number().int().positive().default(50),
3807
+ contextMaxTokens: z.number().int().positive().default(1e5),
4264
3808
  // Seconds without any stream part before a stalled response is ended
4265
3809
  // gracefully (some relays never send the terminal chunks). Applies once
4266
3810
  // streaming has started; the first part gets a longer, fixed allowance.
4267
- streamIdleTimeoutSec: z11.number().positive().default(20),
4268
- contextCompaction: z11.enum(["summary", "truncate"]).default("summary"),
4269
- permissions: PermissionsConfigSchema.default({ allow: [] })
3811
+ streamIdleTimeoutSec: z.number().positive().default(20),
3812
+ contextCompaction: z.enum(["summary", "truncate"]).default("summary"),
3813
+ // Optional per-session dollar budget; when set, the REPL stops the session
3814
+ // once the estimated cost for the session reaches this amount.
3815
+ sessionBudgetUsd: z.number().positive().optional(),
3816
+ // Terminal bell (REPL only): ring when a turn takes longer than
3817
+ // notifyBellThresholdSec, and when a background task finishes.
3818
+ // STAR_NO_NOTIFY=1 disables without touching the config.
3819
+ notifyBell: z.boolean().default(true),
3820
+ notifyBellThresholdSec: z.number().positive().default(10),
3821
+ permissions: PermissionsConfigSchema.default({ allow: [] }),
3822
+ hooks: z.array(HookConfigSchema).default([])
4270
3823
  });
4271
3824
 
4272
3825
  // src/config/loader.ts
@@ -4296,8 +3849,8 @@ function applyOverrides(config, overrides) {
4296
3849
  return merged;
4297
3850
  }
4298
3851
  function readTomlFileSync(filePath) {
4299
- if (!fs7.existsSync(filePath)) return {};
4300
- const content = fs7.readFileSync(filePath, "utf8");
3852
+ if (!fs10.existsSync(filePath)) return {};
3853
+ const content = fs10.readFileSync(filePath, "utf8");
4301
3854
  return parseTomlFile(content, filePath);
4302
3855
  }
4303
3856
  function loadConfigSync(cwd, overrides) {
@@ -4324,9 +3877,32 @@ async function createLoop(config, modelName, cwd, sessionStore) {
4324
3877
  sessionStore
4325
3878
  });
4326
3879
  }
4327
- async function printMode(loop, prompt, cwd, json) {
3880
+ async function printMode(loop, prompt, cwd, json, imagePaths) {
3881
+ const flagImages = [];
3882
+ for (const imagePath of imagePaths) {
3883
+ if (!imageMimeType(imagePath)) {
3884
+ process.stderr.write(
3885
+ `[error] unsupported image type: ${imagePath} (supported: png, jpg, jpeg, gif, webp)
3886
+ `
3887
+ );
3888
+ return 1;
3889
+ }
3890
+ const image = await readImageInput(imagePath, cwd);
3891
+ if (!image) {
3892
+ process.stderr.write(
3893
+ `[error] cannot read image: ${imagePath} (missing, unreadable, or larger than ${MAX_IMAGE_BYTES / (1024 * 1024)}MB)
3894
+ `
3895
+ );
3896
+ return 1;
3897
+ }
3898
+ flagImages.push(image);
3899
+ }
4328
3900
  const controller = new AbortController();
4329
3901
  process.on("SIGINT", () => controller.abort());
3902
+ loop.onHookWarning = (message) => {
3903
+ process.stderr.write(`[hook] ${message}
3904
+ `);
3905
+ };
4330
3906
  const resolved = await resolveMentions(prompt, cwd);
4331
3907
  if (resolved.attached.length > 0) {
4332
3908
  process.stderr.write(`[attached] ${resolved.attached.join(", ")}
@@ -4336,10 +3912,12 @@ async function printMode(loop, prompt, cwd, json) {
4336
3912
  process.stderr.write(`[skipped] @${skip.path}: ${skip.reason}
4337
3913
  `);
4338
3914
  }
3915
+ const images = [...flagImages, ...resolved.images];
3916
+ const input = images.length > 0 ? { text: resolved.input, images } : resolved.input;
4339
3917
  const usage = new UsageTracker();
4340
3918
  let exitCode = 0;
4341
3919
  try {
4342
- for await (const event of loop.stream(resolved.input, controller.signal, {
3920
+ for await (const event of loop.stream(input, controller.signal, {
4343
3921
  persistAs: prompt
4344
3922
  })) {
4345
3923
  if (json) {
@@ -4405,7 +3983,15 @@ async function printMode(loop, prompt, cwd, json) {
4405
3983
  return exitCode;
4406
3984
  }
4407
3985
  var program = new Command();
4408
- program.name("star").description("Star CLI \u2014 an AI agent command-line interface").version(VERSION).option("-m, --model <model>", "model to use").option("--permission-mode <mode>", "permission mode: ask | auto | readonly | yolo | plan").option("-p, --print <prompt>", "non-interactive print mode").option("--json", "output NDJSON events on stdout (print mode only)").option("-r, --resume <sessionId>", "resume a previous session").action(async (opts) => {
3986
+ program.name("star").description("Star CLI \u2014 an AI agent command-line interface").version(VERSION).option("-m, --model <model>", "model to use").option("--permission-mode <mode>", "permission mode: ask | auto | readonly | yolo | plan").option("-p, --print <prompt>", "non-interactive print mode").option("--json", "output NDJSON events on stdout (print mode only)").option(
3987
+ "--image <path>",
3988
+ "attach an image file (png/jpg/jpeg/gif/webp, max 5MB); repeatable",
3989
+ (value, previous) => [...previous, value],
3990
+ []
3991
+ ).option("-r, --resume [sessionId]", "resume a previous session (lists sessions when no id given)").option("-c, --continue", "continue the most recent session for the current directory").option(
3992
+ "--clear-sessions [scope]",
3993
+ "delete stored sessions for the current directory ('all' deletes every session) and exit"
3994
+ ).action(async (opts) => {
4409
3995
  const cwd = process.cwd();
4410
3996
  if (opts.json && opts.print === void 0) {
4411
3997
  console.error(
@@ -4413,6 +3999,32 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
4413
3999
  );
4414
4000
  process.exit(1);
4415
4001
  }
4002
+ if (opts.resume !== void 0 && opts.continue) {
4003
+ console.error("Options --resume and --continue are mutually exclusive: pick one.");
4004
+ process.exit(1);
4005
+ }
4006
+ if (opts.resume === true) {
4007
+ const entries = await listSessionEntries(cwd);
4008
+ if (entries.length === 0) {
4009
+ console.error("No sessions found for this directory.");
4010
+ } else {
4011
+ console.log(formatSessionEntries(entries));
4012
+ }
4013
+ process.exit(0);
4014
+ }
4015
+ if (opts.clearSessions !== void 0) {
4016
+ const scope = opts.clearSessions;
4017
+ if (scope !== true && scope !== "all") {
4018
+ console.error(`Invalid scope: ${scope}. Use --clear-sessions or --clear-sessions all.`);
4019
+ process.exit(1);
4020
+ }
4021
+ const all = scope === "all";
4022
+ const removed = await clearSessions(all ? void 0 : cwd);
4023
+ console.log(
4024
+ all ? `Deleted ${removed} session(s) across all directories.` : `Deleted ${removed} session(s) for this directory.`
4025
+ );
4026
+ process.exit(0);
4027
+ }
4416
4028
  let config;
4417
4029
  try {
4418
4030
  config = loadConfigSync(cwd, { model: opts.model, permissionMode: opts.permissionMode });
@@ -4429,13 +4041,28 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
4429
4041
  }
4430
4042
  let sessionStore = null;
4431
4043
  let resumed = null;
4432
- if (opts.resume) {
4433
- resumed = await resumeSession(opts.resume);
4434
- if (!resumed) {
4044
+ let resumeId = null;
4045
+ if (typeof opts.resume === "string") {
4046
+ resumeId = await resolveSessionId(opts.resume);
4047
+ if (!resumeId) {
4435
4048
  console.error(`Session not found: ${opts.resume}`);
4436
4049
  process.exit(1);
4437
4050
  }
4438
- sessionStore = await SessionStore.open(opts.resume);
4051
+ } else if (opts.continue) {
4052
+ const latest = await findLatestSession(cwd);
4053
+ if (latest) {
4054
+ resumeId = latest.id;
4055
+ } else {
4056
+ process.stderr.write("No previous session for this directory \u2014 starting a new session.\n");
4057
+ }
4058
+ }
4059
+ if (resumeId) {
4060
+ resumed = await resumeSession(resumeId);
4061
+ if (!resumed) {
4062
+ console.error(`Session not found: ${resumeId}`);
4063
+ process.exit(1);
4064
+ }
4065
+ sessionStore = await SessionStore.open(resumeId);
4439
4066
  } else if (!opts.print) {
4440
4067
  sessionStore = await SessionStore.create(cwd, modelName);
4441
4068
  }
@@ -4448,9 +4075,18 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
4448
4075
  }
4449
4076
  if (resumed) {
4450
4077
  await loop.loadMessages(resumed.messages);
4078
+ if (sessionStore) {
4079
+ hydrateSnapshots(await loadSessionSnapshots(sessionStore.dir));
4080
+ }
4451
4081
  }
4452
4082
  if (opts.print) {
4453
- process.exitCode = await printMode(loop, opts.print, cwd, Boolean(opts.json));
4083
+ process.exitCode = await printMode(
4084
+ loop,
4085
+ opts.print,
4086
+ cwd,
4087
+ Boolean(opts.json),
4088
+ opts.image ?? []
4089
+ );
4454
4090
  return;
4455
4091
  }
4456
4092
  renderRepl(loop, {