jinzd-ai-cli 0.4.73 → 0.4.74

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-396%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-529%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
 
@@ -24,6 +24,11 @@
24
24
  - **8 Built-in Providers** — Claude, Gemini, DeepSeek, OpenAI, Zhipu GLM, Kimi, OpenRouter (300+ models), **Ollama** (local models, no API key needed)
25
25
  - **3 Interfaces** — Terminal CLI, browser Web UI (`aicli web`), Electron desktop app
26
26
  - **Agentic Tool Calling** — AI autonomously runs shell commands, reads/writes files, searches code, fetches web, runs tests (default 200 rounds, configurable up to 10000 via `config.maxToolRounds` or `--max-tool-rounds`)
27
+ - **Prompt Caching** *(v0.4.70+)* — System prompt split into stable/volatile halves so Claude caches the stable part with `cache_control: ephemeral`; cached tokens bill at ~10% of the input price
28
+ - **Unified-Diff Patch Edits** *(v0.4.72+)* — `edit_file` accepts standard `@@ -a,b +c,d @@` hunks for the most compact way to apply many scattered small changes to a large file (±200-line drift tolerance + whitespace fallback)
29
+ - **Anthropic Batches API** *(v0.4.73+)* — `aicli batch submit/list/status/results/cancel` for 50%-off, 24-hour async processing — ideal for offline analysis and bulk evals
30
+ - **Web UI Session Replay** *(v0.4.71+)* — 🎬 button on every saved session opens a timeline replay: every message, tool call, reasoning, and cache-aware token usage at a glance
31
+ - **Conversation Branching** *(v0.4.74+)* — `/branch list/new/switch/delete/rename` inside the REPL, plus a 🌿 "fork here" button on every replay step — explore alternate directions without losing the original thread
27
32
  - **Streaming Tool Use** — Real-time streaming of AI reasoning and tool calls as they happen
28
33
  - **Sub-Agents** — Delegate complex subtasks to isolated child agents with independent tool loops
29
34
  - **Extended Thinking** — Claude deep reasoning mode with `/think` toggle
@@ -34,7 +39,7 @@
34
39
  - **PWA Support** — Install Web UI as a desktop/mobile app, accessible over LAN
35
40
  - **Hierarchical Context** — 3-layer context files (global / project / subdirectory) auto-injected
36
41
  - **Headless Mode** — `ai-cli -p "prompt"` for CI/CD pipelines and scripting
37
- - **40 REPL Commands** — Session management, checkpointing, code review, security review, rewind, scaffolding, and more
42
+ - **40 REPL Commands** — Session management, checkpointing, code review, security review, rewind, scaffolding, cross-session history search, and more
38
43
  - **GitHub Actions CI/CD** — Automated testing on Node 20/22 + npm publish on release tags
39
44
  - **Cross-Platform** — Windows, macOS, Linux
40
45
 
@@ -188,7 +193,8 @@ AI autonomously invokes these 24 tools during conversations:
188
193
  | `/compact` | Compress conversation history |
189
194
  | `/session` | Session management (new / list / load) |
190
195
  | `/checkpoint` | Save/restore conversation checkpoints |
191
- | `/fork` | Branch conversation from current point |
196
+ | `/fork` | Fork the current session into a new session file |
197
+ | `/branch` | Create/switch/delete branches *within* the current session (B2) |
192
198
  | `/search <keyword>` | Full-text search across all sessions |
193
199
  | `/skill` | Manage agent skill packs |
194
200
  | `/mcp` | View MCP server status and tools |
@@ -227,8 +233,27 @@ Subcommands:
227
233
  aicli providers List all providers and status
228
234
  aicli sessions List recent sessions
229
235
  aicli user <action> Manage Web UI users
236
+ aicli batch <action> Anthropic Batches API (submit | list | status | results | cancel)
230
237
  ```
231
238
 
239
+ ### Batch Mode (Anthropic Message Batches)
240
+
241
+ For offline analysis, bulk evals, or any workload where latency is flexible, use the Batches API for **50% off** tokens with a 24-hour processing window.
242
+
243
+ ```bash
244
+ # 1. Prepare a JSONL file (one request per line):
245
+ # {"customId":"req-1","messages":[{"role":"user","content":"..."}],"maxTokens":1024}
246
+ aicli batch submit prompts.jsonl # validate + submit + track locally
247
+ aicli batch submit --dry-run prompts.jsonl # parse only, no network
248
+
249
+ aicli batch list # live status of recent batches
250
+ aicli batch status <id> # detailed status + request counts
251
+ aicli batch results <id> out.jsonl # download results (stdout if no path)
252
+ aicli batch cancel <id> # cancel an in-progress batch
253
+ ```
254
+
255
+ Local tracking file: `~/.aicli/batches.json` (last 200 submissions). Requires `AICLI_API_KEY_CLAUDE` or a Claude API key configured via `aicli config`.
256
+
232
257
  ### Headless Mode
233
258
 
234
259
  ```bash
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-396%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-529%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
 
@@ -16,6 +16,11 @@
16
16
  - **8 大内置 Provider** — Claude、Gemini、DeepSeek、OpenAI、智谱 GLM、Kimi、OpenRouter(300+ 模型)、**Ollama**(本地模型,无需 API Key)
17
17
  - **三种使用方式** — 终端 CLI、浏览器 Web UI(`aicli web`)、Electron 桌面应用
18
18
  - **Agentic 工具调用** — AI 自主执行 bash 命令、读写文件、搜索代码、抓取网页、运行测试(默认 200 轮,可通过 `config.maxToolRounds` 或 `--max-tool-rounds` 调整,上限 10000)
19
+ - **Prompt Caching**(v0.4.70+)— system prompt 拆分稳定/易变两段,Claude 对稳定段启用 `cache_control: ephemeral`,命中时按 10% 计费
20
+ - **Unified Diff Patch 编辑**(v0.4.72+)— `edit_file` 支持标准 `@@ -a,b +c,d @@` hunk,大文件多处小改最省 token;容忍 ±200 行漂移与空白差异
21
+ - **Anthropic Batches API**(v0.4.73+)— `aicli batch submit/list/status/results/cancel` 包 Message Batches(50% 折扣 + 24 小时窗口),适合离线分析和批量 eval
22
+ - **Web UI 会话回放**(v0.4.71+)— 会话列表每项 🎬 按钮打开时间轴回放:消息、工具调用、推理内容、cache-aware token 用量一目了然
23
+ - **对话分支**(v0.4.74+)— REPL 内 `/branch list/new/switch/delete/rename`,Web UI 回放面板每条消息旁 🌿 "fork here" 按钮,任意位置开辟新分支探索不同方向,原对话保持不变
19
24
  - **流式工具调用** — 实时流式展示 AI 推理过程和工具调用
