@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.
@@ -13,7 +13,7 @@ import { buildLanguageDirective, buildManagedAutonomyDirective } from "./languag
13
13
  import { generateSessionTopic } from "./session-topic.js";
14
14
  import { resolveSessionCwd } from "./session-cwd.js";
15
15
  function defaultStructuredRunner(provider) {
16
- return provider === "codex" ? "codex-cli-exec" : "claude-cli-print";
16
+ return provider === "codex" ? "codex-cli-exec" : provider === "opencode" ? "opencode-cli-run" : "claude-cli-print";
17
17
  }
18
18
  function defaultStructuredState(provider, runner = defaultStructuredRunner(provider)) {
19
19
  return {
@@ -75,6 +75,18 @@ export function thinkingEffortToCodexReasoningEffort(effort) {
75
75
  default: return null;
76
76
  }
77
77
  }
78
+ /** OpenCode exposes provider-specific reasoning presets through `--variant`. */
79
+ export function thinkingEffortToOpenCodeVariant(effort) {
80
+ if (!effort || effort === "off")
81
+ return null;
82
+ if (effort === "standard")
83
+ return "low";
84
+ if (effort === "deep")
85
+ return "high";
86
+ if (effort === "max")
87
+ return "max";
88
+ return effort.startsWith("codex:") ? effort.slice("codex:".length) || null : null;
89
+ }
78
90
  function asRecord(value) {
79
91
  return value && typeof value === "object" && !Array.isArray(value)
80
92
  ? value
@@ -914,7 +926,7 @@ export class StructuredSessionManager {
914
926
  const id = randomUUID();
915
927
  const startedAt = new Date().toISOString();
916
928
  const prompt = options.prompt?.trim();
917
- const provider = options.provider === "codex" ? "codex" : "claude";
929
+ const provider = options.provider === "codex" || options.provider === "opencode" ? options.provider : "claude";
918
930
  const runner = options.runner ?? defaultStructuredRunner(provider);
919
931
  const baseCwd = resolveSessionCwd(options.cwd, this.config.defaultCwd);
920
932
  const worktreeSetup = options.worktreeEnabled
@@ -931,9 +943,11 @@ export class StructuredSessionManager {
931
943
  runner,
932
944
  command: provider === "codex"
933
945
  ? "codex exec --json"
934
- : runner === "claude-sdk"
935
- ? "claude-agent-sdk (stream-json)"
936
- : "claude -p --output-format stream-json",
946
+ : provider === "opencode"
947
+ ? "opencode run --format json"
948
+ : runner === "claude-sdk"
949
+ ? "claude-agent-sdk (stream-json)"
950
+ : "claude -p --output-format stream-json",
937
951
  cwd: worktreeSetup?.cwd ?? baseCwd,
938
952
  mode: options.mode,
939
953
  worktreeEnabled: Boolean(worktreeSetup),
@@ -1122,6 +1136,9 @@ export class StructuredSessionManager {
1122
1136
  if ((updated.provider ?? "claude") === "codex") {
1123
1137
  await this.runCodexStreaming(id, updated, prompt);
1124
1138
  }
1139
+ else if ((updated.provider ?? "claude") === "opencode") {
1140
+ await this.runOpenCodeStreaming(id, updated, prompt);
1141
+ }
1125
1142
  else if (this.config.structuredRunner === "sdk") {
1126
1143
  await this.runClaudeSdkStreaming(id, updated, prompt);
1127
1144
  }
@@ -1850,6 +1867,280 @@ export class StructuredSessionManager {
1850
1867
  });
1851
1868
  });
1852
1869
  }
1870
+ buildOpenCodeArgs(session) {
1871
+ const args = ["run", "--format", "json", "--thinking"];
1872
+ const modelChoice = session.selectedModel?.trim();
1873
+ if (modelChoice && modelChoice !== "default") {
1874
+ args.push("--model", modelChoice);
1875
+ }
1876
+ const variant = thinkingEffortToOpenCodeVariant(session.thinkingEffort);
1877
+ if (variant)
1878
+ args.push("--variant", variant);
1879
+ if (session.autoApprovePermissions === true || session.mode === "full-access" || session.mode === "managed" || session.mode === "auto-edit") {
1880
+ args.push("--dangerously-skip-permissions");
1881
+ }
1882
+ if (session.claudeSessionId) {
1883
+ args.push("--session", session.claudeSessionId);
1884
+ }
1885
+ return args;
1886
+ }
1887
+ openCodeToolName(name) {
1888
+ const mapped = {
1889
+ bash: "Bash",
1890
+ shell: "Bash",
1891
+ read: "Read",
1892
+ edit: "Edit",
1893
+ write: "Write",
1894
+ glob: "Glob",
1895
+ grep: "Grep",
1896
+ webfetch: "WebFetch",
1897
+ websearch: "WebSearch",
1898
+ todowrite: "TodoWrite",
1899
+ task: "Task",
1900
+ skill: "Skill",
1901
+ };
1902
+ return mapped[name.toLowerCase()] ?? `OpenCode/${name}`;
1903
+ }
1904
+ applyOpenCodeEvent(turnState, event) {
1905
+ if (typeof event.sessionID === "string" && event.sessionID)
1906
+ turnState.sessionId = event.sessionID;
1907
+ const type = typeof event.type === "string" ? event.type : "";
1908
+ const part = asRecord(event.part);
1909
+ if (!part) {
1910
+ if (type === "error")
1911
+ return this.extractCodexText(event.error) || "OpenCode run failed";
1912
+ return null;
1913
+ }
1914
+ if (type === "text" && typeof part.text === "string" && part.text.trim()) {
1915
+ turnState.blocks.push({ type: "text", text: part.text });
1916
+ turnState.result += (turnState.result ? "\n" : "") + part.text;
1917
+ return null;
1918
+ }
1919
+ if (type === "reasoning" && typeof part.text === "string" && part.text.trim()) {
1920
+ turnState.blocks.push({ type: "thinking", thinking: part.text });
1921
+ return null;
1922
+ }
1923
+ if (type === "tool_use") {
1924
+ const state = asRecord(part.state) ?? {};
1925
+ const tool = typeof part.tool === "string" && part.tool ? part.tool : "tool";
1926
+ const toolId = typeof part.callID === "string" && part.callID
1927
+ ? part.callID
1928
+ : typeof part.id === "string" && part.id
1929
+ ? part.id
1930
+ : randomUUID();
1931
+ const input = asRecord(state.input) ?? {};
1932
+ turnState.blocks.push({
1933
+ type: "tool_use",
1934
+ id: toolId,
1935
+ name: this.openCodeToolName(tool),
1936
+ description: typeof state.title === "string" ? state.title : undefined,
1937
+ input,
1938
+ });
1939
+ const failed = state.status === "error";
1940
+ const content = failed
1941
+ ? (typeof state.error === "string" ? state.error : "OpenCode tool failed")
1942
+ : (typeof state.output === "string" ? state.output : "");
1943
+ turnState.blocks.push({ type: "tool_result", tool_use_id: toolId, content, is_error: failed });
1944
+ return null;
1945
+ }
1946
+ if (type === "step_finish") {
1947
+ const tokens = asRecord(part.tokens);
1948
+ const cache = asRecord(tokens?.cache);
1949
+ const previous = turnState.usage ?? {};
1950
+ turnState.usage = {
1951
+ inputTokens: (previous.inputTokens ?? 0) + (typeof tokens?.input === "number" ? tokens.input : 0),
1952
+ outputTokens: (previous.outputTokens ?? 0) + (typeof tokens?.output === "number" ? tokens.output : 0),
1953
+ reasoningOutputTokens: (previous.reasoningOutputTokens ?? 0) + (typeof tokens?.reasoning === "number" ? tokens.reasoning : 0),
1954
+ cacheReadInputTokens: (previous.cacheReadInputTokens ?? 0) + (typeof cache?.read === "number" ? cache.read : 0),
1955
+ cacheCreationInputTokens: (previous.cacheCreationInputTokens ?? 0) + (typeof cache?.write === "number" ? cache.write : 0),
1956
+ totalCostUsd: (previous.totalCostUsd ?? 0) + (typeof part.cost === "number" ? part.cost : 0),
1957
+ };
1958
+ }
1959
+ return null;
1960
+ }
1961
+ runOpenCodeStreaming(sessionId, session, prompt) {
1962
+ this.userStopped.delete(sessionId);
1963
+ return new Promise((resolve, reject) => {
1964
+ const args = this.buildOpenCodeArgs(session);
1965
+ const spawnedAt = new Date().toISOString();
1966
+ const child = spawn("opencode", args, {
1967
+ cwd: session.cwd,
1968
+ env: buildChildEnv(this.config.inheritEnv !== false),
1969
+ stdio: ["pipe", "pipe", "pipe"],
1970
+ });
1971
+ this.logger?.appendStructuredSpawn(sessionId, {
1972
+ kind: "opencode-run",
1973
+ provider: "opencode",
1974
+ pid: child.pid ?? null,
1975
+ cwd: session.cwd,
1976
+ args,
1977
+ prompt: prompt.slice(0, 2048),
1978
+ promptLength: prompt.length,
1979
+ sessionId: session.claudeSessionId,
1980
+ spawnedAt,
1981
+ });
1982
+ this.pendingChildren.set(sessionId, child);
1983
+ child.stdin?.end(prompt);
1984
+ const turnState = {
1985
+ blocks: [],
1986
+ result: "",
1987
+ sessionId: session.claudeSessionId,
1988
+ model: session.selectedModel ?? session.structuredState?.model,
1989
+ usage: undefined,
1990
+ codexBlockIndex: new Map(),
1991
+ cwd: session.cwd,
1992
+ };
1993
+ let lineBuf = "";
1994
+ let stderr = "";
1995
+ let primaryError = null;
1996
+ let emitTimer = null;
1997
+ const syncSnapshot = () => {
1998
+ const current = this.sessions.get(sessionId);
1999
+ if (!current)
2000
+ return;
2001
+ const turn = {
2002
+ role: "assistant",
2003
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
2004
+ usage: turnState.usage,
2005
+ };
2006
+ const messages = [...(current.messages ?? [])];
2007
+ if (messages[messages.length - 1]?.role === "assistant")
2008
+ messages[messages.length - 1] = turn;
2009
+ else
2010
+ messages.push(turn);
2011
+ const patched = {
2012
+ ...current,
2013
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2014
+ messages,
2015
+ output: turnState.result || current.output,
2016
+ };
2017
+ this.sessions.set(sessionId, patched);
2018
+ this.saveStreamingSnapshot(patched);
2019
+ };
2020
+ const flushEmit = () => {
2021
+ if (emitTimer)
2022
+ clearTimeout(emitTimer);
2023
+ emitTimer = null;
2024
+ const current = this.sessions.get(sessionId);
2025
+ if (current)
2026
+ this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
2027
+ };
2028
+ const scheduleEmit = () => {
2029
+ if (!emitTimer)
2030
+ emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
2031
+ };
2032
+ const processLine = (line) => {
2033
+ const trimmed = line.trim();
2034
+ if (!trimmed)
2035
+ return;
2036
+ let event;
2037
+ try {
2038
+ event = JSON.parse(trimmed);
2039
+ }
2040
+ catch {
2041
+ return;
2042
+ }
2043
+ this.logger?.appendStreamEvent(sessionId, event);
2044
+ const error = this.applyOpenCodeEvent(turnState, event);
2045
+ if (error)
2046
+ primaryError = error;
2047
+ syncSnapshot();
2048
+ scheduleEmit();
2049
+ };
2050
+ child.stdout?.on("data", (chunk) => {
2051
+ const text = chunk.toString();
2052
+ this.logger?.appendStructuredStdout(sessionId, text);
2053
+ lineBuf += text;
2054
+ const lines = lineBuf.split("\n");
2055
+ lineBuf = lines.pop() ?? "";
2056
+ for (const line of lines)
2057
+ processLine(line);
2058
+ });
2059
+ child.stderr?.on("data", (chunk) => {
2060
+ const text = chunk.toString();
2061
+ this.logger?.appendStructuredStderr(sessionId, text);
2062
+ stderr += text;
2063
+ });
2064
+ child.on("error", (error) => {
2065
+ this.pendingChildren.delete(sessionId);
2066
+ this.lastStreamSaveAt.delete(sessionId);
2067
+ if (emitTimer)
2068
+ clearTimeout(emitTimer);
2069
+ const nodeError = error;
2070
+ const hint = nodeError.code === "ENOENT"
2071
+ ? "(PATH 中找不到 opencode;请安装 opencode-ai,或重跑 `wand service:install` 刷新服务 PATH)"
2072
+ : "";
2073
+ reject(new Error(`opencode run 启动失败:${error.message}${hint}`));
2074
+ });
2075
+ child.on("close", (code, signal) => {
2076
+ this.pendingChildren.delete(sessionId);
2077
+ this.lastStreamSaveAt.delete(sessionId);
2078
+ if (lineBuf.trim())
2079
+ processLine(lineBuf);
2080
+ flushEmit();
2081
+ const current = this.sessions.get(sessionId);
2082
+ if (!current) {
2083
+ reject(new Error("Session removed during execution."));
2084
+ return;
2085
+ }
2086
+ const interruptedByUser = this.interruptedWith.has(sessionId);
2087
+ const interruptPrompt = this.interruptedWith.get(sessionId);
2088
+ const userStopped = this.userStopped.delete(sessionId);
2089
+ if ((primaryError || (code !== 0 && code !== null) || signal) && !interruptedByUser && !userStopped) {
2090
+ const legacyHint = /unknown command|unknown flag|No help topic for 'run'/i.test(stderr)
2091
+ ? "\n检测到旧版 OpenCode CLI;请卸载 0.0.x 旧包并安装 `opencode-ai@latest`。"
2092
+ : "";
2093
+ const errorText = this.formatStructuredExitError("opencode run", code, signal, { stderr, primary: primaryError }) + legacyHint;
2094
+ const failed = this.finishStructuredFailure(current, typeof code === "number" ? code : 1, errorText, turnState);
2095
+ this.sessions.set(sessionId, failed);
2096
+ this.storage.saveSession(failed);
2097
+ this.emitStructuredSnapshot(failed);
2098
+ this.emitStructuredSnapshot(failed, "ended");
2099
+ reject(new Error(errorText));
2100
+ return;
2101
+ }
2102
+ const messages = this.buildCompletedAssistantMessages(current, turnState);
2103
+ const keepRunning = !!interruptPrompt;
2104
+ const finished = {
2105
+ ...current,
2106
+ status: keepRunning ? "running" : "idle",
2107
+ exitCode: keepRunning ? null : 0,
2108
+ endedAt: keepRunning ? null : new Date().toISOString(),
2109
+ output: turnState.result,
2110
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2111
+ messages,
2112
+ queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
2113
+ pendingEscalation: null,
2114
+ permissionBlocked: false,
2115
+ structuredState: {
2116
+ ...current.structuredState,
2117
+ model: turnState.model ?? current.structuredState?.model,
2118
+ inFlight: false,
2119
+ activeRequestId: null,
2120
+ lastError: null,
2121
+ },
2122
+ };
2123
+ this.sessions.set(sessionId, finished);
2124
+ this.storage.saveSession(finished);
2125
+ this.emitStructuredSnapshot(finished);
2126
+ if (!keepRunning)
2127
+ this.emitStructuredSnapshot(finished, "ended");
2128
+ if (interruptPrompt) {
2129
+ this.interruptedWith.delete(sessionId);
2130
+ this.preserveQueueOnInterrupt.delete(sessionId);
2131
+ resolve();
2132
+ setImmediate(() => {
2133
+ this.sendMessage(sessionId, interruptPrompt).catch((error) => {
2134
+ console.error("[WAND] opencode interrupt-and-send failed:", error);
2135
+ });
2136
+ });
2137
+ return;
2138
+ }
2139
+ resolve();
2140
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
2141
+ });
2142
+ });
2143
+ }
1853
2144
  // ---------------------------------------------------------------------------
1854
2145
  // Streaming claude -p execution
1855
2146
  // ---------------------------------------------------------------------------
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type SessionKind = "pty" | "structured";
2
- export type SessionProvider = "claude" | "codex";
3
- export type SessionRunner = "claude-cli" | "claude-cli-print" | "claude-sdk" | "codex-cli-exec" | "pty";
2
+ export type SessionProvider = "claude" | "codex" | "opencode";
3
+ export type SessionRunner = "claude-cli" | "claude-cli-print" | "claude-sdk" | "codex-cli-exec" | "opencode-cli-run" | "pty";
4
4
  export type SessionSource = "interactive" | "automation" | "startup";
5
5
  export type ExecutionMode = "assist" | "agent" | "agent-max" | "default" | "auto-edit" | "full-access" | "native" | "managed";
6
6
  export type AutonomyPolicy = "assist" | "agent" | "agent-max";
@@ -109,6 +109,8 @@ export interface WandConfig {
109
109
  defaultModel?: string;
110
110
  /** 新建 Codex 会话时默认使用的模型。留空则不传 --model,由 codex 自行决定。 */
111
111
  defaultCodexModel?: string;
112
+ /** 新建 OpenCode 会话时默认使用的 provider/model。留空则由 opencode 自行决定。 */
113
+ defaultOpenCodeModel?: string;
112
114
  /** 快捷提交生成 commit message / tag 时使用的 CLI。 */
113
115
  commitCli?: SessionProvider;
114
116
  /** 快捷提交专用模型。留空则跟随所选 CLI 的默认模型。 */