@co0ontty/wand 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,12 +21,23 @@
21
21
  bash <(curl -Ls https://raw.githubusercontent.com/co0ontty/wand/master/install.sh)
22
22
  ```
23
23
 
24
+ 装完后脚本会询问:
25
+
26
+ - **1) 装为系统服务(推荐,默认)** — 写入 system-wide systemd(`/etc/systemd/system/wand.service`)或 launchd LaunchDaemon(`/Library/LaunchDaemons/com.wand.web.plist`),后台运行、开机自启、崩了自重启。**需要 sudo**(脚本会自动加)。
27
+ - **2) 单次启动** — 不装服务,之后手动跑 `wand web`。
28
+
29
+ > 通过管道运行(`bash <(curl ...)`)时 stdin 不是终端,默认走 **1(系统服务)**。想强制单次启动可以 `WAND_INSTALL_MODE=oneshot bash install.sh`。
30
+ >
31
+ > 不想用 sudo?可以装 user-level 版本:`wand service:install --user`(写入 `~/.config/systemd/user/wand.service`,登出会被回收,除非 `loginctl enable-linger $USER`)。
32
+
24
33
  ### 手动安装
25
34
 
26
35
  ```bash
27
36
  npm install -g @co0ontty/wand
28
37
  wand init
29
- wand web
38
+ sudo wand service:install # 装为系统服务(system-wide, 默认)
39
+ # 或者:wand service:install --user # 不要 sudo,但登出会被回收
40
+ wand web # 没服务时启动新实例;有服务时 attach TUI
30
41
  ```
31
42
 
32
43
  安装完成后打开浏览器访问终端中提示的地址即可。
@@ -41,7 +52,7 @@ bash <(curl -Ls https://raw.githubusercontent.com/co0ontty/wand/master/install.s
41
52
 
42
53
  > 也可以直接在网页设置里点「更新」按钮,或在 TUI 模式按 `u`,wand 自己会调用同样的清理逻辑。Web 端点击更新后会自动重启服务,无需手动操作。
43
54
 
44
- 如果以前装过 systemd 自启服务但还是 `Restart=on-failure`(v1.25.x 前的版本),重新进入 TUI 按一次 `i` 重装服务即可换成 `Restart=always`,自动更新后才能正确拉起新进程。
55
+ 如果以前装过 systemd 自启服务但还是 `Restart=on-failure`(v1.25.x 前的版本),重新跑 `sudo wand service:install` 重装服务即可换成 `Restart=always`,自动更新后才能正确拉起新进程。
45
56
 
46
57
  ## 功能
47
58
 
@@ -101,6 +112,32 @@ wand config:set port 9443
101
112
  | `password` | (随机生成) | 登录密码 |
102
113
  | `language` | `""` | Claude 回复语言偏好 |
103
114
 
115
+ ## 系统服务
116
+
117
+ 默认走 **system-wide**:Linux 写 `/etc/systemd/system/wand.service`,macOS 写 `/Library/LaunchDaemons/com.wand.web.plist`。开机自启、不依赖 login session、`service wand` / `systemctl status wand` 这些老命令都能用。装/卸需要 sudo。
118
+
119
+ ```bash
120
+ sudo wand service:install # 注册并启动(首次安装走这里)
121
+ wand service:status # 查状态(active / inactive / failed) — 读取不要 sudo
122
+ sudo wand service:start # 启动
123
+ sudo wand service:stop # 停止
124
+ sudo wand service:restart # 重启
125
+ wand service:logs # 看最近日志(--lines N 调整行数)
126
+ sudo wand service:uninstall # 卸载(停服 + 删 unit)
127
+ ```
128
+
129
+ 不想用 sudo?传 `--user` 切到 user-level(写 `~/.config/systemd/user/wand.service` 或 `~/Library/LaunchAgents/`):
130
+
131
+ ```bash
132
+ wand service:install --user
133
+ wand service:status --user
134
+ # ...其他子命令同理
135
+ ```
136
+
137
+ > User-level 版本登出后会被回收,除非跑 `loginctl enable-linger $USER`。
138
+
139
+ 服务装好后,`wand web` 会自动检测正在运行的实例(同一份 `config.json` 下)并以 TUI 模式 **attach 到现有 service**,不会重复启动第二个进程。多份配置(`-c` 指向不同路径)之间彼此隔离,互不影响。
140
+
104
141
  ## 开发
105
142
 
106
143
  ```bash
@@ -0,0 +1,31 @@
1
+ export type ClaudeRunErrorCode = "CLAUDE_CLI_MISSING" | "CLAUDE_TIMEOUT" | "CLAUDE_CLI_FAILED" | "CLAUDE_EMPTY_RESULT";
2
+ export declare class ClaudeRunError extends Error {
3
+ readonly code: ClaudeRunErrorCode;
4
+ constructor(message: string, code: ClaudeRunErrorCode);
5
+ }
6
+ export interface RunClaudePrintOptions {
7
+ cwd?: string;
8
+ timeoutMs: number;
9
+ }
10
+ /**
11
+ * 用 `@anthropic-ai/claude-agent-sdk` 跑一次"prompt → 单段纯文本"调用,
12
+ * 等价以前的 `claude -p --output-format text`。
13
+ *
14
+ * 行为对齐 Claude Code 默认:
15
+ * - 不指定 model / appendSystemPrompt / agent / mcpServers / hooks 等覆盖项,
16
+ * 完全由 `~/.claude/settings.json`、OAuth 凭据、`CLAUDE_*` 环境变量等
17
+ * 用户侧配置接管,与 Claude Code 自身一致。
18
+ * - `tools: []` 关掉所有内置工具:这两个调用点(commit message / prompt 优化)
19
+ * 本质就是"纯文本生成",关掉工具能 (1) 防止 Claude 随手开个工具卡住权限询问;
20
+ * (2) 避免一次性短调用还顺便加载文件 / 跑 bash 这种副作用。
21
+ * - `persistSession: false`:这些 ephemeral 调用不应该污染 `~/.claude/projects/`
22
+ * 的会话历史;用户在 wand UI 里也压根看不到这些"虚拟会话"。
23
+ *
24
+ * 选择 SDK 而非以前的 `execFile("claude")`:
25
+ * - SDK 包内置各平台 native binary,`pathToClaudeCodeExecutable` 直接指到
26
+ * `node_modules` 里,**零** PATH 依赖。systemd / launchd / 双击图标启动
27
+ * wand server 时不会再因为 PATH 缺 nvm/npm-global 而报"未找到 claude CLI"。
28
+ * - 与现有 `structured-session-manager.ts` 的 SDK 调用路径同源,行为/认证/
29
+ * 更新策略统一。
30
+ */
31
+ export declare function runClaudePrint(prompt: string, options: RunClaudePrintOptions): Promise<string>;
@@ -0,0 +1,142 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { query as sdkQuery, } from "@anthropic-ai/claude-agent-sdk";
4
+ export class ClaudeRunError extends Error {
5
+ code;
6
+ constructor(message, code) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = "ClaudeRunError";
10
+ }
11
+ }
12
+ /**
13
+ * 判断当前 Linux 是否是 musl 系(Alpine 等)。glibc 系跑不动 musl native binary,
14
+ * 反之亦然,SDK 默认的优先级与本机不匹配时会抛 "Claude Code native binary not found"。
15
+ */
16
+ function isMuslSystem() {
17
+ try {
18
+ const header = process.report?.getReport()?.header;
19
+ return !header?.glibcVersionRuntime;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ /**
26
+ * 把 SDK 应使用的 native claude binary 路径解析出来。逻辑与
27
+ * `structured-session-manager.ts` 的 `resolveSdkClaudeBinary` 保持一致。
28
+ */
29
+ function resolveSdkClaudeBinary() {
30
+ if (process.platform !== "linux")
31
+ return undefined;
32
+ const musl = isMuslSystem();
33
+ const arch = process.arch;
34
+ const require = createRequire(import.meta.url);
35
+ const candidates = musl
36
+ ? [
37
+ `@anthropic-ai/claude-agent-sdk-linux-${arch}-musl/claude`,
38
+ `@anthropic-ai/claude-agent-sdk-linux-${arch}/claude`,
39
+ ]
40
+ : [
41
+ `@anthropic-ai/claude-agent-sdk-linux-${arch}/claude`,
42
+ `@anthropic-ai/claude-agent-sdk-linux-${arch}-musl/claude`,
43
+ ];
44
+ for (const pkg of candidates) {
45
+ try {
46
+ const resolved = require.resolve(pkg);
47
+ if (existsSync(resolved))
48
+ return resolved;
49
+ }
50
+ catch {
51
+ // 包不存在,尝试下一个
52
+ }
53
+ }
54
+ return undefined;
55
+ }
56
+ /**
57
+ * 用 `@anthropic-ai/claude-agent-sdk` 跑一次"prompt → 单段纯文本"调用,
58
+ * 等价以前的 `claude -p --output-format text`。
59
+ *
60
+ * 行为对齐 Claude Code 默认:
61
+ * - 不指定 model / appendSystemPrompt / agent / mcpServers / hooks 等覆盖项,
62
+ * 完全由 `~/.claude/settings.json`、OAuth 凭据、`CLAUDE_*` 环境变量等
63
+ * 用户侧配置接管,与 Claude Code 自身一致。
64
+ * - `tools: []` 关掉所有内置工具:这两个调用点(commit message / prompt 优化)
65
+ * 本质就是"纯文本生成",关掉工具能 (1) 防止 Claude 随手开个工具卡住权限询问;
66
+ * (2) 避免一次性短调用还顺便加载文件 / 跑 bash 这种副作用。
67
+ * - `persistSession: false`:这些 ephemeral 调用不应该污染 `~/.claude/projects/`
68
+ * 的会话历史;用户在 wand UI 里也压根看不到这些"虚拟会话"。
69
+ *
70
+ * 选择 SDK 而非以前的 `execFile("claude")`:
71
+ * - SDK 包内置各平台 native binary,`pathToClaudeCodeExecutable` 直接指到
72
+ * `node_modules` 里,**零** PATH 依赖。systemd / launchd / 双击图标启动
73
+ * wand server 时不会再因为 PATH 缺 nvm/npm-global 而报"未找到 claude CLI"。
74
+ * - 与现有 `structured-session-manager.ts` 的 SDK 调用路径同源,行为/认证/
75
+ * 更新策略统一。
76
+ */
77
+ export async function runClaudePrint(prompt, options) {
78
+ const cwd = options.cwd && options.cwd.length > 0 ? options.cwd : undefined;
79
+ const abortController = new AbortController();
80
+ const timeoutHandle = setTimeout(() => abortController.abort(), options.timeoutMs);
81
+ const sdkClaudeBinary = resolveSdkClaudeBinary();
82
+ const sdkOptions = {
83
+ abortController,
84
+ tools: [],
85
+ persistSession: false,
86
+ ...(cwd ? { cwd } : {}),
87
+ ...(sdkClaudeBinary ? { pathToClaudeCodeExecutable: sdkClaudeBinary } : {}),
88
+ };
89
+ // 单条 user message → AsyncGenerator,SDK 的 streaming input 协议要求。
90
+ async function* singleShot() {
91
+ yield {
92
+ type: "user",
93
+ message: {
94
+ role: "user",
95
+ content: [{ type: "text", text: prompt }],
96
+ },
97
+ parent_tool_use_id: null,
98
+ };
99
+ }
100
+ let resultText = "";
101
+ let resultError = null;
102
+ try {
103
+ for await (const msg of sdkQuery({
104
+ prompt: singleShot(),
105
+ options: sdkOptions,
106
+ })) {
107
+ if (msg.type !== "result")
108
+ continue;
109
+ if (msg.subtype === "success" && typeof msg.result === "string") {
110
+ resultText = msg.result.trim();
111
+ }
112
+ else {
113
+ const errs = Array.isArray(msg.errors)
114
+ ? msg.errors.join("; ")
115
+ : msg.subtype;
116
+ resultError = new ClaudeRunError(`Claude SDK 失败:${errs}`, "CLAUDE_CLI_FAILED");
117
+ }
118
+ break; // 一次性调用,拿到 result 即退出
119
+ }
120
+ }
121
+ catch (err) {
122
+ if (abortController.signal.aborted) {
123
+ throw new ClaudeRunError("Claude 调用超时。", "CLAUDE_TIMEOUT");
124
+ }
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ // SDK 找不到 native binary 时会抛 "Claude Code native binary not found"。
127
+ // 极少数情况下也可能透出 ENOENT。两种都归到"CLI_MISSING",文案给用户。
128
+ if (/Claude Code native binary not found|ENOENT/i.test(message)) {
129
+ throw new ClaudeRunError("未找到 claude CLI。", "CLAUDE_CLI_MISSING");
130
+ }
131
+ throw new ClaudeRunError(`Claude SDK 失败:${message}`, "CLAUDE_CLI_FAILED");
132
+ }
133
+ finally {
134
+ clearTimeout(timeoutHandle);
135
+ }
136
+ if (resultError)
137
+ throw resultError;
138
+ if (!resultText) {
139
+ throw new ClaudeRunError("Claude 返回了空结果。", "CLAUDE_EMPTY_RESULT");
140
+ }
141
+ return resultText;
142
+ }
package/dist/cli.js CHANGED
@@ -136,6 +136,17 @@ async function main() {
136
136
  }
137
137
  break;
138
138
  }
139
+ case "service:install":
140
+ case "service:uninstall":
141
+ case "service:start":
142
+ case "service:stop":
143
+ case "service:restart":
144
+ case "service:status":
145
+ case "service:logs": {
146
+ const exitCode = await runServiceCommand(command, args, configPath);
147
+ process.exitCode = exitCode;
148
+ break;
149
+ }
139
150
  case "help":
140
151
  default: {
141
152
  printHelp();
@@ -160,8 +171,21 @@ Commands:
160
171
  wand config:show Print current config
161
172
  wand config:set Update a simple config value
162
173
 
174
+ System service (default = system-wide; pass --user for user-level):
175
+ wand service:install Register and start the background service (needs sudo for system)
176
+ wand service:uninstall Stop and remove the service
177
+ wand service:start Start the service
178
+ wand service:stop Stop the service
179
+ wand service:restart Restart the service
180
+ wand service:status Show service status
181
+ wand service:logs Tail recent service logs
182
+
163
183
  Options:
164
184
  -c, --config <path> Use a custom config file (default: ~/.wand/config.json)
185
+ --user (service:*) Operate on the user-level service (no root needed)
186
+ --system (service:*) Operate on the system-wide service (default; needs root)
187
+ --verbose (service:*) Print full detail output
188
+ --lines <N> (service:logs) Number of log lines (default 80)
165
189
  `);
166
190
  }
167
191
  async function ensureRequiredFiles(configPath, opts = {}) {
@@ -341,6 +365,86 @@ function setConfigValue(config, key, value) {
341
365
  throw new Error(`Unsupported config key: ${key}`);
342
366
  }
343
367
  }
368
+ /**
369
+ * 把 `wand service:*` 子命令路由到 src/tui/commands.ts 里已有的服务管理实现。
370
+ *
371
+ * 这里只做:把 CLI args → ServiceContext,调对应函数,把 CommandResult 打印出来,
372
+ * 按 ok 决定 exit code。所有平台分支(Linux user-systemd / macOS launchd / 其他不支持)
373
+ * 都在 tui/commands.ts 内部处理。
374
+ */
375
+ async function runServiceCommand(command, args, configPath) {
376
+ const { installService, uninstallService, serviceStart, serviceStop, serviceRestart, serviceStatus, serviceLogs, } = await import("./tui/commands.js");
377
+ const verbose = args.includes("--verbose");
378
+ // --user / --system 决定 scope;不传走库里 default(= system)。
379
+ // 同时传 --user 和 --system 时 --user 胜(更"友好"那一个不需要 root)。
380
+ const wantUser = args.includes("--user");
381
+ const wantSystem = args.includes("--system");
382
+ const scope = wantUser ? "user" : (wantSystem ? "system" : undefined);
383
+ switch (command) {
384
+ case "service:install": {
385
+ const result = installService({ configPath, scope });
386
+ printServiceResult(result, verbose);
387
+ if (result.ok && process.platform === "linux") {
388
+ // 仅 user scope 才需要 linger 提示
389
+ const installedScope = scope ?? "system";
390
+ if (installedScope === "user") {
391
+ process.stdout.write("[wand] 想保持登出后也运行:loginctl enable-linger $USER\n");
392
+ }
393
+ }
394
+ return result.ok ? 0 : 1;
395
+ }
396
+ case "service:uninstall": {
397
+ const result = uninstallService(scope ? { scope } : undefined);
398
+ printServiceResult(result, verbose);
399
+ return result.ok ? 0 : 1;
400
+ }
401
+ case "service:start": {
402
+ const result = serviceStart(scope ? { scope } : undefined);
403
+ printServiceResult(result, verbose);
404
+ return result.ok ? 0 : 1;
405
+ }
406
+ case "service:stop": {
407
+ const result = serviceStop(scope ? { scope } : undefined);
408
+ printServiceResult(result, verbose);
409
+ return result.ok ? 0 : 1;
410
+ }
411
+ case "service:restart": {
412
+ const result = serviceRestart(scope ? { scope } : undefined);
413
+ printServiceResult(result, verbose);
414
+ return result.ok ? 0 : 1;
415
+ }
416
+ case "service:status": {
417
+ const status = serviceStatus(scope ? { scope } : undefined);
418
+ process.stdout.write(`[wand] ${status.installed ? "installed" : "not installed"} · ${status.state} · ${status.description}\n`);
419
+ if (verbose && status.raw) {
420
+ process.stdout.write(status.raw + "\n");
421
+ }
422
+ return status.installed && status.state === "active" ? 0 : 1;
423
+ }
424
+ case "service:logs": {
425
+ const linesArg = readFlagValue(args, "--lines");
426
+ const lines = linesArg ? Math.max(1, Math.min(2000, Number(linesArg) || 80)) : 80;
427
+ const result = serviceLogs(lines, scope ? { scope } : undefined);
428
+ if (result.detail) {
429
+ process.stdout.write(result.detail + "\n");
430
+ }
431
+ else {
432
+ process.stdout.write(`[wand] ${result.message}\n`);
433
+ }
434
+ return result.ok ? 0 : 1;
435
+ }
436
+ default:
437
+ process.stderr.write(`[wand] unknown service command: ${command}\n`);
438
+ return 1;
439
+ }
440
+ }
441
+ function printServiceResult(result, verbose) {
442
+ const prefix = result.ok ? "[wand]" : "[wand] ✗";
443
+ process.stdout.write(`${prefix} ${result.message}\n`);
444
+ if (verbose && result.detail) {
445
+ process.stdout.write(result.detail + "\n");
446
+ }
447
+ }
344
448
  main().catch((error) => {
345
449
  process.stderr.write(`[wand] ${error instanceof Error ? error.message : String(error)}\n`);
346
450
  process.exitCode = 1;
@@ -1,5 +1,6 @@
1
- import { execFile, execFileSync } from "node:child_process";
1
+ import { execFileSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { ClaudeRunError, runClaudePrint } from "./claude-sdk-runner.js";
3
4
  const GIT_TIMEOUT_MS = 1500;
4
5
  const GIT_PUSH_TIMEOUT_MS = 30_000;
5
6
  const MAX_FILE_ENTRIES = 200;
@@ -239,32 +240,23 @@ export class QuickCommitError extends Error {
239
240
  }
240
241
  }
241
242
  // ── AI commit message generation ──
242
- function callClaudeText(prompt, cwd) {
243
- return new Promise((resolve, reject) => {
244
- const child = execFile("claude", ["-p", "--output-format", "text"], {
245
- cwd,
246
- encoding: "utf8",
247
- maxBuffer: 4 * 1024 * 1024,
248
- timeout: CLAUDE_MESSAGE_TIMEOUT_MS,
249
- }, (error, stdout, stderr) => {
250
- if (error) {
251
- const e = error;
252
- if (e.code === "ENOENT") {
253
- reject(new QuickCommitError("未找到 claude CLI。", "CLAUDE_CLI_MISSING"));
254
- return;
255
- }
256
- if (e.code === "ETIMEDOUT") {
257
- reject(new QuickCommitError("Claude 生成超时,请手动填写 commit message。", "CLAUDE_TIMEOUT"));
258
- return;
259
- }
260
- const msg = (stderr || "").trim() || e.message || "claude 调用失败";
261
- reject(new QuickCommitError(`Claude CLI 失败:${msg}`, "CLAUDE_CLI_FAILED"));
262
- return;
243
+ async function callClaudeText(prompt, cwd) {
244
+ try {
245
+ return await runClaudePrint(prompt, { cwd, timeoutMs: CLAUDE_MESSAGE_TIMEOUT_MS });
246
+ }
247
+ catch (error) {
248
+ if (error instanceof ClaudeRunError) {
249
+ // 把通用 ClaudeRunError 翻译成 quick-commit 自己的错误码 + 中文话术。
250
+ if (error.code === "CLAUDE_TIMEOUT") {
251
+ throw new QuickCommitError("Claude 生成超时,请手动填写 commit message。", "CLAUDE_TIMEOUT");
263
252
  }
264
- resolve((stdout || "").trim());
265
- });
266
- child.stdin?.end(prompt);
267
- });
253
+ if (error.code === "CLAUDE_EMPTY_RESULT") {
254
+ throw new QuickCommitError("Claude 返回了空的 commit message。", "EMPTY_AI_MESSAGE");
255
+ }
256
+ throw new QuickCommitError(error.message, error.code);
257
+ }
258
+ throw error;
259
+ }
268
260
  }
269
261
  function collectStagedDiff(cwd) {
270
262
  let diff;
@@ -52,6 +52,7 @@ export declare class ProcessManager extends EventEmitter {
52
52
  reuseId?: string;
53
53
  cols?: number;
54
54
  rows?: number;
55
+ thinkingEffort?: SessionSnapshot["thinkingEffort"];
55
56
  }): SessionSnapshot;
56
57
  list(): SessionSnapshot[];
57
58
  /** Return lightweight snapshots for the session list (no output/messages). */
@@ -72,6 +73,12 @@ export declare class ProcessManager extends EventEmitter {
72
73
  * the PTY so Claude Code switches on the fly.
73
74
  */
74
75
  setSessionModel(id: string, model: string | null): SessionSnapshot;
76
+ /**
77
+ * Set the thinking-effort level for a PTY session. For interactive Claude PTY
78
+ * we don't intercept raw key input; the effort is applied only when wand UI
79
+ * sends a chat-view message (see sendInput → applyThinkingEffortToPrompt).
80
+ */
81
+ setSessionThinkingEffort(id: string, effort: SessionSnapshot["thinkingEffort"]): SessionSnapshot;
75
82
  sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string): SessionSnapshot;
76
83
  /** Emit a task event for a session, debounced to avoid flooding */
77
84
  private emitTask;
@@ -12,6 +12,7 @@ import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, nor
12
12
  import { buildChildEnv } from "./env-utils.js";
13
13
  import { prepareSessionWorktree } from "./git-worktree.js";
14
14
  import { getResumeCommandSessionId } from "./resume-policy.js";
15
+ import { applyThinkingEffortToPrompt, normalizeThinkingEffort } from "./structured-session-manager.js";
15
16
  function resolveProviderFromCommand(command) {
16
17
  return /^codex\b/.test(command.trim()) ? "codex" : "claude";
17
18
  }
@@ -634,6 +635,7 @@ export class ProcessManager extends EventEmitter {
634
635
  knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
635
636
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
636
637
  selectedModel: selectedModel ?? null,
638
+ thinkingEffort: normalizeThinkingEffort(opts?.thinkingEffort),
637
639
  // cols 上限 256:与 @wterm/dom WASM grid 的 maxCols 硬编码一致,
638
640
  // 防止服务端按 >256 cols 让 Claude 用 CSI 绝对列定位写到 wterm 实际
639
641
  // 渲染不到的列上(表现为"内容神奇复制下行")。
@@ -926,6 +928,19 @@ export class ProcessManager extends EventEmitter {
926
928
  this.emitEvent({ type: "status", sessionId: id, data: { selectedModel: normalized } });
927
929
  return this.snapshot(record);
928
930
  }
931
+ /**
932
+ * Set the thinking-effort level for a PTY session. For interactive Claude PTY
933
+ * we don't intercept raw key input; the effort is applied only when wand UI
934
+ * sends a chat-view message (see sendInput → applyThinkingEffortToPrompt).
935
+ */
936
+ setSessionThinkingEffort(id, effort) {
937
+ const record = this.mustGet(id);
938
+ const normalized = normalizeThinkingEffort(effort);
939
+ record.thinkingEffort = normalized;
940
+ this.persist(record);
941
+ this.emitEvent({ type: "status", sessionId: id, data: { thinkingEffort: normalized } });
942
+ return this.snapshot(record);
943
+ }
929
944
  sendInput(id, input, view, shortcutKey) {
930
945
  const record = this.mustGet(id);
931
946
  if (record.status !== "running") {
@@ -949,11 +964,19 @@ export class ProcessManager extends EventEmitter {
949
964
  };
950
965
  this.logger.appendShortcutLog(id, shortcutKey, tailLines, ctx);
951
966
  }
967
+ // Thinking-depth magic-word injection. Only applied to chat-view submits
968
+ // (terminal direct keystrokes are pass-through). applyThinkingEffortToPrompt
969
+ // is safe on empty / lone-\r chunks (returns input unchanged) and won't
970
+ // double-prefix if the user already wrote the magic word themselves.
971
+ let effectiveInput = input;
972
+ if (view === "chat" && record.thinkingEffort && record.thinkingEffort !== "off") {
973
+ effectiveInput = applyThinkingEffortToPrompt(input, record.thinkingEffort);
974
+ }
952
975
  // Track user input via bridge for Chat mode
953
976
  if (record.ptyBridge) {
954
- record.ptyBridge.onUserInput(input);
977
+ record.ptyBridge.onUserInput(effectiveInput);
955
978
  }
956
- record.ptyProcess.write(input);
979
+ record.ptyProcess.write(effectiveInput);
957
980
  this.persist(record);
958
981
  return this.snapshot(record);
959
982
  }
@@ -1203,6 +1226,7 @@ export class ProcessManager extends EventEmitter {
1203
1226
  summary: deriveSessionSummary(messages, record.currentTask?.title ?? null),
1204
1227
  currentTaskTitle: record.status === "running" ? record.currentTask?.title ?? undefined : undefined,
1205
1228
  selectedModel: record.selectedModel ?? null,
1229
+ thinkingEffort: record.thinkingEffort ?? null,
1206
1230
  ptyCols: record.ptyCols,
1207
1231
  ptyRows: record.ptyRows,
1208
1232
  };
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { ClaudeRunError, runClaudePrint } from "./claude-sdk-runner.js";
2
2
  const CLAUDE_TIMEOUT_MS = 60_000;
3
3
  const MAX_INPUT_LENGTH = 8000;
4
4
  export class PromptOptimizeError extends Error {
@@ -9,32 +9,23 @@ export class PromptOptimizeError extends Error {
9
9
  this.name = "PromptOptimizeError";
10
10
  }
11
11
  }
12
- function callClaudeText(prompt, cwd) {
13
- return new Promise((resolve, reject) => {
14
- const child = execFile("claude", ["-p", "--output-format", "text"], {
15
- cwd: cwd && cwd.length > 0 ? cwd : undefined,
16
- encoding: "utf8",
17
- maxBuffer: 4 * 1024 * 1024,
18
- timeout: CLAUDE_TIMEOUT_MS,
19
- }, (error, stdout, stderr) => {
20
- if (error) {
21
- const e = error;
22
- if (e.code === "ENOENT") {
23
- reject(new PromptOptimizeError("未找到 claude CLI。", "CLAUDE_CLI_MISSING"));
24
- return;
25
- }
26
- if (e.code === "ETIMEDOUT") {
27
- reject(new PromptOptimizeError("Claude 优化超时,请稍后重试。", "CLAUDE_TIMEOUT"));
28
- return;
29
- }
30
- const msg = (stderr || "").trim() || e.message || "claude 调用失败";
31
- reject(new PromptOptimizeError(`Claude CLI 失败:${msg}`, "CLAUDE_CLI_FAILED"));
32
- return;
12
+ async function callClaudeText(prompt, cwd) {
13
+ try {
14
+ return await runClaudePrint(prompt, { cwd, timeoutMs: CLAUDE_TIMEOUT_MS });
15
+ }
16
+ catch (error) {
17
+ if (error instanceof ClaudeRunError) {
18
+ // 翻译成 prompt-optimizer 自己的话术 + 错误码(与原文案保持一致)。
19
+ if (error.code === "CLAUDE_TIMEOUT") {
20
+ throw new PromptOptimizeError("Claude 优化超时,请稍后重试。", "CLAUDE_TIMEOUT");
21
+ }
22
+ if (error.code === "CLAUDE_EMPTY_RESULT") {
23
+ throw new PromptOptimizeError("Claude 返回了空结果。", "EMPTY_RESULT");
33
24
  }
34
- resolve((stdout || "").trim());
35
- });
36
- child.stdin?.end(prompt);
37
- });
25
+ throw new PromptOptimizeError(error.message, error.code);
26
+ }
27
+ throw error;
28
+ }
38
29
  }
39
30
  function buildOptimizePrompt(userInput, language) {
40
31
  const lang = (language || "").trim() || "中文";
@@ -144,7 +144,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
144
144
  });
145
145
  app.post("/api/structured-sessions", express.json(), async (req, res) => {
146
146
  const body = req.body;
147
- console.log("[WAND] POST /api/structured-sessions body:", JSON.stringify({ cwd: body.cwd, mode: body.mode, runner: body.runner, provider: body.provider, worktreeEnabled: body.worktreeEnabled === true, hasPrompt: !!body.prompt, model: body.model }));
147
+ console.log("[WAND] POST /api/structured-sessions body:", JSON.stringify({ cwd: body.cwd, mode: body.mode, runner: body.runner, provider: body.provider, worktreeEnabled: body.worktreeEnabled === true, hasPrompt: !!body.prompt, model: body.model, thinkingEffort: body.thinkingEffort }));
148
148
  try {
149
149
  if (body.provider && body.provider !== "claude" && body.provider !== "codex") {
150
150
  res.status(400).json({ error: "结构化会话当前仅支持 Claude 或 Codex provider。" });
@@ -158,6 +158,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
158
158
  runner: body.runner ?? (provider === "codex" ? "codex-cli-exec" : "claude-cli-print"),
159
159
  worktreeEnabled: body.worktreeEnabled === true,
160
160
  model: typeof body.model === "string" ? body.model.trim() : undefined,
161
+ thinkingEffort: typeof body.thinkingEffort === "string"
162
+ ? body.thinkingEffort
163
+ : undefined,
161
164
  });
162
165
  console.log("[WAND] structured session created:", JSON.stringify({ id: snapshot.id, sessionKind: snapshot.sessionKind, runner: snapshot.runner, status: snapshot.status }));
163
166
  onSessionCreated?.(body.cwd ?? snapshot.cwd);
@@ -196,6 +199,29 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
196
199
  res.status(400).json({ error: getErrorMessage(error, "切换模型失败。") });
197
200
  }
198
201
  });
202
+ // 思考深度切换:与 /model 路由对称。结构化会话立即影响下一条 prompt(CLI/SDK 各自接入
203
+ // applyThinkingEffortToPrompt / thinking budget),PTY 会话仅影响通过 chat 视图发送的输入。
204
+ app.post("/api/sessions/:id/thinking-effort", express.json(), (req, res) => {
205
+ const body = req.body;
206
+ const raw = typeof body?.thinkingEffort === "string" ? body.thinkingEffort : null;
207
+ const id = req.params.id;
208
+ try {
209
+ if (structured.get(id)) {
210
+ const updated = structured.setSessionThinkingEffort(id, raw);
211
+ res.json(updated);
212
+ return;
213
+ }
214
+ if (!processes.get(id)) {
215
+ res.status(404).json({ error: "未找到该会话。" });
216
+ return;
217
+ }
218
+ const updated = processes.setSessionThinkingEffort(id, raw);
219
+ res.json(updated);
220
+ }
221
+ catch (error) {
222
+ res.status(400).json({ error: getErrorMessage(error, "切换思考深度失败。") });
223
+ }
224
+ });
199
225
  app.get("/api/structured-sessions/:id/messages", (req, res) => {
200
226
  const snapshot = structured.get(req.params.id);
201
227
  if (!snapshot) {
package/dist/server.js CHANGED
@@ -1813,6 +1813,7 @@ export async function startServer(config, configPath) {
1813
1813
  model: effectiveModel,
1814
1814
  cols: reqCols,
1815
1815
  rows: reqRows,
1816
+ thinkingEffort: body.thinkingEffort ?? undefined,
1816
1817
  });
1817
1818
  recordRecentPath(storage, body.cwd ?? snapshot.cwd);
1818
1819
  res.status(201).json(snapshot);
@@ -10,6 +10,8 @@ interface CreateStructuredSessionOptions {
10
10
  worktreeEnabled?: boolean;
11
11
  /** 用户指定的 Claude 模型(别名或完整 ID)。留空则 spawn 时不加 --model。 */
12
12
  model?: string;
13
+ /** 用户预设的思考深度。留空 / null 视为 off。 */
14
+ thinkingEffort?: SessionSnapshot["thinkingEffort"];
13
15
  }
14
16
  /**
15
17
  * 把任意外部输入收敛到合法的 thinkingEffort 枚举值。`null` / 非法值都视为