20
25
  - **子代理系统** — 将复杂子任务委派给独立子代理执行
21
26
  - **深度推理** — Claude Extended Thinking,`/think` 一键切换
@@ -180,7 +185,8 @@ AI 在对话中可自主调用 24 个工具:
180
185
  | `/compact` | 压缩对话历史 |
181
186
  | `/session` | 会话管理(new / list / load) |
182
187
  | `/checkpoint` | 保存/恢复会话检查点 |
183
- | `/fork` | 从当前位置或检查点分叉对话 |
188
+ | `/fork` | 复制整个会话为新的 session 文件 |
189
+ | `/branch` | 在当前 session **内部**创建/切换/删除分支(B2)|
184
190
  | `/search <关键词>` | 跨会话全文搜索 |
185
191
  | `/skill` | 管理 Agent 技能包 |
186
192
  | `/mcp` | 查看 MCP 服务器状态 |
@@ -219,8 +225,27 @@ aicli [选项]
219
225
  aicli providers 列出所有 Provider
220
226
  aicli sessions 列出最近会话
221
227
  aicli user <操作> 管理 Web UI 用户
228
+ aicli batch <操作> Anthropic Batches API(submit | list | status | results | cancel)
222
229
  ```
223
230
 
231
+ ### 批处理模式(Anthropic Message Batches)
232
+
233
+ 离线分析、批量 eval 等对延迟不敏感的场景,使用 Batches API 可享 **50% 折扣** + 24 小时处理窗口。
234
+
235
+ ```bash
236
+ # 1. 准备 JSONL 输入(每行一个请求):
237
+ # {"customId":"req-1","messages":[{"role":"user","content":"..."}],"maxTokens":1024}
238
+ aicli batch submit prompts.jsonl # 校验 + 提交 + 本地追踪
239
+ aicli batch submit --dry-run prompts.jsonl # 只解析不提交
240
+
241
+ aicli batch list # 实时查看已追踪批次状态
242
+ aicli batch status <id> # 单批详情 + 请求计数分解
243
+ aicli batch results <id> out.jsonl # 下载结果(省略文件名则输出到 stdout)
244
+ aicli batch cancel <id> # 取消进行中的批次
245
+ ```
246
+
247
+ 本地追踪文件:`~/.aicli/batches.json`(保留最近 200 次提交)。需配置 `AICLI_API_KEY_CLAUDE` 或通过 `aicli config` 设置 Claude API Key。
248
+
224
249
  ### 无头模式
225
250
 
226
251
  ```bash
@@ -391,7 +416,7 @@ Web UI(`aicli web`)提供功能完备的浏览器界面:
391
416
  ## 测试
392
417
 
393
418
  ```bash
394
- npm test # 运行全部 396 个测试
419
+ npm test # 运行全部 515 个测试(30 suites)
395
420
  npm run test:watch # 监听模式
396
421
  ```
397
422
 
@@ -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.73";
9
+ var VERSION = "0.4.74";
10
10
  var APP_NAME = "ai-cli";
11
11
  var CONFIG_DIR_NAME = ".aicli";
12
12
  var CONFIG_FILE_NAME = "config.json";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/core/constants.ts
4
- var VERSION = "0.4.73";
4
+ var VERSION = "0.4.74";
5
5
  var APP_NAME = "ai-cli";
6
6
  var CONFIG_DIR_NAME = ".aicli";
7
7
  var CONFIG_FILE_NAME = "config.json";
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-4BKXL7SM.js";
5
5
  import {
6
6
  runTestsTool
7
- } from "./chunk-HAOCJWW2.js";
7
+ } from "./chunk-FKVJRBPO.js";
8
8
  import {
9
9
  EnvLoader,
10
10
  NetworkError,
@@ -17,7 +17,7 @@ import {
17
17
  SUBAGENT_ALLOWED_TOOLS,
18
18
  SUBAGENT_DEFAULT_MAX_ROUNDS,
19
19
  SUBAGENT_MAX_ROUNDS_LIMIT
20
- } from "./chunk-T2OUKQOX.js";
20
+ } from "./chunk-BT2TCINO.js";
21
21
 
22
22
  // src/tools/builtin/bash.ts
23
23
  import { execSync } from "child_process";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  TEST_TIMEOUT
4
- } from "./chunk-T2OUKQOX.js";
4
+ } from "./chunk-BT2TCINO.js";
5
5
 
6
6
  // src/tools/builtin/run-tests.ts
7
7
  import { execSync } from "child_process";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  schemaToJsonSchema,
4
4
  truncateForPersist
5
- } from "./chunk-H65WPFDO.js";
5
+ } from "./chunk-D5ZDVEJJ.js";
6
6
  import {
7
7
  AuthError,
8
8
  ProviderError,
@@ -18,7 +18,7 @@ import {
18
18
  MCP_PROTOCOL_VERSION,
19
19
  MCP_TOOL_PREFIX,
20
20
  VERSION
21
- } from "./chunk-T2OUKQOX.js";
21
+ } from "./chunk-BT2TCINO.js";
22
22
 
23
23
  // src/providers/claude.ts
24
24
  import Anthropic from "@anthropic-ai/sdk";
@@ -2132,6 +2132,9 @@ function getContentText(content) {
2132
2132
  }
2133
2133
 
2134
2134
  // src/session/session.ts
