@co0ontty/wand 1.10.0 → 1.14.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/README.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # wand
2
2
 
3
- 通过浏览器远程访问和管理本地 CLI 工具的 Web 控制台。专为 Claude Code 设计,支持终端和结构化对话双视图、会话持久化与恢复、权限管控、文件浏览等功能。
3
+ [![npm version](https://img.shields.io/npm/v/@co0ontty/wand.svg)](https://www.npmjs.com/package/@co0ontty/wand)
4
+ [![license](https://img.shields.io/npm/l/@co0ontty/wand.svg)](https://github.com/co0ontty/wand/blob/master/LICENSE)
5
+ [![node](https://img.shields.io/node/v/@co0ontty/wand.svg)](https://nodejs.org)
6
+ [![GitHub last commit](https://img.shields.io/github/last-commit/co0ontty/wand)](https://github.com/co0ontty/wand/commits/master)
7
+
8
+ 通过浏览器远程访问和管理本地 CLI 工具的 Web 控制台。专为 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 和 [Codex](https://github.com/openai/codex) 设计,支持终端和结构化对话双视图、会话持久化与恢复、权限管控、文件浏览、Android 客户端等功能。
9
+
10
+ <p align="center">
11
+ <img src="docs/screenshots/chat-view.png" width="800" alt="结构化对话视图" />
12
+ </p>
4
13
 
5
14
  ## 安装
6
15
 
@@ -22,6 +31,43 @@ wand web
22
31
 
23
32
  安装完成后打开浏览器访问终端中提示的地址即可。
24
33
 
34
+ ## 功能
35
+
36
+ <p align="center">
37
+ <img src="docs/screenshots/home.png" width="800" alt="欢迎页" />
38
+ </p>
39
+
40
+ ### 核心
41
+
42
+ - **双视图模式** — 终端原始输出和结构化对话视图可随时切换,同一会话两种呈现
43
+ - **多 Provider 支持** — 同时支持 Claude Code 和 Codex,可按需创建不同类型的会话
44
+ - **会话管理** — 创建、归档、恢复会话;支持从 Claude 原生历史记录恢复;会话列表显示摘要,懒加载
45
+ - **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略;工具调用自动分组与审批统计
46
+
47
+ ### 交互体验
48
+
49
+ - **结构化对话** — 代码块语法高亮、工具调用折叠/展开、多问题分组渲染、Token 用量按轮累计
50
+ - **个性化角色** — 像素风猫咪头像(赛博虎妞 / 勤劳初二),支持自定义对话角色名称
51
+ - **消息排队** — 在 AI 思考时可继续输入,消息自动排队发送
52
+ - **文件浏览器** — 内置路径浏览和搜索功能
53
+
54
+ ### 部署与访问
55
+
56
+ - **PWA 支持** — 可添加到主屏幕作为独立应用使用
57
+ - **Android 客户端** — WebView 壳应用,支持加密连接码分发、APK 自动更新检查、原生通知推送、启动器图标切换
58
+ - **HTTPS** — 可选自签证书,适合远程或移动端访问
59
+ - **版本管理** — 内置更新检查与升级提示
60
+
61
+ ## 截图
62
+
63
+ | 登录 | 对话视图 |
64
+ |:---:|:---:|
65
+ | <img src="docs/screenshots/login.png" width="400" alt="登录页" /> | <img src="docs/screenshots/chat-view.png" width="400" alt="对话视图" /> |
66
+
67
+ | 终端 PTY 视图 |
68
+ |:---:|
69
+ | <img src="docs/screenshots/terminal-pty.png" width="800" alt="终端 PTY 视图" /> |
70
+
25
71
  ## 配置
26
72
 
27
73
  配置文件位于 `~/.wand/config.json`,首次 `wand init` 时自动生成。
@@ -43,17 +89,6 @@ wand config:set port 9443
43
89
  | `password` | (随机生成) | 登录密码 |
44
90
  | `language` | `""` | Claude 回复语言偏好 |
45
91
 
46
- ## 功能
47
-
48
- - **双视图模式** — 终端原始输出和结构化对话视图可随时切换
49
- - **会话管理** — 创建、归档、恢复会话;支持从 Claude 原生历史记录恢复;会话列表显示摘要
50
- - **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略;工具调用自动分组
51
- - **文件浏览器** — 内置路径浏览和搜索功能
52
- - **多种运行模式** — full-access / default / auto-edit 等 Claude 运行模式
53
- - **个性化** — 像素风猫咪头像、回复语言偏好设置
54
- - **PWA 支持** — 可添加到主屏幕作为独立应用使用
55
- - **HTTPS** — 可选自签证书,适合远程或移动端访问
56
-
57
92
  ## 开发
58
93
 
59
94
  ```bash
@@ -84,6 +119,7 @@ src/
84
119
  session-logger.ts # 文件日志 ~/.wand/sessions/
85
120
  resume-policy.ts # Claude 历史绑定与恢复策略
86
121
  web-ui/ # 服务端渲染的前端 HTML/CSS/JS
122
+ android/ # Android WebView 壳应用
87
123
  ```
88
124
 
89
125
  数据存储在 `~/.wand/` 下:`config.json`(配置)、`wand.db`(SQLite)、`sessions/`(日志)。
package/dist/config.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { ExecutionMode, WandConfig } from "./types.js";
1
+ import { CardExpandDefaults, ExecutionMode, WandConfig } from "./types.js";
2
2
  export declare const defaultConfig: () => WandConfig;
3
3
  export declare function resolveConfigPath(inputPath?: string): string;
4
4
  export declare function resolveConfigDir(configPath: string): string;
5
5
  export declare function hasConfigFile(configPath: string): boolean;
6
6
  export declare function ensureConfig(configPath: string): Promise<WandConfig>;
7
7
  export declare function saveConfig(configPath: string, config: WandConfig): Promise<void>;
8
+ export declare function normalizeCardDefaults(input: unknown): CardExpandDefaults;
8
9
  export declare function isExecutionMode(value: unknown): value is ExecutionMode;
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import { existsSync } from "node:fs";
2
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
@@ -16,6 +17,8 @@ export const defaultConfig = () => ({
16
17
  allowedCommandPrefixes: [],
17
18
  shortcutLogMaxBytes: 10 * 1024 * 1024,
18
19
  language: "",
20
+ android: defaultAndroidApkConfig(),
21
+ cardDefaults: defaultCardExpandDefaults(),
19
22
  commandPresets: [
20
23
  {
21
24
  label: "Claude",
@@ -79,6 +82,49 @@ export async function saveConfig(configPath, config) {
79
82
  await mkdir(path.dirname(configPath), { recursive: true });
80
83
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
81
84
  }
85
+ function defaultCardExpandDefaults() {
86
+ return {
87
+ editCards: false,
88
+ inlineTools: false,
89
+ terminal: false,
90
+ thinking: false,
91
+ toolGroup: false,
92
+ };
93
+ }
94
+ export function normalizeCardDefaults(input) {
95
+ if (!input || typeof input !== "object")
96
+ return defaultCardExpandDefaults();
97
+ const raw = input;
98
+ return {
99
+ editCards: typeof raw.editCards === "boolean" ? raw.editCards : false,
100
+ inlineTools: typeof raw.inlineTools === "boolean" ? raw.inlineTools : false,
101
+ terminal: typeof raw.terminal === "boolean" ? raw.terminal : false,
102
+ thinking: typeof raw.thinking === "boolean" ? raw.thinking : false,
103
+ toolGroup: typeof raw.toolGroup === "boolean" ? raw.toolGroup : false,
104
+ };
105
+ }
106
+ function defaultAndroidApkConfig() {
107
+ return {
108
+ enabled: false,
109
+ apkDir: "android",
110
+ currentApkFile: "",
111
+ };
112
+ }
113
+ function normalizeAndroidApkConfig(input) {
114
+ if (!input || typeof input !== "object")
115
+ return undefined;
116
+ const defaults = defaultAndroidApkConfig();
117
+ const androidInput = input;
118
+ return {
119
+ enabled: typeof androidInput.enabled === "boolean" ? androidInput.enabled : defaults.enabled,
120
+ apkDir: typeof androidInput.apkDir === "string" && androidInput.apkDir.trim()
121
+ ? androidInput.apkDir.trim()
122
+ : defaults.apkDir,
123
+ currentApkFile: typeof androidInput.currentApkFile === "string"
124
+ ? androidInput.currentApkFile.trim()
125
+ : defaults.currentApkFile,
126
+ };
127
+ }
82
128
  function normalizeStructuredChatPersona(input) {
83
129
  if (!input || typeof input !== "object")
84
130
  return undefined;
@@ -132,6 +178,11 @@ function mergeWithDefaults(input) {
132
178
  : defaults.commandPresets,
133
179
  structuredChatPersona: normalizeStructuredChatPersona(input.structuredChatPersona),
134
180
  language: typeof input.language === "string" ? input.language.trim() : defaults.language,
181
+ appSecret: typeof input.appSecret === "string" && input.appSecret.length >= 32
182
+ ? input.appSecret
183
+ : crypto.randomBytes(32).toString("hex"),
184
+ android: normalizeAndroidApkConfig(input.android) ?? defaults.android,
185
+ cardDefaults: normalizeCardDefaults(input.cardDefaults),
135
186
  };
136
187
  }
137
188
  export function isExecutionMode(value) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Truncates tool_result content in messages for WebSocket transport.
3
+ * Cards that are collapsed by default have their large results replaced
4
+ * with a summary, and clients fetch full content on-demand via API.
5
+ */
6
+ import type { CardExpandDefaults, ConversationTurn } from "./types.js";
7
+ /**
8
+ * Truncate messages for WebSocket transport. Tool results for collapsed card
9
+ * types are replaced with a short summary when they exceed the threshold.
10
+ *
11
+ * @param messages - Original messages array (not mutated)
12
+ * @param cardDefaults - Current card expand defaults from config
13
+ * @param streamingTurnIndex - Index of the currently streaming turn (-1 if none).
14
+ * Tool results in the streaming turn are never truncated.
15
+ */
16
+ export declare function truncateMessagesForTransport(messages: ConversationTurn[], cardDefaults: CardExpandDefaults, streamingTurnIndex?: number): ConversationTurn[];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Truncates tool_result content in messages for WebSocket transport.
3
+ * Cards that are collapsed by default have their large results replaced
4
+ * with a summary, and clients fetch full content on-demand via API.
5
+ */
6
+ const TRUNCATION_THRESHOLD = 200;
7
+ const SUMMARY_LENGTH = 100;
8
+ /** Tool name → cardDefaults field mapping */
9
+ function isToolDefaultCollapsed(toolName, defaults) {
10
+ switch (toolName) {
11
+ case "Read":
12
+ case "Glob":
13
+ case "Grep":
14
+ case "WebFetch":
15
+ case "WebSearch":
16
+ case "TodoRead":
17
+ return defaults.inlineTools !== true;
18
+ case "Bash":
19
+ return defaults.terminal !== true;
20
+ case "Edit":
21
+ case "Write":
22
+ case "MultiEdit":
23
+ return defaults.editCards !== true;
24
+ default:
25
+ return false;
26
+ }
27
+ }
28
+ function getContentString(content) {
29
+ return typeof content === "string" ? content : JSON.stringify(content);
30
+ }
31
+ /**
32
+ * Truncate messages for WebSocket transport. Tool results for collapsed card
33
+ * types are replaced with a short summary when they exceed the threshold.
34
+ *
35
+ * @param messages - Original messages array (not mutated)
36
+ * @param cardDefaults - Current card expand defaults from config
37
+ * @param streamingTurnIndex - Index of the currently streaming turn (-1 if none).
38
+ * Tool results in the streaming turn are never truncated.
39
+ */
40
+ export function truncateMessagesForTransport(messages, cardDefaults, streamingTurnIndex = -1) {
41
+ return messages.map((turn, turnIndex) => {
42
+ // Never truncate the currently streaming turn
43
+ if (turnIndex === streamingTurnIndex)
44
+ return turn;
45
+ // Build tool_use_id → tool_name map from this turn's blocks
46
+ const toolNameMap = new Map();
47
+ for (const block of turn.content) {
48
+ if (block.type === "tool_use") {
49
+ toolNameMap.set(block.id, block.name);
50
+ }
51
+ }
52
+ let changed = false;
53
+ const truncatedContent = turn.content.map((block) => {
54
+ if (block.type !== "tool_result")
55
+ return block;
56
+ const result = block;
57
+ // Never truncate errors
58
+ if (result.is_error)
59
+ return block;
60
+ const toolName = toolNameMap.get(result.tool_use_id) || "";
61
+ if (!isToolDefaultCollapsed(toolName, cardDefaults))
62
+ return block;
63
+ const contentStr = getContentString(result.content);
64
+ if (contentStr.length <= TRUNCATION_THRESHOLD)
65
+ return block;
66
+ changed = true;
67
+ return {
68
+ ...result,
69
+ content: contentStr.slice(0, SUMMARY_LENGTH) + "…",
70
+ _truncated: true,
71
+ _originalSize: contentStr.length,
72
+ };
73
+ });
74
+ return changed ? { ...turn, content: truncatedContent } : turn;
75
+ });
76
+ }
@@ -45,6 +45,8 @@ export declare class ProcessManager extends EventEmitter {
45
45
  provider?: SessionProvider;
46
46
  }): SessionSnapshot;
47
47
  list(): SessionSnapshot[];
48
+ /** Return lightweight snapshots for the session list (no output/messages). */
49
+ listSlim(): SessionSnapshot[];
48
50
  hasClaudeSessionFile(cwd: string, claudeSessionId: string): boolean;
49
51
  private claudeHistoryCache;
50
52
  private static readonly HISTORY_CACHE_TTL_MS;
@@ -64,6 +66,8 @@ export declare class ProcessManager extends EventEmitter {
64
66
  private deleteClaudeCache;
65
67
  runStartupCommands(): SessionSnapshot[];
66
68
  private snapshot;
69
+ /** Lightweight snapshot for list views — omits output and messages. */
70
+ private snapshotSlim;
67
71
  private isPermissionBlocked;
68
72
  private defaultAutonomyPolicy;
69
73
  resolveEscalation(id: string, requestId: string, resolution?: "approve_once" | "approve_turn" | "deny"): SessionSnapshot;
@@ -8,6 +8,7 @@ import pty from "node-pty";
8
8
  import { SessionLogger } from "./session-logger.js";
9
9
  import { SessionLifecycleManager } from "./session-lifecycle.js";
10
10
  import { ClaudePtyBridge } from "./claude-pty-bridge.js";
11
+ import { truncateMessagesForTransport } from "./message-truncator.js";
11
12
  import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
12
13
  import { prepareSessionWorktree } from "./git-worktree.js";
13
14
  import { getResumeCommandSessionId, hasRealConversationMessages, } from "./resume-policy.js";
@@ -863,6 +864,13 @@ export class ProcessManager extends EventEmitter {
863
864
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
864
865
  .map((session) => this.snapshot(session));
865
866
  }
867
+ /** Return lightweight snapshots for the session list (no output/messages). */
868
+ listSlim() {
869
+ this.archiveExpiredSessions();
870
+ return Array.from(this.sessions.values())
871
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
872
+ .map((session) => this.snapshotSlim(session));
873
+ }
866
874
  hasClaudeSessionFile(cwd, claudeSessionId) {
867
875
  return isClaudeSessionFileAvailable(cwd, claudeSessionId);
868
876
  }
@@ -1171,6 +1179,43 @@ export class ProcessManager extends EventEmitter {
1171
1179
  autoApprovePermissions: record.autoApprovePermissions || undefined,
1172
1180
  approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined,
1173
1181
  summary: deriveSessionSummary(messages, record.currentTask?.title ?? null),
1182
+ currentTaskTitle: record.status === "running" ? record.currentTask?.title ?? undefined : undefined,
1183
+ };
1184
+ }
1185
+ /** Lightweight snapshot for list views — omits output and messages. */
1186
+ snapshotSlim(record) {
1187
+ const messages = record.ptyBridge?.getMessages() ?? record.messages;
1188
+ return {
1189
+ id: record.id,
1190
+ sessionKind: "pty",
1191
+ provider: record.provider,
1192
+ runner: "pty",
1193
+ command: record.command,
1194
+ cwd: record.cwd,
1195
+ mode: record.mode,
1196
+ worktreeEnabled: record.worktreeEnabled ?? false,
1197
+ worktree: record.worktree ?? null,
1198
+ autonomyPolicy: record.autonomyPolicy,
1199
+ approvalPolicy: record.approvalPolicy,
1200
+ allowedScopes: record.allowedScopes,
1201
+ status: record.status,
1202
+ exitCode: record.exitCode,
1203
+ startedAt: record.startedAt,
1204
+ endedAt: record.endedAt,
1205
+ output: "",
1206
+ archived: record.archived,
1207
+ archivedAt: record.archivedAt,
1208
+ permissionBlocked: this.isPermissionBlocked(record),
1209
+ pendingEscalation: record.pendingEscalation || undefined,
1210
+ lastEscalationResult: record.lastEscalationResult || undefined,
1211
+ claudeSessionId: record.claudeSessionId || null,
1212
+ resumedFromSessionId: record.resumedFromSessionId ?? undefined,
1213
+ resumedToSessionId: record.resumedToSessionId ?? undefined,
1214
+ autoRecovered: record.autoRecovered ?? false,
1215
+ autoApprovePermissions: record.autoApprovePermissions || undefined,
1216
+ approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined,
1217
+ summary: deriveSessionSummary(messages, record.currentTask?.title ?? null),
1218
+ currentTaskTitle: record.status === "running" ? record.currentTask?.title ?? undefined : undefined,
1174
1219
  };
1175
1220
  }
1176
1221
  isPermissionBlocked(record) {
@@ -1438,31 +1483,39 @@ export class ProcessManager extends EventEmitter {
1438
1483
  case "output.raw":
1439
1484
  // Sync record.output from bridge before emitting so the event carries fresh data
1440
1485
  record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1441
- // Emit output event for terminal view
1442
- this.emitEvent({
1443
- type: "output",
1444
- sessionId: event.sessionId,
1445
- data: {
1446
- chunk: event.data.chunk,
1447
- output: record.output,
1448
- messages: record.ptyBridge?.getMessages(),
1449
- permissionBlocked: this.isPermissionBlocked(record),
1450
- },
1451
- });
1486
+ {
1487
+ const rawMessages = record.ptyBridge?.getMessages() ?? [];
1488
+ const messages = truncateMessagesForTransport(rawMessages, this.config.cardDefaults ?? {}, rawMessages.length - 1);
1489
+ // Emit output event for terminal view
1490
+ this.emitEvent({
1491
+ type: "output",
1492
+ sessionId: event.sessionId,
1493
+ data: {
1494
+ chunk: event.data.chunk,
1495
+ output: record.output,
1496
+ messages,
1497
+ permissionBlocked: this.isPermissionBlocked(record),
1498
+ },
1499
+ });
1500
+ }
1452
1501
  break;
1453
1502
  case "output.chat":
1454
1503
  // Sync record.output from bridge before emitting so the event carries fresh data
1455
1504
  record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1456
- // Emit output event with updated messages for chat view
1457
- this.emitEvent({
1458
- type: "output",
1459
- sessionId: event.sessionId,
1460
- data: {
1461
- output: record.output,
1462
- messages: record.ptyBridge?.getMessages(),
1463
- permissionBlocked: this.isPermissionBlocked(record),
1464
- },
1465
- });
1505
+ {
1506
+ const rawMessages = record.ptyBridge?.getMessages() ?? [];
1507
+ const messages = truncateMessagesForTransport(rawMessages, this.config.cardDefaults ?? {}, rawMessages.length - 1);
1508
+ // Emit output event with updated messages for chat view
1509
+ this.emitEvent({
1510
+ type: "output",
1511
+ sessionId: event.sessionId,
1512
+ data: {
1513
+ output: record.output,
1514
+ messages,
1515
+ permissionBlocked: this.isPermissionBlocked(record),
1516
+ },
1517
+ });
1518
+ }
1466
1519
  break;
1467
1520
  case "permission.prompt": {
1468
1521
  const data = event.data;
@@ -71,6 +71,11 @@ function listAllSessions(processes, structured) {
71
71
  return [...structured.list(), ...processes.list()]
72
72
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
73
73
  }
74
+ /** Lightweight session list — omits output and messages to reduce payload. */
75
+ function listAllSessionsSlim(processes, structured) {
76
+ return [...structured.listSlim(), ...processes.listSlim()]
77
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
78
+ }
74
79
  function requireWorktreeSession(snapshot) {
75
80
  if (!snapshot) {
76
81
  throw new Error("未找到该会话。");
@@ -133,7 +138,7 @@ function isMergeActionAllowed(snapshot) {
133
138
  }
134
139
  export function registerSessionRoutes(app, processes, structured, storage, defaultMode) {
135
140
  app.get("/api/sessions", (_req, res) => {
136
- const all = listAllSessions(processes, structured);
141
+ const all = listAllSessionsSlim(processes, structured);
137
142
  console.log("[WAND] GET /api/sessions count:", all.length, "sessions:", all.map(s => ({ id: s.id.substring(0, 8), kind: s.sessionKind, runner: s.runner, status: s.status })));
138
143
  res.json(all);
139
144
  });
@@ -178,6 +183,29 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
178
183
  res.status(400).json({ error: getErrorMessage(error, "无法发送结构化消息。") });
179
184
  }
180
185
  });
186
+ // ── Tool content lazy-load endpoint ──
187
+ app.get("/api/sessions/:id/tool-content/:toolUseId", (req, res) => {
188
+ const snapshot = getSessionById(processes, structured, req.params.id);
189
+ if (!snapshot) {
190
+ res.status(404).json({ error: "未找到该会话。" });
191
+ return;
192
+ }
193
+ const toolUseId = req.params.toolUseId;
194
+ const messages = snapshot.messages ?? [];
195
+ for (const turn of messages) {
196
+ for (const block of turn.content) {
197
+ if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
198
+ res.json({
199
+ tool_use_id: block.tool_use_id,
200
+ content: block.content,
201
+ is_error: block.is_error || false,
202
+ });
203
+ return;
204
+ }
205
+ }
206
+ }
207
+ res.status(404).json({ error: "未找到该工具结果。" });
208
+ });
181
209
  app.post("/api/sessions/:id/worktree/merge/check", (req, res) => {
182
210
  try {
183
211
  const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));