@cryer/star-cli 0.1.0 → 0.1.2

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
@@ -124,8 +124,89 @@ function compactMessages(messages, maxTokens) {
124
124
  };
125
125
  }
126
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
+
127
172
  // src/permissions/gate.ts
128
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
129
210
  var FILE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file", "read_file"]);
130
211
  var DANGEROUS_COMMAND_PATTERNS = [
131
212
  /rm\s+-rf\s+\/(?:\s|$|;|&)/i,
@@ -138,6 +219,9 @@ var DANGEROUS_COMMAND_PATTERNS = [
138
219
  /\breboot\b/i,
139
220
  /\bpoweroff\b/i
140
221
  ];
222
+ function isDangerousCommand(command) {
223
+ return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
224
+ }
141
225
  function getStringArg(args, key) {
142
226
  if (typeof args === "object" && args !== null) {
143
227
  const value = args[key];
@@ -163,10 +247,13 @@ function isSensitivePath(p) {
163
247
  }
164
248
  return base === ".env" || base.startsWith(".env.") || base === "id_rsa" || base.endsWith(".pem");
165
249
  }
166
- function checkPermission(mode, req, ctx) {
250
+ function checkPermission(mode, req, ctx, allowRules = []) {
251
+ if (mode === "yolo") {
252
+ return "allow";
253
+ }
167
254
  const command = getStringArg(req.args, "command");
168
255
  const filePath = getStringArg(req.args, "path");
169
- if (req.toolName === "bash" && command !== void 0 && DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(command))) {
256
+ if (req.toolName === "bash" && command !== void 0 && isDangerousCommand(command)) {
170
257
  return "deny";
171
258
  }
172
259
  if (FILE_TOOLS.has(req.toolName) && filePath !== void 0) {
@@ -186,13 +273,78 @@ function checkPermission(mode, req, ctx) {
186
273
  if (mode === "readonly") {
187
274
  return req.level === "read" ? "allow" : "deny";
188
275
  }
276
+ if (mode === "plan") {
277
+ return req.level === "read" ? "allow" : "deny";
278
+ }
279
+ if (isAllowedByRules(allowRules, req)) {
280
+ return "allow";
281
+ }
189
282
  return req.level === "read" ? "allow" : "ask";
190
283
  }
191
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
+
192
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.";
193
340
  var AgentLoop = class {
194
341
  messages = [];
195
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 = [];
196
348
  confirmHandler;
197
349
  constructor(opts) {
198
350
  this.opts = opts;
@@ -204,15 +356,44 @@ var AgentLoop = class {
204
356
  return this.messages;
205
357
  }
206
358
  async loadMessages(messages) {
207
- this.messages = 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);
208
385
  }
209
386
  async persist(message) {
210
387
  await this.opts.sessionStore?.append(message);
211
388
  }
212
- async *stream(input, signal) {
389
+ async *stream(input, signal, opts) {
390
+ this.syncSystemMessage();
213
391
  const userMessage = { role: "user", content: input };
214
392
  this.messages.push(userMessage);
215
- await this.persist(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
+ );
216
397
  const { config, registry, cwd } = this.opts;
217
398
  const aiTools = this.buildAiTools();
218
399
  for (let step = 0; step < config.maxSteps; step++) {
@@ -228,6 +409,8 @@ var AgentLoop = class {
228
409
  if (event.type === "text-delta") {
229
410
  text += event.text;
230
411
  yield event;
412
+ } else if (event.type === "reasoning") {
413
+ yield event;
231
414
  } else if (event.type === "tool-call") {
232
415
  toolCalls.push({ id: event.id, name: event.name, args: event.args });
233
416
  yield event;
@@ -239,6 +422,7 @@ var AgentLoop = class {
239
422
  }
240
423
  }
241
424
  } catch (error) {
425
+ if (signal.aborted) return;
242
426
  yield { type: "error", error: error instanceof Error ? error : new Error(String(error)) };
243
427
  return;
244
428
  }
@@ -258,35 +442,77 @@ var AgentLoop = class {
258
442
  this.messages.push(assistantMessage);
259
443
  await this.persist(assistantMessage);
260
444
  if (toolCalls.length === 0) return;
261
- for (const call of toolCalls) {
262
- const result = await this.executeTool(call, signal);
263
- const toolMessage = {
264
- role: "tool",
265
- content: [
266
- {
267
- type: "tool-result",
268
- toolCallId: call.id,
269
- toolName: call.name,
270
- result: result.content
271
- }
272
- ]
273
- };
274
- this.messages.push(toolMessage);
275
- await this.persist(toolMessage);
276
- yield {
277
- type: "tool-result",
278
- id: call.id,
279
- name: call.name,
280
- content: result.content,
281
- isError: result.isError
282
- };
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
+ }
283
492
  }
493
+ if (signal.aborted) return;
284
494
  }
285
495
  yield {
286
496
  type: "error",
287
497
  error: new Error(`Max steps (${config.maxSteps}) reached, stopping.`)
288
498
  };
289
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
+ }
290
516
  async applyCompactionSummary(compacted) {
291
517
  const { config, model } = this.opts;
292
518
  if (config.contextCompaction !== "summary" || !model) {
@@ -308,17 +534,20 @@ ${summary}`
308
534
  }
309
535
  }
310
536
  async *streamOnce(aiTools, signal) {
311
- const { streamChat } = await import("./stream-FJQZGETE.js");
537
+ const { streamChat } = await import("./stream-Y55NRF2S.js");
312
538
  yield* streamChat({
313
539
  model: this.opts.model,
314
540
  messages: this.messages,
315
541
  tools: aiTools,
316
- abortSignal: signal
542
+ abortSignal: signal,
543
+ idleTimeoutMs: this.opts.config.streamIdleTimeoutSec * 1e3
317
544
  });
318
545
  }
319
546
  buildAiTools() {
547
+ const plan = this.opts.config.permissionMode === "plan";
320
548
  const tools = {};
321
549
  for (const t of this.opts.registry.list()) {
550
+ if (plan && t.permission !== "read") continue;
322
551
  tools[t.name] = aiTool({
323
552
  description: t.description,
324
553
  parameters: t.parameters
@@ -335,17 +564,23 @@ ${summary}`
335
564
  const decision = checkPermission(
336
565
  config.permissionMode,
337
566
  { toolName: call.name, args: call.args, level: tool.permission },
338
- { cwd }
567
+ { cwd },
568
+ config.permissions.allow
339
569
  );
340
570
  if (decision === "deny") {
341
571
  return { content: `Permission denied for tool "${call.name}".`, isError: true };
342
572
  }
343
573
  if (decision === "ask") {
344
- const approved = this.confirmHandler ? await this.confirmHandler({
345
- toolName: call.name,
346
- args: call.args,
347
- level: tool.permission
348
- }) : false;
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
+ }
349
584
  if (!approved) {
350
585
  return { content: `User rejected tool "${call.name}".`, isError: true };
351
586
  }
@@ -368,100 +603,36 @@ ${summary}`
368
603
  }
369
604
  };
370
605
 
371
- // src/cli/repl.tsx
372
- import { Box as Box7, render, useApp, useInput as useInput3 } from "ink";
373
- import { useCallback, useEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
374
-
375
- // src/llm/provider.ts
376
- import { createAnthropic } from "@ai-sdk/anthropic";
377
- import { createOpenAI } from "@ai-sdk/openai";
378
- import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
379
-
380
- // src/llm/registry.ts
381
- function listModels(config) {
382
- return config.models;
383
- }
384
- function resolveModelConfig(config, modelName) {
385
- const name = modelName ?? config.defaultModel;
386
- const model = config.models.find((m) => m.name === name);
387
- if (!model) {
388
- const available = config.models.map((m) => m.name).join(", ") || "(none)";
389
- throw new Error(`Model "${name}" not found. Available models: ${available}`);
390
- }
391
- return model;
392
- }
393
-
394
- // src/llm/provider.ts
395
- function resolveApiKey(provider) {
396
- const fromEnv = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : void 0;
397
- const apiKey = fromEnv ?? provider.apiKey;
398
- if (!apiKey) {
399
- const hint = provider.apiKeyEnv ? `set ${provider.apiKeyEnv} or configure apiKey` : "configure apiKey";
400
- throw new Error(`Missing API key for provider "${provider.name}": ${hint}`);
401
- }
402
- return apiKey;
403
- }
404
- function createModel(config, modelName) {
405
- const modelConfig = resolveModelConfig(config, modelName);
406
- const provider = config.providers.find((p) => p.name === modelConfig.provider);
407
- if (!provider) {
408
- const available = config.providers.map((p) => p.name).join(", ") || "(none)";
409
- throw new Error(
410
- `Provider "${modelConfig.provider}" for model "${modelConfig.name}" not found. Available providers: ${available}`
411
- );
412
- }
413
- const apiKey = resolveApiKey(provider);
414
- switch (provider.protocol) {
415
- case "anthropic":
416
- return createAnthropic({
417
- baseURL: provider.baseURL,
418
- apiKey,
419
- headers: provider.headers
420
- })(modelConfig.model);
421
- case "openai-compatible":
422
- return createOpenAICompatible({
423
- name: provider.name,
424
- baseURL: provider.baseURL,
425
- apiKey,
426
- headers: provider.headers
427
- })(modelConfig.model);
428
- case "openai-responses":
429
- return createOpenAI({
430
- baseURL: provider.baseURL,
431
- apiKey,
432
- headers: provider.headers
433
- }).responses(modelConfig.model);
606
+ // src/cli/format.ts
607
+ function summarizeArgs(args, maxLength = 120) {
608
+ let json;
609
+ try {
610
+ json = JSON.stringify(args) ?? String(args);
611
+ } catch {
612
+ json = String(args);
434
613
  }
614
+ return json.length > maxLength ? `${json.slice(0, maxLength)}...` : json;
435
615
  }
436
-
437
- // src/session/store.ts
438
- import fs from "fs/promises";
439
- import path3 from "path";
440
-
441
- // src/config/paths.ts
442
- import os from "os";
443
- import path2 from "path";
444
- function starHome() {
445
- return process.env.STAR_HOME ?? path2.join(os.homedir(), ".star-cli");
446
- }
447
- function globalConfigPath() {
448
- return path2.join(starHome(), "config.toml");
449
- }
450
- function sessionsDir() {
451
- return path2.join(starHome(), "sessions");
616
+ function previewLines(text, maxLines = 10) {
617
+ const lines = text.split("\n");
618
+ if (lines.length <= maxLines) return { text, truncated: false };
619
+ return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
452
620
  }
453
- function projectConfigPath(cwd) {
454
- return path2.join(cwd, ".star", "config.toml");
621
+ function splitCommittableLines(text, minCompleteLines) {
622
+ const lastNewline = text.lastIndexOf("\n");
623
+ if (lastNewline < 0) return null;
624
+ const complete = text.slice(0, lastNewline);
625
+ const completeLines = complete.split("\n").length;
626
+ if (completeLines < minCompleteLines) return null;
627
+ return { committed: complete, rest: text.slice(lastNewline + 1) };
455
628
  }
456
-
457
- // src/session/store.ts
458
- function generateId(now) {
459
- const pad = (n) => String(n).padStart(2, "0");
460
- const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
461
- const suffix = Math.random().toString(16).slice(2, 8).padEnd(6, "0");
462
- return `${stamp}-${suffix}`;
629
+ function formatStreamError(error) {
630
+ if (error.message.includes("Unexpected end of JSON input")) {
631
+ return `${error.message} (response stream was truncated \u2014 try again)`;
632
+ }
633
+ return error.message;
463
634
  }
464
- function messageText(message) {
635
+ function coreMessageText(message) {
465
636
  const content = message.content;
466
637
  if (typeof content === "string") return content;
467
638
  if (Array.isArray(content)) {
@@ -469,90 +640,477 @@ function messageText(message) {
469
640
  }
470
641
  return "";
471
642
  }
472
- var SessionStore = class _SessionStore {
473
- id;
474
- dir;
475
- pendingMeta = null;
476
- initialized = false;
477
- constructor(id) {
478
- this.id = id;
479
- this.dir = path3.join(sessionsDir(), id);
480
- }
481
- metaPath() {
482
- return path3.join(this.dir, "meta.json");
483
- }
484
- messagesPath() {
485
- return path3.join(this.dir, "messages.jsonl");
486
- }
487
- static async create(cwd, model) {
488
- const store = new _SessionStore(generateId(/* @__PURE__ */ new Date()));
489
- store.pendingMeta = { cwd, model, createdAt: Date.now() };
490
- return store;
491
- }
492
- static async open(id) {
493
- const store = new _SessionStore(id);
494
- try {
495
- const raw = await fs.readFile(store.metaPath(), "utf8");
496
- const meta = JSON.parse(raw);
497
- if (meta.id !== id) return null;
498
- store.initialized = true;
499
- return store;
500
- } catch {
501
- return null;
643
+ function buildDisplayMessages(messages) {
644
+ const display = [];
645
+ let collapsed = 0;
646
+ for (const message of messages) {
647
+ if (message.role === "user" || message.role === "assistant") {
648
+ const text = coreMessageText(message);
649
+ if (text) {
650
+ display.push({ id: display.length, role: message.role, text });
651
+ } else {
652
+ collapsed++;
653
+ }
654
+ } else if (message.role === "tool") {
655
+ collapsed++;
502
656
  }
503
657
  }
504
- static async list(cwd) {
505
- let entries;
506
- try {
507
- entries = await fs.readdir(sessionsDir());
508
- } catch {
509
- return [];
658
+ if (collapsed > 0) {
659
+ display.push({
660
+ id: display.length,
661
+ role: "system",
662
+ text: `\u5DF2\u6062\u590D ${collapsed} \u6761\u5386\u53F2\u6D88\u606F`
663
+ });
664
+ }
665
+ return display;
666
+ }
667
+
668
+ // 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;
510
682
  }
511
- const metas = [];
512
683
  for (const entry of entries) {
513
- try {
514
- const raw = await fs.readFile(path3.join(sessionsDir(), entry, "meta.json"), "utf8");
515
- const meta = JSON.parse(raw);
516
- if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
517
- } catch {
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
+ }
518
696
  }
519
697
  }
520
- return metas.sort((a, b) => b.updatedAt - a.updatedAt);
521
698
  }
522
- async ensureInitialized() {
523
- if (this.initialized) return;
524
- const pending = this.pendingMeta;
525
- if (!pending) {
526
- this.initialized = true;
527
- return;
528
- }
529
- await fs.mkdir(this.dir, { recursive: true });
530
- const meta = {
531
- id: this.id,
532
- title: "",
533
- model: pending.model,
534
- cwd: pending.cwd,
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
754
+ var MAX_MENTION_BYTES = 100 * 1024;
755
+ var MENTION_RE = /(?<=^|\s)@([A-Za-z]:[\\/][A-Za-z0-9._\-/\\]*|[A-Za-z0-9._\-/\\]+)/g;
756
+ function parseMentions(text) {
757
+ const mentions = [];
758
+ const seen = /* @__PURE__ */ new Set();
759
+ const ranges = [];
760
+ for (const match of text.matchAll(MENTION_RE)) {
761
+ const mention = match[1];
762
+ const full = match[0];
763
+ if (mention === void 0 || full === void 0) continue;
764
+ ranges.push([match.index, match.index + full.length]);
765
+ if (!seen.has(mention)) {
766
+ seen.add(mention);
767
+ mentions.push(mention);
768
+ }
769
+ }
770
+ if (ranges.length === 0) {
771
+ return { cleanText: text, mentions };
772
+ }
773
+ let clean = "";
774
+ let last = 0;
775
+ for (const [start, end] of ranges) {
776
+ clean += text.slice(last, start);
777
+ last = end;
778
+ }
779
+ clean += text.slice(last);
780
+ const cleanText = clean.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
781
+ return { cleanText, mentions };
782
+ }
783
+ function isBinaryContent(buf) {
784
+ const probe = buf.subarray(0, 8e3);
785
+ return probe.includes(0);
786
+ }
787
+ async function resolveMentions(text, cwd) {
788
+ const { cleanText, mentions } = parseMentions(text);
789
+ if (mentions.length === 0) {
790
+ return { input: text, attached: [], skipped: [] };
791
+ }
792
+ const blocks = [];
793
+ const attached = [];
794
+ const skipped = [];
795
+ for (const mention of mentions) {
796
+ const abs = path4.resolve(cwd, mention);
797
+ if (isSensitivePath2(abs)) {
798
+ skipped.push({ path: mention, reason: "sensitive file" });
799
+ continue;
800
+ }
801
+ const st = await stat2(abs).catch(() => null);
802
+ if (!st) {
803
+ skipped.push({ path: mention, reason: "file not found" });
804
+ continue;
805
+ }
806
+ if (st.isDirectory()) {
807
+ skipped.push({ path: mention, reason: "is a directory" });
808
+ continue;
809
+ }
810
+ if (st.size > MAX_MENTION_BYTES) {
811
+ skipped.push({ path: mention, reason: `exceeds ${MAX_MENTION_BYTES / 1024}KB limit` });
812
+ continue;
813
+ }
814
+ const buf = await readFile2(abs).catch(() => null);
815
+ if (!buf) {
816
+ skipped.push({ path: mention, reason: "unreadable" });
817
+ continue;
818
+ }
819
+ if (isBinaryContent(buf)) {
820
+ skipped.push({ path: mention, reason: "binary file" });
821
+ continue;
822
+ }
823
+ attached.push(mention);
824
+ blocks.push(`--- @${mention} ---
825
+ ${buf.toString("utf8")}
826
+ --- end ---`);
827
+ }
828
+ const input = [cleanText, ...blocks].filter((s) => s.length > 0).join("\n\n") || text;
829
+ return { input, attached, skipped };
830
+ }
831
+
832
+ // src/cli/print-json.ts
833
+ function eventToJsonLine(event) {
834
+ switch (event.type) {
835
+ case "finish":
836
+ return null;
837
+ case "error":
838
+ return JSON.stringify({ type: "error", error: { message: event.error.message } });
839
+ default:
840
+ return JSON.stringify(event);
841
+ }
842
+ }
843
+ var UsageTracker = class {
844
+ requests = 0;
845
+ promptTokens = 0;
846
+ completionTokens = 0;
847
+ totalTokens = 0;
848
+ add(usage) {
849
+ if (!usage) return;
850
+ this.requests += 1;
851
+ this.promptTokens += usage.promptTokens;
852
+ this.completionTokens += usage.completionTokens;
853
+ this.totalTokens += usage.totalTokens;
854
+ }
855
+ get totals() {
856
+ return {
857
+ requests: this.requests,
858
+ promptTokens: this.promptTokens,
859
+ completionTokens: this.completionTokens,
860
+ totalTokens: this.totalTokens
861
+ };
862
+ }
863
+ toJsonLine() {
864
+ if (this.requests === 0) return null;
865
+ return JSON.stringify({
866
+ type: "usage",
867
+ requests: this.requests,
868
+ promptTokens: this.promptTokens,
869
+ completionTokens: this.completionTokens,
870
+ totalTokens: this.totalTokens
871
+ });
872
+ }
873
+ };
874
+
875
+ // 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";
878
+
879
+ // src/config/save.ts
880
+ import fs from "fs";
881
+ import path6 from "path";
882
+ import { parse, stringify } from "smol-toml";
883
+
884
+ // src/config/paths.ts
885
+ import os from "os";
886
+ import path5 from "path";
887
+ function starHome() {
888
+ return process.env.STAR_HOME ?? path5.join(os.homedir(), ".star-cli");
889
+ }
890
+ function globalConfigPath() {
891
+ return path5.join(starHome(), "config.toml");
892
+ }
893
+ function sessionsDir() {
894
+ return path5.join(starHome(), "sessions");
895
+ }
896
+ function projectConfigPath(cwd) {
897
+ return path5.join(cwd, ".star", "config.toml");
898
+ }
899
+
900
+ // src/config/save.ts
901
+ async function addAllowRule(rule) {
902
+ const filePath = globalConfigPath();
903
+ let raw = {};
904
+ try {
905
+ const content = await fs.promises.readFile(filePath, "utf8");
906
+ raw = parse(content);
907
+ } catch (error) {
908
+ if (error.code !== "ENOENT") throw error;
909
+ }
910
+ const permissions = typeof raw.permissions === "object" && raw.permissions !== null ? raw.permissions : {};
911
+ const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
912
+ if (allow.includes(rule)) return false;
913
+ permissions.allow = [...allow, rule];
914
+ raw.permissions = permissions;
915
+ await fs.promises.mkdir(path6.dirname(filePath), { recursive: true });
916
+ await fs.promises.writeFile(filePath, stringify(raw), "utf8");
917
+ return true;
918
+ }
919
+ async function savePermissionMode(mode) {
920
+ const filePath = globalConfigPath();
921
+ let raw = {};
922
+ try {
923
+ const content = await fs.promises.readFile(filePath, "utf8");
924
+ raw = parse(content);
925
+ } catch (error) {
926
+ if (error.code !== "ENOENT") throw error;
927
+ }
928
+ raw.permissionMode = mode;
929
+ await fs.promises.mkdir(path6.dirname(filePath), { recursive: true });
930
+ await fs.promises.writeFile(filePath, stringify(raw), "utf8");
931
+ }
932
+
933
+ // src/llm/provider.ts
934
+ import { createAnthropic } from "@ai-sdk/anthropic";
935
+ import { createOpenAI } from "@ai-sdk/openai";
936
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
937
+
938
+ // src/llm/registry.ts
939
+ function listModels(config) {
940
+ return config.models;
941
+ }
942
+ function resolveModelConfig(config, modelName) {
943
+ const name = modelName ?? config.defaultModel;
944
+ const model = config.models.find((m) => m.name === name);
945
+ if (!model) {
946
+ const available = config.models.map((m) => m.name).join(", ") || "(none)";
947
+ throw new Error(`Model "${name}" not found. Available models: ${available}`);
948
+ }
949
+ return model;
950
+ }
951
+
952
+ // src/llm/provider.ts
953
+ function resolveApiKey(provider) {
954
+ const fromEnv = provider.apiKeyEnv ? process.env[provider.apiKeyEnv] : void 0;
955
+ const apiKey = fromEnv ?? provider.apiKey;
956
+ if (!apiKey) {
957
+ const hint = provider.apiKeyEnv ? `set ${provider.apiKeyEnv} or configure apiKey` : "configure apiKey";
958
+ throw new Error(`Missing API key for provider "${provider.name}": ${hint}`);
959
+ }
960
+ return apiKey;
961
+ }
962
+ function createModel(config, modelName) {
963
+ const modelConfig = resolveModelConfig(config, modelName);
964
+ const provider = config.providers.find((p) => p.name === modelConfig.provider);
965
+ if (!provider) {
966
+ const available = config.providers.map((p) => p.name).join(", ") || "(none)";
967
+ throw new Error(
968
+ `Provider "${modelConfig.provider}" for model "${modelConfig.name}" not found. Available providers: ${available}`
969
+ );
970
+ }
971
+ const apiKey = resolveApiKey(provider);
972
+ switch (provider.protocol) {
973
+ case "anthropic":
974
+ return createAnthropic({
975
+ baseURL: provider.baseURL,
976
+ apiKey,
977
+ headers: provider.headers
978
+ })(modelConfig.model);
979
+ case "openai-compatible":
980
+ return createOpenAICompatible({
981
+ name: provider.name,
982
+ baseURL: provider.baseURL,
983
+ apiKey,
984
+ headers: provider.headers
985
+ })(modelConfig.model);
986
+ case "openai-responses":
987
+ return createOpenAI({
988
+ baseURL: provider.baseURL,
989
+ apiKey,
990
+ headers: provider.headers
991
+ }).responses(modelConfig.model);
992
+ }
993
+ }
994
+
995
+ // src/session/store.ts
996
+ import fs2 from "fs/promises";
997
+ import path7 from "path";
998
+ function generateId(now) {
999
+ const pad = (n) => String(n).padStart(2, "0");
1000
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
1001
+ const suffix = Math.random().toString(16).slice(2, 8).padEnd(6, "0");
1002
+ return `${stamp}-${suffix}`;
1003
+ }
1004
+ function debugWarn(message) {
1005
+ if (process.env.STAR_DEBUG === "1") process.stderr.write(`[star-cli] ${message}
1006
+ `);
1007
+ }
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 "";
1015
+ }
1016
+ var SessionStore = class _SessionStore {
1017
+ id;
1018
+ dir;
1019
+ pendingMeta = null;
1020
+ initialized = false;
1021
+ constructor(id) {
1022
+ this.id = id;
1023
+ this.dir = path7.join(sessionsDir(), id);
1024
+ }
1025
+ metaPath() {
1026
+ return path7.join(this.dir, "meta.json");
1027
+ }
1028
+ messagesPath() {
1029
+ return path7.join(this.dir, "messages.jsonl");
1030
+ }
1031
+ static async create(cwd, model) {
1032
+ const store = new _SessionStore(generateId(/* @__PURE__ */ new Date()));
1033
+ store.pendingMeta = { cwd, model, createdAt: Date.now() };
1034
+ return store;
1035
+ }
1036
+ static async open(id) {
1037
+ const store = new _SessionStore(id);
1038
+ let raw;
1039
+ try {
1040
+ raw = await fs2.readFile(store.metaPath(), "utf8");
1041
+ } catch {
1042
+ return null;
1043
+ }
1044
+ try {
1045
+ const meta = JSON.parse(raw);
1046
+ if (meta.id !== id) return null;
1047
+ } catch {
1048
+ debugWarn(`session ${id}: corrupt meta.json, using defaults`);
1049
+ }
1050
+ store.initialized = true;
1051
+ return store;
1052
+ }
1053
+ static async list(cwd) {
1054
+ let entries;
1055
+ try {
1056
+ entries = await fs2.readdir(sessionsDir());
1057
+ } catch {
1058
+ return [];
1059
+ }
1060
+ const metas = [];
1061
+ for (const entry of entries) {
1062
+ try {
1063
+ const raw = await fs2.readFile(path7.join(sessionsDir(), entry, "meta.json"), "utf8");
1064
+ const meta = JSON.parse(raw);
1065
+ if (meta.id === entry && (cwd === void 0 || meta.cwd === cwd)) metas.push(meta);
1066
+ } catch {
1067
+ }
1068
+ }
1069
+ return metas.sort((a, b) => b.updatedAt - a.updatedAt);
1070
+ }
1071
+ async ensureInitialized() {
1072
+ if (this.initialized) return;
1073
+ const pending = this.pendingMeta;
1074
+ if (!pending) {
1075
+ this.initialized = true;
1076
+ return;
1077
+ }
1078
+ await fs2.mkdir(this.dir, { recursive: true });
1079
+ const meta = {
1080
+ id: this.id,
1081
+ title: "",
1082
+ model: pending.model,
1083
+ cwd: pending.cwd,
535
1084
  createdAt: pending.createdAt,
536
1085
  updatedAt: pending.createdAt
537
1086
  };
538
- await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1087
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
539
1088
  this.initialized = true;
540
1089
  }
541
1090
  async append(message) {
542
1091
  await this.ensureInitialized();
543
- await fs.appendFile(this.messagesPath(), `${JSON.stringify(message)}
1092
+ await fs2.appendFile(this.messagesPath(), `${JSON.stringify(message)}
544
1093
  `);
545
1094
  const meta = await this.meta();
546
1095
  meta.updatedAt = Date.now();
547
1096
  if (!meta.title && message.role === "user") {
548
1097
  meta.title = messageText(message).slice(0, 60);
549
1098
  }
550
- await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1099
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1100
+ }
1101
+ async replaceMessages(messages) {
1102
+ await this.ensureInitialized();
1103
+ const content = messages.map((message) => JSON.stringify(message)).join("\n");
1104
+ await fs2.writeFile(this.messagesPath(), content ? `${content}
1105
+ ` : "");
1106
+ const meta = await this.meta();
1107
+ meta.updatedAt = Date.now();
1108
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
551
1109
  }
552
1110
  async messages() {
553
1111
  let raw;
554
1112
  try {
555
- raw = await fs.readFile(this.messagesPath(), "utf8");
1113
+ raw = await fs2.readFile(this.messagesPath(), "utf8");
556
1114
  } catch {
557
1115
  return [];
558
1116
  }
@@ -563,21 +1121,31 @@ var SessionStore = class _SessionStore {
563
1121
  try {
564
1122
  messages.push(JSON.parse(trimmed));
565
1123
  } catch {
1124
+ debugWarn(`session ${this.id}: skipping corrupt messages.jsonl line`);
566
1125
  }
567
1126
  }
568
1127
  return messages;
569
1128
  }
1129
+ fallbackMeta() {
1130
+ const now = Date.now();
1131
+ return { id: this.id, title: "", model: "", cwd: "", createdAt: now, updatedAt: now };
1132
+ }
570
1133
  async meta() {
571
1134
  await this.ensureInitialized();
572
- const raw = await fs.readFile(this.metaPath(), "utf8");
573
- return JSON.parse(raw);
1135
+ try {
1136
+ const raw = await fs2.readFile(this.metaPath(), "utf8");
1137
+ return JSON.parse(raw);
1138
+ } catch {
1139
+ debugWarn(`session ${this.id}: unreadable meta.json, using defaults`);
1140
+ return this.fallbackMeta();
1141
+ }
574
1142
  }
575
1143
  async setTitle(title) {
576
1144
  await this.ensureInitialized();
577
1145
  const meta = await this.meta();
578
1146
  meta.title = title;
579
1147
  meta.updatedAt = Date.now();
580
- await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1148
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
581
1149
  }
582
1150
  async addUsage(delta) {
583
1151
  await this.ensureInitialized();
@@ -593,7 +1161,7 @@ var SessionStore = class _SessionStore {
593
1161
  usage.completionTokens += delta.completionTokens;
594
1162
  usage.totalTokens += delta.totalTokens;
595
1163
  meta.usage = usage;
596
- await fs.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
1164
+ await fs2.writeFile(this.metaPath(), JSON.stringify(meta, null, 2));
597
1165
  }
598
1166
  };
599
1167
 
@@ -601,7 +1169,11 @@ var SessionStore = class _SessionStore {
601
1169
  async function resumeSession(id) {
602
1170
  const store = await SessionStore.open(id);
603
1171
  if (!store) return null;
604
- const [meta, messages] = await Promise.all([store.meta(), store.messages()]);
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);
1176
+ }
605
1177
  return { meta, messages };
606
1178
  }
607
1179
  function relativeTime(timestamp) {
@@ -624,20 +1196,67 @@ function formatSessionList(metas) {
624
1196
  }).join("\n");
625
1197
  }
626
1198
 
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}`;
1239
+ }
1240
+ }
1241
+
1242
+ // src/tasks/manager.ts
1243
+ import { spawn as spawn2 } from "child_process";
1244
+ import { EventEmitter } from "events";
1245
+
627
1246
  // src/tools/bash.ts
628
1247
  import { spawn } from "child_process";
629
1248
  import { existsSync } from "fs";
630
- import path4 from "path";
1249
+ import path8 from "path";
631
1250
  import { z } from "zod";
632
1251
  var MAX_OUTPUT = 3e4;
633
1252
  var DEFAULT_TIMEOUT = 120;
634
1253
  var MAX_TIMEOUT = 600;
635
1254
  function findOnPath(exe, exclude) {
636
- for (const dir of (process.env.PATH ?? "").split(path4.delimiter)) {
1255
+ for (const dir of (process.env.PATH ?? "").split(path8.delimiter)) {
637
1256
  if (!dir || exclude?.(dir)) {
638
1257
  continue;
639
1258
  }
640
- const full = path4.join(dir, exe);
1259
+ const full = path8.join(dir, exe);
641
1260
  if (existsSync(full)) {
642
1261
  return full;
643
1262
  }
@@ -655,7 +1274,7 @@ function resolveShell() {
655
1274
  }
656
1275
  const gitExe = findOnPath("git.exe");
657
1276
  if (gitExe) {
658
- const sibling = path4.join(path4.dirname(path4.dirname(gitExe)), "bin", "bash.exe");
1277
+ const sibling = path8.join(path8.dirname(path8.dirname(gitExe)), "bin", "bash.exe");
659
1278
  if (existsSync(sibling)) {
660
1279
  return { shell: sibling, wrap: (c) => ["-c", c], label: "bash" };
661
1280
  }
@@ -675,7 +1294,10 @@ function resolveShell() {
675
1294
  var schema = z.object({
676
1295
  command: z.string().describe("Shell command to execute"),
677
1296
  timeout: z.number().positive().max(MAX_TIMEOUT).optional().describe("Timeout in seconds (default 120, max 600)"),
678
- description: z.string().optional().describe("Short description of what the command does")
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
+ )
679
1301
  });
680
1302
  function killTree(child) {
681
1303
  if (process.platform === "win32" && child.pid) {
@@ -700,10 +1322,23 @@ ${tail}`;
700
1322
  }
701
1323
  var bashTool = {
702
1324
  name: "bash",
703
- 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.",
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.",
704
1326
  permission: "exec",
705
1327
  parameters: schema,
706
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
+ }
707
1342
  const timeoutSeconds = Math.min(args.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
708
1343
  const spec = resolveShell();
709
1344
  return new Promise((resolve) => {
@@ -761,9 +1396,119 @@ Command timed out after ${timeoutSeconds}s`,
761
1396
  }
762
1397
  };
763
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
+
764
1509
  // src/tools/fs/edit.ts
765
- import { readFile, writeFile } from "fs/promises";
766
- import path5 from "path";
1510
+ import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1511
+ import path9 from "path";
767
1512
  import { z as z2 } from "zod";
768
1513
  var schema2 = z2.object({
769
1514
  path: z2.string().describe("File path, absolute or relative to the working directory"),
@@ -777,120 +1522,49 @@ var editFileTool = {
777
1522
  permission: "write",
778
1523
  parameters: schema2,
779
1524
  async execute(args, ctx) {
780
- const filePath = path5.resolve(ctx.cwd, args.path);
1525
+ const filePath = path9.resolve(ctx.cwd, args.path);
781
1526
  let content;
782
1527
  try {
783
- content = await readFile(filePath, "utf8");
1528
+ content = await readFile3(filePath, "utf8");
784
1529
  } catch (err) {
785
1530
  return { content: `Failed to read ${args.path}: ${err.message}`, isError: true };
786
1531
  }
787
1532
  let count = 0;
788
1533
  let idx = content.indexOf(args.old_string);
789
- while (idx !== -1) {
790
- count += 1;
791
- idx = content.indexOf(args.old_string, idx + args.old_string.length);
792
- }
793
- if (count === 0) {
794
- return { content: `old_string not found in ${args.path}`, isError: true };
795
- }
796
- if (count > 1 && !args.replace_all) {
797
- return {
798
- content: `old_string matches ${count} locations in ${args.path}; provide more surrounding context or set replace_all`,
799
- isError: true
800
- };
801
- }
802
- const updated = args.replace_all ? content.split(args.old_string).join(args.new_string) : content.replace(args.old_string, args.new_string);
803
- await writeFile(filePath, updated, "utf8");
804
- return { content: `Edited ${args.path}: ${count} replacement${count > 1 ? "s" : ""}` };
805
- }
806
- };
807
-
808
- // src/tools/fs/glob.ts
809
- import path7 from "path";
810
- import { z as z3 } from "zod";
811
-
812
- // src/tools/fs/util.ts
813
- import { readdir, stat } from "fs/promises";
814
- import path6 from "path";
815
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
816
- async function walkFiles(root, skipDirs = SKIP_DIRS) {
817
- const out = [];
818
- async function walk(dir, relBase) {
819
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
820
- if (!entries) {
821
- return;
822
- }
823
- for (const entry of entries) {
824
- const abs = path6.join(dir, entry.name);
825
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
826
- if (entry.isDirectory()) {
827
- if (!skipDirs.has(entry.name)) {
828
- await walk(abs, rel);
829
- }
830
- } else if (entry.isFile()) {
831
- try {
832
- const st = await stat(abs);
833
- out.push({ abs, rel, mtimeMs: st.mtimeMs });
834
- } catch {
835
- }
836
- }
837
- }
838
- }
839
- await walk(root, "");
840
- return out;
841
- }
842
- var REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
843
- function globToRegExp(pattern) {
844
- let re = "";
845
- let i = 0;
846
- while (i < pattern.length) {
847
- const c = pattern.charAt(i);
848
- if (c === "*") {
849
- if (pattern.charAt(i + 1) === "*") {
850
- i += 2;
851
- if (pattern.charAt(i) === "/") {
852
- i += 1;
853
- re += "(?:[^/]+/)*";
854
- } else {
855
- re += ".*";
856
- }
857
- } else {
858
- re += "[^/]*";
859
- i += 1;
860
- }
861
- } else if (c === "?") {
862
- re += "[^/]";
863
- i += 1;
864
- } else {
865
- re += c.replace(REGEX_SPECIALS, "\\$&");
866
- i += 1;
1534
+ while (idx !== -1) {
1535
+ count += 1;
1536
+ idx = content.indexOf(args.old_string, idx + args.old_string.length);
867
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" : ""}` };
868
1562
  }
869
- return new RegExp(`^${re}$`);
870
- }
871
- function matchesGlob(pattern, relPath) {
872
- const normalized = pattern.replace(/\\/g, "/");
873
- if (!normalized.includes("/")) {
874
- const base = relPath.split("/").pop() ?? relPath;
875
- return globToRegExp(normalized).test(base);
876
- }
877
- return globToRegExp(normalized).test(relPath);
878
- }
879
- function isSensitivePath2(filePath) {
880
- const base = path6.basename(filePath);
881
- if (base === ".env.example" || base === ".env.sample" || base === ".env.template") {
882
- return false;
883
- }
884
- if (base === ".env" || base.startsWith(".env.")) {
885
- return true;
886
- }
887
- if (base === "id_rsa" || base.endsWith(".pem")) {
888
- return true;
889
- }
890
- return false;
891
- }
1563
+ };
892
1564
 
893
1565
  // src/tools/fs/glob.ts
1566
+ import path10 from "path";
1567
+ import { z as z3 } from "zod";
894
1568
  var MAX_RESULTS = 100;
895
1569
  var schema3 = z3.object({
896
1570
  pattern: z3.string().describe(
@@ -904,7 +1578,7 @@ var globTool = {
904
1578
  permission: "read",
905
1579
  parameters: schema3,
906
1580
  async execute(args, ctx) {
907
- const root = path7.resolve(ctx.cwd, args.path ?? ".");
1581
+ const root = path10.resolve(ctx.cwd, args.path ?? ".");
908
1582
  const files = await walkFiles(root);
909
1583
  const matched = files.filter((f) => matchesGlob(args.pattern, f.rel)).sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, MAX_RESULTS);
910
1584
  if (matched.length === 0) {
@@ -915,8 +1589,8 @@ var globTool = {
915
1589
  };
916
1590
 
917
1591
  // src/tools/fs/grep.ts
918
- import { readFile as readFile2, stat as stat2 } from "fs/promises";
919
- import path8 from "path";
1592
+ import { readFile as readFile4, stat as stat3 } from "fs/promises";
1593
+ import path11 from "path";
920
1594
  import { z as z4 } from "zod";
921
1595
  var MAX_MATCHES = 250;
922
1596
  var schema4 = z4.object({
@@ -937,12 +1611,12 @@ var grepTool = {
937
1611
  } catch (err) {
938
1612
  return { content: `Invalid regular expression: ${err.message}`, isError: true };
939
1613
  }
940
- const root = path8.resolve(ctx.cwd, args.path ?? ".");
1614
+ const root = path11.resolve(ctx.cwd, args.path ?? ".");
941
1615
  let files;
942
1616
  try {
943
- const st = await stat2(root);
1617
+ const st = await stat3(root);
944
1618
  if (st.isFile()) {
945
- files = [{ abs: root, rel: path8.basename(root), mtimeMs: st.mtimeMs }];
1619
+ files = [{ abs: root, rel: path11.basename(root), mtimeMs: st.mtimeMs }];
946
1620
  } else {
947
1621
  files = await walkFiles(root);
948
1622
  }
@@ -958,7 +1632,7 @@ var grepTool = {
958
1632
  outer: for (const file of files) {
959
1633
  let buf;
960
1634
  try {
961
- buf = await readFile2(file.abs);
1635
+ buf = await readFile4(file.abs);
962
1636
  } catch {
963
1637
  continue;
964
1638
  }
@@ -988,8 +1662,8 @@ var grepTool = {
988
1662
  };
989
1663
 
990
1664
  // src/tools/fs/read.ts
991
- import { readFile as readFile3, stat as stat3 } from "fs/promises";
992
- import path9 from "path";
1665
+ import { readFile as readFile5, stat as stat4 } from "fs/promises";
1666
+ import path12 from "path";
993
1667
  import { z as z5 } from "zod";
994
1668
  var DEFAULT_LIMIT = 2e3;
995
1669
  var MAX_CHARS = 100 * 1024;
@@ -1004,18 +1678,18 @@ var readFileTool = {
1004
1678
  permission: "read",
1005
1679
  parameters: schema5,
1006
1680
  async execute(args, ctx) {
1007
- const filePath = path9.resolve(ctx.cwd, args.path);
1681
+ const filePath = path12.resolve(ctx.cwd, args.path);
1008
1682
  if (isSensitivePath2(filePath)) {
1009
1683
  return { content: `Refused to read sensitive file: ${args.path}`, isError: true };
1010
1684
  }
1011
- const st = await stat3(filePath).catch(() => null);
1685
+ const st = await stat4(filePath).catch(() => null);
1012
1686
  if (!st) {
1013
1687
  return { content: `File not found: ${args.path}`, isError: true };
1014
1688
  }
1015
1689
  if (st.isDirectory()) {
1016
1690
  return { content: `Path is a directory, not a file: ${args.path}`, isError: true };
1017
1691
  }
1018
- const raw = await readFile3(filePath, "utf8");
1692
+ const raw = await readFile5(filePath, "utf8");
1019
1693
  const lines = raw.split("\n");
1020
1694
  const start = args.offset ?? 1;
1021
1695
  const limit = args.limit ?? DEFAULT_LIMIT;
@@ -1049,8 +1723,8 @@ var readFileTool = {
1049
1723
  };
1050
1724
 
1051
1725
  // src/tools/fs/write.ts
1052
- import { mkdir, writeFile as writeFile2 } from "fs/promises";
1053
- import path10 from "path";
1726
+ import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
1727
+ import path13 from "path";
1054
1728
  import { z as z6 } from "zod";
1055
1729
  var schema6 = z6.object({
1056
1730
  path: z6.string().describe("File path, absolute or relative to the working directory"),
@@ -1062,32 +1736,84 @@ var writeFileTool = {
1062
1736
  permission: "write",
1063
1737
  parameters: schema6,
1064
1738
  async execute(args, ctx) {
1065
- const filePath = path10.resolve(ctx.cwd, args.path);
1739
+ const filePath = path13.resolve(ctx.cwd, args.path);
1740
+ const snapshot = await captureSnapshot(filePath, "write_file");
1066
1741
  try {
1067
- await mkdir(path10.dirname(filePath), { recursive: true });
1068
- await writeFile2(filePath, args.content, "utf8");
1742
+ await mkdir2(path13.dirname(filePath), { recursive: true });
1743
+ await writeFile3(filePath, args.content, "utf8");
1069
1744
  } catch (err) {
1070
1745
  return { content: `Failed to write ${args.path}: ${err.message}`, isError: true };
1071
1746
  }
1747
+ pushSnapshot(snapshot);
1072
1748
  return { content: `Wrote ${Buffer.byteLength(args.content, "utf8")} bytes to ${args.path}` };
1073
1749
  }
1074
1750
  };
1075
1751
 
1076
- // src/tools/todo.ts
1077
- import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1078
- import path11 from "path";
1752
+ // src/tools/tasks.ts
1079
1753
  import { z as z7 } from "zod";
1080
- var todoItemSchema = z7.object({
1081
- id: z7.number().int(),
1082
- title: z7.string(),
1083
- status: z7.enum(["pending", "in_progress", "done"])
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.` });
1799
+ }
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"])
1084
1810
  });
1085
- var writeSchema = z7.object({
1086
- todos: z7.array(todoItemSchema).describe("The full todo list, replacing the current one")
1811
+ var writeSchema = z8.object({
1812
+ todos: z8.array(todoItemSchema).describe("The full todo list, replacing the current one")
1087
1813
  });
1088
- var readSchema = z7.object({});
1814
+ var readSchema = z8.object({});
1089
1815
  function fileFor(cwd) {
1090
- return path11.join(cwd, ".star", "todos.json");
1816
+ return path14.join(cwd, ".star", "todos.json");
1091
1817
  }
1092
1818
  var TodoStore = class {
1093
1819
  items = /* @__PURE__ */ new Map();
@@ -1103,7 +1829,7 @@ var TodoStore = class {
1103
1829
  async load(cwd) {
1104
1830
  let raw;
1105
1831
  try {
1106
- raw = await readFile4(fileFor(cwd), "utf8");
1832
+ raw = await readFile6(fileFor(cwd), "utf8");
1107
1833
  } catch {
1108
1834
  return;
1109
1835
  }
@@ -1116,8 +1842,8 @@ var TodoStore = class {
1116
1842
  }
1117
1843
  }
1118
1844
  async save(cwd) {
1119
- await mkdir2(path11.join(cwd, ".star"), { recursive: true });
1120
- await writeFile3(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
1845
+ await mkdir3(path14.join(cwd, ".star"), { recursive: true });
1846
+ await writeFile4(fileFor(cwd), `${JSON.stringify(this.list(), null, 2)}
1121
1847
  `);
1122
1848
  }
1123
1849
  };
@@ -1169,13 +1895,13 @@ function createTodoTools(store = defaultStore) {
1169
1895
  }
1170
1896
 
1171
1897
  // src/tools/web/fetch.ts
1172
- import { z as z8 } from "zod";
1898
+ import { z as z9 } from "zod";
1173
1899
  var DEFAULT_MAX_CHARS = 2e4;
1174
1900
  var MAX_BYTES = 2 * 1024 * 1024;
1175
1901
  var TIMEOUT_MS = 3e4;
1176
- var schema7 = z8.object({
1177
- url: z8.string().describe("The http(s) URL to fetch"),
1178
- maxChars: z8.number().int().positive().optional().describe("Maximum characters of text to return (default 20000)")
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)")
1179
1905
  });
1180
1906
  function decodeEntities(s) {
1181
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) => {
@@ -1302,6 +2028,121 @@ var webFetchTool = {
1302
2028
  }
1303
2029
  };
1304
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 };
2107
+ }
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
+ }
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") };
2143
+ }
2144
+ };
2145
+
1305
2146
  // src/tools/registry.ts
1306
2147
  var ToolRegistry = class {
1307
2148
  tools = /* @__PURE__ */ new Map();
@@ -1314,6 +2155,10 @@ var ToolRegistry = class {
1314
2155
  grepTool,
1315
2156
  bashTool,
1316
2157
  webFetchTool,
2158
+ webSearchTool,
2159
+ taskListTool,
2160
+ taskOutputTool,
2161
+ taskKillTool,
1317
2162
  ...createTodoTools()
1318
2163
  ]) {
1319
2164
  this.register(tool);
@@ -1342,6 +2187,155 @@ function createDefaultRegistry() {
1342
2187
  return registry;
1343
2188
  }
1344
2189
 
2190
+ // src/version.ts
2191
+ import fs3 from "fs";
2192
+ import path15 from "path";
2193
+ import { fileURLToPath } from "url";
2194
+ function readPackageVersion() {
2195
+ try {
2196
+ const pkgPath = path15.join(path15.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
2197
+ const pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
2198
+ if (typeof pkg === "object" && pkg !== null) {
2199
+ const version = pkg.version;
2200
+ if (typeof version === "string") return version;
2201
+ }
2202
+ } catch {
2203
+ }
2204
+ return "0.0.0";
2205
+ }
2206
+ var VERSION = readPackageVersion();
2207
+
2208
+ // src/cli/commands/actions.ts
2209
+ import fs4 from "fs/promises";
2210
+ import path16 from "path";
2211
+ var MIN_COMPACT_MESSAGES = 4;
2212
+ async function compactSession(opts) {
2213
+ const { backend, sessionStore, config, model } = opts;
2214
+ if (!(backend instanceof AgentLoop)) {
2215
+ return { message: "Current backend does not support compaction.", compacted: false };
2216
+ }
2217
+ const messages = [...backend.getMessages()];
2218
+ if (messages.length < MIN_COMPACT_MESSAGES) {
2219
+ return {
2220
+ message: `Nothing to compact: history has only ${messages.length} message(s).`,
2221
+ compacted: false
2222
+ };
2223
+ }
2224
+ const beforeTokens = estimateTokens(messages);
2225
+ const compacted = compactMessages(messages, config.contextMaxTokens);
2226
+ if (!compacted.compacted) {
2227
+ return {
2228
+ message: `Nothing to compact: ~${beforeTokens} estimated tokens, below the limit of ${config.contextMaxTokens}.`,
2229
+ compacted: false
2230
+ };
2231
+ }
2232
+ const next = await applyCompactionSummary(messages, compacted, config, model);
2233
+ await backend.loadMessages(next);
2234
+ await sessionStore?.replaceMessages(next);
2235
+ const afterTokens = estimateTokens(next);
2236
+ return {
2237
+ message: `Compacted context: ${messages.length} -> ${next.length} messages (~${beforeTokens} -> ~${afterTokens} estimated tokens).`,
2238
+ compacted: true,
2239
+ messages: next
2240
+ };
2241
+ }
2242
+ async function applyCompactionSummary(original, compacted, config, model) {
2243
+ if (config.contextCompaction !== "summary" || !model) {
2244
+ return compacted.messages;
2245
+ }
2246
+ const headCount = compacted.messages[0]?.role === "system" ? 1 : 0;
2247
+ const dropped = original.slice(headCount, headCount + compacted.droppedCount);
2248
+ try {
2249
+ const summary = await summarizeMessages(dropped, model);
2250
+ const messages = compacted.messages.slice();
2251
+ messages[headCount] = {
2252
+ role: "user",
2253
+ content: `[earlier conversation summarized]
2254
+ ${summary}`
2255
+ };
2256
+ return messages;
2257
+ } catch {
2258
+ return compacted.messages;
2259
+ }
2260
+ }
2261
+ async function exportSession(opts) {
2262
+ const { backend, sessionStore, cwd, arg } = opts;
2263
+ if (!(backend instanceof AgentLoop)) {
2264
+ return "Current backend does not support exporting sessions.";
2265
+ }
2266
+ const messages = [...backend.getMessages()];
2267
+ if (messages.length === 0) {
2268
+ return "Nothing to export: the session has no messages.";
2269
+ }
2270
+ const sessionId = sessionStore?.id ?? `unknown-${Date.now()}`;
2271
+ const target = arg ? path16.resolve(cwd, arg) : path16.resolve(cwd, `star-session-${sessionId}.md`);
2272
+ const existed = await fileExists(target);
2273
+ await fs4.mkdir(path16.dirname(target), { recursive: true });
2274
+ await fs4.writeFile(target, renderSessionMarkdown(sessionId, messages));
2275
+ return existed ? `Exported ${messages.length} messages to ${target} (overwrote existing file).` : `Exported ${messages.length} messages to ${target}.`;
2276
+ }
2277
+ async function fileExists(target) {
2278
+ try {
2279
+ await fs4.access(target);
2280
+ return true;
2281
+ } catch {
2282
+ return false;
2283
+ }
2284
+ }
2285
+ function renderSessionMarkdown(sessionId, messages, exportedAt = /* @__PURE__ */ new Date()) {
2286
+ const lines = [
2287
+ `# Star CLI Session ${sessionId}`,
2288
+ "",
2289
+ `_Exported at ${exportedAt.toISOString()}_`,
2290
+ ""
2291
+ ];
2292
+ for (const message of messages) {
2293
+ lines.push(`## ${roleHeading(message.role)}`, "");
2294
+ lines.push(...renderContent(message), "");
2295
+ }
2296
+ return lines.join("\n");
2297
+ }
2298
+ function roleHeading(role) {
2299
+ return role.charAt(0).toUpperCase() + role.slice(1);
2300
+ }
2301
+ function renderContent(message) {
2302
+ const content = message.content;
2303
+ if (typeof content === "string") return [content];
2304
+ const lines = [];
2305
+ for (const part of content) {
2306
+ if (part.type === "text") {
2307
+ lines.push(part.text, "");
2308
+ } else if (part.type === "tool-call") {
2309
+ lines.push(
2310
+ `**Tool call: ${part.toolName}**`,
2311
+ "",
2312
+ "```json",
2313
+ JSON.stringify(part.args, null, 2),
2314
+ "```",
2315
+ ""
2316
+ );
2317
+ } else if (part.type === "tool-result") {
2318
+ lines.push(
2319
+ `**Tool result: ${part.toolName}**`,
2320
+ "",
2321
+ "```",
2322
+ stringifyPart(part.result),
2323
+ "```",
2324
+ ""
2325
+ );
2326
+ } else if (part.type === "reasoning") {
2327
+ lines.push(part.text, "");
2328
+ } else {
2329
+ lines.push(`[${part.type}]`, "");
2330
+ }
2331
+ }
2332
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
2333
+ return lines;
2334
+ }
2335
+ function stringifyPart(value) {
2336
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
2337
+ }
2338
+
1345
2339
  // src/cli/commands/builtin.ts
1346
2340
  function registerBuiltinCommands(registry) {
1347
2341
  registry.register({
@@ -1405,30 +2399,498 @@ ${lines.join("\n")}`);
1405
2399
  }
1406
2400
  }
1407
2401
  });
1408
- registry.register({
1409
- name: "todo",
1410
- description: "Show the current todo list",
1411
- usage: "/todo",
1412
- async run(_args, ctx) {
1413
- ctx.addSystemMessage(await ctx.showTodos());
2402
+ registry.register({
2403
+ name: "todo",
2404
+ description: "Show the current todo list",
2405
+ usage: "/todo",
2406
+ async run(_args, ctx) {
2407
+ ctx.addSystemMessage(await ctx.showTodos());
2408
+ }
2409
+ });
2410
+ registry.register({
2411
+ name: "tasks",
2412
+ description: "List background shell tasks",
2413
+ usage: "/tasks",
2414
+ run(_args, ctx) {
2415
+ ctx.addSystemMessage(ctx.listTasks());
2416
+ }
2417
+ });
2418
+ registry.register({
2419
+ name: "cost",
2420
+ description: "Show API token usage for this session",
2421
+ usage: "/cost",
2422
+ run(_args, ctx) {
2423
+ ctx.addSystemMessage(ctx.showUsage());
2424
+ }
2425
+ });
2426
+ registry.register({
2427
+ name: "config",
2428
+ description: "Show the current configuration",
2429
+ usage: "/config",
2430
+ run(_args, ctx) {
2431
+ ctx.addSystemMessage(ctx.describeConfig());
2432
+ }
2433
+ });
2434
+ registry.register({
2435
+ name: "compact",
2436
+ description: "Compact the conversation history to free up context",
2437
+ usage: "/compact",
2438
+ async run(_args, ctx) {
2439
+ ctx.addSystemMessage(await ctx.compactContext());
2440
+ }
2441
+ });
2442
+ registry.register({
2443
+ name: "export",
2444
+ description: "Export the current session to a Markdown file",
2445
+ usage: "/export [path]",
2446
+ async run(args, ctx) {
2447
+ ctx.addSystemMessage(await ctx.exportSession(args));
2448
+ }
2449
+ });
2450
+ registry.register({
2451
+ name: "permission",
2452
+ description: "Show or set the global permission mode (ask | auto | readonly | yolo)",
2453
+ usage: "/permission [ask|auto|readonly|yolo]",
2454
+ async run(args, ctx) {
2455
+ ctx.addSystemMessage(await ctx.permissionMode(args.trim()));
2456
+ }
2457
+ });
2458
+ registry.register({
2459
+ name: "plan",
2460
+ description: "Toggle plan mode: read-only research, then approve the plan before executing",
2461
+ usage: "/plan",
2462
+ async run(_args, ctx) {
2463
+ ctx.addSystemMessage(await ctx.planMode());
2464
+ }
2465
+ });
2466
+ registry.register({
2467
+ name: "undo",
2468
+ description: "Undo the last conversation turn: revert its file changes (write_file/edit_file) and retract its messages",
2469
+ usage: "/undo",
2470
+ async run(_args, ctx) {
2471
+ ctx.addSystemMessage(await ctx.undo());
2472
+ }
2473
+ });
2474
+ registry.register({
2475
+ name: "init",
2476
+ description: "Generate an AGENTS.md for the current project",
2477
+ usage: "/init [force]",
2478
+ async run(args, ctx) {
2479
+ ctx.addSystemMessage(await ctx.initProject(args));
2480
+ }
2481
+ });
2482
+ registry.register({
2483
+ name: "doctor",
2484
+ description: "Run environment and configuration checks",
2485
+ usage: "/doctor",
2486
+ async run(_args, ctx) {
2487
+ ctx.addSystemMessage(await ctx.runDoctor());
2488
+ }
2489
+ });
2490
+ }
2491
+
2492
+ // src/cli/commands/custom.ts
2493
+ import { readFileSync, readdirSync } from "fs";
2494
+ import path17 from "path";
2495
+ var NAME_PATTERN = /^[a-z0-9-]+$/;
2496
+ var DESCRIPTION_PATTERN = /^<!--\s*description:\s*(.+?)\s*-->\s*$/;
2497
+ var DEFAULT_DESCRIPTION = "Custom prompt command";
2498
+ function parseCommandFile(filePath, name) {
2499
+ let raw;
2500
+ try {
2501
+ raw = readFileSync(filePath, "utf8");
2502
+ } catch {
2503
+ return null;
2504
+ }
2505
+ const lines = raw.split(/\r?\n/);
2506
+ let description = DEFAULT_DESCRIPTION;
2507
+ const first = lines[0] ?? "";
2508
+ const match = first.match(DESCRIPTION_PATTERN);
2509
+ if (match) {
2510
+ description = match[1] ?? DEFAULT_DESCRIPTION;
2511
+ lines.shift();
2512
+ if (lines[0] !== void 0 && lines[0].trim() === "") lines.shift();
2513
+ }
2514
+ const template = lines.join("\n").trim();
2515
+ if (!template) return null;
2516
+ return { name, description, template, source: filePath };
2517
+ }
2518
+ function scanDir(dir, into) {
2519
+ let entries;
2520
+ try {
2521
+ entries = readdirSync(dir);
2522
+ } catch {
2523
+ return;
2524
+ }
2525
+ for (const entry of entries.sort()) {
2526
+ if (!entry.endsWith(".md")) continue;
2527
+ const name = entry.slice(0, -".md".length);
2528
+ if (!NAME_PATTERN.test(name)) continue;
2529
+ const parsed = parseCommandFile(path17.join(dir, entry), name);
2530
+ if (parsed) into.set(name, parsed);
2531
+ }
2532
+ }
2533
+ function loadCustomCommands(cwd, home = starHome()) {
2534
+ const byName = /* @__PURE__ */ new Map();
2535
+ scanDir(path17.join(home, "commands"), byName);
2536
+ scanDir(path17.join(cwd, ".star", "commands"), byName);
2537
+ return [...byName.values()];
2538
+ }
2539
+ function registerCustomCommands(registry, cwd, home) {
2540
+ const registered = [];
2541
+ for (const command of loadCustomCommands(cwd, home)) {
2542
+ if (registry.get(command.name)) continue;
2543
+ registry.register({
2544
+ name: command.name,
2545
+ description: command.description,
2546
+ usage: `/${command.name} [args]`,
2547
+ run(args, ctx) {
2548
+ const prompt = command.template.split("$ARGUMENTS").join(args);
2549
+ if (ctx.submitPrompt) {
2550
+ return ctx.submitPrompt(prompt);
2551
+ }
2552
+ ctx.addSystemMessage(`/${command.name}: this context cannot submit prompts to the model.`);
2553
+ }
2554
+ });
2555
+ registered.push(command);
2556
+ }
2557
+ return registered;
2558
+ }
2559
+
2560
+ // src/cli/commands/doctor.ts
2561
+ import fs5 from "fs/promises";
2562
+ import path18 from "path";
2563
+ var MIN_NODE_MAJOR = 20;
2564
+ var REGISTRY_URL = "https://registry.npmjs.org/";
2565
+ var REGISTRY_TIMEOUT_MS = 5e3;
2566
+ async function defaultFetcher(url, timeoutMs) {
2567
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
2568
+ return res.ok;
2569
+ }
2570
+ function nodeMajor(version) {
2571
+ const match = /^v?(\d+)/.exec(version);
2572
+ return match ? Number.parseInt(match[1] ?? "", 10) : null;
2573
+ }
2574
+ async function pathExists(target) {
2575
+ try {
2576
+ await fs5.access(target);
2577
+ return true;
2578
+ } catch {
2579
+ return false;
2580
+ }
2581
+ }
2582
+ function resolveApiKeyConfigured(config, providerName, env) {
2583
+ const provider = config.providers.find((p) => p.name === providerName);
2584
+ if (!provider) return null;
2585
+ const fromEnv = provider.apiKeyEnv ? env[provider.apiKeyEnv] : void 0;
2586
+ return Boolean(fromEnv ?? provider.apiKey);
2587
+ }
2588
+ async function runDoctor(opts) {
2589
+ const env = opts.env ?? process.env;
2590
+ const home = opts.home ?? starHome();
2591
+ const checks = [];
2592
+ const version = opts.nodeVersion ?? process.version;
2593
+ const major = nodeMajor(version);
2594
+ const nodeOk = major !== null && major >= MIN_NODE_MAJOR;
2595
+ checks.push({
2596
+ name: "Node.js version",
2597
+ ok: nodeOk,
2598
+ detail: nodeOk ? `${version} (>= ${MIN_NODE_MAJOR} required)` : `${version} \u2014 Node.js >= ${MIN_NODE_MAJOR} is required`
2599
+ });
2600
+ if (opts.shellLabel !== void 0 || opts.shellPath !== void 0) {
2601
+ checks.push({
2602
+ name: "Shell for bash tool",
2603
+ ok: true,
2604
+ detail: `${opts.shellLabel ?? "unknown"}${opts.shellPath ? ` (${opts.shellPath})` : ""}`
2605
+ });
2606
+ } else {
2607
+ const spec = resolveShell();
2608
+ checks.push({
2609
+ name: "Shell for bash tool",
2610
+ ok: true,
2611
+ detail: `${spec.label} (${spec.shell})`
2612
+ });
2613
+ }
2614
+ const configPath = path18.join(home, "config.toml");
2615
+ const configExists = await pathExists(configPath);
2616
+ checks.push({
2617
+ name: "STAR_HOME / config",
2618
+ ok: configExists,
2619
+ detail: configExists ? `STAR_HOME=${home}, config.toml found at ${configPath}` : `STAR_HOME=${home}, config.toml missing at ${configPath}`
2620
+ });
2621
+ const defaultModel = opts.config.defaultModel;
2622
+ if (!defaultModel) {
2623
+ checks.push({
2624
+ name: "Default model",
2625
+ ok: false,
2626
+ detail: "no defaultModel configured"
2627
+ });
2628
+ } else {
2629
+ const modelConfig = opts.config.models.find((m) => m.name === defaultModel);
2630
+ if (!modelConfig) {
2631
+ checks.push({
2632
+ name: "Default model",
2633
+ ok: false,
2634
+ detail: `model "${defaultModel}" not found in configured models`
2635
+ });
2636
+ } else {
2637
+ const provider = opts.config.providers.find((p) => p.name === modelConfig.provider);
2638
+ checks.push({
2639
+ name: "Default model",
2640
+ ok: provider !== void 0,
2641
+ detail: provider ? `${modelConfig.name} (${provider.name}/${modelConfig.model}, ${provider.baseURL})` : `provider "${modelConfig.provider}" for model "${modelConfig.name}" not found`
2642
+ });
2643
+ const keyConfigured = resolveApiKeyConfigured(opts.config, modelConfig.provider, env);
2644
+ checks.push({
2645
+ name: "Provider API key",
2646
+ ok: keyConfigured === true,
2647
+ detail: keyConfigured === null ? `provider "${modelConfig.provider}" not found` : keyConfigured ? `apiKey resolved for provider "${modelConfig.provider}"` : `apiKey missing for provider "${modelConfig.provider}"${provider?.apiKeyEnv ? ` (set ${provider.apiKeyEnv} or configure apiKey)` : ""}`
2648
+ });
1414
2649
  }
2650
+ }
2651
+ const sessions = path18.join(home, "sessions");
2652
+ let writable = false;
2653
+ let writeError = "";
2654
+ 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);
2659
+ writable = true;
2660
+ } catch (error) {
2661
+ writeError = error instanceof Error ? error.message : String(error);
2662
+ }
2663
+ checks.push({
2664
+ name: "Sessions directory writable",
2665
+ ok: writable,
2666
+ detail: writable ? `${sessions} is writable` : `${sessions} not writable: ${writeError}`
1415
2667
  });
1416
- registry.register({
1417
- name: "cost",
1418
- description: "Show API token usage for this session",
1419
- usage: "/cost",
1420
- run(_args, ctx) {
1421
- ctx.addSystemMessage(ctx.showUsage());
1422
- }
2668
+ const fetcher = opts.fetcher ?? defaultFetcher;
2669
+ let registryOk = false;
2670
+ try {
2671
+ registryOk = await fetcher(REGISTRY_URL, REGISTRY_TIMEOUT_MS);
2672
+ } catch {
2673
+ registryOk = false;
2674
+ }
2675
+ checks.push({
2676
+ name: "npm registry connectivity",
2677
+ ok: registryOk,
2678
+ detail: registryOk ? `${REGISTRY_URL} reachable` : `${REGISTRY_URL} unreachable (optional \u2014 only affects update checks)`,
2679
+ optional: true
1423
2680
  });
1424
- registry.register({
1425
- name: "config",
1426
- description: "Show the current configuration",
1427
- usage: "/config",
1428
- run(_args, ctx) {
1429
- ctx.addSystemMessage(ctx.describeConfig());
2681
+ const passed = checks.filter((c) => c.ok).length;
2682
+ return { checks, passed, failed: checks.length - passed };
2683
+ }
2684
+ function formatDoctorReport(report) {
2685
+ const lines = report.checks.map(
2686
+ (check) => `${check.ok ? "\u2713" : "\u2717"} ${check.name}: ${check.detail}`
2687
+ );
2688
+ lines.push("", `${report.passed} passed, ${report.failed} failed`);
2689
+ return lines.join("\n");
2690
+ }
2691
+
2692
+ // src/cli/commands/init-project.ts
2693
+ import fs6 from "fs/promises";
2694
+ import path19 from "path";
2695
+ import { generateText as generateText2 } from "ai";
2696
+ var IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "out", "coverage"]);
2697
+ var MAX_ENTRIES = 20;
2698
+ var README_HEAD_LINES = 5;
2699
+ async function fileExists2(target) {
2700
+ try {
2701
+ await fs6.access(target);
2702
+ return true;
2703
+ } catch {
2704
+ return false;
2705
+ }
2706
+ }
2707
+ async function readPackageJson(cwd) {
2708
+ try {
2709
+ const raw = await fs6.readFile(path19.join(cwd, "package.json"), "utf8");
2710
+ const parsed = JSON.parse(raw);
2711
+ if (typeof parsed !== "object" || parsed === null) return null;
2712
+ return parsed;
2713
+ } catch {
2714
+ return null;
2715
+ }
2716
+ }
2717
+ function stringRecord(value) {
2718
+ if (typeof value !== "object" || value === null) return {};
2719
+ const out = {};
2720
+ for (const [key, val] of Object.entries(value)) {
2721
+ if (typeof val === "string") out[key] = val;
2722
+ }
2723
+ return out;
2724
+ }
2725
+ 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";
2730
+ return null;
2731
+ }
2732
+ function inferCommands(pm, scripts) {
2733
+ const runner = pm ?? "npm";
2734
+ const pick = (script) => script in scripts ? `${runner} ${script}` : null;
2735
+ return {
2736
+ test: pick("test"),
2737
+ build: pick("build"),
2738
+ lint: pick("lint"),
2739
+ typecheck: pick("typecheck")
2740
+ };
2741
+ }
2742
+ async function listEntries(cwd) {
2743
+ try {
2744
+ const dirents = await fs6.readdir(cwd, { withFileTypes: true });
2745
+ const entries = [];
2746
+ for (const dirent of dirents) {
2747
+ if (dirent.isDirectory()) {
2748
+ if (IGNORED_DIRS.has(dirent.name)) continue;
2749
+ entries.push(`${dirent.name}/`);
2750
+ } else if (!dirent.name.startsWith(".")) {
2751
+ entries.push(dirent.name);
2752
+ }
2753
+ if (entries.length >= MAX_ENTRIES) break;
2754
+ }
2755
+ return entries.sort();
2756
+ } catch {
2757
+ return [];
2758
+ }
2759
+ }
2760
+ async function readReadmeHead(cwd) {
2761
+ for (const name of ["README.md", "readme.md", "README"]) {
2762
+ try {
2763
+ const raw = await fs6.readFile(path19.join(cwd, name), "utf8");
2764
+ return raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).slice(0, README_HEAD_LINES);
2765
+ } catch {
1430
2766
  }
2767
+ }
2768
+ return [];
2769
+ }
2770
+ async function scanProject(cwd) {
2771
+ const pkg = await readPackageJson(cwd);
2772
+ const packageManager = await detectPackageManager(cwd);
2773
+ const scripts = stringRecord(pkg?.scripts);
2774
+ return {
2775
+ name: typeof pkg?.name === "string" ? pkg.name : null,
2776
+ dirName: path19.basename(cwd),
2777
+ packageManager,
2778
+ scripts,
2779
+ dependencies: Object.keys(stringRecord(pkg?.dependencies)),
2780
+ devDependencies: Object.keys(stringRecord(pkg?.devDependencies)),
2781
+ commands: inferCommands(packageManager, scripts),
2782
+ hasTsconfig: await fileExists2(path19.join(cwd, "tsconfig.json")),
2783
+ isGitRepo: await fileExists2(path19.join(cwd, ".git")),
2784
+ readmeHead: await readReadmeHead(cwd),
2785
+ entries: await listEntries(cwd)
2786
+ };
2787
+ }
2788
+ function renderAgentsMd(facts) {
2789
+ const title = facts.name ?? facts.dirName;
2790
+ const lines = [
2791
+ "# AGENTS.md",
2792
+ "",
2793
+ "Guidance for AI agents working in this repository.",
2794
+ "",
2795
+ "## Project",
2796
+ "",
2797
+ facts.readmeHead.length > 0 ? facts.readmeHead.join(" ") : `${title}`,
2798
+ ""
2799
+ ];
2800
+ const commandLines = [];
2801
+ const push = (label, command) => {
2802
+ if (command) commandLines.push(`- \`${command}\` \u2014 ${label}`);
2803
+ };
2804
+ push("run tests", facts.commands.test);
2805
+ push("build", facts.commands.build);
2806
+ push("lint", facts.commands.lint);
2807
+ push("typecheck", facts.commands.typecheck);
2808
+ if (commandLines.length > 0) {
2809
+ lines.push("## Commands", "", ...commandLines, "");
2810
+ }
2811
+ lines.push("## Layout", "");
2812
+ for (const entry of facts.entries) {
2813
+ lines.push(`- \`${entry}\``);
2814
+ }
2815
+ lines.push("", "## Conventions", "");
2816
+ const conventions = [];
2817
+ if (facts.hasTsconfig) conventions.push("TypeScript project (tsconfig.json present).");
2818
+ if (facts.packageManager) conventions.push(`Use ${facts.packageManager} as the package manager.`);
2819
+ if (facts.isGitRepo) conventions.push("Git repository.");
2820
+ if (facts.dependencies.length > 0) {
2821
+ conventions.push(`Key dependencies: ${facts.dependencies.slice(0, 10).join(", ")}.`);
2822
+ }
2823
+ if (conventions.length === 0) conventions.push("No conventions detected yet \u2014 fill this in.");
2824
+ for (const item of conventions) lines.push(`- ${item}`);
2825
+ lines.push("");
2826
+ return lines.join("\n");
2827
+ }
2828
+ var INIT_SYSTEM_PROMPT = [
2829
+ "You are writing an AGENTS.md file that guides AI coding agents working in a repository.",
2830
+ "You are given scanned project facts as JSON.",
2831
+ "Produce a concise Markdown document with sections: Project, Commands, Layout, Conventions.",
2832
+ "Keep commands exactly as given in the facts. Do not invent tools, scripts, or directories.",
2833
+ "Output only the Markdown, no preamble, no code fences."
2834
+ ].join("\n");
2835
+ async function polishWithModel(facts, model) {
2836
+ const { text } = await generateText2({
2837
+ model,
2838
+ system: INIT_SYSTEM_PROMPT,
2839
+ prompt: `Project facts:
2840
+
2841
+ ${JSON.stringify(facts, null, 2)}
2842
+
2843
+ Write the AGENTS.md.`
1431
2844
  });
2845
+ const trimmed = text.trim();
2846
+ return trimmed.length > 0 ? trimmed : null;
2847
+ }
2848
+ async function initProject(opts) {
2849
+ const { cwd, args, model } = opts;
2850
+ const target = path19.join(cwd, "AGENTS.md");
2851
+ const force = args.split(/\s+/).includes("force");
2852
+ if (!force && await fileExists2(target)) {
2853
+ return {
2854
+ message: `AGENTS.md already exists at ${target}. Use /init force to overwrite it.`,
2855
+ written: false,
2856
+ path: target,
2857
+ lines: 0,
2858
+ usedLlm: false
2859
+ };
2860
+ }
2861
+ const facts = await scanProject(cwd);
2862
+ let usedLlm = false;
2863
+ let content;
2864
+ let note = "";
2865
+ if (model) {
2866
+ try {
2867
+ const polished = await polishWithModel(facts, model);
2868
+ if (polished) {
2869
+ content = polished.endsWith("\n") ? polished : `${polished}
2870
+ `;
2871
+ usedLlm = true;
2872
+ } else {
2873
+ content = renderAgentsMd(facts);
2874
+ note = " Model returned an empty draft; used the template instead \u2014 review and refine manually.";
2875
+ }
2876
+ } catch {
2877
+ content = renderAgentsMd(facts);
2878
+ note = " Model refinement failed; used the template instead \u2014 review and refine manually.";
2879
+ }
2880
+ } else {
2881
+ content = renderAgentsMd(facts);
2882
+ note = " No model available; used the template \u2014 review and refine manually.";
2883
+ }
2884
+ await fs6.writeFile(target, content);
2885
+ const lines = content.split("\n").length;
2886
+ const how = usedLlm ? "generated with model refinement" : "generated from template";
2887
+ return {
2888
+ message: `Wrote ${target} (${lines} lines, ${how}).${note}`,
2889
+ written: true,
2890
+ path: target,
2891
+ lines,
2892
+ usedLlm
2893
+ };
1432
2894
  }
1433
2895
 
1434
2896
  // src/cli/commands/registry.ts
@@ -1460,21 +2922,67 @@ function parseSlashCommand(input) {
1460
2922
  };
1461
2923
  }
1462
2924
 
1463
- // src/cli/components/InputBox.tsx
2925
+ // src/cli/components/ExitPlanPrompt.tsx
1464
2926
  import { Box, Text, useInput } from "ink";
1465
- import { useRef, useState } from "react";
1466
2927
  import { jsx, jsxs } from "react/jsx-runtime";
1467
- function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
2928
+ function ExitPlanPrompt({ onDecision }) {
2929
+ useInput((input, key) => {
2930
+ const ch = input.toLowerCase();
2931
+ if (ch === "y") onDecision("yes");
2932
+ else if (ch === "n" || key.escape) onDecision("no");
2933
+ });
2934
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [
2935
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "Plan ready (plan mode)" }),
2936
+ /* @__PURE__ */ jsx(Text, { children: "The plan above was produced without making any changes." }),
2937
+ /* @__PURE__ */ jsx(Text, { children: "[y] approve and execute [n] keep refining (Esc = n)" })
2938
+ ] });
2939
+ }
2940
+
2941
+ // src/cli/components/InputBox.tsx
2942
+ import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
2943
+ import { useRef, useState } from "react";
2944
+
2945
+ // src/cli/commands/suggest.ts
2946
+ var MAX_SUGGESTIONS = 5;
2947
+ function filterCommands(input, commands) {
2948
+ if (!input.startsWith("/")) return [];
2949
+ if (/\s/.test(input)) return [];
2950
+ const prefix = input.slice(1).toLowerCase();
2951
+ return commands.filter((cmd) => cmd.name.toLowerCase().startsWith(prefix)).sort((a, b) => a.name.localeCompare(b.name)).slice(0, MAX_SUGGESTIONS);
2952
+ }
2953
+
2954
+ // src/cli/components/InputBox.tsx
2955
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
2956
+ function InputBox({
2957
+ isStreaming,
2958
+ disabled,
2959
+ commands,
2960
+ onSubmit,
2961
+ onInterrupt,
2962
+ onExit
2963
+ }) {
1468
2964
  const [value, setValue] = useState("");
1469
2965
  const [cursor, setCursor] = useState(0);
1470
2966
  const [history, setHistory] = useState([]);
2967
+ const [highlight, setHighlight] = useState(0);
2968
+ const [suggestionsDismissed, setSuggestionsDismissed] = useState(false);
1471
2969
  const historyIndexRef = useRef(null);
1472
2970
  const draftRef = useRef("");
1473
2971
  const edit = (next, nextCursor) => {
1474
2972
  setValue(next);
1475
2973
  setCursor(Math.max(0, Math.min(nextCursor, next.length)));
2974
+ setHighlight(0);
2975
+ setSuggestionsDismissed(false);
1476
2976
  };
1477
- useInput((input, key) => {
2977
+ const suggestions = isStreaming || disabled || suggestionsDismissed || !commands ? [] : filterCommands(value, commands);
2978
+ const activeIndex = suggestions.length === 0 ? 0 : Math.min(highlight, suggestions.length - 1);
2979
+ const completeHighlighted = () => {
2980
+ const cmd = suggestions[activeIndex];
2981
+ if (!cmd) return;
2982
+ const text = `/${cmd.name} `;
2983
+ edit(text, text.length);
2984
+ };
2985
+ useInput2((input, key) => {
1478
2986
  if (key.ctrl && input === "c") {
1479
2987
  if (isStreaming || disabled) {
1480
2988
  onInterrupt();
@@ -1489,6 +2997,10 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1489
2997
  return;
1490
2998
  }
1491
2999
  if (isStreaming || disabled) return;
3000
+ if (key.escape) {
3001
+ if (suggestions.length > 0) setSuggestionsDismissed(true);
3002
+ return;
3003
+ }
1492
3004
  if (key.return) {
1493
3005
  const text = value.trim();
1494
3006
  if (text.length > 0) {
@@ -1500,7 +3012,17 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1500
3012
  draftRef.current = "";
1501
3013
  return;
1502
3014
  }
1503
- if (key.upArrow) {
3015
+ if (key.tab) {
3016
+ if (key.shift) return;
3017
+ if (suggestions.length > 0) {
3018
+ completeHighlighted();
3019
+ return;
3020
+ }
3021
+ } else if (key.upArrow) {
3022
+ if (suggestions.length > 0) {
3023
+ setHighlight((prev) => (prev - 1 + suggestions.length) % suggestions.length);
3024
+ return;
3025
+ }
1504
3026
  if (history.length === 0) return;
1505
3027
  if (historyIndexRef.current === null) {
1506
3028
  draftRef.current = value;
@@ -1511,8 +3033,11 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1511
3033
  const entry = history[historyIndexRef.current] ?? "";
1512
3034
  edit(entry, entry.length);
1513
3035
  return;
1514
- }
1515
- if (key.downArrow) {
3036
+ } else if (key.downArrow) {
3037
+ if (suggestions.length > 0) {
3038
+ setHighlight((prev) => (prev + 1) % suggestions.length);
3039
+ return;
3040
+ }
1516
3041
  if (historyIndexRef.current === null) return;
1517
3042
  if (historyIndexRef.current < history.length - 1) {
1518
3043
  historyIndexRef.current += 1;
@@ -1523,13 +3048,15 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1523
3048
  edit(draftRef.current, draftRef.current.length);
1524
3049
  }
1525
3050
  return;
1526
- }
1527
- if (key.leftArrow) {
3051
+ } else if (key.leftArrow) {
1528
3052
  setCursor((prev) => Math.max(0, prev - 1));
1529
3053
  return;
1530
- }
1531
- if (key.rightArrow) {
1532
- setCursor((prev) => Math.min(value.length, prev + 1));
3054
+ } else if (key.rightArrow) {
3055
+ if (suggestions.length > 0 && cursor === value.length) {
3056
+ completeHighlighted();
3057
+ } else {
3058
+ setCursor((prev) => Math.min(value.length, prev + 1));
3059
+ }
1533
3060
  return;
1534
3061
  }
1535
3062
  if (key.ctrl && input === "a") {
@@ -1567,169 +3094,457 @@ function InputBox({ isStreaming, disabled, onSubmit, onInterrupt, onExit }) {
1567
3094
  const before = value.slice(0, cursor);
1568
3095
  const at = value[cursor] ?? " ";
1569
3096
  const after = value.slice(cursor + 1);
1570
- return /* @__PURE__ */ jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [
1571
- /* @__PURE__ */ jsx(Text, { color: "cyan", children: "> " }),
1572
- /* @__PURE__ */ jsx(Text, { children: before }),
1573
- /* @__PURE__ */ jsx(Text, { inverse: true, children: at }),
1574
- /* @__PURE__ */ jsx(Text, { children: after })
3097
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
3098
+ /* @__PURE__ */ jsxs2(Box2, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: [
3099
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "> " }),
3100
+ /* @__PURE__ */ jsx2(Text2, { children: before }),
3101
+ /* @__PURE__ */ jsx2(Text2, { inverse: true, children: at }),
3102
+ /* @__PURE__ */ jsx2(Text2, { children: after })
3103
+ ] }),
3104
+ suggestions.length > 0 && /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", paddingLeft: 2, children: suggestions.map(
3105
+ (cmd, index) => index === activeIndex ? /* @__PURE__ */ jsx2(Text2, { bold: true, inverse: true, children: `/${cmd.name} - ${cmd.description}` }, cmd.name) : /* @__PURE__ */ jsxs2(Text2, { children: [
3106
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", children: `/${cmd.name}` }),
3107
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: ` - ${cmd.description}` })
3108
+ ] }, cmd.name)
3109
+ ) })
1575
3110
  ] });
1576
3111
  }
1577
3112
 
1578
3113
  // src/cli/components/MessageList.tsx
1579
- import { Box as Box2, Static, Text as Text2 } from "ink";
1580
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
3114
+ import { Box as Box3, Static, Text as Text3 } from "ink";
3115
+ import { memo } from "react";
3116
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1581
3117
  var roleStyles = {
1582
3118
  user: { label: "you", color: "cyan" },
1583
3119
  assistant: { label: "star", color: "green" },
1584
3120
  system: { label: "system", color: "yellow" },
1585
3121
  tool: { label: "tool", color: "magenta" }
1586
3122
  };
1587
- function MessageList({ messages }) {
1588
- return /* @__PURE__ */ jsx2(Static, { items: messages, children: (message) => {
3123
+ var MessageList = memo(function MessageList2({ messages }) {
3124
+ return /* @__PURE__ */ jsx3(Static, { items: messages, children: (message) => {
3125
+ const marginBottom = message.tight ? 0 : 1;
3126
+ if (message.role === "assistant-cont") {
3127
+ return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", marginBottom, children: /* @__PURE__ */ jsx3(Text3, { color: "green", children: message.text }) }, message.id);
3128
+ }
1589
3129
  const style = roleStyles[message.role];
1590
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginBottom: 1, children: [
1591
- /* @__PURE__ */ jsx2(Text2, { bold: true, color: style.color, children: style.label }),
1592
- /* @__PURE__ */ jsx2(Text2, { color: style.color, children: message.text })
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 })
1593
3134
  ] }, message.id);
1594
3135
  } });
1595
- }
3136
+ });
1596
3137
 
1597
3138
  // src/cli/components/PermissionPrompt.tsx
1598
- import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
1599
-
1600
- // src/cli/format.ts
1601
- function summarizeArgs(args, maxLength = 120) {
1602
- let json;
1603
- try {
1604
- json = JSON.stringify(args) ?? String(args);
1605
- } catch {
1606
- json = String(args);
1607
- }
1608
- return json.length > maxLength ? `${json.slice(0, maxLength)}...` : json;
1609
- }
1610
- function previewLines(text, maxLines = 10) {
1611
- const lines = text.split("\n");
1612
- if (lines.length <= maxLines) return { text, truncated: false };
1613
- return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
1614
- }
1615
- function coreMessageText(message) {
1616
- const content = message.content;
1617
- if (typeof content === "string") return content;
1618
- if (Array.isArray(content)) {
1619
- return content.filter((part) => part.type === "text").map((part) => "text" in part ? part.text : "").join(" ");
1620
- }
1621
- return "";
1622
- }
1623
- function buildDisplayMessages(messages) {
1624
- const display = [];
1625
- let collapsed = 0;
1626
- for (const message of messages) {
1627
- if (message.role === "user" || message.role === "assistant") {
1628
- const text = coreMessageText(message);
1629
- if (text) {
1630
- display.push({ id: display.length, role: message.role, text });
1631
- } else {
1632
- collapsed++;
1633
- }
1634
- } else if (message.role === "tool") {
1635
- collapsed++;
1636
- }
1637
- }
1638
- if (collapsed > 0) {
1639
- display.push({
1640
- id: display.length,
1641
- role: "system",
1642
- text: `\u5DF2\u6062\u590D ${collapsed} \u6761\u5386\u53F2\u6D88\u606F`
1643
- });
1644
- }
1645
- return display;
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
+ ] });
1646
3155
  }
1647
-
1648
- // src/cli/components/PermissionPrompt.tsx
1649
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1650
- function PermissionPrompt({ request, onDecision }) {
1651
- useInput2((input) => {
3156
+ function PermissionPrompt({ request, preview, onDecision }) {
3157
+ useInput3((input) => {
1652
3158
  const ch = input.toLowerCase();
1653
3159
  if (ch === "y") onDecision("yes");
1654
3160
  else if (ch === "n") onDecision("no");
1655
3161
  else if (ch === "a") onDecision("always");
1656
3162
  });
1657
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
1658
- /* @__PURE__ */ jsxs3(Text3, { bold: true, color: "yellow", children: [
3163
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
3164
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "yellow", children: [
1659
3165
  "Permission required: ",
1660
3166
  request.toolName,
1661
3167
  " (",
1662
3168
  request.level,
1663
3169
  ")"
1664
3170
  ] }),
1665
- /* @__PURE__ */ jsx3(Text3, { children: summarizeArgs(request.args) }),
1666
- /* @__PURE__ */ jsx3(Text3, { children: "[y] allow [n] deny [a] always" })
3171
+ preview ? /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
3172
+ /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
3173
+ preview.type === "new-file" ? "New file: " : "",
3174
+ preview.label
3175
+ ] }),
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)" })
3182
+ ] });
3183
+ }
3184
+
3185
+ // src/cli/components/StatusBar.tsx
3186
+ import { Box as Box5, Text as Text5 } from "ink";
3187
+ import { memo as memo2 } from "react";
3188
+ import { jsxs as jsxs5 } from "react/jsx-runtime";
3189
+ var BG_LABEL_MAX = 40;
3190
+ var StatusBar = memo2(function StatusBar2({
3191
+ model,
3192
+ permissionMode,
3193
+ tokens,
3194
+ backgroundTasks = []
3195
+ }) {
3196
+ const joined = backgroundTasks.join(", ");
3197
+ 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
3202
+ ] }),
3203
+ backgroundTasks.length > 0 && /* @__PURE__ */ jsxs5(Text5, { color: "yellow", children: [
3204
+ "bg: ",
3205
+ backgroundTasks.length,
3206
+ " (",
3207
+ labels,
3208
+ ")"
3209
+ ] }),
3210
+ /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3211
+ tokens,
3212
+ " tokens"
3213
+ ] }),
3214
+ /* @__PURE__ */ jsxs5(Text5, { dimColor: true, children: [
3215
+ "mode: ",
3216
+ permissionMode
3217
+ ] })
3218
+ ] });
3219
+ });
3220
+
3221
+ // src/cli/components/StreamingMessage.tsx
3222
+ import { Box as Box6, Text as Text6 } from "ink";
3223
+ import { memo as memo3 } from "react";
3224
+ import { jsx as jsx5, jsxs as jsxs6 } from "react/jsx-runtime";
3225
+ 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
+ ] })
1667
3263
  ] });