2135
+ function makeBranchId() {
2136
+ return Math.random().toString(16).slice(2, 8);
2137
+ }
2135
2138
  var Session = class _Session {
2136
2139
  id;
2137
2140
  provider;
@@ -2147,12 +2150,32 @@ var Session = class _Session {
2147
2150
  cacheReadTokens: 0
2148
2151
  };
2149
2152
  checkpoints = [];
2153
+ // ── B2 Branches (v0.4.74+) ──────────────────────────────────────
2154
+ /**
2155
+ * All branches in this session. The 'main' branch is auto-created and
2156
+ * represents the linear conversation for pre-B2 sessions.
2157
+ */
2158
+ branches = [];
2159
+ /** Currently active branch — its messages live in `this.messages`. */
2160
+ activeBranchId = "main";
2161
+ /**
2162
+ * Stashed message arrays for INACTIVE branches. The active branch's
2163
+ * messages are always in `this.messages`, never duplicated here.
2164
+ */
2165
+ _inactiveBranchMessages = /* @__PURE__ */ new Map();
2150
2166
  constructor(id, provider, model) {
2151
2167
  this.id = id;
2152
2168
  this.provider = provider;
2153
2169
  this.model = model;
2154
2170
  this.created = /* @__PURE__ */ new Date();
2155
2171
  this.updated = /* @__PURE__ */ new Date();
2172
+ this.branches.push({
2173
+ id: "main",
2174
+ title: "main",
2175
+ parentBranchId: null,
2176
+ parentMessageIndex: 0,
2177
+ created: this.created
2178
+ });
2156
2179
  }
2157
2180
  /**
2158
2181
  * 更新 session 关联的 provider 和 model(在 /provider 或 /model 切换时调用)。
@@ -2239,6 +2262,120 @@ var Session = class _Session {
2239
2262
  this.checkpoints = this.checkpoints.filter((c) => c.name !== name);
2240
2263
  return this.checkpoints.length < len;
2241
2264
  }
2265
+ // ── B2 Branch operations ────────────────────────────────────────
2266
+ /** Deep-clone a messages array (matches `Session.fork` semantics). */
2267
+ static cloneMessages(msgs) {
2268
+ return msgs.map((m) => {
2269
+ const cloned = { ...m };
2270
+ if (Array.isArray(cloned.content)) {
2271
+ cloned.content = cloned.content.map(
2272
+ (part) => typeof part === "object" && part !== null ? { ...part } : part
2273
+ );
2274
+ }
2275
+ if (cloned.toolCalls) {
2276
+ cloned.toolCalls = cloned.toolCalls.map((tc) => ({ ...tc }));
2277
+ }
2278
+ return cloned;
2279
+ });
2280
+ }
2281
+ /** List all branches (metadata only). */
2282
+ listBranches() {
2283
+ return this.branches.map((b) => ({ ...b }));
2284
+ }
2285
+ /** Current active branch metadata. */
2286
+ getActiveBranch() {
2287
+ const b = this.branches.find((b2) => b2.id === this.activeBranchId);
2288
+ if (!b) {
2289
+ this.activeBranchId = this.branches[0]?.id ?? "main";
2290
+ return this.branches[0] ?? { id: "main", title: "main", parentBranchId: null, parentMessageIndex: 0, created: /* @__PURE__ */ new Date() };
2291
+ }
2292
+ return b;
2293
+ }
2294
+ /**
2295
+ * Create a new branch by forking the active branch at message index
2296
+ * `fromIndex`. Copies `messages[0..fromIndex]` into the new branch
2297
+ * and switches to it. The original active branch is preserved intact
2298
+ * in the stash.
2299
+ *
2300
+ * @returns new branch id
2301
+ * @throws if fromIndex is out of range
2302
+ */
2303
+ createBranch(fromIndex, title) {
2304
+ if (fromIndex < 0 || fromIndex > this.messages.length) {
2305
+ throw new Error(
2306
+ `createBranch: fromIndex ${fromIndex} out of range [0, ${this.messages.length}]`
2307
+ );
2308
+ }
2309
+ this._inactiveBranchMessages.set(this.activeBranchId, this.messages);
2310
+ const id = makeBranchId();
2311
+ const meta = {
2312
+ id,
2313
+ title: title || `branch-${this.branches.length + 1}`,
2314
+ parentBranchId: this.activeBranchId,
2315
+ parentMessageIndex: fromIndex,
2316
+ created: /* @__PURE__ */ new Date()
2317
+ };
2318
+ this.branches.push(meta);
2319
+ this.messages = _Session.cloneMessages(this.messages.slice(0, fromIndex));
2320
+ this.activeBranchId = id;
2321
+ this.updated = /* @__PURE__ */ new Date();
2322
+ return id;
2323
+ }
2324
+ /**
2325
+ * Switch the active branch. Stashes current messages under the old
2326
+ * active id and loads the target branch's messages into `this.messages`.
2327
+ *
2328
+ * @returns true if switched, false if id not found or already active
2329
+ */
2330
+ switchBranch(id) {
2331
+ if (id === this.activeBranchId) return false;
2332
+ if (!this.branches.some((b) => b.id === id)) return false;
2333
+ this._inactiveBranchMessages.set(this.activeBranchId, this.messages);
2334
+ const target = this._inactiveBranchMessages.get(id) ?? [];
2335
+ this._inactiveBranchMessages.delete(id);
2336
+ this.messages = target;
2337
+ this.activeBranchId = id;
2338
+ this.updated = /* @__PURE__ */ new Date();
2339
+ return true;
2340
+ }
2341
+ /**
2342
+ * Delete a branch by id. Cannot delete the active branch or the last
2343
+ * remaining branch. If other branches list this one as parent, their
2344
+ * parent pointer is retargeted to this branch's parent (transparent
2345
+ * to callers — branches still form a valid forest).
2346
+ *
2347
+ * @returns true if deleted
2348
+ */
2349
+ deleteBranch(id) {
2350
+ if (id === this.activeBranchId) return false;
2351
+ if (this.branches.length <= 1) return false;
2352
+ const idx = this.branches.findIndex((b) => b.id === id);
2353
+ if (idx === -1) return false;
2354
+ const deleted = this.branches[idx];
2355
+ for (const b of this.branches) {
2356
+ if (b.parentBranchId === id) {
2357
+ b.parentBranchId = deleted.parentBranchId;
2358
+ }
2359
+ }
2360
+ this.branches.splice(idx, 1);
2361
+ this._inactiveBranchMessages.delete(id);
2362
+ this.updated = /* @__PURE__ */ new Date();
2363
+ return true;
2364
+ }
2365
+ /** Rename a branch (affects only display title). */
2366
+ renameBranch(id, newTitle) {
2367
+ const b = this.branches.find((b2) => b2.id === id);
2368
+ if (!b) return false;
2369
+ b.title = newTitle;
2370
+ this.updated = /* @__PURE__ */ new Date();
2371
+ return true;
2372
+ }
2373
+ /** Messages of any branch (active or inactive) — read-only copy. */
2374
+ getBranchMessages(id) {
2375
+ if (id === this.activeBranchId) return this.messages.slice();
2376
+ const m = this._inactiveBranchMessages.get(id);
2377
+ return m ? m.slice() : null;
2378
+ }
2242
2379
  getMeta() {
2243
2380
  return {
2244
2381
  id: this.id,
@@ -2251,6 +2388,23 @@ var Session = class _Session {
2251
2388
  };
2252
2389
  }
2253
2390
  toJSON() {
2391
+ const serializeMessages = (msgs) => msgs.map((m) => {
2392
+ const out = {
2393
+ role: m.role,
2394
+ content: m.content,
2395
+ timestamp: m.timestamp.toISOString()
2396
+ };
2397
+ if (m.toolCalls) out.toolCalls = m.toolCalls;
2398
+ if (m.reasoningContent !== void 0) out.reasoningContent = m.reasoningContent;
2399
+ if (m.toolCallId) out.toolCallId = m.toolCallId;
2400
+ if (m.toolName) out.toolName = m.toolName;
2401
+ if (m.isError !== void 0) out.isError = m.isError;
2402
+ return out;
2403
+ });
2404
+ const branchMessages = {};
2405
+ for (const [id, msgs] of this._inactiveBranchMessages.entries()) {
2406
+ branchMessages[id] = serializeMessages(msgs);
2407
+ }
2254
2408
  return {
2255
2409
  id: this.id,
2256
2410
  provider: this.provider,
@@ -2264,19 +2418,21 @@ var Session = class _Session {
2264
2418
  messageIndex: c.messageIndex,
2265
2419
  timestamp: c.timestamp.toISOString()
2266
2420
  })),
2267
- messages: this.messages.map((m) => {
2268
- const out = {
2269
- role: m.role,
2270
- content: m.content,
2271
- timestamp: m.timestamp.toISOString()
2272
- };
2273
- if (m.toolCalls) out.toolCalls = m.toolCalls;
2274
- if (m.reasoningContent !== void 0) out.reasoningContent = m.reasoningContent;
2275
- if (m.toolCallId) out.toolCallId = m.toolCallId;
2276
- if (m.toolName) out.toolName = m.toolName;
2277
- if (m.isError !== void 0) out.isError = m.isError;
2278
- return out;
2279
- })
2421
+ // B2 Branches (v0.4.74+). Omitted for sessions with only the default
2422
+ // 'main' branch and no stashed messages (keeps file size identical
2423
+ // to pre-B2 for the common case).
2424
+ ...this.branches.length > 1 || this._inactiveBranchMessages.size > 0 ? {
2425
+ activeBranchId: this.activeBranchId,
2426
+ branches: this.branches.map((b) => ({
2427
+ id: b.id,
2428
+ title: b.title,
2429
+ parentBranchId: b.parentBranchId,
2430
+ parentMessageIndex: b.parentMessageIndex,
2431
+ created: b.created.toISOString()
2432
+ })),
2433
+ branchMessages
2434
+ } : {},
2435
+ messages: serializeMessages(this.messages)
2280
2436
  };
2281
2437
  }
