@co0ontty/wand 2.12.0 → 2.13.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
@@ -11,7 +11,7 @@
11
11
 
12
12
  ### Overview
13
13
 
14
- Wand is a web console for remotely accessing and managing local CLI tools from a browser. It is designed for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Codex](https://github.com/openai/codex), with terminal and structured conversation views, persistent resumable sessions, permission controls, file browsing, and native clients for multiple platforms.
14
+ Wand is a web console for remotely accessing and managing local CLI tools from a browser. It supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), and [OpenCode](https://opencode.ai/), with terminal and structured conversation views, persistent resumable sessions, permission controls, file browsing, and native clients for multiple platforms.
15
15
 
16
16
  The browser password manager extension source lives in `browser-extension/`; setup and supported baseline features are documented in [docs/browser-extension.md](docs/browser-extension.md).
17
17
 
@@ -56,7 +56,7 @@ wand config:password
56
56
  #### Core
57
57
 
58
58
  - **Dual view modes** — switch between raw terminal output and a structured conversation view for the same session
59
- - **Multiple providers** — create sessions for Claude Code, Codex, or other configured local CLI tools
59
+ - **Multiple providers** — create PTY or structured sessions for Claude Code, Codex, and OpenCode
60
60
  - **Session management** — create, archive, and resume sessions; restore Claude native history; show summaries in the session list
61
61
  - **Permission control** — visual permission prompts with one-time approval, per-turn memory, and related policies
62
62
 
@@ -73,6 +73,7 @@ wand config:password
73
73
  - **Native clients** — Android, iOS, and macOS clients with encrypted connection codes and update checks
74
74
  - **HTTPS** — optional self-signed certificates for remote or mobile access
75
75
  - **Update channels** — built-in stable/beta update checks and upgrade prompts
76
+ - **CLI updates** — check and quickly update Claude Code, Codex, and OpenCode from server settings, with optional automatic updates
76
77
 
77
78
  ### Configuration
78
79
 
@@ -143,11 +144,18 @@ git submodule update --init
143
144
 
144
145
  Runtime data is stored under `~/.wand/`: `config.json`, `wand.db`, and `sessions/`.
145
146
 
147
+ OpenCode structured sessions require the current `opencode-ai` CLI (0.1 or newer). Remove the unrelated legacy `opencode` 0.0.x package before installing it:
148
+
149
+ ```bash
150
+ npm uninstall -g opencode
151
+ npm install -g opencode-ai@latest
152
+ ```
153
+
146
154
  ## 中文
147
155
 
148
156
  ### 概览
149
157
 
150
- 通过浏览器远程访问和管理本地 CLI 工具的 Web 控制台。专为 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)[Codex](https://github.com/openai/codex) 设计,支持终端和结构化对话双视图、会话持久化与恢复、权限管控、文件浏览、多平台客户端。
158
+ 通过浏览器远程访问和管理本地 CLI 工具的 Web 控制台。支持 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)[Codex](https://github.com/openai/codex) 和 [OpenCode](https://opencode.ai/),提供终端和结构化对话双视图、会话持久化与恢复、权限管控、文件浏览和多平台客户端。
151
159
 
152
160
  <p align="center">
153
161
  <img src="docs/screenshots/clients-overview.png" width="100%" alt="Web 居中,iOS 和 Android 客户端在两侧的多端详情总览" />
@@ -185,12 +193,19 @@ wand web
185
193
  wand config:password
186
194
  ```
187
195
 
196
+ OpenCode 结构化会话需要当前的 `opencode-ai` CLI(0.1 或更高版本)。如果装过无关的旧版 `opencode` 0.0.x 包,请先替换:
197
+
198
+ ```bash
199
+ npm uninstall -g opencode
200
+ npm install -g opencode-ai@latest
201
+ ```
202
+
188
203
  ### 功能
189
204
 
190
205
  #### 核心
191
206
 
192
207
  - **双视图模式** — 终端原始输出和结构化对话视图可随时切换,同一会话两种呈现
193
- - **多 Provider 支持** — 同时支持 Claude Code 和 Codex,可按需创建不同类型的会话
208
+ - **多 Provider 支持** — Claude Code、CodexOpenCode 均可创建 PTY 或结构化会话
194
209
  - **会话管理** — 创建、归档、恢复会话;支持从 Claude 原生历史记录恢复;会话列表显示摘要
195
210
  - **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略
196
211
 
@@ -207,6 +222,7 @@ wand config:password
207
222
  - **多平台客户端** — Android / iOS / macOS 原生客户端,支持加密连接码分发、自动更新检查
208
223
  - **HTTPS** — 可选自签证书,适合远程或移动端访问
209
224
  - **版本管理** — 内置更新检查与升级提示,支持 stable/beta 双通道
225
+ - **CLI 更新** — 在服务端设置中检查并快速更新 Claude Code、Codex、OpenCode,也可开启自动更新
210
226
 
211
227
  ### 截图
212
228
 
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "2ffe360ae10acb539bd5e5fe785073348e2b0482",
3
- "builtAt": "2026-07-12T04:24:13.405Z",
4
- "version": "2.12.0",
2
+ "commit": "f1b77e3be3f2f0b4a1a158e3fc2020ccbc590c68",
3
+ "builtAt": "2026-07-12T10:02:57.593Z",
4
+ "version": "2.13.0",
5
5
  "channel": "stable"
6
6
  }
package/dist/config.d.ts CHANGED
@@ -7,7 +7,7 @@ import type { WandStorage } from "./storage.js";
7
7
  * 升级路径:老 JSON 里仍存有这些字段时,首次启动会被搬到 DB(见 migrateLegacyPreferencesToDb),
8
8
  * 然后下一次 saveConfig 写回 JSON 时它们会被剥离(见 stripPreferenceFields)。
9
9
  */
10
- export declare const PREFERENCE_KEYS: readonly ["defaultProvider", "defaultSessionKind", "defaultMode", "defaultCwd", "defaultModel", "defaultCodexModel", "commitCli", "commitModel", "defaultThinkingEffort", "structuredRunner", "language", "cardDefaults", "inheritEnv"];
10
+ export declare const PREFERENCE_KEYS: readonly ["defaultProvider", "defaultSessionKind", "defaultMode", "defaultCwd", "defaultModel", "defaultCodexModel", "defaultOpenCodeModel", "commitCli", "commitModel", "defaultThinkingEffort", "structuredRunner", "language", "cardDefaults", "inheritEnv"];
11
11
  export type PreferenceKey = (typeof PREFERENCE_KEYS)[number];
12
12
  export declare function isPreferenceKey(key: string): key is PreferenceKey;
13
13
  export declare const defaultConfig: () => WandConfig;
@@ -40,9 +40,10 @@ export declare function applyStoragePreferences(config: WandConfig, storage: Wan
40
40
  export declare function writePreferenceToStorage(config: WandConfig, storage: WandStorage, key: PreferenceKey, value: unknown): void;
41
41
  export declare function normalizeCardDefaults(input: unknown): CardExpandDefaults;
42
42
  export declare function isExecutionMode(value: unknown): value is ExecutionMode;
43
- export declare function getProviderDefaultModels(config: Pick<WandConfig, "defaultModel" | "defaultCodexModel">): {
43
+ export declare function getProviderDefaultModels(config: Pick<WandConfig, "defaultModel" | "defaultCodexModel" | "defaultOpenCodeModel">): {
44
44
  claude: string;
45
45
  codex: string;
46
+ opencode: string;
46
47
  };
47
- export declare function getDefaultModelForProvider(config: Pick<WandConfig, "defaultModel" | "defaultCodexModel">, provider: "claude" | "codex" | undefined): string;
48
+ export declare function getDefaultModelForProvider(config: Pick<WandConfig, "defaultModel" | "defaultCodexModel" | "defaultOpenCodeModel">, provider: "claude" | "codex" | "opencode" | undefined): string;
48
49
  export declare function normalizeMode(input: string | undefined, fallback: ExecutionMode): ExecutionMode;
package/dist/config.js CHANGED
@@ -27,6 +27,7 @@ export const PREFERENCE_KEYS = [
27
27
  "defaultCwd",
28
28
  "defaultModel",
29
29
  "defaultCodexModel",
30
+ "defaultOpenCodeModel",
30
31
  "commitCli",
31
32
  "commitModel",
32
33
  "defaultThinkingEffort",
@@ -66,6 +67,7 @@ export const defaultConfig = () => ({
66
67
  cardDefaults: defaultCardExpandDefaults(),
67
68
  defaultModel: "",
68
69
  defaultCodexModel: "",
70
+ defaultOpenCodeModel: "",
69
71
  commitCli: "claude",
70
72
  commitModel: "",
71
73
  defaultThinkingEffort: "off",
@@ -254,7 +256,7 @@ export function applyStoragePreferences(config, storage) {
254
256
  const defaults = defaultConfig();
255
257
  if (storage.hasPreference(preferenceStorageKey("defaultProvider"))) {
256
258
  const v = storage.getPreference(preferenceStorageKey("defaultProvider"), defaults.defaultProvider ?? "claude");
257
- if (v === "claude" || v === "codex")
259
+ if (v === "claude" || v === "codex" || v === "opencode")
258
260
  config.defaultProvider = v;
259
261
  }
260
262
  if (storage.hasPreference(preferenceStorageKey("defaultSessionKind"))) {
@@ -282,9 +284,14 @@ export function applyStoragePreferences(config, storage) {
282
284
  if (typeof v === "string")
283
285
  config.defaultCodexModel = v.trim();
284
286
  }
287
+ if (storage.hasPreference(preferenceStorageKey("defaultOpenCodeModel"))) {
288
+ const v = storage.getPreference(preferenceStorageKey("defaultOpenCodeModel"), defaults.defaultOpenCodeModel ?? "");
289
+ if (typeof v === "string")
290
+ config.defaultOpenCodeModel = v.trim();
291
+ }
285
292
  if (storage.hasPreference(preferenceStorageKey("commitCli"))) {
286
293
  const v = storage.getPreference(preferenceStorageKey("commitCli"), defaults.commitCli ?? "claude");
287
- if (v === "claude" || v === "codex")
294
+ if (v === "claude" || v === "codex" || v === "opencode")
288
295
  config.commitCli = v;
289
296
  }
290
297
  if (storage.hasPreference(preferenceStorageKey("commitModel"))) {
@@ -322,7 +329,7 @@ export function writePreferenceToStorage(config, storage, key, value) {
322
329
  const dbKey = preferenceStorageKey(key);
323
330
  switch (key) {
324
331
  case "defaultProvider": {
325
- if (value !== "claude" && value !== "codex")
332
+ if (value !== "claude" && value !== "codex" && value !== "opencode")
326
333
  throw new Error(`无效 Provider: ${value}`);
327
334
  storage.setPreference(dbKey, value);
328
335
  config.defaultProvider = value;
@@ -360,8 +367,14 @@ export function writePreferenceToStorage(config, storage, key, value) {
360
367
  config.defaultCodexModel = v;
361
368
  break;
362
369
  }
370
+ case "defaultOpenCodeModel": {
371
+ const v = typeof value === "string" ? value.trim() : "";
372
+ storage.setPreference(dbKey, v);
373
+ config.defaultOpenCodeModel = v;
374
+ break;
375
+ }
363
376
  case "commitCli": {
364
- if (value !== "claude" && value !== "codex")
377
+ if (value !== "claude" && value !== "codex" && value !== "opencode")
365
378
  throw new Error(`无效 commit CLI: ${value}`);
366
379
  storage.setPreference(dbKey, value);
367
380
  config.commitCli = value;
@@ -541,11 +554,12 @@ function mergeWithDefaults(input) {
541
554
  android: normalizeAndroidApkConfig(input.android) ?? defaults.android,
542
555
  macos: normalizeMacosDmgConfig(input.macos) ?? defaults.macos,
543
556
  cardDefaults: normalizeCardDefaults(input.cardDefaults),
544
- defaultProvider: input.defaultProvider === "codex" ? "codex" : "claude",
557
+ defaultProvider: input.defaultProvider === "codex" || input.defaultProvider === "opencode" ? input.defaultProvider : "claude",
545
558
  defaultSessionKind: input.defaultSessionKind === "pty" ? "pty" : "structured",
546
559
  defaultModel: typeof input.defaultModel === "string" ? input.defaultModel.trim() : defaults.defaultModel,
547
560
  defaultCodexModel: typeof input.defaultCodexModel === "string" ? input.defaultCodexModel.trim() : defaults.defaultCodexModel,
548
- commitCli: input.commitCli === "codex" ? "codex" : "claude",
561
+ defaultOpenCodeModel: typeof input.defaultOpenCodeModel === "string" ? input.defaultOpenCodeModel.trim() : defaults.defaultOpenCodeModel,
562
+ commitCli: input.commitCli === "codex" || input.commitCli === "opencode" ? input.commitCli : "claude",
549
563
  commitModel: typeof input.commitModel === "string" ? input.commitModel.trim() : defaults.commitModel,
550
564
  defaultThinkingEffort: isThinkingEffort(input.defaultThinkingEffort) ? input.defaultThinkingEffort : "off",
551
565
  structuredRunner: (input.structuredRunner === "sdk" || input.structuredRunner === "cli") ? input.structuredRunner : defaults.structuredRunner,
@@ -559,11 +573,12 @@ export function getProviderDefaultModels(config) {
559
573
  return {
560
574
  claude: (config.defaultModel ?? "").trim(),
561
575
  codex: (config.defaultCodexModel ?? "").trim(),
576
+ opencode: (config.defaultOpenCodeModel ?? "").trim(),
562
577
  };
563
578
  }
564
579
  export function getDefaultModelForProvider(config, provider) {
565
580
  const defaults = getProviderDefaultModels(config);
566
- return provider === "codex" ? defaults.codex : defaults.claude;
581
+ return provider === "codex" ? defaults.codex : provider === "opencode" ? defaults.opencode : defaults.claude;
567
582
  }
568
583
  export function normalizeMode(input, fallback) {
569
584
  return isExecutionMode(input) ? input : fallback;
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { ClaudeRunError, runClaudePrint } from "./claude-sdk-runner.js";
4
4
  import { buildChildEnv } from "./env-utils.js";
5
5
  import { runGit as runGitBase, runGitRaw as runGitRawBase, getGitErrorMessage } from "./git-utils.js";
6
- import { thinkingEffortToClaudeCliEffort, thinkingEffortToCodexReasoningEffort } from "./structured-session-manager.js";
6
+ import { thinkingEffortToClaudeCliEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant } from "./structured-session-manager.js";
7
7
  const GIT_TIMEOUT_MS = 1500;
8
8
  const GIT_PUSH_TIMEOUT_MS = 30_000;
9
9
  const MAX_FILE_ENTRIES = 200;
@@ -281,7 +281,7 @@ async function callClaudeText(prompt, cwd, language, model) {
281
281
  }
282
282
  }
283
283
  function normalizeProvider(provider) {
284
- return provider === "codex" ? "codex" : "claude";
284
+ return provider === "codex" || provider === "opencode" ? provider : "claude";
285
285
  }
286
286
  function stripFences(raw) {
287
287
  return raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
@@ -315,6 +315,24 @@ function extractCodexText(stdout) {
315
315
  }
316
316
  return "";
317
317
  }
318
+ function extractOpenCodeText(stdout) {
319
+ const texts = [];
320
+ for (const line of stdout.split(/\r?\n/)) {
321
+ const trimmed = line.trim();
322
+ if (!trimmed.startsWith("{"))
323
+ continue;
324
+ try {
325
+ const parsed = JSON.parse(trimmed);
326
+ if (parsed.type === "text" && typeof parsed.part?.text === "string" && parsed.part.text.trim()) {
327
+ texts.push(parsed.part.text.trim());
328
+ }
329
+ }
330
+ catch {
331
+ // ignore diagnostics mixed into stdout
332
+ }
333
+ }
334
+ return texts.join("\n").trim();
335
+ }
318
336
  function runCliText(command, args, prompt, opts) {
319
337
  return new Promise((resolve, reject) => {
320
338
  const child = spawn(command, args, {
@@ -374,10 +392,31 @@ async function callCodexText(prompt, cwd, opts) {
374
392
  }
375
393
  return text;
376
394
  }
395
+ async function callOpenCodeText(prompt, cwd, opts) {
396
+ const args = ["run", "--format", "json"];
397
+ const model = opts.model?.trim();
398
+ if (model && model !== "default")
399
+ args.push("--model", model);
400
+ const variant = thinkingEffortToOpenCodeVariant(opts.thinkingEffort ?? "off");
401
+ if (variant)
402
+ args.push("--variant", variant);
403
+ const stdout = await runCliText("opencode", args, prompt, {
404
+ cwd,
405
+ timeoutMs: CODEX_MESSAGE_TIMEOUT_MS,
406
+ inheritEnv: opts.inheritEnv,
407
+ });
408
+ const text = extractOpenCodeText(stdout);
409
+ if (!text)
410
+ throw new QuickCommitError("OpenCode 返回了空的 commit message。", "EMPTY_AI_MESSAGE");
411
+ return text;
412
+ }
377
413
  async function callAiText(prompt, cwd, language, opts) {
378
- if (normalizeProvider(opts.provider) === "codex") {
414
+ const provider = normalizeProvider(opts.provider);
415
+ if (provider === "codex") {
379
416
  return callCodexText(prompt, cwd, opts);
380
417
  }
418
+ if (provider === "opencode")
419
+ return callOpenCodeText(prompt, cwd, opts);
381
420
  return callClaudeText(prompt, cwd, language, opts.model);
382
421
  }
383
422
  function collectStagedDiff(cwd) {
@@ -957,6 +996,20 @@ async function runQuickCommitFallbackCli(opts, priorError) {
957
996
  inheritEnv: opts.inheritEnv,
958
997
  });
959
998
  }
999
+ else if (provider === "opencode") {
1000
+ const args = ["run", "--format", "json", "--dangerously-skip-permissions"];
1001
+ const model = opts.model?.trim();
1002
+ if (model && model !== "default")
1003
+ args.push("--model", model);
1004
+ const variant = thinkingEffortToOpenCodeVariant(opts.thinkingEffort ?? "off");
1005
+ if (variant)
1006
+ args.push("--variant", variant);
1007
+ await runCliText("opencode", args, prompt, {
1008
+ cwd: opts.cwd,
1009
+ timeoutMs: QUICK_COMMIT_CLI_TIMEOUT_MS,
1010
+ inheritEnv: opts.inheritEnv,
1011
+ });
1012
+ }
960
1013
  else {
961
1014
  const args = ["-p", "--verbose", "--output-format", "stream-json"];
962
1015
  const model = opts.model?.trim();
package/dist/models.d.ts CHANGED
@@ -2,9 +2,13 @@ import { ClaudeModelInfo } from "./types.js";
2
2
  interface ModelCache {
3
3
  models: ClaudeModelInfo[];
4
4
  codexModels: ClaudeModelInfo[];
5
+ opencodeModels: ClaudeModelInfo[];
5
6
  claudeVersion: string | null;
7
+ opencodeVersion: string | null;
6
8
  refreshedAt: string;
7
9
  }
10
+ /** Parse `opencode models`, whose stable machine-friendly output is one provider/model id per line. */
11
+ export declare function parseOpenCodeModels(stdout: string): ClaudeModelInfo[];
8
12
  /** Parse the machine-readable model registry emitted by the installed Codex CLI. */
9
13
  export declare function parseCodexModels(stdout: string): ClaudeModelInfo[];
10
14
  export declare function getCachedModels(): ModelCache;
package/dist/models.js CHANGED
@@ -15,6 +15,9 @@ const CLAUDE_MODELS = [
15
15
  const CODEX_FALLBACK_MODELS = [
16
16
  { id: "default", label: "GPT-5.5 · gpt-5.5(Codex 默认)", alias: true },
17
17
  ];
18
+ const OPENCODE_FALLBACK_MODELS = [
19
+ { id: "default", label: "跟随 OpenCode 默认", alias: true },
20
+ ];
18
21
  let cache = null;
19
22
  function cloneClaudeModels() {
20
23
  return CLAUDE_MODELS.map((m) => ({ ...m }));
@@ -37,6 +40,32 @@ async function probeCodexModels() {
37
40
  return CODEX_FALLBACK_MODELS.map((m) => ({ ...m }));
38
41
  }
39
42
  }
43
+ async function probeOpenCode() {
44
+ const [modelsResult, versionResult] = await Promise.allSettled([
45
+ execAsync("opencode models", { timeout: 8000 }),
46
+ execAsync("opencode --version", { timeout: 5000 }),
47
+ ]);
48
+ const models = modelsResult.status === "fulfilled"
49
+ ? parseOpenCodeModels(modelsResult.value.stdout)
50
+ : OPENCODE_FALLBACK_MODELS.map((m) => ({ ...m }));
51
+ const version = versionResult.status === "fulfilled"
52
+ ? extractSemver(versionResult.value.stdout) ?? (versionResult.value.stdout.trim().slice(0, 64) || null)
53
+ : null;
54
+ return { models, version };
55
+ }
56
+ /** Parse `opencode models`, whose stable machine-friendly output is one provider/model id per line. */
57
+ export function parseOpenCodeModels(stdout) {
58
+ const ids = Array.from(new Set(stdout
59
+ .split(/\r?\n/)
60
+ .map((line) => line.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, "").trim())
61
+ .filter((line) => /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._:/-]*$/i.test(line))));
62
+ if (!ids.length)
63
+ return OPENCODE_FALLBACK_MODELS.map((m) => ({ ...m }));
64
+ return [
65
+ { id: "default", label: "跟随 OpenCode 默认", alias: true },
66
+ ...ids.map((id) => ({ id, label: id })),
67
+ ];
68
+ }
40
69
  /** Parse the machine-readable model registry emitted by the installed Codex CLI. */
41
70
  export function parseCodexModels(stdout) {
42
71
  try {
@@ -94,21 +123,26 @@ export function getCachedModels() {
94
123
  cache = {
95
124
  models: cloneClaudeModels(),
96
125
  codexModels: CODEX_FALLBACK_MODELS.map((m) => ({ ...m })),
126
+ opencodeModels: OPENCODE_FALLBACK_MODELS.map((m) => ({ ...m })),
97
127
  claudeVersion: null,
128
+ opencodeVersion: null,
98
129
  refreshedAt: new Date().toISOString(),
99
130
  };
100
131
  }
101
132
  return cache;
102
133
  }
103
134
  export async function refreshModels() {
104
- const [version, codexModels] = await Promise.all([
135
+ const [version, codexModels, opencode] = await Promise.all([
105
136
  probeClaudeVersion(),
106
137
  probeCodexModels(),
138
+ probeOpenCode(),
107
139
  ]);
108
140
  cache = {
109
141
  models: cloneClaudeModels(),
110
142
  codexModels,
143
+ opencodeModels: opencode.models,
111
144
  claudeVersion: version,
145
+ opencodeVersion: opencode.version,
112
146
  refreshedAt: new Date().toISOString(),
113
147
  };
114
148
  return cache;
@@ -57,7 +57,7 @@ export declare function repairRuntimePath(): PathRepairResult;
57
57
  * 这种动态注入的 PATH。
58
58
  *
59
59
  * 行为:
60
- * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex(4s 超时)
60
+ * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex/opencode(4s 超时)
61
61
  * - 按 login shell 的 PATH 顺序重排 process.env.PATH,再把仅存在于 service 的
62
62
  * 目录追加回去。不能只前插新增目录:同一个 CLI 同时装在 nvm 和 Homebrew 时,
63
63
  * 两个目录本来都存在,旧实现不会重排,structured 与 PTY 会命中不同版本。
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  /** 关键的 CLI 工具,会被诊断输出。 */
9
- const PROBE_COMMANDS = ["claude", "codex"];
9
+ const PROBE_COMMANDS = ["claude", "codex", "opencode"];
10
10
  const DEEP_PROBE_TIMEOUT_MS = 4000;
11
11
  /**
12
12
  * 构造候选 bin 目录列表(按优先级,前面的更可信)。
@@ -152,7 +152,7 @@ export function repairRuntimePath() {
152
152
  * 这种动态注入的 PATH。
153
153
  *
154
154
  * 行为:
155
- * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex(4s 超时)
155
+ * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex/opencode(4s 超时)
156
156
  * - 按 login shell 的 PATH 顺序重排 process.env.PATH,再把仅存在于 service 的
157
157
  * 目录追加回去。不能只前插新增目录:同一个 CLI 同时装在 nvm 和 Homebrew 时,
158
158
  * 两个目录本来都存在,旧实现不会重排,structured 与 PTY 会命中不同版本。
@@ -190,7 +190,7 @@ export async function deepRepairRuntimePath(result, opts = {}) {
190
190
  .map((seg) => seg.trim())
191
191
  .filter((seg) => seg.length > 0);
192
192
  const existing = new Set(currentSegments);
193
- // login shell 报告的所有 PATH 段 + claude/codex 解析出的目录都纳入候选。
193
+ // login shell 报告的所有 PATH 段 + provider CLI 解析出的目录都纳入候选。
194
194
  const fromShell = [];
195
195
  for (const seg of probe.path.split(delim).map((s) => s.trim()).filter(Boolean)) {
196
196
  fromShell.push(seg);
@@ -199,6 +199,8 @@ export async function deepRepairRuntimePath(result, opts = {}) {
199
199
  fromShell.push(path.dirname(probe.claude));
200
200
  if (probe.codex)
201
201
  fromShell.push(path.dirname(probe.codex));
202
+ if (probe.opencode)
203
+ fromShell.push(path.dirname(probe.opencode));
202
204
  const preferred = [];
203
205
  const preferredSet = new Set();
204
206
  for (const dir of fromShell) {
@@ -250,13 +252,14 @@ function pickProbeShell(configured) {
250
252
  return null;
251
253
  }
252
254
  /**
253
- * 用 login shell 跑一段最小脚本,拿到用户实际的 PATH 和 claude/codex 解析路径。
255
+ * 用 login shell 跑一段最小脚本,拿到用户实际的 PATH 和 provider CLI 解析路径。
254
256
  * 用 \x1f(ASCII Unit Separator)作字段分隔符避免和路径里的字符冲突。
255
257
  */
256
258
  function probeLoginShell(shell, timeoutMs) {
257
259
  const script = `printf 'PATH\\x1f%s\\n' "$PATH"; ` +
258
260
  `printf 'CLAUDE\\x1f%s\\n' "$(command -v claude 2>/dev/null)"; ` +
259
- `printf 'CODEX\\x1f%s\\n' "$(command -v codex 2>/dev/null)"`;
261
+ `printf 'CODEX\\x1f%s\\n' "$(command -v codex 2>/dev/null)"; ` +
262
+ `printf 'OPENCODE\\x1f%s\\n' "$(command -v opencode 2>/dev/null)"`;
260
263
  return new Promise((resolve, reject) => {
261
264
  const child = spawn(shell, ["-l", "-c", script], {
262
265
  env: {
@@ -296,7 +299,7 @@ function probeLoginShell(shell, timeoutMs) {
296
299
  reject(new Error(`login shell exited ${code}: ${stderr.trim().slice(0, 200)}`));
297
300
  return;
298
301
  }
299
- const out = { path: "", claude: null, codex: null };
302
+ const out = { path: "", claude: null, codex: null, opencode: null };
300
303
  for (const line of stdout.split("\n")) {
301
304
  const idx = line.indexOf("\x1f");
302
305
  if (idx < 0)
@@ -311,6 +314,8 @@ function probeLoginShell(shell, timeoutMs) {
311
314
  out.claude = val;
312
315
  else if (key === "CODEX")
313
316
  out.codex = val;
317
+ else if (key === "OPENCODE")
318
+ out.opencode = val;
314
319
  }
315
320
  resolve(out);
316
321
  });
@@ -103,7 +103,7 @@ export declare class ProcessManager extends EventEmitter {
103
103
  setSessionThinkingEffort(id: string, effort: SessionSnapshot["thinkingEffort"]): SessionSnapshot;
104
104
  /**
105
105
  * Switch the execution mode of a PTY session mid-flight. The already-launched
106
- * claude/codex process keeps its original CLI flags, but wand's own permission
106
+ * provider process keeps its original CLI flags, but wand's own permission
107
107
  * auto-approval (shouldAutoApprovePermissions / escalation handling) reads
108
108
  * record.mode, so this changes the permission posture for subsequent prompts.
109
109
  * Mirrors setSessionModel/setSessionThinkingEffort.
@@ -14,12 +14,14 @@ import { ensureNodePtyHelperExecutable } from "./ensure-node-pty-helper.js";
14
14
  import { buildLanguageDirective, buildManagedAutonomyDirective } from "./language-prompt.js";
15
15
  import { prepareSessionWorktree } from "./git-worktree.js";
16
16
  import { getCodexResumeCommandSessionId, getResumeCommandSessionId } from "./resume-policy.js";
17
- import { normalizeThinkingEffort, thinkingEffortToClaudeCliEffort, thinkingEffortToClaudeSlashEffort, thinkingEffortToCodexReasoningEffort } from "./structured-session-manager.js";
17
+ import { normalizeThinkingEffort, thinkingEffortToClaudeCliEffort, thinkingEffortToClaudeSlashEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant } from "./structured-session-manager.js";
18
18
  import { generateSessionTopic } from "./session-topic.js";
19
19
  import { getErrorMessage } from "./error-utils.js";
20
20
  import { resolveSessionCwd } from "./session-cwd.js";
21
21
  function resolveProviderFromCommand(command) {
22
- return /^codex\b/.test(command.trim()) ? "codex" : "claude";
22
+ if (/^codex\b/.test(command.trim()))
23
+ return "codex";
24
+ return /^opencode\b/.test(command.trim()) ? "opencode" : "claude";
23
25
  }
24
26
  export class SessionInputError extends Error {
25
27
  code;
@@ -1010,7 +1012,7 @@ export class ProcessManager extends EventEmitter {
1010
1012
  }),
1011
1013
  name: "xterm-color",
1012
1014
  // 使用 record 上由前端协商好的真实尺寸,避免"先 120 列、几百毫秒后再 resize"
1013
- // 期间 Claude/Codex 用错列宽渲染出 \x1b[120G 这类绝对列定位序列。
1015
+ // 期间 provider TUI 用错列宽渲染出 \x1b[120G 这类绝对列定位序列。
1014
1016
  cols: record.ptyCols,
1015
1017
  rows: record.ptyRows
1016
1018
  });
@@ -1399,7 +1401,7 @@ export class ProcessManager extends EventEmitter {
1399
1401
  }
1400
1402
  /**
1401
1403
  * Switch the execution mode of a PTY session mid-flight. The already-launched
1402
- * claude/codex process keeps its original CLI flags, but wand's own permission
1404
+ * provider process keeps its original CLI flags, but wand's own permission
1403
1405
  * auto-approval (shouldAutoApprovePermissions / escalation handling) reads
1404
1406
  * record.mode, so this changes the permission posture for subsequent prompts.
1405
1407
  * Mirrors setSessionModel/setSessionThinkingEffort.
@@ -1974,7 +1976,7 @@ export class ProcessManager extends EventEmitter {
1974
1976
  permissionBlocked: this.isPermissionBlocked(record),
1975
1977
  };
1976
1978
  // 透传 bridge 给出的 isResponding(true=流式中, false=本轮已完成)。
1977
- // 前端用它检测 thinking→idle 边界并主动做一次终端 resync,把 Claude/Codex
1979
+ // 前端用它检测 thinking→idle 边界并主动做一次终端 resync,把 provider TUI
1978
1980
  // 在流式渲染过程中残留的错位光标定位序列洗掉(等价于按一次右上角缩放)。
1979
1981
  if (bridgeData && typeof bridgeData.isResponding === "boolean") {
1980
1982
  data.isResponding = bridgeData.isResponding;
@@ -2142,6 +2144,22 @@ export class ProcessManager extends EventEmitter {
2142
2144
  }
2143
2145
  return result;
2144
2146
  }
2147
+ if (provider === "opencode") {
2148
+ let result = command;
2149
+ const trimmedModel = model?.trim();
2150
+ if (trimmedModel && trimmedModel !== "default" && !/--model(?:\s|=)/.test(result) && !/(?:^|\s)-m(?:\s|$)/.test(result)) {
2151
+ const escapedModel = trimmedModel.replace(/'/g, "'\\''");
2152
+ result += ` --model '${escapedModel}'`;
2153
+ }
2154
+ const variant = thinkingEffortToOpenCodeVariant(thinkingEffort ?? null);
2155
+ if (variant && !/--variant(?:\s|=)/.test(result)) {
2156
+ result += ` --variant '${variant.replace(/'/g, "'\\''")}'`;
2157
+ }
2158
+ if ((mode === "managed" || mode === "full-access" || mode === "auto-edit") && !/--auto(?:\s|$)/.test(result)) {
2159
+ result += " --auto";
2160
+ }
2161
+ return result;
2162
+ }
2145
2163
  const isClaudeCmd = /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command);