1668
3264
  }
1669
3265
 
1670
- // src/cli/components/StatusBar.tsx
1671
- import { Box as Box4, Text as Text4 } from "ink";
1672
- import { jsxs as jsxs4 } from "react/jsx-runtime";
1673
- function StatusBar({ model, permissionMode, tokens }) {
1674
- return /* @__PURE__ */ jsxs4(Box4, { justifyContent: "space-between", children: [
1675
- /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1676
- "model: ",
1677
- model
1678
- ] }),
1679
- /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1680
- tokens,
1681
- " tokens"
1682
- ] }),
1683
- /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1684
- "mode: ",
1685
- permissionMode
1686
- ] })
1687
- ] });
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;
3361
+ }
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;
3384
+ }
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;
3410
+ }
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;
3417
+ }
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);
3428
+ }
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 };
3437
+ }
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;
3448
+ }
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) };
3457
+ }
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
+ }
3463
+
3464
+ // src/cli/shell-bang.ts
3465
+ var SHELL_BANG_DISPLAY_MAX_CHARS = 3e3;
3466
+ function truncateShellOutput(output, max = SHELL_BANG_DISPLAY_MAX_CHARS) {
3467
+ if (output.length <= max) return output;
3468
+ const half = Math.floor(max / 2);
3469
+ return `${output.slice(0, half)}
3470
+ ... [${output.length - max} characters truncated] ...
3471
+ ${output.slice(-half)}`;
3472
+ }
3473
+ function buildShellContextMessage(command, output) {
3474
+ return `The user ran \`!${command}\` directly in the shell (no permission prompt). Output:
3475
+ ${output}`;
3476
+ }
3477
+ async function executeShellBang(raw, cwd, signal) {
3478
+ const command = raw.trim();
3479
+ if (!command) return { ok: false, reason: "empty" };
3480
+ if (isDangerousCommand(command)) return { ok: false, reason: "dangerous" };
3481
+ const result = await bashTool.execute({ command }, { cwd, abortSignal: signal });
3482
+ const output = truncateShellOutput(result.content);
3483
+ return {
3484
+ ok: true,
3485
+ command,
3486
+ output,
3487
+ isError: result.isError ?? false,
3488
+ contextMessage: buildShellContextMessage(command, output)
3489
+ };
1688
3490
  }