2282
2438
  /**
@@ -2344,7 +2500,7 @@ var Session = class _Session {
2344
2500
  timestamp: new Date(c.timestamp)
2345
2501
  }));
2346
2502
  }
2347
- session.messages = d.messages.map((m) => {
2503
+ const deserializeMessages = (arr) => arr.map((m) => {
2348
2504
  const ts = new Date(m.timestamp);
2349
2505
  const msg = {
2350
2506
  role: m.role ?? "user",
@@ -2358,6 +2514,31 @@ var Session = class _Session {
2358
2514
  if (typeof m.isError === "boolean") msg.isError = m.isError;
2359
2515
  return msg;
2360
2516
  });
2517
+ session.messages = deserializeMessages(d.messages);
2518
+ if (Array.isArray(d.branches) && d.branches.length > 0) {
2519
+ session.branches = d.branches.map((b) => {
2520
+ const ts = new Date(b.created);
2521
+ return {
2522
+ id: String(b.id ?? "main"),
2523
+ title: String(b.title ?? b.id ?? "main"),
2524
+ parentBranchId: typeof b.parentBranchId === "string" ? b.parentBranchId : null,
2525
+ parentMessageIndex: typeof b.parentMessageIndex === "number" ? b.parentMessageIndex : 0,
2526
+ created: isNaN(ts.getTime()) ? /* @__PURE__ */ new Date() : ts
2527
+ };
2528
+ });
2529
+ session.activeBranchId = typeof d.activeBranchId === "string" ? d.activeBranchId : session.branches[0]?.id ?? "main";
2530
+ const bm = d.branchMessages;
2531
+ if (bm && typeof bm === "object") {
2532
+ for (const [id, arr] of Object.entries(bm)) {
2533
+ if (id === session.activeBranchId) continue;
2534
+ if (!Array.isArray(arr)) continue;
2535
+ session._inactiveBranchMessages.set(
2536
+ id,
2537
+ deserializeMessages(arr)
2538
+ );
2539
+ }
2540
+ }
2541
+ }
2361
2542
  return session;
2362
2543
  }
2363
2544
  };
@@ -8,7 +8,7 @@ import {
8
8
  CONFIG_FILE_NAME,
9
9
  HISTORY_DIR_NAME,
10
10
  PLUGINS_DIR_NAME
11
- } from "./chunk-T2OUKQOX.js";
11
+ } from "./chunk-BT2TCINO.js";
12
12
 
13
13
  // src/config/config-manager.ts
14
14
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
@@ -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-M7Y32LNH.js");
388
+ const { TaskOrchestrator } = await import("./task-orchestrator-24UUKJW5.js");
389
389
  const orchestrator = new TaskOrchestrator(config, providers, configManager);
390
390
  let interrupted = false;
391
391
  const onSigint = () => {
package/dist/index.js CHANGED
@@ -30,10 +30,10 @@ import {
30
30
  saveDevState,
31
31
  sessionHasMeaningfulContent,
32
32
  setupProxy
33
- } from "./chunk-TIGB5ADX.js";
33
+ } from "./chunk-PLJUAA3J.js";
34
34
  import {
35
35
  ConfigManager
36
- } from "./chunk-LR7IV4SK.js";
36
+ } from "./chunk-VG3MFZYG.js";
37
37
  import {
38
38
  ToolExecutor,
39
39
  ToolRegistry,
@@ -49,11 +49,11 @@ import {
49
49
  spawnAgentContext,
50
50
  theme,
51
51
  undoStack
52
- } from "./chunk-H65WPFDO.js";
52
+ } from "./chunk-D5ZDVEJJ.js";
53
53
  import {
54
54
  fileCheckpoints
55
55
  } from "./chunk-4BKXL7SM.js";