2146
2164
  if (!isClaudeCmd)
2147
2165
  return command;
@@ -0,0 +1,38 @@
1
+ export type ProviderCliId = "claude" | "codex" | "opencode";
2
+ export interface ProviderCliUpdateStatus {
3
+ id: ProviderCliId;
4
+ label: string;
5
+ command: string;
6
+ executable: string | null;
7
+ installed: boolean;
8
+ currentVersion: string | null;
9
+ latestVersion: string | null;
10
+ updateAvailable: boolean;
11
+ updateSupported: boolean;
12
+ installKind: "native" | "npm" | "brew" | "legacy" | "unknown";
13
+ error?: string;
14
+ }
15
+ export interface ProviderCliUpdateResult {
16
+ id: ProviderCliId;
17
+ label: string;
18
+ ok: boolean;
19
+ skipped: boolean;
20
+ fromVersion: string | null;
21
+ toVersion: string | null;
22
+ message: string;
23
+ output?: string;
24
+ }
25
+ export interface ProviderCliUpdaterOptions {
26
+ inheritEnv?: boolean;
27
+ versionTimeoutMs?: number;
28
+ registryTimeoutMs?: number;
29
+ updateTimeoutMs?: number;
30
+ env?: NodeJS.ProcessEnv;
31
+ onLog?: (line: string) => void;
32
+ }
33
+ export declare function parseProviderCliVersion(output: string): string | null;
34
+ export declare function providerCliUpdateAvailable(currentVersion: string | null, latestVersion: string | null): boolean;
35
+ export declare function checkProviderCliUpdates(options?: ProviderCliUpdaterOptions): Promise<ProviderCliUpdateStatus[]>;
36
+ export declare function updateProviderClis(statuses: ProviderCliUpdateStatus[], ids?: ProviderCliId[], options?: ProviderCliUpdaterOptions): Promise<ProviderCliUpdateResult[]>;
37
+ /** Re-check the active PATH after updating so duplicate installs cannot masquerade as success. */
38
+ export declare function verifyProviderCliUpdateResults(results: ProviderCliUpdateResult[], statuses: ProviderCliUpdateStatus[]): ProviderCliUpdateResult[];