1689
3491
 
1690
- // src/cli/components/StreamingMessage.tsx
1691
- import { Box as Box5, Text as Text5 } from "ink";
1692
- import { jsx as jsx4, jsxs as jsxs5 } from "react/jsx-runtime";
1693
- function StreamingMessage({ text }) {
1694
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", marginBottom: 1, children: [
1695
- /* @__PURE__ */ jsx4(Text5, { bold: true, color: "green", children: "star" }),
1696
- /* @__PURE__ */ jsx4(Text5, { color: "green", children: text })
1697
- ] });
3492
+ // src/cli/ticker.ts
3493
+ var TICK_MS = 100;
3494
+ function nextFlush(prev, next) {
3495
+ if (prev.streamed === next.streamed && prev.reasoning === next.reasoning) return null;
3496
+ return next;
3497
+ }
3498
+ function startTicker(onTick, intervalMs = TICK_MS) {
3499
+ let tick = 0;
3500
+ const timer = setInterval(() => {
3501
+ tick += 1;
3502
+ onTick(tick);
3503
+ }, intervalMs);
3504
+ return () => clearInterval(timer);
1698
3505
  }
1699
3506
 
1700
- // src/cli/components/ToolCallCard.tsx
1701
- import { Box as Box6, Text as Text6 } from "ink";
1702
- import { jsx as jsx5, jsxs as jsxs6 } from "react/jsx-runtime";
1703
- function ToolCallCard({ card }) {
1704
- const color = card.isError ? "red" : "magenta";
1705
- const preview = card.result !== void 0 ? previewLines(card.result, 10) : null;
1706
- return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginBottom: 1, children: [
1707
- /* @__PURE__ */ jsxs6(Text6, { bold: true, color, children: [
1708
- "tool: ",
1709
- card.name,
1710
- card.isError ? " (error)" : ""
1711
- ] }),
1712
- /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: card.argsSummary }),
1713
- preview && /* @__PURE__ */ jsxs6(Text6, { color, children: [
1714
- preview.text,
1715
- preview.truncated ? "\n... (truncated)" : ""
1716
- ] })
1717
- ] });
3507
+ // src/cli/update-check.ts
3508
+ var REGISTRY_URL2 = "https://registry.npmjs.org/@cryer%2fstar-cli/latest";
3509
+ var TIMEOUT_MS3 = 5e3;
3510
+ async function defaultFetcher2(url) {
3511
+ return fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS3) });
1718
3512
  }