56
- import "./chunk-HAOCJWW2.js";
56
+ import "./chunk-FKVJRBPO.js";
57
57
  import "./chunk-2ZD3YTVM.js";
58
58
  import {
59
59
  AGENTIC_BEHAVIOR_GUIDELINE,
@@ -76,7 +76,7 @@ import {
76
76
  SKILLS_DIR_NAME,
77
77
  VERSION,
78
78
  buildUserIdentityPrompt
79
- } from "./chunk-T2OUKQOX.js";
79
+ } from "./chunk-BT2TCINO.js";
80
80
 
81
81
  // src/index.ts
82
82
  import { program } from "commander";
@@ -316,6 +316,11 @@ var Renderer = class {
316
316
  console.log(feat("Session size control: auto-trim old tool output when session exceeds 2MB, keep recent rounds intact"));
317
317
  console.log(feat("Crash recovery: detect incomplete agentic loops on /resume, warn and offer continuation"));
318
318
  console.log(feat("Cost dashboard: /cost history shows cross-session daily/weekly/monthly spend with budget progress bar"));
319
+ console.log(feat("Prompt caching (A1, v0.4.70+): system prompt split into stable/volatile \u2014 Claude caches stable half, 10% cost on hits"));
320
+ console.log(feat("edit_file patch mode (A2, v0.4.72+): accepts unified diff (@@ hunks) \u2014 most compact for scattered small changes in large files"));
321
+ console.log(feat("Anthropic Batches API (A3, v0.4.73+): aicli batch submit/list/status/results/cancel \u2014 50% off, 24h window"));
322
+ console.log(feat("Session Replay (B1, v0.4.71+): Web UI \u{1F3AC} button \u2014 timeline view of every message, tool call, reasoning, and cache-aware token usage"));
323
+ console.log(feat("Conversation Branching (B2, v0.4.74+): /branch list/new/switch/delete/rename \u2014 fork the conversation at any message; Web UI replay \u{1F33F} fork-here button"));
319
324
  console.log();
320
325
  }
321
326
  printPrompt(provider, _model) {
@@ -2235,6 +2240,122 @@ ${hint}` : "")
2235
2240
  console.log();
2236
2241
  }
2237
2242
  },
2243
+ // ── /branch ───────────────────────────────────────────────────
2244
+ {
2245
+ name: "branch",
2246
+ description: "Manage conversation branches (fork/switch/list/delete/rename)",
2247
+ usage: "/branch [list | new <msgIndex> [title] | switch <id> | delete <id> | rename <id> <title>]",
2248
+ async execute(args, ctx) {
2249
+ const session = ctx.sessions.current;
2250
+ if (!session) {
2251
+ ctx.renderer.renderError("No active session.");
2252
+ return;
2253
+ }
2254
+ const sub = args[0]?.toLowerCase();
2255
+ if (!sub || sub === "list") {
2256
+ const branches = session.listBranches();
2257
+ console.log(theme.heading(`
2258
+ Branches (${branches.length}):
2259
+ `));
2260
+ for (const b of branches) {
2261
+ const active = b.id === session.activeBranchId ? theme.success("\u25CF ") : " ";
2262
+ const parent = b.parentBranchId ? theme.dim(` \u2190 ${b.parentBranchId}@${b.parentMessageIndex}`) : "";
2263
+ const count = b.id === session.activeBranchId ? session.messages.length : session.getBranchMessages(b.id)?.length ?? 0;
2264
+ console.log(
2265
+ ` ${active}${theme.accent(b.id.padEnd(10))} ${b.title.padEnd(20)} ` + theme.dim(`(${count} msgs)`) + parent
2266
+ );
2267
+ }
2268
+ console.log();
2269
+ console.log(theme.dim(" /branch new <msgIndex> [title] \u2014 fork active branch at message N"));
2270
+ console.log(theme.dim(" /branch switch <id> \u2014 switch active branch"));
2271
+ console.log(theme.dim(" /branch delete <id> \u2014 delete inactive branch"));
2272
+ console.log(theme.dim(" /branch rename <id> <title> \u2014 rename a branch"));
2273
+ console.log();
2274
+ return;
2275
+ }
2276
+ if (sub === "new") {
2277
+ const idxArg = args[1];
2278
+ if (!idxArg) {
2279
+ ctx.renderer.renderError("Usage: /branch new <msgIndex> [title]");
2280
+ return;
2281
+ }
2282
+ const fromIndex = parseInt(idxArg, 10);
2283
+ if (isNaN(fromIndex) || fromIndex < 0 || fromIndex > session.messages.length) {
2284
+ ctx.renderer.renderError(`Invalid msgIndex: ${idxArg}. Range: 0-${session.messages.length}`);
2285
+ return;
2286
+ }
2287
+ const title = args.slice(2).join(" ").trim() || void 0;
2288
+ try {
2289
+ const newId = session.createBranch(fromIndex, title);
2290
+ await ctx.sessions.save();
2291
+ console.log(theme.success(`
2292
+ \u2713 Created branch "${newId}" from message #${fromIndex}`));
2293
+ console.log(theme.dim(` Now on branch "${newId}" (${session.messages.length} messages)
2294
+ `));
2295
+ } catch (err) {
2296
+ ctx.renderer.renderError(err.message);
2297
+ }
2298
+ return;
2299
+ }
2300
+ if (sub === "switch") {
2301
+ const id = args[1];
2302
+ if (!id) {
2303
+ ctx.renderer.renderError("Usage: /branch switch <id>");
2304
+ return;
2305
+ }
2306
+ const ok = session.switchBranch(id);
2307
+ if (ok) {
2308
+ await ctx.sessions.save();
2309
+ const b = session.getActiveBranch();
2310
+ console.log(theme.success(`
2311
+ \u2713 Switched to branch "${b.id}" \u2014 ${b.title}`));
2312
+ console.log(theme.dim(` ${session.messages.length} messages on this branch
2313
+ `));
2314
+ } else {
2315
+ ctx.renderer.renderError(`Cannot switch to "${id}" (not found or already active).`);
2316
+ }
2317
+ return;
2318
+ }
2319
+ if (sub === "delete") {
2320
+ const id = args[1];
2321
+ if (!id) {
2322
+ ctx.renderer.renderError("Usage: /branch delete <id>");
2323
+ return;
2324
+ }
2325
+ const ok = session.deleteBranch(id);
2326
+ if (ok) {
2327
+ await ctx.sessions.save();
2328
+ console.log(theme.success(`
2329
+ \u2713 Deleted branch "${id}"
2330
+ `));
2331
+ } else {
2332
+ ctx.renderer.renderError(
2333
+ `Cannot delete "${id}" (not found, active, or last remaining branch).`
2334
+ );
2335
+ }
2336
+ return;
2337
+ }
2338
+ if (sub === "rename") {
2339
+ const id = args[1];
2340
+ const title = args.slice(2).join(" ").trim();
2341
+ if (!id || !title) {
2342
+ ctx.renderer.renderError("Usage: /branch rename <id> <new title>");
2343
+ return;
2344
+ }
2345
+ const ok = session.renameBranch(id, title);
2346
+ if (ok) {
2347
+ await ctx.sessions.save();
2348
+ console.log(theme.success(`
2349
+ \u2713 Renamed branch "${id}" \u2192 "${title}"
2350
+ `));
2351
+ } else {
2352
+ ctx.renderer.renderError(`Branch "${id}" not found.`);
2353
+ }
2354
+ return;
2355
+ }
2356
+ ctx.renderer.renderError(`Unknown subcommand: ${sub}. Use list/new/switch/delete/rename.`);
2357
+ }
2358
+ },
2238
2359
  // ── /commands ─────────────────────────────────────────────────
