jinzd-ai-cli 0.4.62 → 0.4.64

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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm version](https://img.shields.io/npm/v/jinzd-ai-cli)](https://www.npmjs.com/package/jinzd-ai-cli)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
9
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
10
- [![Tests](https://img.shields.io/badge/tests-343%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-396%20passing-brightgreen)]()
11
11
  [![GitHub Release](https://img.shields.io/github/v/release/jinzhengdong/ai-cli)](https://github.com/jinzhengdong/ai-cli/releases)
12
12
  [![CI](https://github.com/jinzhengdong/ai-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/jinzhengdong/ai-cli/actions/workflows/ci.yml)
13
13
 
@@ -378,11 +378,11 @@ The Web UI (`aicli web`) provides a full-featured browser interface:
378
378
  ## Testing
379
379
 
380
380
  ```bash
381
- npm test # Run all 343 tests
381
+ npm test # Run all 396 tests
382
382
  npm run test:watch # Watch mode
383
383
  ```
384
384
 
385
- 21 test suites covering: authentication, sessions, tool types & danger levels, permissions, output truncation, diff rendering, edit-file similarity, error hierarchy, config management, env loading, provider registry, web-fetch, grep-files, hub renderer, hub discussion, hub presets, dev-state.
385
+ 26 test suites covering: authentication, sessions, tool types & danger levels, permissions, output truncation, diff rendering, edit-file similarity, error hierarchy, config management, env loading, provider registry, web-fetch, grep-files, hub renderer, hub discussion, hub presets, dev-state, token estimator, tool registry budget, parallel tool execution, cost tracker, session tool history.
386
386
 
387
387
  ## Documentation
388
388
 
package/README.zh-CN.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm version](https://img.shields.io/npm/v/jinzd-ai-cli)](https://www.npmjs.com/package/jinzd-ai-cli)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
9
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
10
- [![Tests](https://img.shields.io/badge/tests-343%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-396%20passing-brightgreen)]()
11
11
  [![GitHub Release](https://img.shields.io/github/v/release/jinzhengdong/ai-cli)](https://github.com/jinzhengdong/ai-cli/releases)
12
12
  [![CI](https://github.com/jinzhengdong/ai-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/jinzhengdong/ai-cli/actions/workflows/ci.yml)
13
13
 
@@ -391,11 +391,11 @@ Web UI(`aicli web`)提供功能完备的浏览器界面:
391
391
  ## 测试
392
392
 
393
393
  ```bash
394
- npm test # 运行全部 343 个测试
394
+ npm test # 运行全部 396 个测试
395
395
  npm run test:watch # 监听模式
396
396
  ```
397
397
 
398
- 21 个测试套件覆盖:认证、会话、工具类型与危险级别、权限、输出截断、diff 渲染、edit-file 相似度、错误层级、配置管理、环境变量、Provider 注册、web-fetch、grep-files、Hub 渲染、Hub 讨论、Hub 预设、开发状态。
398
+ 26 个测试套件覆盖:认证、会话、工具类型与危险级别、权限、输出截断、diff 渲染、edit-file 相似度、错误层级、配置管理、环境变量、Provider 注册、web-fetch、grep-files、Hub 渲染、Hub 讨论、Hub 预设、开发状态、Token 估算、工具注册表预算、并行工具执行、费用追踪、会话工具历史。
399
399
 
400
400
  ## 文档
401
401
 
@@ -8,7 +8,7 @@ import {
8
8
  RateLimitError,
9
9
  schemaToJsonSchema,
10
10
  truncateForPersist
11
- } from "./chunk-672OV76Z.js";
11
+ } from "./chunk-YF3Z47AT.js";
12
12
  import {
13
13
  APP_NAME,
14
14
  CONFIG_DIR_NAME,
@@ -21,7 +21,7 @@ import {
21
21
  MCP_TOOL_PREFIX,
22
22
  PLUGINS_DIR_NAME,
23
23
  VERSION
24
- } from "./chunk-LLI6COMK.js";
24
+ } from "./chunk-S6P5MYUF.js";
25
25
 
26
26
  // src/config/config-manager.ts
27
27
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
@@ -182,6 +182,10 @@ var ConfigSchema = z.object({
182
182
  // 实际上限还会受模型 contextWindow 动态约束(取 contextWindow/4 作为下限)。
183
183
  // 设置为 0 或未配置时使用默认值;不建议设为小于 12_000 或大于模型 contextWindow/2。
184
184
  maxToolOutputChars: z.number().int().min(0).default(5e5),
185
+ // 月度成本预算(USD)。
186
+ // 设置后,每次 AI 回复后会跟踪成本,接近或超过预算时在 /status 和 /cost 中显示警告。
187
+ // 默认 0 = 不限制。例:50 表示每月最多花 $50。
188
+ monthlyBudget: z.number().min(0).default(0),
185
189
  // 插件加载开关(安全控制)
186
190
  // 默认 false:不自动加载 ~/.aicli/plugins/ 中的插件文件。
187
191
  // 插件以完整 Node.js 权限在主进程中执行(可读写文件、访问网络、执行命令),
@@ -6,7 +6,7 @@ import { platform } from "os";
6
6
  import chalk from "chalk";
7
7
 
8
8
  // src/core/constants.ts
9
- var VERSION = "0.4.62";
9
+ var VERSION = "0.4.64";
10
10
  var APP_NAME = "ai-cli";
11
11
  var CONFIG_DIR_NAME = ".aicli";
12
12
  var CONFIG_FILE_NAME = "config.json";
@@ -8,7 +8,7 @@ import { platform } from "os";
8
8
  import chalk from "chalk";
9
9
 
10
10
  // src/core/constants.ts
11
- var VERSION = "0.4.62";
11
+ var VERSION = "0.4.64";
12
12
  var APP_NAME = "ai-cli";
13
13
  var CONFIG_DIR_NAME = ".aicli";
14
14
  var CONFIG_FILE_NAME = "config.json";
@@ -10,7 +10,7 @@ import {
10
10
  SUBAGENT_DEFAULT_MAX_ROUNDS,
11
11
  SUBAGENT_MAX_ROUNDS_LIMIT,
12
12
  runTestsTool
13
- } from "./chunk-LLI6COMK.js";
13
+ } from "./chunk-S6P5MYUF.js";
14
14
 
15
15
  // src/tools/builtin/bash.ts
16
16
  import { execSync } from "child_process";
@@ -1365,14 +1365,19 @@ var ToolExecutor = class {
1365
1365
  }
1366
1366
  }
1367
1367
  async executeAll(calls) {
1368
- const safeCalls = [];
1368
+ const safeParallel = [];
1369
+ const safeBash = [];
1369
1370
  const fileWriteCalls = [];
1370
1371
  const otherCalls = [];
1371
1372
  for (let i = 0; i < calls.length; i++) {
1372
1373
  const call = calls[i];
1373
1374
  const level = getDangerLevel(call.name, call.arguments);
1374
1375
  if (level === "safe") {
1375
- safeCalls.push({ idx: i, call });
1376
+ if (call.name === "bash") {
1377
+ safeBash.push({ idx: i, call });
1378
+ } else {
1379
+ safeParallel.push({ idx: i, call });
1380
+ }
1376
1381
  } else if (isFileWriteTool(call.name) && level === "write") {
1377
1382
  fileWriteCalls.push({ idx: i, call });
1378
1383
  } else {
@@ -1380,11 +1385,20 @@ var ToolExecutor = class {
1380
1385
  }
1381
1386
  }
1382
1387
  const results = new Array(calls.length);
1383
- await Promise.all(
1384
- safeCalls.map(async ({ idx, call }) => {
1388
+ const t0 = Date.now();
1389
+ const parallelPhase = safeParallel.length > 0 ? Promise.all(safeParallel.map(async ({ idx, call }) => {
1390
+ results[idx] = await this.execute(call);
1391
+ })) : Promise.resolve();
1392
+ const bashPhase = (async () => {
1393
+ for (const { idx, call } of safeBash) {
1385
1394
  results[idx] = await this.execute(call);
1386
- })
1387
- );
1395
+ }
1396
+ })();
1397
+ await Promise.all([parallelPhase, bashPhase]);
1398
+ if (safeParallel.length >= 2) {
1399
+ const elapsed = Date.now() - t0;
1400
+ console.log(theme.dim(` \u26A1 ${safeParallel.length} tools executed in parallel (${elapsed}ms)`));
1401
+ }
1388
1402
  if (fileWriteCalls.length === 1) {
1389
1403
  const { idx, call } = fileWriteCalls[0];
1390
1404
  results[idx] = await this.execute(call);
@@ -1418,31 +1432,41 @@ var ToolExecutor = class {
1418
1432
  if (this.sessionAutoApprove) {
1419
1433
  console.log(theme.warning(" \u26A1 All auto-approved (session /yolo mode)"));
1420
1434
  }
1421
- const results = [];
1435
+ const results = new Array(calls.length);
1436
+ const approvedIndices = [];
1422
1437
  for (let i = 0; i < calls.length; i++) {
1423
- const call = calls[i];
1424
1438
  const approved = decision === "all" || decision !== "none" && decision.has(i + 1);
1425
1439
  if (approved) {
1440
+ approvedIndices.push(i);
1441
+ } else {
1442
+ console.log(theme.dim(` [${i + 1}] `) + theme.dim("rejected"));
1443
+ results[i] = { callId: calls[i].id, content: `[User rejected] The user rejected this ${calls[i].name} operation. Do not retry without asking.`, isError: true };
1444
+ }
1445
+ }
1446
+ const t0 = Date.now();
1447
+ await Promise.all(
1448
+ approvedIndices.map(async (i) => {
1449
+ const call = calls[i];
1426
1450
  const tool = this.registry.get(call.name);
1427
1451
  if (!tool) {
1428
- results.push({ callId: call.id, content: `Unknown tool: ${call.name}`, isError: true });
1429
- continue;
1452
+ results[i] = { callId: call.id, content: `Unknown tool: ${call.name}`, isError: true };
1453
+ return;
1430
1454
  }
1431
1455
  try {
1432
1456
  const rawContent = await tool.execute(call.arguments);
1433
1457
  const content = truncateOutput(rawContent, call.name);
1434
1458
  const wasTruncated = content !== rawContent;
1435
1459
  this.printToolResult(call.name, rawContent, false, wasTruncated);
1436
- results.push({ callId: call.id, content, isError: false });
1460
+ results[i] = { callId: call.id, content, isError: false };
1437
1461
  } catch (err) {
1438
1462
  const message = err instanceof Error ? err.message : String(err);
1439
1463
  this.printToolResult(call.name, message, true, false);
1440
- results.push({ callId: call.id, content: message, isError: true });
1464
+ results[i] = { callId: call.id, content: message, isError: true };
1441
1465
  }
1442
- } else {
1443
- console.log(theme.dim(` [${i + 1}] `) + theme.dim("rejected"));
1444
- results.push({ callId: call.id, content: `[User rejected] The user rejected this ${call.name} operation. Do not retry without asking.`, isError: true });
1445
- }
1466
+ })
1467
+ );
1468
+ if (approvedIndices.length >= 2) {
1469
+ console.log(theme.dim(` \u26A1 ${approvedIndices.length} file writes executed in parallel (${Date.now() - t0}ms)`));
1446
1470
  }
1447
1471
  return results;
1448
1472
  }
@@ -385,7 +385,7 @@ ${content}`);
385
385
  }
386
386
  }
387
387
  async function runTaskMode(config, providers, configManager, topic) {
388
- const { TaskOrchestrator } = await import("./task-orchestrator-2ATTBG2S.js");
388
+ const { TaskOrchestrator } = await import("./task-orchestrator-ZTMZIHNF.js");
389
389
  const orchestrator = new TaskOrchestrator(config, providers, configManager);
390
390
  let interrupted = false;
391
391
  const onSigint = () => {
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  saveDevState,
32
32
  sessionHasMeaningfulContent,
33
33
  setupProxy
34
- } from "./chunk-6OTF2ILP.js";
34
+ } from "./chunk-JSAPO5GI.js";
35
35
  import {
36
36
  ToolExecutor,
37
37
  ToolRegistry,
@@ -46,7 +46,7 @@ import {
46
46
  spawnAgentContext,
47
47
  theme,
48
48
  undoStack
49
- } from "./chunk-672OV76Z.js";
49
+ } from "./chunk-YF3Z47AT.js";
50
50
  import {
51
51
  fileCheckpoints
52
52
  } from "./chunk-4BKXL7SM.js";
@@ -71,15 +71,15 @@ import {
71
71
  SKILLS_DIR_NAME,
72
72
  VERSION,
73
73
  buildUserIdentityPrompt
74
- } from "./chunk-LLI6COMK.js";
74
+ } from "./chunk-S6P5MYUF.js";
75
75
 
76
76
  // src/index.ts
77
77
  import { program } from "commander";
78
78
 
79
79
  // src/repl/repl.ts
80
80
  import * as readline from "readline";
81
- import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
82
- import { join as join4, resolve as resolve2, extname as extname2, dirname as dirname3, basename as basename2 } from "path";
81
+ import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
82
+ import { join as join5, resolve as resolve2, extname as extname2, dirname as dirname3, basename as basename2 } from "path";
83
83
  import chalk4 from "chalk";
84
84
 
85
85
  // src/repl/renderer.ts
@@ -249,7 +249,7 @@ var Renderer = class {
249
249
  console.log(theme.dim(" /yolo /exit"));
250
250
  console.log(HR);
251
251
  console.log(theme.dim(" Key Features:"));
252
- console.log(feat("Agentic loop (up to 25 tool-call rounds, final answer streamed)"));
252
+ console.log(feat("Agentic loop (up to 200 tool-call rounds, configurable via config/CLI, final answer streamed)"));
253
253
  console.log(feat("Multimodal input: @filepath to inline images (base64) or text into messages"));
254
254
  console.log(feat("Git context awareness: auto-inject branch name and file change status on startup"));
255
255
  console.log(feat("Project context files: auto-load AICLI.md / CLAUDE.md (3 levels: global/project/subdir)"));
@@ -306,6 +306,11 @@ var Renderer = class {
306
306
  console.log(feat("Context injection: aicli hub -c doc.md \u2014 inject external documents for all agents"));
307
307
  console.log(feat("Task Mode: aicli hub --task \u2014 agents plan, write code, and execute with tools (plan\u2192approve\u2192execute\u2192review)"));
308
308
  console.log(feat("Ollama local models: built-in provider, no API key, auto-discovers installed models via /v1/models"));
309
+ console.log(feat("MCP tool budget: auto-trim MCP tool definitions when exceeding 20% context window, prioritize used tools"));
310
+ console.log(feat("Smart compact: tool-history-aware compression preserves full tool call rounds (no mid-round splits)"));
311
+ console.log(feat("Session size control: auto-trim old tool output when session exceeds 2MB, keep recent rounds intact"));
312
+ console.log(feat("Crash recovery: detect incomplete agentic loops on /resume, warn and offer continuation"));
313
+ console.log(feat("Cost dashboard: /cost history shows cross-session daily/weekly/monthly spend with budget progress bar"));
309
314
  console.log();
310
315
  }
311
316
  printPrompt(provider, _model) {
@@ -938,7 +943,7 @@ function createDefaultCommands() {
938
943
  " /config [set|get|show] - Config wizard, or get/set values, show all",
939
944
  " /copy - Copy last AI response to clipboard",
940
945
  " /paste [description] - Read image from clipboard and send to AI",
941
- " /cost [reset] - Show session token usage (or reset counters)",
946
+ " /cost [reset | history] - Show session token usage, reset, or cross-session cost dashboard",
942
947
  " /init [--force] - Generate AICLI.md by scanning project structure",
943
948
  " /skill [name|off|list] - Manage agent skills (reusable prompt packs)",
944
949
  " /checkpoint [save|restore|delete] <name> - Session checkpoints",
@@ -1843,7 +1848,7 @@ ${hint}` : "")
1843
1848
  {
1844
1849
  name: "cost",
1845
1850
  description: "Show session token usage, prompt-cache hits, and USD cost",
1846
- usage: "/cost [reset]",
1851
+ usage: "/cost [reset | history]",
1847
1852
  execute(args, ctx) {
1848
1853
  const sub = args[0]?.toLowerCase();
1849
1854
  if (sub === "reset") {
@@ -1851,6 +1856,28 @@ ${hint}` : "")
1851
1856
  ctx.renderer.printSuccess("Session token counters reset.");
1852
1857
  return;
1853
1858
  }
1859
+ if (sub === "history" || sub === "h") {
1860
+ const tracker = ctx.getCostTracker();
1861
+ const budget = ctx.config.get("monthlyBudget");
1862
+ console.log();
1863
+ console.log(theme.heading(" \u{1F4B0} Cross-Session Cost Dashboard"));
1864
+ console.log(theme.dim(" " + "\u2500".repeat(48)));
1865
+ const summary = tracker.formatSummary(budget);
1866
+ for (const line of summary.split("\n")) {
1867
+ console.log(theme.dim(" ") + chalk2.white(line));
1868
+ }
1869
+ console.log(theme.dim(" " + "\u2500".repeat(48)));
1870
+ const warning = tracker.checkBudget(budget);
1871
+ if (warning) {
1872
+ console.log(theme.warning(` ${warning}`));
1873
+ } else if (budget && budget > 0) {
1874
+ console.log(theme.success(" \u2713 Within monthly budget"));
1875
+ } else {
1876
+ console.log(theme.dim(' Tip: set "monthlyBudget" in config (e.g., 50 for $50/month)'));
1877
+ }
1878
+ console.log();
1879
+ return;
1880
+ }
1854
1881
  const session = ctx.sessions.current;
1855
1882
  const usage = session?.tokenUsage ?? {
1856
1883
  inputTokens: 0,
@@ -2166,7 +2193,7 @@ ${hint}` : "")
2166
2193
  usage: "/test [command|filter]",
2167
2194
  async execute(args, ctx) {
2168
2195
  try {
2169
- const { executeTests } = await import("./run-tests-ORVJAUJG.js");
2196
+ const { executeTests } = await import("./run-tests-WNHU6QH3.js");
2170
2197
  const argStr = args.join(" ").trim();
2171
2198
  let testArgs = {};
2172
2199
  if (argStr) {
@@ -3230,6 +3257,125 @@ var CustomCommandManager = class {
3230
3257
  }
3231
3258
  };
3232
3259
 
3260
+ // src/core/cost-tracker.ts
3261
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, renameSync } from "fs";
3262
+ import { join as join4 } from "path";
3263
+ var CostTracker = class {
3264
+ filePath;
3265
+ records = [];
3266
+ dirty = false;
3267
+ constructor(configDir) {
3268
+ this.filePath = join4(configDir, "cost-history.json");
3269
+ this.load();
3270
+ }
3271
+ load() {
3272
+ try {
3273
+ if (existsSync4(this.filePath)) {
3274
+ const data = JSON.parse(readFileSync3(this.filePath, "utf-8"));
3275
+ if (data.version === 1 && Array.isArray(data.records)) {
3276
+ this.records = data.records;
3277
+ }
3278
+ }
3279
+ } catch {
3280
+ this.records = [];
3281
+ }
3282
+ }
3283
+ /** Save to disk (atomic write). */
3284
+ save() {
3285
+ if (!this.dirty) return;
3286
+ const data = { version: 1, records: this.records };
3287
+ const tmp = this.filePath + ".tmp";
3288
+ writeFileSync2(tmp, JSON.stringify(data, null, 2), "utf-8");
3289
+ renameSync(tmp, this.filePath);
3290
+ this.dirty = false;
3291
+ }
3292
+ /**
3293
+ * Record cost from a completed session/interaction.
3294
+ */
3295
+ addCost(provider, model, usage) {
3296
+ const cost = computeCost(provider, model, usage);
3297
+ if (cost === null || cost === 0) return;
3298
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3299
+ let record = this.records.find((r) => r.date === today);
3300
+ if (!record) {
3301
+ record = { date: today, cost: 0, sessions: 0, inputTokens: 0, outputTokens: 0 };
3302
+ this.records.push(record);
3303
+ }
3304
+ record.cost += cost;
3305
+ record.sessions += 1;
3306
+ record.inputTokens += usage.inputTokens;
3307
+ record.outputTokens += usage.outputTokens;
3308
+ this.dirty = true;
3309
+ if (this.records.length > 90) {
3310
+ this.records = this.records.slice(-90);
3311
+ }
3312
+ }
3313
+ /** Get total cost for a given month ("2026-04"). */
3314
+ getMonthlyCost(yearMonth) {
3315
+ const prefix = yearMonth ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
3316
+ return this.records.filter((r) => r.date.startsWith(prefix)).reduce((sum, r) => sum + r.cost, 0);
3317
+ }
3318
+ /** Get today's cost. */
3319
+ getTodayCost() {
3320
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
3321
+ return this.records.find((r) => r.date === today)?.cost ?? 0;
3322
+ }
3323
+ /** Get total cost for last N days. */
3324
+ getRecentCost(days) {
3325
+ const cutoff = /* @__PURE__ */ new Date();
3326
+ cutoff.setDate(cutoff.getDate() - days);
3327
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
3328
+ return this.records.filter((r) => r.date >= cutoffStr).reduce((sum, r) => sum + r.cost, 0);
3329
+ }
3330
+ /** Get all records for display. */
3331
+ getRecords() {
3332
+ return [...this.records];
3333
+ }
3334
+ /**
3335
+ * Check if monthly cost exceeds budget, return warning message or null.
3336
+ */
3337
+ checkBudget(monthlyBudget) {
3338
+ if (!monthlyBudget || monthlyBudget <= 0) return null;
3339
+ const monthlyCost = this.getMonthlyCost();
3340
+ const ratio = monthlyCost / monthlyBudget;
3341
+ if (ratio >= 1) {
3342
+ return `\u{1F6A8} Monthly budget exceeded: ${formatCost(monthlyCost)} / ${formatCost(monthlyBudget)} (${Math.round(ratio * 100)}%)`;
3343
+ }
3344
+ if (ratio >= 0.8) {
3345
+ return `\u26A0 Monthly budget warning: ${formatCost(monthlyCost)} / ${formatCost(monthlyBudget)} (${Math.round(ratio * 100)}%)`;
3346
+ }
3347
+ return null;
3348
+ }
3349
+ /**
3350
+ * Format a cost summary for display.
3351
+ */
3352
+ formatSummary(monthlyBudget) {
3353
+ const today = this.getTodayCost();
3354
+ const monthly = this.getMonthlyCost();
3355
+ const yearMonth = (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
3356
+ const lines = [
3357
+ `Today: ${formatCost(today)}`,
3358
+ `This month: ${formatCost(monthly)} (${yearMonth})`
3359
+ ];
3360
+ if (monthlyBudget && monthlyBudget > 0) {
3361
+ const ratio = monthly / monthlyBudget;
3362
+ const bar = "\u2588".repeat(Math.min(20, Math.round(ratio * 20))) + "\u2591".repeat(Math.max(0, 20 - Math.round(ratio * 20)));
3363
+ lines.push(`Budget: ${formatCost(monthlyBudget)} [${bar}] ${Math.round(ratio * 100)}%`);
3364
+ }
3365
+ const last7 = [];
3366
+ for (let i = 6; i >= 0; i--) {
3367
+ const d = /* @__PURE__ */ new Date();
3368
+ d.setDate(d.getDate() - i);
3369
+ const dateStr = d.toISOString().slice(0, 10);
3370
+ const record = this.records.find((r) => r.date === dateStr);
3371
+ const dayLabel = dateStr.slice(5);
3372
+ last7.push(`${dayLabel}: ${record ? formatCost(record.cost) : "$0.00"}`);
3373
+ }
3374
+ lines.push(`Last 7 days: ${last7.join(" ")}`);
3375
+ return lines.join("\n");
3376
+ }
3377
+ };
3378
+
3233
3379
  // src/repl/notify.ts
3234
3380
  import { spawn } from "child_process";
3235
3381
  import { platform as platform2 } from "os";
@@ -3303,7 +3449,7 @@ function parseAtReferences(input2, cwd) {
3303
3449
  const absPath = resolve2(cwd, rawPath);
3304
3450
  const ext = extname2(rawPath).toLowerCase();
3305
3451
  const mime = IMAGE_MIME[ext];
3306
- if (!existsSync4(absPath)) {
3452
+ if (!existsSync5(absPath)) {
3307
3453
  refs.push({ path: rawPath, type: "notfound" });
3308
3454
  continue;
3309
3455
  }
@@ -3313,7 +3459,7 @@ function parseAtReferences(input2, cwd) {
3313
3459
  refs.push({ path: rawPath, type: "toolarge" });
3314
3460
  continue;
3315
3461
  }
3316
- const data = readFileSync3(absPath).toString("base64");
3462
+ const data = readFileSync4(absPath).toString("base64");
3317
3463
  imageParts.push({
3318
3464
  type: "image_url",
3319
3465
  image_url: { url: `data:${mime};base64,${data}` }
@@ -3321,7 +3467,7 @@ function parseAtReferences(input2, cwd) {
3321
3467
  refs.push({ path: rawPath, type: "image" });
3322
3468
  textBody = textBody.replace(match[0], "").trim();
3323
3469
  } else {
3324
- const content = readFileSync3(absPath, "utf-8");
3470
+ const content = readFileSync4(absPath, "utf-8");
3325
3471
  const inlined = `
3326
3472
 
3327
3473
  [File: ${rawPath}]
@@ -3390,6 +3536,7 @@ var Repl = class {
3390
3536
  if (options?.blockedTools) this.blockedTools = options.blockedTools;
3391
3537
  if (options?.resumeSessionId) this.resumeSessionId = options.resumeSessionId;
3392
3538
  if (options?.maxToolRoundsOverride !== void 0) this.maxToolRoundsOverride = options.maxToolRoundsOverride;
3539
+ this.costTracker = new CostTracker(this.config.getConfigDir());
3393
3540
  }
3394
3541
  rl;
3395
3542
  currentProvider;
@@ -3405,12 +3552,13 @@ var Repl = class {
3405
3552
  contextLayers = [];
3406
3553
  /** 本次会话累计 token 用量 */
3407
3554
  sessionTokenUsage = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
3408
- /** Fold a single-request TokenUsage (with optional cache fields) into sessionTokenUsage. */
3555
+ /** Fold a single-request TokenUsage (with optional cache fields) into sessionTokenUsage + cost tracker. */
3409
3556
  addSessionUsage(u) {
3410
3557
  this.sessionTokenUsage.inputTokens += u.inputTokens;
3411
3558
  this.sessionTokenUsage.outputTokens += u.outputTokens;
3412
3559
  this.sessionTokenUsage.cacheCreationTokens += u.cacheCreationTokens ?? 0;
3413
3560
  this.sessionTokenUsage.cacheReadTokens += u.cacheReadTokens ?? 0;
3561
+ this.costTracker.addCost(this.currentProvider, this.currentModel, u);
3414
3562
  }
3415
3563
  /** 启动时检测到的 Git 分支(无 git 仓库时为 null) */
3416
3564
  gitBranch = null;
@@ -3452,6 +3600,8 @@ var Repl = class {
3452
3600
  selecting = false;
3453
3601
  /** CLI --max-tool-rounds 覆盖值;未指定时从 config.maxToolRounds 读取 */
3454
3602
  maxToolRoundsOverride;
3603
+ /** 跨 session 成本追踪器 */
3604
+ costTracker;
3455
3605
  // ── /add-dir 目录上下文支持 ────────────────────────────────────────────────
3456
3606
  /**
3457
3607
  * 扫描目录内容,返回格式化字符串(含目录树 + 关键文件内容)。
@@ -3531,7 +3681,7 @@ var Repl = class {
3531
3681
  const filtered = entries.filter((e) => !SKIP_DIRS_SET.has(e));
3532
3682
  for (let i = 0; i < filtered.length && entryCount < MAX_TREE_ENTRIES; i++) {
3533
3683
  const name = filtered[i];
3534
- const fullPath = join4(dir, name);
3684
+ const fullPath = join5(dir, name);
3535
3685
  const isLast = i === filtered.length - 1;
3536
3686
  const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
3537
3687
  let isDir;
@@ -3565,7 +3715,7 @@ ${treeLines.join("\n")}`
3565
3715
  for (const name of entries) {
3566
3716
  if (totalChars >= MAX_TOTAL_CHARS) break;
3567
3717
  if (SKIP_DIRS_SET.has(name)) continue;
3568
- const fullPath = join4(dir, name);
3718
+ const fullPath = join5(dir, name);
3569
3719
  let st;
3570
3720
  try {
3571
3721
  st = statSync3(fullPath);
@@ -3580,7 +3730,7 @@ ${treeLines.join("\n")}`
3580
3730
  if (!TEXT_EXTS.has(ext) && !isSpecial) continue;
3581
3731
  if (st.size > MAX_FILE_CHARS * 3) continue;
3582
3732
  try {
3583
- let content = readFileSync3(fullPath, "utf-8");
3733
+ let content = readFileSync4(fullPath, "utf-8");
3584
3734
  if (content.length > MAX_FILE_CHARS) {
3585
3735
  content = content.slice(0, MAX_FILE_CHARS) + `
3586
3736
  ... (truncated, ${content.length} chars total)`;
@@ -3610,7 +3760,7 @@ ${content}
3610
3760
  */
3611
3761
  addExtraContextDir(dirPath) {
3612
3762
  const absPath = resolve2(dirPath);
3613
- if (!existsSync4(absPath)) {
3763
+ if (!existsSync5(absPath)) {
3614
3764
  return { success: false, charCount: 0, added: false, error: `Directory not found: ${dirPath}` };
3615
3765
  }
3616
3766
  let isDir;
@@ -3644,9 +3794,9 @@ ${content}
3644
3794
  */
3645
3795
  findContextFile(dir, candidates = CONTEXT_FILE_CANDIDATES) {
3646
3796
  for (const candidate of candidates) {
3647
- const fullPath = join4(dir, candidate);
3648
- if (existsSync4(fullPath)) {
3649
- const content = readFileSync3(fullPath, "utf-8").trim();
3797
+ const fullPath = join5(dir, candidate);
3798
+ if (existsSync5(fullPath)) {
3799
+ const content = readFileSync4(fullPath, "utf-8").trim();
3650
3800
  if (content) return { filePath: fullPath, content };
3651
3801
  }
3652
3802
  }
@@ -3674,10 +3824,10 @@ ${content}
3674
3824
  const cwd = process.cwd();
3675
3825
  const gitRoot = getGitRoot(cwd);
3676
3826
  const projectRoot = gitRoot ?? cwd;
3677
- const mcpPath = join4(projectRoot, MCP_PROJECT_CONFIG_NAME);
3678
- if (!existsSync4(mcpPath)) return null;
3827
+ const mcpPath = join5(projectRoot, MCP_PROJECT_CONFIG_NAME);
3828
+ if (!existsSync5(mcpPath)) return null;
3679
3829
  try {
3680
- const raw = JSON.parse(readFileSync3(mcpPath, "utf-8"));
3830
+ const raw = JSON.parse(readFileSync4(mcpPath, "utf-8"));
3681
3831
  const servers = raw?.mcpServers;
3682
3832
  if (!servers || typeof servers !== "object") {
3683
3833
  process.stderr.write(
@@ -3723,8 +3873,8 @@ ${content}
3723
3873
  );
3724
3874
  return { layers: [], mergedContent: "" };
3725
3875
  }
3726
- if (existsSync4(fullPath)) {
3727
- const content = readFileSync3(fullPath, "utf-8").trim();
3876
+ if (existsSync5(fullPath)) {
3877
+ const content = readFileSync4(fullPath, "utf-8").trim();
3728
3878
  if (content) {
3729
3879
  const layer = {
3730
3880
  level: "project",
@@ -3781,9 +3931,9 @@ ${content}
3781
3931
  * 超过 MEMORY_MAX_CHARS 时只取末尾最新部分。
3782
3932
  */
3783
3933
  loadMemoryContent() {
3784
- const memoryPath = join4(this.config.getConfigDir(), MEMORY_FILE_NAME);
3785
- if (!existsSync4(memoryPath)) return null;
3786
- let content = readFileSync3(memoryPath, "utf-8").trim();
3934
+ const memoryPath = join5(this.config.getConfigDir(), MEMORY_FILE_NAME);
3935
+ if (!existsSync5(memoryPath)) return null;
3936
+ let content = readFileSync4(memoryPath, "utf-8").trim();
3787
3937
  if (!content) return null;
3788
3938
  if (content.length > MEMORY_MAX_CHARS) {
3789
3939
  content = content.slice(-MEMORY_MAX_CHARS);
@@ -4094,6 +4244,16 @@ Session '${this.resumeSessionId}' not found.
4094
4244
  process.stdout.write(
4095
4245
  theme.dim(` \u{1F4C2} Resumed session: ${session.id.slice(0, 8)} `) + theme.dim(`(${session.messages.length} messages`) + (session.title ? theme.dim(`, "${session.title}"`) : "") + theme.dim(")\n")
4096
4246
  );
4247
+ const msgs = session.messages;
4248
+ if (msgs.length > 0) {
4249
+ const lastMsg = msgs[msgs.length - 1];
4250
+ const isIncomplete = lastMsg.role === "tool" || lastMsg.role === "assistant" && lastMsg.toolCalls && lastMsg.toolCalls.length > 0;
4251
+ if (isIncomplete) {
4252
+ process.stdout.write(
4253
+ theme.warning(" \u26A0 Session appears to have been interrupted mid-task (last message is tool output).\n") + theme.dim(" The AI will see the tool history and can continue where it left off.\n") + theme.dim(' Tip: type "continue where you left off" or describe what to do next.\n')
4254
+ );
4255
+ }
4256
+ }
4097
4257
  }
4098
4258
  if (layers.length > 0) {
4099
4259
  if (layers.length === 1) {
@@ -4134,14 +4294,14 @@ Session '${this.resumeSessionId}' not found.
4134
4294
  process.stdout.write(theme.dim(` \u{1F50C} Plugins loaded: ${pluginCount} tool(s) from plugins/
4135
4295
  `));
4136
4296
  }
4137
- const skillsDir = join4(this.config.getConfigDir(), SKILLS_DIR_NAME);
4297
+ const skillsDir = join5(this.config.getConfigDir(), SKILLS_DIR_NAME);
4138
4298
  this.skillManager = new SkillManager(skillsDir);
4139
4299
  const skillCount = this.skillManager.loadSkills();
4140
4300
  if (skillCount > 0) {
4141
4301
  process.stdout.write(theme.dim(` \u{1F3AF} Skills: ${skillCount} available (use /skill to manage)
4142
4302
  `));
4143
4303
  }
4144
- const commandsDir = join4(this.config.getConfigDir(), CUSTOM_COMMANDS_DIR_NAME);
4304
+ const commandsDir = join5(this.config.getConfigDir(), CUSTOM_COMMANDS_DIR_NAME);
4145
4305
  this.customCommandManager = new CustomCommandManager(commandsDir);
4146
4306
  const customCmdCount = this.customCommandManager.loadCommands();
4147
4307
  if (customCmdCount > 0) {
@@ -4329,6 +4489,12 @@ Session '${this.resumeSessionId}' not found.
4329
4489
  }
4330
4490
  await this.sessions.save();
4331
4491
  }
4492
+ this.costTracker.save();
4493
+ const budgetWarning = this.costTracker.checkBudget(this.config.get("monthlyBudget"));
4494
+ if (budgetWarning) {
4495
+ process.stdout.write(theme.warning(` ${budgetWarning}
4496
+ `));
4497
+ }
4332
4498
  const elapsed = Date.now() - t0;
4333
4499
  const threshold = this.config.get("ui").notificationThreshold;
4334
4500
  if (threshold > 0 && elapsed >= threshold) {
@@ -4592,14 +4758,14 @@ Session '${this.resumeSessionId}' not found.
4592
4758
  const dir = normalized.includes("/") ? dirname3(normalized) : ".";
4593
4759
  const prefix = normalized.includes("/") ? basename2(normalized) : normalized;
4594
4760
  const absDir = resolve2(process.cwd(), dir);
4595
- if (!existsSync4(absDir)) return [];
4761
+ if (!existsSync5(absDir)) return [];
4596
4762
  const entries = readdirSync3(absDir);
4597
4763
  const results = [];
4598
4764
  for (const entry of entries) {
4599
4765
  if (entry.startsWith(".")) continue;
4600
4766
  if (!entry.toLowerCase().startsWith(prefix.toLowerCase())) continue;
4601
4767
  try {
4602
- const fullPath = join4(absDir, entry);
4768
+ const fullPath = join5(absDir, entry);
4603
4769
  const stat = statSync3(fullPath);
4604
4770
  const rel = dir === "." ? entry : `${dir}/${entry}`;
4605
4771
  results.push(stat.isDirectory() ? `${rel}/` : rel);
@@ -5634,6 +5800,7 @@ Tip: You can continue the conversation by asking the AI to proceed.`
5634
5800
  listContextDirs: () => [...this.extraContextDirs],
5635
5801
  forkSession: (messageCount, title) => this.sessions.forkSession(messageCount, title),
5636
5802
  getToolExecutor: () => this.toolExecutor,
5803
+ getCostTracker: () => this.costTracker,
5637
5804
  exit: () => this.handleExit()
5638
5805
  };
5639
5806
  await cmd.execute(args, ctx);
@@ -5738,7 +5905,7 @@ program.command("web").description("Start Web UI server with browser-based chat
5738
5905
  console.error("Error: Invalid port number. Must be between 1 and 65535.");
5739
5906
  process.exit(1);
5740
5907
  }
5741
- const { startWebServer } = await import("./server-HBAOUOIC.js");
5908
+ const { startWebServer } = await import("./server-R7OJYTSP.js");
5742
5909
  await startWebServer({ port, host: options.host });
5743
5910
  });
5744
5911
  program.command("user [action] [username]").description("Manage Web UI users (list | create <name> | delete <name> | reset-password <name> | migrate <name>)").action(async (action, username) => {
@@ -5971,7 +6138,7 @@ program.command("hub [topic]").description("Start multi-agent hub (discuss / bra
5971
6138
  }),
5972
6139
  config.get("customProviders")
5973
6140
  );
5974
- const { startHub } = await import("./hub-2XLQSZY2.js");
6141
+ const { startHub } = await import("./hub-KEBKTLLR.js");
5975
6142
  await startHub(
5976
6143
  {
5977
6144
  topic: topic ?? "",
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  executeTests,
4
4
  runTestsTool
5
- } from "./chunk-LLI6COMK.js";
5
+ } from "./chunk-S6P5MYUF.js";
6
6
  export {
7
7
  executeTests,
8
8
  runTestsTool
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  executeTests,
3
3
  runTestsTool
4
- } from "./chunk-YUUCUJHU.js";
4
+ } from "./chunk-NJEMUAJD.js";
5
5
  export {
6
6
  executeTests,
7
7
  runTestsTool
@@ -21,7 +21,7 @@ import {
21
21
  persistToolRound,
22
22
  rebuildExtraMessages,
23
23
  setupProxy
24
- } from "./chunk-6OTF2ILP.js";
24
+ } from "./chunk-JSAPO5GI.js";
25
25
  import {
26
26
  AuthManager
27
27
  } from "./chunk-BYNY5JPB.js";
@@ -41,7 +41,7 @@ import {
41
41
  spawnAgentContext,
42
42
  truncateOutput,
43
43
  undoStack
44
- } from "./chunk-672OV76Z.js";
44
+ } from "./chunk-YF3Z47AT.js";
45
45
  import "./chunk-4BKXL7SM.js";
46
46
  import {
47
47
  AGENTIC_BEHAVIOR_GUIDELINE,
@@ -61,7 +61,7 @@ import {
61
61
  SKILLS_DIR_NAME,
62
62
  VERSION,
63
63
  buildUserIdentityPrompt
64
- } from "./chunk-LLI6COMK.js";
64
+ } from "./chunk-S6P5MYUF.js";
65
65
 
66
66
  // src/web/server.ts
67
67
  import express from "express";
@@ -325,14 +325,19 @@ var ToolExecutorWeb = class _ToolExecutorWeb {
325
325
  }
326
326
  }
327
327
  async executeAll(calls) {
328
- const safeCalls = [];
328
+ const safeParallel = [];
329
+ const safeBash = [];
329
330
  const fileWriteCalls = [];
330
331
  const otherCalls = [];
331
332
  for (let i = 0; i < calls.length; i++) {
332
333
  const call = calls[i];
333
334
  const level = getDangerLevel(call.name, call.arguments);
334
335
  if (level === "safe") {
335
- safeCalls.push({ idx: i, call });
336
+ if (call.name === "bash") {
337
+ safeBash.push({ idx: i, call });
338
+ } else {
339
+ safeParallel.push({ idx: i, call });
340
+ }
336
341
  } else if (isFileWriteTool(call.name) && level === "write") {
337
342
  fileWriteCalls.push({ idx: i, call });
338
343
  } else {
@@ -340,11 +345,15 @@ var ToolExecutorWeb = class _ToolExecutorWeb {
340
345
  }
341
346
  }
342
347
  const results = new Array(calls.length);
343
- await Promise.all(
344
- safeCalls.map(async ({ idx, call }) => {
348
+ const parallelPhase = safeParallel.length > 0 ? Promise.all(safeParallel.map(async ({ idx, call }) => {
349
+ results[idx] = await this.execute(call);
350
+ })) : Promise.resolve();
351
+ const bashPhase = (async () => {
352
+ for (const { idx, call } of safeBash) {
345
353
  results[idx] = await this.execute(call);
346
- })
347
- );
354
+ }
355
+ })();
356
+ await Promise.all([parallelPhase, bashPhase]);
348
357
  if (fileWriteCalls.length === 1) {
349
358
  const { idx, call } = fileWriteCalls[0];
350
359
  results[idx] = await this.execute(call);
@@ -362,33 +371,39 @@ var ToolExecutorWeb = class _ToolExecutorWeb {
362
371
  async executeBatchFileWrites(items) {
363
372
  const calls = items.map((i) => i.call);
364
373
  const decision = this.sessionAutoApprove ? "all" : await this.batchConfirm(calls);
365
- const results = [];
374
+ const results = new Array(calls.length);
375
+ const approvedIndices = [];
366
376
  for (let i = 0; i < calls.length; i++) {
367
- const call = calls[i];
368
377
  if (decision === "all" || decision instanceof Set && decision.has(i + 1)) {
378
+ approvedIndices.push(i);
379
+ } else {
380
+ results[i] = {
381
+ callId: calls[i].id,
382
+ content: `[User rejected] File write rejected by user. Do not retry without asking.`,
383
+ isError: true
384
+ };
385
+ }
386
+ }
387
+ await Promise.all(
388
+ approvedIndices.map(async (i) => {
389
+ const call = calls[i];
369
390
  const tool = this.registry.get(call.name);
370
391
  if (!tool) {
371
- results.push({ callId: call.id, content: `Unknown tool: ${call.name}`, isError: true });
372
- continue;
392
+ results[i] = { callId: call.id, content: `Unknown tool: ${call.name}`, isError: true };
393
+ return;
373
394
  }
374
395
  try {
375
396
  const rawContent = await tool.execute(call.arguments);
376
397
  const content = truncateOutput(rawContent, call.name);
377
398
  this.sendToolCallResult(call, rawContent, false);
378
- results.push({ callId: call.id, content, isError: false });
399
+ results[i] = { callId: call.id, content, isError: false };
379
400
  } catch (err) {
380
401
  const message = err instanceof Error ? err.message : String(err);
381
402
  this.sendToolCallResult(call, message, true);
382
- results.push({ callId: call.id, content: message, isError: true });
403
+ results[i] = { callId: call.id, content: message, isError: true };
383
404
  }
384
- } else {
385
- results.push({
386
- callId: call.id,
387
- content: `[User rejected] File write rejected by user. Do not retry without asking.`,
388
- isError: true
389
- });
390
- }
391
- }
405
+ })
406
+ );
392
407
  return results;
393
408
  }
394
409
  };
@@ -1327,6 +1342,17 @@ Tokens: in=${this.sessionTokenUsage.inputTokens} out=${this.sessionTokenUsage.ou
1327
1342
  this.sessions.loadSession(found.id);
1328
1343
  this.resetWebSessionUsage();
1329
1344
  this.send({ type: "info", message: `Loaded session: ${found.id.slice(0, 8)} "${found.title ?? ""}" (${found.messageCount} messages)` });
1345
+ const loadedSession = this.sessions.current;
1346
+ if (loadedSession && loadedSession.messages.length > 0) {
1347
+ const lastMsg = loadedSession.messages[loadedSession.messages.length - 1];
1348
+ const isIncomplete = lastMsg.role === "tool" || lastMsg.role === "assistant" && lastMsg.toolCalls && lastMsg.toolCalls.length > 0;
1349
+ if (isIncomplete) {
1350
+ this.send({
1351
+ type: "info",
1352
+ message: '\u26A0 This session appears to have been interrupted mid-task. The AI will see the tool history and can continue where it left off. Type "continue where you left off" to resume.'
1353
+ });
1354
+ }
1355
+ }
1330
1356
  this.sendSessionMessages();
1331
1357
  this.sendStatus();
1332
1358
  this.sendSessionList();
@@ -1933,7 +1959,7 @@ ${undoResults.map((r) => ` \u2022 ${r}`).join("\n")}` });
1933
1959
  case "test": {
1934
1960
  this.send({ type: "info", message: "\u{1F9EA} Running tests..." });
1935
1961
  try {
1936
- const { executeTests } = await import("./run-tests-ORVJAUJG.js");
1962
+ const { executeTests } = await import("./run-tests-WNHU6QH3.js");
1937
1963
  const argStr = args.join(" ").trim();
1938
1964
  let testArgs = {};
1939
1965
  if (argStr) {
@@ -4,11 +4,11 @@ import {
4
4
  getDangerLevel,
5
5
  googleSearchContext,
6
6
  truncateOutput
7
- } from "./chunk-672OV76Z.js";
7
+ } from "./chunk-YF3Z47AT.js";
8
8
  import "./chunk-4BKXL7SM.js";
9
9
  import {
10
10
  SUBAGENT_ALLOWED_TOOLS
11
- } from "./chunk-LLI6COMK.js";
11
+ } from "./chunk-S6P5MYUF.js";
12
12
 
13
13
  // src/hub/task-orchestrator.ts
14
14
  import { createInterface } from "readline";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.4.62",
3
+ "version": "0.4.64",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",