1719
- function formatToolCard(card) {
1720
- const header = `${card.name} ${card.argsSummary}${card.isError ? " [error]" : ""}`;
1721
- if (card.result === void 0) return header;
1722
- const preview = previewLines(card.result, 10);
1723
- return `${header}
1724
- ${preview.text}${preview.truncated ? "\n... (truncated)" : ""}`;
3513
+ function isNewerVersion(latest, current) {
3514
+ const parse3 = (v) => v.split(".").slice(0, 3).map((part) => Number.parseInt(part, 10));
3515
+ const a = parse3(latest);
3516
+ const b = parse3(current);
3517
+ for (let i = 0; i < 3; i++) {
3518
+ const x = a[i] ?? 0;
3519
+ const y = b[i] ?? 0;
3520
+ if (Number.isNaN(x) || Number.isNaN(y)) return false;
3521
+ if (x !== y) return x > y;
3522
+ }
3523
+ return false;
3524
+ }
3525
+ async function checkForUpdate(currentVersion, fetcher = defaultFetcher2) {
3526
+ try {
3527
+ const res = await fetcher(REGISTRY_URL2);
3528
+ if (!res.ok) return null;
3529
+ const data = await res.json();
3530
+ const latest = typeof data === "object" && data !== null ? data.version : null;
3531
+ if (typeof latest !== "string" || !isNewerVersion(latest, currentVersion)) return null;
3532
+ return `New version available: ${currentVersion} -> ${latest} \u2014 run: npm i -g @cryer/star-cli`;
3533
+ } catch {
3534
+ return null;
3535
+ }
1725
3536
  }
1726
3537
 
1727
3538
  // src/cli/repl.tsx
1728
- import { jsx as jsx6, jsxs as jsxs7 } from "react/jsx-runtime";
1729
- var FLUSH_INTERVAL_MS = 30;
3539
+ import { jsx as jsx6, jsxs as jsxs8 } from "react/jsx-runtime";
3540
+ var CLEAR_TERMINAL = process.platform === "win32" ? "\x1B[2J\x1B[0f" : "\x1B[2J\x1B[3J\x1B[H";
1730
3541
  function emptyUsage() {
1731
3542
  return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1732
3543
  }
3544
+ function runningTaskLabels() {
3545
+ return defaultTaskManager.list().filter((t) => t.status === "running").map((t) => t.description ?? t.command);
3546
+ }
3547
+ var SESSION_MODE_CYCLE = ["ask", "auto", "readonly", "plan"];
1733
3548
  function formatUsage(usage) {
1734
3549
  return `API usage this session: ${usage.requests} requests, ${usage.promptTokens} prompt + ${usage.completionTokens} completion = ${usage.totalTokens} tokens`;
1735
3550
  }
@@ -1749,48 +3564,129 @@ function Repl({
1749
3564
  const [epoch, setEpoch] = useState2(0);
1750
3565
  const [streamingText, setStreamingText] = useState2(null);
1751
3566
  const [isStreaming, setIsStreaming] = useState2(false);
1752
- const [usageVersion, setUsageVersion] = useState2(0);
3567
+ const [thinking, setThinking] = useState2(false);
3568
+ const [thinkingText, setThinkingText] = useState2("");
3569
+ const [thoughtSummary, setThoughtSummary] = useState2(null);
3570
+ const [, setUsageVersion] = useState2(0);
1753
3571
  const [modelName, setModelName] = useState2(model);
1754
3572
  const [pending, setPending] = useState2(null);
1755
- const [cardsVersion, setCardsVersion] = useState2(0);
3573
+ const [spinnerTick, setSpinnerTick] = useState2(0);
3574
+ const [activity, setActivity] = useState2(null);
3575
+ const [bgLabels, setBgLabels] = useState2(() => runningTaskLabels());
3576
+ const [permissionModeState, setPermissionModeState] = useState2(permissionMode);
3577
+ const [planApproval, setPlanApprovalState] = useState2(false);
1756
3578
  const backendRef = useRef2(backend);
1757
3579
  const nextIdRef = useRef2(initialDisplay.length);
1758
3580
  const abortRef = useRef2(null);
1759
3581
  const streamedRef = useRef2("");
1760
- const flushTimerRef = useRef2(null);
3582
+ const thinkingRef = useRef2(false);
3583
+ const reasoningRef = useRef2("");
3584
+ const tickerStopRef = useRef2(null);
3585
+ const flushedRef = useRef2({ streamed: "", reasoning: "" });
1761
3586
  const toolCardsRef = useRef2(/* @__PURE__ */ new Map());
1762
3587
  const pendingRef = useRef2(null);
1763
3588
  const alwaysAllowedRef = useRef2(/* @__PURE__ */ new Set());
3589
+ const prevModeRef = useRef2(null);
3590
+ const planApprovalRef = useRef2(false);
1764
3591
  const modelNameRef = useRef2(model);
1765
3592
  const usageRef = useRef2(initialUsage ? { ...initialUsage } : emptyUsage());
1766
- const pushMessage = useCallback((role, text) => {
1767
- setMessages((prev) => [...prev, { id: nextIdRef.current++, role, text }]);
3593
+ const turnChunksRef = useRef2(0);
3594
+ const messagesRef = useRef2(initialDisplay);
3595
+ const pushMessage = useCallback(
3596
+ (role, text, note, tight) => {
3597
+ setMessages((prev) => {
3598
+ const next = [...prev, { id: nextIdRef.current++, role, text, note, tight }];
3599
+ messagesRef.current = next;
3600
+ return next;
3601
+ });
3602
+ },
3603
+ []
3604
+ );
3605
+ const applyMessages = useCallback((next) => {
3606
+ messagesRef.current = next;
3607
+ setMessages(next);
3608
+ setEpoch((e) => e + 1);
1768
3609
  }, []);
3610
+ const redrawMessages = useCallback(
3611
+ (next) => {
3612
+ if (process.stdout.isTTY) {
3613
+ process.stdout.write(CLEAR_TERMINAL);
3614
+ }
3615
+ applyMessages(next);
3616
+ },
3617
+ [applyMessages]
3618
+ );
3619
+ const pushAssistantChunk = useCallback(
3620
+ (text, tight) => {
3621
+ const role = turnChunksRef.current === 0 ? "assistant" : "assistant-cont";
3622
+ turnChunksRef.current += 1;
3623
+ pushMessage(role, text, void 0, tight);
3624
+ },
3625
+ [pushMessage]
3626
+ );
1769
3627
  const setPendingPermission = useCallback((p) => {
1770
3628
  pendingRef.current = p;
1771
3629
  setPending(p);
1772
3630
  }, []);
3631
+ const setPlanApproval = useCallback((v) => {
3632
+ planApprovalRef.current = v;
3633
+ setPlanApprovalState(v);
3634
+ }, []);
3635
+ const applySessionMode = useCallback(
3636
+ (mode) => {
3637
+ if (mode === config.permissionMode) return;
3638
+ if (mode === "plan") {
3639
+ prevModeRef.current = config.permissionMode;
3640
+ } else if (config.permissionMode === "plan") {
3641
+ prevModeRef.current = null;
3642
+ }
3643
+ config.permissionMode = mode;
3644
+ setPermissionModeState(mode);
3645
+ },
3646
+ [config]
3647
+ );
1773
3648
  const attachConfirmHandler = useCallback(
1774
3649
  (target) => {
1775
- target.confirmHandler = (req) => {
1776
- if (alwaysAllowedRef.current.has(req.toolName)) {
1777
- return Promise.resolve(true);
3650
+ target.confirmHandler = async (req) => {
3651
+ if (isAllowedByRules([...alwaysAllowedRef.current], req)) {
3652
+ return true;
1778
3653
  }
3654
+ const preview = await generateDiffPreview(req.toolName, req.args, cwd).catch(() => null);
1779
3655
  return new Promise((resolve) => {
1780
- setPendingPermission({ request: req, resolve });
3656
+ setPendingPermission({ request: req, preview, resolve });
1781
3657
  });
1782
3658
  };
1783
3659
  },
1784
- [setPendingPermission]
3660
+ [setPendingPermission, cwd]
1785
3661
  );
1786
3662
  useEffect(() => {
1787
3663
  attachConfirmHandler(backendRef.current);
1788
3664
  return () => {
1789
- if (flushTimerRef.current !== null) clearInterval(flushTimerRef.current);
3665
+ tickerStopRef.current?.();
1790
3666
  abortRef.current?.abort();
1791
3667
  pendingRef.current?.resolve(false);
1792
3668
  };
1793
3669
  }, [attachConfirmHandler]);
3670
+ useEffect(() => {
3671
+ if (process.env.STAR_NO_UPDATE_CHECK === "1") return;
3672
+ void checkForUpdate(VERSION).then((message) => {
3673
+ if (message) pushMessage("system", message);
3674
+ });
3675
+ }, [pushMessage]);
3676
+ useEffect(() => {
3677
+ const onUpdate = (task) => {
3678
+ setBgLabels(runningTaskLabels());
3679
+ pushMessage(
3680
+ "system",
3681
+ task.status === "running" ? formatTaskStarted(task) : formatTaskFinished(task)
3682
+ );
3683
+ };
3684
+ defaultTaskManager.on("update", onUpdate);
3685
+ return () => {
3686
+ defaultTaskManager.off("update", onUpdate);
3687
+ defaultTaskManager.cleanup();
3688
+ };
3689
+ }, [pushMessage]);
1794
3690
  const interrupt = useCallback(() => {
1795
3691
  abortRef.current?.abort();
1796
3692
  const p = pendingRef.current;
@@ -1799,20 +3695,46 @@ function Repl({
1799
3695
  p.resolve(false);
1800
3696
  }
1801
3697
  }, [setPendingPermission]);
1802
- useInput3((_input, key) => {
3698
+ const handleExit = useCallback(() => {
3699
+ const killed = defaultTaskManager.cleanup();
3700
+ if (killed.length > 0) {
3701
+ pushMessage(
3702
+ "system",
3703
+ `Stopped ${killed.length} background task(s): ${killed.map((t) => t.id).join(", ")}`
3704
+ );
3705
+ }
3706
+ exit();
3707
+ }, [exit, pushMessage]);
3708
+ useInput4((_input, key) => {
1803
3709
  if (key.escape) interrupt();
3710
+ if (key.shift && key.tab) {
3711
+ if (abortRef.current || pendingRef.current || planApprovalRef.current) return;
3712
+ const current = config.permissionMode;
3713
+ const index = SESSION_MODE_CYCLE.indexOf(current);
3714
+ const next = SESSION_MODE_CYCLE[(index + 1) % SESSION_MODE_CYCLE.length] ?? "ask";
3715
+ applySessionMode(next);
3716
+ }
1804
3717
  });
1805
3718
  const handleDecision = useCallback(
1806
3719
  (decision) => {
1807
3720
  const p = pendingRef.current;
1808
3721
  if (!p) return;
1809
3722
  if (decision === "always") {
1810
- alwaysAllowedRef.current.add(p.request.toolName);
3723
+ const rule = buildAllowRule(p.request);
3724
+ alwaysAllowedRef.current.add(rule);
3725
+ void addAllowRule(rule).then((added) => {
3726
+ pushMessage(
3727
+ "system",
3728
+ added ? `Always allow ${rule} \u2014 saved to config` : `Always allow ${rule} (already in config)`
3729
+ );
3730
+ }).catch(() => {
3731
+ pushMessage("system", `Always allow ${rule} for this session (failed to save)`);
3732
+ });
1811
3733
  }
1812
3734
  setPendingPermission(null);
1813
3735
  p.resolve(decision !== "no");
1814
3736
  },
1815
- [setPendingPermission]
3737
+ [setPendingPermission, pushMessage]
1816
3738
  );
1817
3739
  const switchModel = useCallback(
1818
3740
  async (name) => {
@@ -1840,95 +3762,119 @@ function Repl({
1840
3762
  },
1841
3763
  [config, cwd, sessionStore, attachConfirmHandler]
1842
3764
  );
1843
- const resume = useCallback(async (id) => {
1844
- const current = backendRef.current;
1845
- if (!(current instanceof AgentLoop)) {
1846
- return "Current backend does not support resuming sessions.";
1847
- }
1848
- const resumed = await resumeSession(id);
1849
- if (!resumed) {
1850
- return `Session not found: ${id}`;
1851
- }
1852
- await current.loadMessages(resumed.messages);
1853
- const display = buildDisplayMessages(resumed.messages);
1854
- nextIdRef.current = display.length;
1855
- setMessages(display);
1856
- setEpoch((e) => e + 1);
1857
- usageRef.current = resumed.meta.usage ? { ...resumed.meta.usage } : emptyUsage();
1858
- setUsageVersion((v) => v + 1);
1859
- return `Resumed session ${id} (${resumed.messages.length} messages).`;
1860
- }, []);
1861
- const registry = useMemo(() => {
1862
- const ctx = {
1863
- addSystemMessage: (text) => pushMessage("system", text),
1864
- clearMessages: () => {
1865
- setMessages([]);
1866
- setEpoch((e) => e + 1);
1867
- },
1868
- exit: () => exit(),
1869
- listModels: () => {
1870
- const models = listModels(config);
1871
- if (models.length === 0) return "No models configured.";
1872
- const lines = models.map(
1873
- (m) => `${m.name === modelNameRef.current ? "*" : " "} ${m.name} (${m.provider}/${m.model})`
1874
- );
1875
- return `Models (* = current):
1876
- ${lines.join("\n")}`;
1877
- },
1878
- switchModel,
1879
- listSessions: async () => {
1880
- const metas = await SessionStore.list(cwd);
1881
- return metas.length === 0 ? "No sessions found for this directory." : formatSessionList(metas);
1882
- },
1883
- resumeSession: resume,
1884
- showTodos: async () => {
1885
- const store = new TodoStore();
1886
- await store.load(cwd);
1887
- return formatTodos(store.list());
1888
- },
1889
- showUsage: () => formatUsage(usageRef.current),
1890
- describeConfig: () => [
1891
- `defaultModel: ${config.defaultModel || "(none)"}`,
1892
- `permissionMode: ${config.permissionMode}`,
1893
- `providers (${config.providers.length}): ${config.providers.map((p) => p.name).join(", ") || "(none)"}`,
1894
- `models (${config.models.length}): ${config.models.map((m) => m.name).join(", ") || "(none)"}`,
1895
- `maxSteps: ${config.maxSteps}`,
1896
- `contextMaxTokens: ${config.contextMaxTokens}`
1897
- ].join("\n")
1898
- };
1899
- const reg = new CommandRegistry();
1900
- registerBuiltinCommands(reg);
1901
- return Object.assign(reg, { ctx });
1902
- }, [pushMessage, exit, config, cwd, switchModel, resume]);
3765
+ const resume = useCallback(
3766
+ async (id) => {
3767
+ const current = backendRef.current;
3768
+ if (!(current instanceof AgentLoop)) {
3769
+ return "Current backend does not support resuming sessions.";
3770
+ }
3771
+ const resumed = await resumeSession(id);
3772
+ if (!resumed) {
3773
+ return `Session not found: ${id}`;
3774
+ }
3775
+ await current.loadMessages(resumed.messages);
3776
+ const display = buildDisplayMessages(resumed.messages);
3777
+ nextIdRef.current = display.length;
3778
+ applyMessages(display);
3779
+ usageRef.current = resumed.meta.usage ? { ...resumed.meta.usage } : emptyUsage();
3780
+ setUsageVersion((v) => v + 1);
3781
+ return `Resumed session ${id} (${resumed.messages.length} messages).`;
3782
+ },
3783
+ [applyMessages]
3784
+ );
1903
3785
  const runStream = useCallback(
1904
3786
  async (input) => {
1905
- pushMessage("user", input);
3787
+ const resolved = await resolveMentions(input, cwd);
3788
+ pushMessage(
3789
+ "user",
3790
+ input,
3791
+ resolved.attached.length > 0 ? `attached: ${resolved.attached.join(", ")}` : void 0
3792
+ );
3793
+ for (const skip of resolved.skipped) {
3794
+ pushMessage("system", `Skipped @${skip.path}: ${skip.reason}`);
3795
+ }
1906
3796
  const controller = new AbortController();
1907
3797
  abortRef.current = controller;
1908
3798
  streamedRef.current = "";
3799
+ thinkingRef.current = true;
3800
+ reasoningRef.current = "";
3801
+ turnChunksRef.current = 0;
3802
+ setThinking(true);
3803
+ setThinkingText("");
3804
+ setThoughtSummary(null);
1909
3805
  setStreamingText("");
1910
3806
  setIsStreaming(true);
1911
- flushTimerRef.current = setInterval(() => {
1912
- setStreamingText(streamedRef.current);
1913
- }, FLUSH_INTERVAL_MS);
3807
+ flushedRef.current = { streamed: "", reasoning: "" };
3808
+ const commitStreamed = (tight) => {
3809
+ if (streamedRef.current.length === 0) return;
3810
+ pushAssistantChunk(streamedRef.current, tight);
3811
+ streamedRef.current = "";
3812
+ flushedRef.current = { ...flushedRef.current, streamed: "" };
3813
+ setStreamingText("");
3814
+ };
3815
+ tickerStopRef.current = startTicker((tick) => {
3816
+ setSpinnerTick(tick);
3817
+ const split = splitCommittableLines(streamedRef.current, 8);
3818
+ if (split) {
3819
+ pushAssistantChunk(split.committed, true);
3820
+ streamedRef.current = split.rest;
3821
+ flushedRef.current = { ...flushedRef.current, streamed: "" };
3822
+ }
3823
+ const next = {
3824
+ streamed: streamedRef.current,
3825
+ reasoning: reasoningRef.current
3826
+ };
3827
+ if (nextFlush(flushedRef.current, next) !== null) {
3828
+ flushedRef.current = next;
3829
+ setStreamingText(next.streamed);
3830
+ setThinkingText(next.reasoning);
3831
+ }
3832
+ });
1914
3833
  try {
1915
- for await (const event of backendRef.current.stream(input, controller.signal)) {
3834
+ for await (const event of backendRef.current.stream(resolved.input, controller.signal, {
3835
+ persistAs: input
3836
+ })) {
1916
3837
  if (event.type === "text-delta") {
3838
+ if (thinkingRef.current) {
3839
+ thinkingRef.current = false;
3840
+ setThinking(false);
3841
+ if (reasoningRef.current.length > 0) {
3842
+ setThoughtSummary(
3843
+ `thought: ${truncateTail(reasoningRef.current, THOUGHT_SUMMARY_LENGTH)}`
3844
+ );
3845
+ }
3846
+ }
1917
3847
  streamedRef.current += event.text;
3848
+ } else if (event.type === "reasoning") {
3849
+ reasoningRef.current += event.text;
1918
3850
  } else if (event.type === "tool-call") {
3851
+ commitStreamed(true);
1919
3852
  toolCardsRef.current.set(event.id, {
1920
3853
  id: event.id,
1921
3854
  name: event.name,
1922
3855
  argsSummary: summarizeArgs(event.args)
1923
3856
  });
1924
- setCardsVersion((v) => v + 1);
3857
+ setActivity(`running ${event.name}: ${summarizeArgs(event.args, 60)}`);
1925
3858
  } else if (event.type === "tool-result") {
1926
- const card = toolCardsRef.current.get(event.id);
1927
- if (card) {
1928
- card.result = event.content;
1929
- card.isError = event.isError ?? false;
1930
- setCardsVersion((v) => v + 1);
1931
- }
3859
+ const card = toolCardsRef.current.get(event.id) ?? {
3860
+ id: event.id,
3861
+ name: event.name,
3862
+ argsSummary: ""
3863
+ };
3864
+ toolCardsRef.current.delete(event.id);
3865
+ pushMessage(
3866
+ "tool",
3867
+ formatToolCard({
3868
+ ...card,
3869
+ result: event.content,
3870
+ isError: event.isError ?? false
3871
+ })
3872
+ );
3873
+ setActivity(null);
3874
+ thinkingRef.current = true;
3875
+ reasoningRef.current = "";
3876
+ setThinking(true);
3877
+ setThinkingText("");
1932
3878
  } else if (event.type === "finish") {
1933
3879
  if (event.usage) {
1934
3880
  const usage = usageRef.current;
@@ -1941,45 +3887,265 @@ ${lines.join("\n")}`;
1941
3887
  });
1942
3888
  }
1943
3889
  } else if (event.type === "error") {
1944
- pushMessage("system", `Error: ${event.error.message}`);
3890
+ pushMessage("system", `Error: ${formatStreamError(event.error)}`);
1945
3891
  }
1946
3892
  }
1947
3893
  } catch (error) {
1948
- if (!(error instanceof DOMException && error.name === "AbortError")) {
1949
- pushMessage("system", `Error: ${error instanceof Error ? error.message : String(error)}`);
3894
+ const aborted = controller.signal.aborted || error instanceof DOMException && error.name === "AbortError";
3895
+ if (!aborted) {
3896
+ pushMessage(
3897
+ "system",
3898
+ `Error: ${error instanceof Error ? formatStreamError(error) : String(error)}`
3899
+ );
1950
3900
  }
1951
3901
  } finally {
1952
- if (flushTimerRef.current !== null) {
1953
- clearInterval(flushTimerRef.current);
1954
- flushTimerRef.current = null;
1955
- }
3902
+ tickerStopRef.current?.();
3903
+ tickerStopRef.current = null;
1956
3904
  abortRef.current = null;
1957
3905
  setIsStreaming(false);
3906
+ thinkingRef.current = false;
3907
+ reasoningRef.current = "";
3908
+ setThinking(false);
3909
+ setThinkingText("");
3910
+ setThoughtSummary(null);
3911
+ setActivity(null);
1958
3912
  const finalText = streamedRef.current;
1959
3913
  streamedRef.current = "";
1960
3914
  setStreamingText(null);
3915
+ const interrupted = controller.signal.aborted;
1961
3916
  if (finalText.length > 0) {
1962
- pushMessage(
1963
- "assistant",
1964
- controller.signal.aborted ? `${finalText} [interrupted]` : finalText
1965
- );
3917
+ pushAssistantChunk(`${finalText}${interrupted ? " [interrupted]" : ""}`, false);
3918
+ } else if (interrupted && turnChunksRef.current > 0) {
3919
+ pushAssistantChunk("[interrupted]", false);
1966
3920
  }
1967
3921
  for (const card of toolCardsRef.current.values()) {
1968
3922
  pushMessage("tool", formatToolCard(card));
1969
3923
  }
1970
3924
  toolCardsRef.current.clear();
1971
- setCardsVersion((v) => v + 1);
3925
+ turnChunksRef.current = 0;
1972
3926
  const p = pendingRef.current;
1973
3927
  if (p) {
1974
3928
  setPendingPermission(null);
1975
3929
  p.resolve(false);
1976
3930
  }
3931
+ if (!interrupted && config.permissionMode === "plan") {
3932
+ setPlanApproval(true);
3933
+ }
3934
+ }
3935
+ },
3936
+ [
3937
+ pushMessage,
3938
+ pushAssistantChunk,
3939
+ setPendingPermission,
3940
+ setPlanApproval,
3941
+ sessionStore,
3942
+ cwd,
3943
+ config
3944
+ ]
3945
+ );
3946
+ const handlePlanDecision = useCallback(
3947
+ (decision) => {
3948
+ setPlanApproval(false);
3949
+ if (decision === "no") return;
3950
+ const restore = prevModeRef.current ?? "ask";
3951
+ applySessionMode(restore);
3952
+ pushMessage("system", `Plan approved \u2014 permission mode restored to ${restore}.`);
3953
+ void runStream("The plan above is approved. Proceed with the implementation.");
3954
+ },
3955
+ [applySessionMode, pushMessage, runStream, setPlanApproval]
3956
+ );
3957
+ const registry = useMemo(() => {
3958
+ const ctx = {
3959
+ addSystemMessage: (text) => pushMessage("system", text),
3960
+ clearMessages: () => {
3961
+ redrawMessages([]);
3962
+ },
3963
+ exit: () => handleExit(),
3964
+ listModels: () => {
3965
+ const models = listModels(config);
3966
+ if (models.length === 0) return "No models configured.";
3967
+ const lines = models.map(
3968
+ (m) => `${m.name === modelNameRef.current ? "*" : " "} ${m.name} (${m.provider}/${m.model})`
3969
+ );
3970
+ return `Models (* = current):
3971
+ ${lines.join("\n")}`;
3972
+ },
3973
+ switchModel,
3974
+ listSessions: async () => {
3975
+ const metas = await SessionStore.list(cwd);
3976
+ return metas.length === 0 ? "No sessions found for this directory." : formatSessionList(metas);
3977
+ },
3978
+ resumeSession: resume,
3979
+ showTodos: async () => {
3980
+ const store = new TodoStore();
3981
+ await store.load(cwd);
3982
+ return formatTodos(store.list());
3983
+ },
3984
+ listTasks: () => formatTaskList(defaultTaskManager.list()),
3985
+ showUsage: () => {
3986
+ const modelConfig = config.models.find((m) => m.name === modelNameRef.current);
3987
+ return `${formatUsage(usageRef.current)}
3988
+ ${estimateCost(usageRef.current, modelNameRef.current, modelConfig)}`;
3989
+ },
3990
+ compactContext: async () => {
3991
+ let summaryModel = null;
3992
+ try {
3993
+ summaryModel = createModel(config, modelNameRef.current);
3994
+ } catch {
3995
+ summaryModel = null;
3996
+ }
3997
+ const result = await compactSession({
3998
+ backend: backendRef.current,
3999
+ sessionStore,
4000
+ config,
4001
+ model: summaryModel
4002
+ });
4003
+ if (result.compacted && result.messages) {
4004
+ const display = buildDisplayMessages(result.messages);
4005
+ nextIdRef.current = display.length;
4006
+ applyMessages(display);
4007
+ }
4008
+ return result.message;
4009
+ },
4010
+ exportSession: (arg) => exportSession({ backend: backendRef.current, sessionStore, cwd, arg }),
4011
+ undo: async () => {
4012
+ const current = backendRef.current;
4013
+ if (!(current instanceof AgentLoop)) {
4014
+ return "Nothing to undo.";
4015
+ }
4016
+ const { removed, turn } = await current.retractLastTurn();
4017
+ if (removed === 0) {
4018
+ return "Nothing to undo (no conversation turn to retract).";
4019
+ }
4020
+ const reverted = turn !== void 0 ? await undoTurnSnapshots(turn) : [];
4021
+ const visible = messagesRef.current;
4022
+ let cut = -1;
4023
+ for (let i = visible.length - 1; i >= 0; i--) {
4024
+ if (visible[i]?.role === "user") {
4025
+ cut = i;
4026
+ break;
4027
+ }
4028
+ }
4029
+ redrawMessages(cut >= 0 ? visible.slice(0, cut) : visible);
4030
+ return [...reverted, `Retracted the last conversation turn (${removed} messages).`].join(
4031
+ "\n"
4032
+ );
4033
+ },
4034
+ permissionMode: async (args) => {
4035
+ const current = config.permissionMode;
4036
+ if (!args) {
4037
+ const mark = (m) => m === current ? "*" : " ";
4038
+ return [
4039
+ "Permission modes (* = current):",
4040
+ `${mark("ask")} ask \u2014 reads allowed; writes/exec ask for confirmation`,
4041
+ `${mark("auto")} auto \u2014 everything allowed except hard-denied dangerous commands/paths`,
4042
+ `${mark("readonly")} readonly \u2014 read-only; all writes/exec denied`,
4043
+ `${mark("yolo")} yolo \u2014 allow everything, never ask (disables ALL safety checks)`
4044
+ ].join("\n");
4045
+ }
4046
+ const mode = args.toLowerCase();
4047
+ if (!["ask", "auto", "readonly", "yolo"].includes(mode)) {
4048
+ return `Unknown permission mode: ${args} (expected ask | auto | readonly | yolo)`;
4049
+ }
4050
+ config.permissionMode = mode;
4051
+ prevModeRef.current = null;
4052
+ setPermissionModeState(mode);
4053
+ await savePermissionMode(mode);
4054
+ return mode === "yolo" ? "Permission mode set to yolo \u2014 ALL safety checks disabled, tools run without asking. Saved to config." : `Permission mode set to ${mode}. Saved to config.`;
4055
+ },
4056
+ planMode: async () => {
4057
+ if (config.permissionMode === "plan") {
4058
+ const restore = prevModeRef.current ?? "ask";
4059
+ applySessionMode(restore);
4060
+ return `Exited plan mode \u2014 permission mode back to ${restore} (this session only).`;
4061
+ }
4062
+ applySessionMode("plan");
4063
+ return "Entered plan mode: the agent researches with read-only tools and presents a plan for approval before anything is executed. /plan again to exit.";
4064
+ },
4065
+ initProject: async (args) => {
4066
+ let model2 = null;
4067
+ try {
4068
+ model2 = createModel(config, modelNameRef.current);
4069
+ } catch {
4070
+ model2 = null;
4071
+ }
4072
+ const result = await initProject({ cwd, args, model: model2 });
4073
+ return result.message;
4074
+ },
4075
+ runDoctor: async () => formatDoctorReport(await runDoctor({ cwd, config })),
4076
+ submitPrompt: async (text) => {
4077
+ await runStream(text);
4078
+ },
4079
+ describeConfig: () => [
4080
+ `defaultModel: ${config.defaultModel || "(none)"}`,
4081
+ `permissionMode: ${config.permissionMode}`,
4082
+ `providers (${config.providers.length}): ${config.providers.map((p) => p.name).join(", ") || "(none)"}`,
4083
+ `models (${config.models.length}): ${config.models.map((m) => m.name).join(", ") || "(none)"}`,
4084
+ `maxSteps: ${config.maxSteps}`,
4085
+ `contextMaxTokens: ${config.contextMaxTokens}`,
4086
+ `streamIdleTimeoutSec: ${config.streamIdleTimeoutSec}`,
4087
+ `permissions.allow (${config.permissions.allow.length}): ${config.permissions.allow.join(", ") || "(none)"}`
4088
+ ].join("\n")
4089
+ };
4090
+ const reg = new CommandRegistry();
4091
+ registerBuiltinCommands(reg);
4092
+ registerCustomCommands(reg, cwd);
4093
+ return Object.assign(reg, { ctx });
4094
+ }, [
4095
+ pushMessage,
4096
+ handleExit,
4097
+ config,
4098
+ cwd,
4099
+ switchModel,
4100
+ resume,
4101
+ sessionStore,
4102
+ runStream,
4103
+ applyMessages,
4104
+ redrawMessages,
4105
+ applySessionMode
4106
+ ]);
4107
+ const runShellBang = useCallback(
4108
+ async (raw) => {
4109
+ pushMessage("user", `!${raw.trim()}`);
4110
+ const controller = new AbortController();
4111
+ abortRef.current = controller;
4112
+ try {
4113
+ const outcome = await executeShellBang(raw, cwd, controller.signal);
4114
+ if (!outcome.ok) {
4115
+ pushMessage(
4116
+ "system",
4117
+ outcome.reason === "dangerous" ? `Refused to run dangerous command: ${raw.trim()}` : "Usage: !<command>"
4118
+ );
4119
+ return;
4120
+ }
4121
+ pushMessage(
4122
+ "tool",
4123
+ formatToolCard({
4124
+ id: `bang-${nextIdRef.current}`,
4125
+ name: "bash",
4126
+ argsSummary: outcome.command,
4127
+ result: outcome.output,
4128
+ isError: outcome.isError
4129
+ })
4130
+ );
4131
+ const current = backendRef.current;
4132
+ if (current instanceof AgentLoop) {
4133
+ await current.appendContextMessage(outcome.contextMessage);
4134
+ }
4135
+ } finally {
4136
+ if (abortRef.current === controller) {
4137
+ abortRef.current = null;
4138
+ }
1977
4139
  }
1978
4140
  },
1979
- [pushMessage, setPendingPermission, sessionStore]
4141
+ [cwd, pushMessage]
1980
4142
  );
1981
4143
  const handleSubmit = useCallback(
1982
4144
  (text) => {
4145
+ if (text.startsWith("!")) {
4146
+ void runShellBang(text.slice(1));
4147
+ return;
4148
+ }
1983
4149
  if (text.startsWith("/")) {
1984
4150
  const parsed = parseSlashCommand(text);
1985
4151
  if (!parsed) return;
@@ -1993,36 +4159,59 @@ ${lines.join("\n")}`;
1993
4159
  }
1994
4160
  void runStream(text);
1995
4161
  },
1996
- [registry, pushMessage, runStream]
4162
+ [registry, pushMessage, runStream, runShellBang]
1997
4163
  );
1998
- const cards = [...toolCardsRef.current.values()];
1999
- return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
2000
- /* @__PURE__ */ jsx6(MessageList, { messages }, epoch),
2001
- cards.length > 0 && /* @__PURE__ */ jsx6(Box7, { flexDirection: "column", children: cards.map((card) => /* @__PURE__ */ jsx6(ToolCallCard, { card }, card.id)) }, cardsVersion),
2002
- streamingText !== null && /* @__PURE__ */ jsx6(StreamingMessage, { text: streamingText }),
2003
- pending && /* @__PURE__ */ jsx6(PermissionPrompt, { request: pending.request, onDecision: handleDecision }),
4164
+ const commandHints = useMemo(
4165
+ () => registry.list().map((cmd) => ({ name: cmd.name, description: cmd.description })),
4166
+ [registry]
4167
+ );
4168
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
4169
+ /* @__PURE__ */ jsx6(MessageList, { messages }, `messages-${epoch}`),
4170
+ (thinking || activity !== null) && /* @__PURE__ */ jsx6(
4171
+ ThinkingIndicator,
4172
+ {
4173
+ reasoning: thinkingText,
4174
+ frame: spinnerTick,
4175
+ activity: activity ?? void 0
4176
+ }
4177
+ ),
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(
4181
+ PermissionPrompt,
4182
+ {
4183
+ request: pending.request,
4184
+ preview: pending.preview,
4185
+ onDecision: handleDecision
4186
+ }
4187
+ ),
4188
+ planApproval && /* @__PURE__ */ jsx6(ExitPlanPrompt, { onDecision: handlePlanDecision }),
2004
4189
  /* @__PURE__ */ jsx6(
2005
4190
  InputBox,
2006
4191
  {
2007
4192
  isStreaming,
2008
- disabled: pending !== null,
4193
+ disabled: pending !== null || planApproval,
4194
+ commands: commandHints,
2009
4195
  onSubmit: handleSubmit,
2010
4196
  onInterrupt: interrupt,
2011
- onExit: exit
4197
+ onExit: handleExit
2012
4198
  }
2013
4199
  ),
2014
4200
  /* @__PURE__ */ jsx6(
2015
4201
  StatusBar,
2016
4202
  {
2017
4203
  model: modelName,
2018
- permissionMode,
2019
- tokens: usageRef.current.totalTokens
2020
- },
2021
- usageVersion
4204
+ permissionMode: permissionModeState,
4205
+ tokens: usageRef.current.totalTokens,
4206
+ backgroundTasks: bgLabels
4207
+ }
2022
4208
  )
2023
4209
  ] });
2024
4210
  }
2025
4211
  function renderRepl(backend, opts) {
4212
+ process.on("exit", () => {
4213
+ defaultTaskManager.cleanup();
4214
+ });
2026
4215
  return render(
2027
4216
  /* @__PURE__ */ jsx6(
2028
4217
  Repl,
@@ -2041,33 +4230,43 @@ function renderRepl(backend, opts) {
2041
4230
  }
2042
4231
 
2043
4232
  // src/config/loader.ts
2044
- import fs2 from "fs";
2045
- import { parse } from "smol-toml";
4233
+ import fs7 from "fs";
4234
+ import { parse as parse2 } from "smol-toml";
2046
4235
 
2047
4236
  // src/config/schema.ts
2048
- import { z as z9 } from "zod";
2049
- var ProviderConfigSchema = z9.object({
2050
- name: z9.string(),
2051
- protocol: z9.enum(["openai-compatible", "anthropic", "openai-responses"]).default("openai-compatible"),
2052
- baseURL: z9.string(),
2053
- apiKeyEnv: z9.string().optional(),
2054
- apiKey: z9.string().optional(),
2055
- headers: z9.record(z9.string()).optional()
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()
4245
+ });
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()
2056
4253
  });
2057
- var ModelConfigSchema = z9.object({
2058
- name: z9.string(),
2059
- provider: z9.string(),
2060
- model: z9.string(),
2061
- maxTokens: z9.number().int().positive().optional()
4254
+ var PermissionsConfigSchema = z11.object({
4255
+ allow: z11.array(z11.string()).default([])
2062
4256
  });
2063
- var ConfigSchema = z9.object({
2064
- defaultModel: z9.string().default(""),
2065
- permissionMode: z9.enum(["auto", "ask", "readonly"]).default("ask"),
2066
- providers: z9.array(ProviderConfigSchema).default([]),
2067
- models: z9.array(ModelConfigSchema).default([]),
2068
- maxSteps: z9.number().int().positive().default(50),
2069
- contextMaxTokens: z9.number().int().positive().default(1e5),
2070
- contextCompaction: z9.enum(["summary", "truncate"]).default("summary")
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),
4264
+ // Seconds without any stream part before a stalled response is ended
4265
+ // gracefully (some relays never send the terminal chunks). Applies once
4266
+ // 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: [] })
2071
4270
  });
2072
4271
 
2073
4272
  // src/config/loader.ts
@@ -2075,7 +4274,7 @@ var PartialConfigSchema = ConfigSchema.partial();
2075
4274
  function parseTomlFile(content, filePath) {
2076
4275
  let raw;
2077
4276
  try {
2078
- raw = parse(content);
4277
+ raw = parse2(content);
2079
4278
  } catch (error) {
2080
4279
  const message = error instanceof Error ? error.message : String(error);
2081
4280
  throw new Error(`Failed to parse TOML in ${filePath}: ${message}`);
@@ -2097,8 +4296,8 @@ function applyOverrides(config, overrides) {
2097
4296
  return merged;
2098
4297
  }
2099
4298
  function readTomlFileSync(filePath) {
2100
- if (!fs2.existsSync(filePath)) return {};
2101
- const content = fs2.readFileSync(filePath, "utf8");
4299
+ if (!fs7.existsSync(filePath)) return {};
4300
+ const content = fs7.readFileSync(filePath, "utf8");
2102
4301
  return parseTomlFile(content, filePath);
2103
4302
  }
2104
4303
  function loadConfigSync(cwd, overrides) {
@@ -2125,59 +4324,95 @@ async function createLoop(config, modelName, cwd, sessionStore) {
2125
4324
  sessionStore
2126
4325
  });
2127
4326
  }
2128
- async function printMode(loop, prompt) {
4327
+ async function printMode(loop, prompt, cwd, json) {
2129
4328
  const controller = new AbortController();
2130
4329
  process.on("SIGINT", () => controller.abort());
2131
- let requests = 0;
2132
- let promptTokens = 0;
2133
- let completionTokens = 0;
2134
- let totalTokens = 0;
4330
+ const resolved = await resolveMentions(prompt, cwd);
4331
+ if (resolved.attached.length > 0) {
4332
+ process.stderr.write(`[attached] ${resolved.attached.join(", ")}
4333
+ `);
4334
+ }
4335
+ for (const skip of resolved.skipped) {
4336
+ process.stderr.write(`[skipped] @${skip.path}: ${skip.reason}
4337
+ `);
4338
+ }
4339
+ const usage = new UsageTracker();
2135
4340
  let exitCode = 0;
2136
- for await (const event of loop.stream(prompt, controller.signal)) {
2137
- switch (event.type) {
2138
- case "text-delta":
2139
- process.stdout.write(event.text);
2140
- break;
2141
- case "tool-call":
2142
- process.stderr.write(`
4341
+ try {
4342
+ for await (const event of loop.stream(resolved.input, controller.signal, {
4343
+ persistAs: prompt
4344
+ })) {
4345
+ if (json) {
4346
+ const line = eventToJsonLine(event);
4347
+ if (line) process.stdout.write(`${line}
4348
+ `);
4349
+ }
4350
+ switch (event.type) {
4351
+ case "text-delta":
4352
+ if (!json) process.stdout.write(event.text);
4353
+ break;
4354
+ case "reasoning":
4355
+ break;
4356
+ case "tool-call":
4357
+ if (!json) process.stderr.write(`
2143
4358
  [tool] ${event.name} ${JSON.stringify(event.args)}
2144
4359
  `);
2145
- break;
2146
- case "tool-result": {
2147
- const preview = event.content.length > 500 ? `${event.content.slice(0, 500)}... (truncated)` : event.content;
2148
- process.stderr.write(`[result] ${event.isError ? "ERROR: " : ""}${preview}
4360
+ break;
4361
+ case "tool-result": {
4362
+ if (!json) {
4363
+ const preview = event.content.length > 500 ? `${event.content.slice(0, 500)}... (truncated)` : event.content;
4364
+ process.stderr.write(`[result] ${event.isError ? "ERROR: " : ""}${preview}
2149
4365
  `);
2150
- break;
2151
- }
2152
- case "finish":
2153
- if (event.usage) {
2154
- requests += 1;
2155
- promptTokens += event.usage.promptTokens;
2156
- completionTokens += event.usage.completionTokens;
2157
- totalTokens += event.usage.totalTokens;
4366
+ }
4367
+ break;
2158
4368
  }
2159
- break;
2160
- case "error":
2161
- process.stderr.write(`
2162
- [error] ${event.error.message}
4369
+ case "finish":
4370
+ usage.add(event.usage);
4371
+ break;
4372
+ case "error":
4373
+ if (!json) process.stderr.write(`
4374
+ [error] ${formatStreamError(event.error)}
2163
4375
  `);
2164
- exitCode = 1;
2165
- break;
4376
+ exitCode = 1;
4377
+ break;
4378
+ }
4379
+ if (exitCode !== 0) break;
4380
+ }
4381
+ } catch (error) {
4382
+ if (!(error instanceof DOMException && error.name === "AbortError")) {
4383
+ const message = error instanceof Error ? formatStreamError(error) : String(error);
4384
+ process.stderr.write(`
4385
+ [error] ${message}
4386
+ `);
4387
+ exitCode = 1;
2166
4388
  }
2167
- if (exitCode !== 0) break;
2168
4389
  }
2169
- if (requests > 0) {
2170
- process.stderr.write(
2171
- `[usage] ${requests} requests, ${promptTokens} prompt + ${completionTokens} completion = ${totalTokens} tokens
4390
+ const usageLine = usage.toJsonLine();
4391
+ if (usageLine) {
4392
+ if (json) {
4393
+ process.stdout.write(`${usageLine}
4394
+ `);
4395
+ } else {
4396
+ const t = usage.totals;
4397
+ process.stderr.write(
4398
+ `[usage] ${t.requests} requests, ${t.promptTokens} prompt + ${t.completionTokens} completion = ${t.totalTokens} tokens
2172
4399
  `
2173
- );
4400
+ );
4401
+ }
2174
4402
  }
2175
- process.stdout.write("\n");
4403
+ if (!json) process.stdout.write("\n");
4404
+ defaultTaskManager.cleanup();
2176
4405
  return exitCode;
2177
4406
  }
2178
4407
  var program = new Command();
2179
- program.name("star").description("Star CLI \u2014 an AI agent command-line interface").version("0.1.0").option("-m, --model <model>", "model to use").option("--permission-mode <mode>", "permission mode: auto | ask | readonly").option("-p, --print <prompt>", "non-interactive print mode").option("-r, --resume <sessionId>", "resume a previous session").action(async (opts) => {
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) => {
2180
4409
  const cwd = process.cwd();
4410
+ if (opts.json && opts.print === void 0) {
4411
+ console.error(
4412
+ '--json requires print mode: use star -p "..." --json (the interactive REPL does not support JSON output).'
4413
+ );
4414
+ process.exit(1);
4415
+ }
2181
4416
  let config;
2182
4417
  try {
2183
4418
  config = loadConfigSync(cwd, { model: opts.model, permissionMode: opts.permissionMode });
@@ -2215,7 +4450,7 @@ program.name("star").description("Star CLI \u2014 an AI agent command-line inter
2215
4450
  await loop.loadMessages(resumed.messages);
2216
4451
  }
2217
4452
  if (opts.print) {
2218
- process.exitCode = await printMode(loop, opts.print);
4453
+ process.exitCode = await printMode(loop, opts.print, cwd, Boolean(opts.json));
2219
4454
  return;
2220
4455
  }
2221
4456
  renderRepl(loop, {