2239
2360
  {
2240
2361
  name: "commands",
@@ -2271,7 +2392,7 @@ ${hint}` : "")
2271
2392
  usage: "/test [command|filter]",
2272
2393
  async execute(args, ctx) {
2273
2394
  try {
2274
- const { executeTests } = await import("./run-tests-6JUSVL4W.js");
2395
+ const { executeTests } = await import("./run-tests-HBLD2R6B.js");
2275
2396
  const argStr = args.join(" ").trim();
2276
2397
  let testArgs = {};
2277
2398
  if (argStr) {
@@ -6143,7 +6264,7 @@ program.command("web").description("Start Web UI server with browser-based chat
6143
6264
  console.error("Error: Invalid port number. Must be between 1 and 65535.");
6144
6265
  process.exit(1);
6145
6266
  }
6146
- const { startWebServer } = await import("./server-QQGBLS42.js");
6267
+ const { startWebServer } = await import("./server-NMMRIWT2.js");
6147
6268
  await startWebServer({ port, host: options.host });
6148
6269
  });
6149
6270
  program.command("user [action] [username]").description("Manage Web UI users (list | create <name> | delete <name> | reset-password <name> | migrate <name>)").action(async (action, username) => {
@@ -6266,7 +6387,7 @@ program.command("sessions").description("List recent conversation sessions").act
6266
6387
  });
6267
6388
  program.command("batch <action> [arg] [arg2]").description("Anthropic Message Batches: submit | list | status <id> | results <id> [out] | cancel <id>").option("--dry-run", "Parse and validate input without submitting (submit only)").action(async (action, arg, arg2, options) => {
6268
6389
  try {
6269
- const batch = await import("./batch-QSOGQ46F.js");
6390
+ const batch = await import("./batch-SEO6BLMQ.js");
6270
6391
  switch (action) {
6271
6392
  case "submit":
6272
6393
  if (!arg) {
@@ -6353,6 +6474,7 @@ program.command("help").description("Show a comprehensive guide to all aicli fea
6353
6474
  `${B}${C} \u25A0 WEB UI OPTIONS${R}`,
6354
6475
  ` ${Y}aicli web --port 8080${R} Custom port (default: 3000)`,
6355
6476
  ` ${Y}aicli web --host 0.0.0.0${R} LAN access (mobile/tablet)`,
6477
+ ` ${D} Features: \u{1F3AC} Session Replay + \u{1F33F} Conversation Branching (fork from any message)${R}`,
6356
6478
  "",
6357
6479
  `${B}${C} \u25A0 MULTI-AGENT HUB${R}`,
6358
6480
  ` ${Y}--preset <name>${R} Role preset (tech-review/brainstorm/code-review/debate)`,
@@ -6375,6 +6497,7 @@ program.command("help").description("Show a comprehensive guide to all aicli fea
6375
6497
  ` ${M}/search${R} Search across all session histories`,
6376
6498
  ` ${M}/compact${R} Compress conversation to save context`,
6377
6499
  ` ${M}/checkpoint${R} ${M}/fork${R} Save/restore/fork session state`,
6500
+ ` ${M}/branch${R} List/create/switch/delete conversation branches (B2)`,
6378
6501
  ` ${M}/undo${R} Undo file operations`,
6379
6502
  ` ${M}/yolo${R} Skip all tool confirmations`,
6380
6503
  ` ${M}/mcp${R} Manage MCP server connections`,
@@ -6386,6 +6509,7 @@ program.command("help").description("Show a comprehensive guide to all aicli fea
6386
6509
  ` ${D}bash read_file write_file edit_file list_dir grep_files glob_files${R}`,
6387
6510
  ` ${D}web_fetch google_search run_interactive run_tests spawn_agent${R}`,
6388
6511
  ` ${D}ask_user write_todos save_memory save_last_response + MCP tools${R}`,
6512
+ ` ${D}edit_file has 5 modes: string replace, line insert/delete, batch edits, unified-diff patch${R}`,
6389
6513
  "",
6390
6514
  `${B}${C} \u25A0 CONFIGURATION${R}`,
6391
6515
  ` ${D}Config: ~/.aicli/config.json${R}`,
@@ -6403,6 +6527,8 @@ program.command("help").description("Show a comprehensive guide to all aicli fea
6403
6527
  ` ${G}aicli hub --preset brainstorm "idea"${R} ${D}# multi-agent brainstorm${R}`,
6404
6528
  ` ${G}aicli hub --task --roles team.json "build app"${R} ${D}# task mode: agents write code${R}`,
6405
6529
  ` ${G}aicli hub -c spec.md -c api.md "build it"${R} ${D}# hub + docs context${R}`,
6530
+ ` ${G}aicli batch submit prompts.jsonl${R} ${D}# Anthropic batch: 50% off, 24h window${R}`,
6531
+ ` ${G}aicli batch status <id>${R} ${G}/ results <id> out.jsonl${R} ${D}# poll / download${R}`,
6406
6532
  "",
6407
6533
  `${D} Docs: https://github.com/jinzhengdong/ai-cli${R}`,
6408
6534
  `${D} Issues: https://github.com/jinzhengdong/ai-cli/issues${R}`,
@@ -6421,7 +6547,7 @@ program.command("hub [topic]").description("Start multi-agent hub (discuss / bra
6421
6547
  }),
6422
6548
  config.get("customProviders")
6423
6549
  );
6424
- const { startHub } = await import("./hub-AFXKRJ5D.js");
6550
+ const { startHub } = await import("./hub-ABCHM2OR.js");
6425
6551
  await startHub(
6426
6552
  {
6427
6553
  topic: topic ?? "",
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  executeTests,
3
3
  runTestsTool
4
- } from "./chunk-76KBSQHA.js";
4
+ } from "./chunk-265H5S5H.js";
5
5
  export {
6
6
  executeTests,
7
7
  runTestsTool
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  executeTests,
4
4
  runTestsTool
5
- } from "./chunk-HAOCJWW2.js";
6
- import "./chunk-T2OUKQOX.js";
5
+ } from "./chunk-FKVJRBPO.js";
6
+ import "./chunk-BT2TCINO.js";
7
7
  export {
8
8
  executeTests,
9
9
  runTestsTool
@@ -20,13 +20,13 @@ import {
20
20
  persistToolRound,
21
21
  rebuildExtraMessages,
22
22
  setupProxy
23
- } from "./chunk-TIGB5ADX.js";
23
+ } from "./chunk-PLJUAA3J.js";
24
24
  import {
25
25
  AuthManager
26
26
  } from "./chunk-BYNY5JPB.js";
27
27
  import {
28
28
  ConfigManager
29
- } from "./chunk-LR7IV4SK.js";
29
+ } from "./chunk-VG3MFZYG.js";
30
30
  import {
31
31
  ToolExecutor,
32
32
  ToolRegistry,
@@ -44,9 +44,9 @@ import {
44
44
  spawnAgentContext,
45
45
  truncateOutput,
46
46
  undoStack
47
- } from "./chunk-H65WPFDO.js";
47
+ } from "./chunk-D5ZDVEJJ.js";
48
48
  import "./chunk-4BKXL7SM.js";
49
- import "./chunk-HAOCJWW2.js";
49
+ import "./chunk-FKVJRBPO.js";
50
50
  import "./chunk-2ZD3YTVM.js";
51
51
  import {
52
52
  AGENTIC_BEHAVIOR_GUIDELINE,
@@ -66,7 +66,7 @@ import {
66
66
  SKILLS_DIR_NAME,
67
67
  VERSION,
68
68
  buildUserIdentityPrompt
69
- } from "./chunk-T2OUKQOX.js";
69
+ } from "./chunk-BT2TCINO.js";
70
70
 
71
71
  // src/web/server.ts
72
72
  import express from "express";
@@ -1787,6 +1787,103 @@ ${undoResults.map((r) => ` \u2022 ${r}`).join("\n")}` });
1787
1787
  }
1788
1788
  break;
1789
1789
  }
1790
+ // ── /branch ─────────────────────────────────────────────────────
1791
+ case "branch": {
1792
+ const session = this.sessions.current;
1793
+ if (!session) {
1794
+ this.send({ type: "error", message: "No active session." });
1795
+ break;
1796
+ }
1797
+ const sub = args[0]?.toLowerCase();
1798
+ if (!sub || sub === "list") {
1799
+ const branches = session.listBranches();
1800
+ const lines = [`\u{1F33F} Branches (${branches.length}):`, ""];
1801
+ for (const b of branches) {
1802
+ const marker = b.id === session.activeBranchId ? "\u25CF " : " ";
1803
+ const count = b.id === session.activeBranchId ? session.messages.length : session.getBranchMessages(b.id)?.length ?? 0;
1804
+ const parent = b.parentBranchId ? ` \u2190 ${b.parentBranchId}@${b.parentMessageIndex}` : "";
1805
+ lines.push(` ${marker}${b.id.padEnd(10)} ${b.title.padEnd(20)} (${count} msgs)${parent}`);
1806
+ }
1807
+ lines.push("");
1808
+ lines.push("Usage: /branch new <msgIndex> [title] | switch <id> | delete <id> | rename <id> <title>");
1809
+ this.send({ type: "info", message: lines.join("\n") });
1810
+ break;
1811
+ }
1812
+ if (sub === "new") {
1813
+ const idxArg = args[1];
1814
+ if (!idxArg) {
1815
+ this.send({ type: "error", message: "Usage: /branch new <msgIndex> [title]" });
1816
+ break;
1817
+ }
1818
+ const fromIndex = parseInt(idxArg, 10);
1819
+ if (isNaN(fromIndex) || fromIndex < 0 || fromIndex > session.messages.length) {
1820
+ this.send({ type: "error", message: `Invalid msgIndex: ${idxArg}. Range: 0-${session.messages.length}` });
1821
+ break;
1822
+ }
1823
+ const title = args.slice(2).join(" ").trim() || void 0;
1824
+ try {
1825
+ const newId = session.createBranch(fromIndex, title);
1826
+ await this.sessions.save();
1827
+ this.send({ type: "info", message: `\u2713 Created branch "${newId}" from message #${fromIndex}. Now active (${session.messages.length} messages).` });
1828
+ this.sendSessionMessages();
1829
+ this.sendStatus();
1830
+ } catch (err) {
1831
+ this.send({ type: "error", message: err.message });
1832
+ }
1833
+ break;
1834
+ }
1835
+ if (sub === "switch") {
1836
+ const id = args[1];
1837
+ if (!id) {
1838
+ this.send({ type: "error", message: "Usage: /branch switch <id>" });
1839
+ break;
1840
+ }
1841
+ const ok = session.switchBranch(id);
1842
+ if (ok) {
1843
+ await this.sessions.save();
1844
+ const b = session.getActiveBranch();
1845
+ this.send({ type: "info", message: `\u2713 Switched to branch "${b.id}" \u2014 ${b.title} (${session.messages.length} messages)` });
1846
+ this.sendSessionMessages();
1847
+ this.sendStatus();
1848
+ } else {
1849
+ this.send({ type: "error", message: `Cannot switch to "${id}" (not found or already active).` });
1850
+ }
1851
+ break;
1852
+ }
1853
+ if (sub === "delete") {
1854
+ const id = args[1];
1855
+ if (!id) {
1856
+ this.send({ type: "error", message: "Usage: /branch delete <id>" });
1857
+ break;
1858
+ }
1859
+ const ok = session.deleteBranch(id);
1860
+ if (ok) {
1861
+ await this.sessions.save();
1862
+ this.send({ type: "info", message: `\u2713 Deleted branch "${id}"` });
1863
+ } else {
1864
+ this.send({ type: "error", message: `Cannot delete "${id}" (not found, active, or last remaining branch).` });
1865
+ }
1866
+ break;
1867
+ }
1868
+ if (sub === "rename") {
1869
+ const id = args[1];
1870
+ const title = args.slice(2).join(" ").trim();
1871
+ if (!id || !title) {
1872
+ this.send({ type: "error", message: "Usage: /branch rename <id> <new title>" });
1873
+ break;
1874
+ }
1875
+ const ok = session.renameBranch(id, title);
1876
+ if (ok) {
1877
+ await this.sessions.save();
1878
+ this.send({ type: "info", message: `\u2713 Renamed branch "${id}" \u2192 "${title}"` });
1879
+ } else {
1880
+ this.send({ type: "error", message: `Branch "${id}" not found.` });
1881
+ }
1882
+ break;
1883
+ }
1884
+ this.send({ type: "error", message: `Unknown subcommand: ${sub}. Use list/new/switch/delete/rename.` });
1885
+ break;
1886
+ }
1790
1887
  // ── /fork ───────────────────────────────────────────────────────
1791
1888
  case "fork": {
1792
1889
  const session = this.sessions.current;
@@ -1952,7 +2049,7 @@ ${undoResults.map((r) => ` \u2022 ${r}`).join("\n")}` });
1952
2049
  case "test": {
1953
2050
  this.send({ type: "info", message: "\u{1F9EA} Running tests..." });
1954
2051
  try {
1955
- const { executeTests } = await import("./run-tests-6JUSVL4W.js");
2052
+ const { executeTests } = await import("./run-tests-HBLD2R6B.js");
1956
2053
  const argStr = args.join(" ").trim();
1957
2054
  let testArgs = {};
1958
2055
  if (argStr) {
@@ -4,13 +4,13 @@ import {
4
4
  getDangerLevel,
5
5
  googleSearchContext,
6
6
  truncateOutput
7
- } from "./chunk-H65WPFDO.js";
7
+ } from "./chunk-D5ZDVEJJ.js";
8
8
  import "./chunk-4BKXL7SM.js";
9
- import "./chunk-HAOCJWW2.js";
9
+ import "./chunk-FKVJRBPO.js";
10
10
  import "./chunk-2ZD3YTVM.js";
11
11
  import {
12
12
  SUBAGENT_ALLOWED_TOOLS
13
- } from "./chunk-T2OUKQOX.js";
13
+ } from "./chunk-BT2TCINO.js";
14
14
 
15
15
  // src/hub/task-orchestrator.ts
16
16
  import { createInterface } from "readline";
@@ -1044,7 +1044,10 @@ function renderFilteredSessions(filter) {
1044
1044
  }
1045
1045
 
1046
1046
  // ── Session Replay (B1) ─────────────────────────────────
1047
+ let _replaySessionId = null; // session shown in the replay modal
1048
+
1047
1049
  async function openReplay(sessionId) {
1050
+ _replaySessionId = sessionId;
1048
1051
  const modal = document.getElementById('replay-modal');
1049
1052
  const metaEl = document.getElementById('replay-meta');
1050
1053
  const usageEl = document.getElementById('replay-usage');
@@ -1092,6 +1095,34 @@ function renderReplay(session, metaEl, usageEl, timelineEl) {
1092
1095
 
1093
1096
  const messages = Array.isArray(session.messages) ? session.messages : [];
1094
1097
  timelineEl.innerHTML = messages.map((m, i) => renderReplayStep(m, i)).join('');
1098
+
1099
+ // Wire 🌿 fork-from-here buttons.
1100
+ timelineEl.querySelectorAll('.replay-fork-btn').forEach((btn) => {
1101
+ btn.addEventListener('click', (e) => {
1102
+ e.stopPropagation();
1103
+ const idx = parseInt(btn.dataset.msgIndex || '0', 10);
1104
+ forkFromReplay(idx);
1105
+ });
1106
+ });
1107
+ }
1108
+
1109
+ function forkFromReplay(msgIndex) {
1110
+ if (!_replaySessionId) return;
1111
+ const activeSid = sessionTabs[activeTabIdx]?.sessionId;
1112
+ const title = prompt(`New branch title (forking at message #${msgIndex}):`, '');
1113
+ if (title === null) return; // cancelled
1114
+ const args = ['new', String(msgIndex), ...(title.trim() ? [title.trim()] : [])];
1115
+ if (activeSid && activeSid === _replaySessionId) {
1116
+ // Replayed session is already active — just branch.
1117
+ send({ type: 'command', name: 'branch', args });
1118
+ } else {
1119
+ // Load the replayed session first, then branch.
1120
+ send({ type: 'command', name: 'session', args: ['load', _replaySessionId] });
1121
+ // Small delay to let the load settle before branching.
1122
+ setTimeout(() => send({ type: 'command', name: 'branch', args }), 400);
1123
+ }
1124
+ const modal = document.getElementById('replay-modal');
1125
+ if (modal && typeof modal.close === 'function') modal.close();
1095
1126
  }
1096
1127
 
1097
1128
  function renderReplayStep(m, idx) {
@@ -1142,6 +1173,7 @@ function renderReplayStep(m, idx) {
1142
1173
  <span class="role-tag">${escapeHtml(roleTag)}</span>
1143
1174
  <span class="opacity-50">${ts}</span>
1144
1175
  ${m.toolCallId ? `<span class="opacity-40">↳ ${escapeHtml(m.toolCallId)}</span>` : ''}
1176
+ <button class="replay-fork-btn opacity-60 hover:opacity-100 text-xs ml-auto" data-msg-index="${idx + 1}" title="Fork a new branch from this point">🌿 fork here</button>
1145
1177
  </div>
1146
1178
  ${body || '<div class="opacity-40 text-xs">(empty)</div>'}
1147
1179
  </div>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.4.73",
3
+ "version": "0.4.74",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",