@co0ontty/wand 1.21.4 → 1.21.7

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.
@@ -177,16 +177,11 @@ export declare class ClaudePtyBridge extends EventEmitter {
177
177
  private finalizeResponse;
178
178
  /**
179
179
  * Find the end index of the echoed user input in the PTY buffer.
180
- * The echo may contain ANSI codes between characters.
181
- * Returns the index after the last character of the echo.
180
+ * Returns 0 if the echo cannot be fully matched.
182
181
  *
183
- * Matching strategy:
184
- * - Keep every printable codepoint of `userInput` (anything that is not a
185
- * control char or whitespace) for comparison. The previous version dropped
186
- * common symbols like `/`, `(`, `:`, space — which made commands such as
187
- * `ls /tmp` mismatch and start parsing the chat response from a wrong offset.
188
- * - In the buffer, skip ANSI escape sequences entirely, and skip whitespace
189
- * so wrapped echoes (line continuation, padded columns) still align.
182
+ * Why: ANSI escapes and whitespace can interleave the echoed characters
183
+ * (line wrapping, padding, color codes), so matching skips them while
184
+ * comparing every printable codepoint of `userInput` in order.
190
185
  */
191
186
  private findEchoEndIndex;
192
187
  private cleanForChat;
@@ -7,14 +7,9 @@
7
7
  * 2. Structured messages for chat view (parsed)
8
8
  */
9
9
  import { EventEmitter } from "node:events";
10
- import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu, stripForEchoMatch, skipAnsiSequence } from "./pty-text-utils.js";
10
+ import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu, stripForEchoMatch, skipAnsiSequence, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
11
11
  // ── Constants ──
12
- /**
13
- * Hard cap on the in-memory PTY replay buffer. Aligned with the non-bridge
14
- * branch of `ProcessManager.start()` so a session keeps the same amount of
15
- * history regardless of which capture path is active.
16
- */
17
- const OUTPUT_MAX_SIZE = 200000;
12
+ const OUTPUT_MAX_SIZE = PTY_OUTPUT_MAX_SIZE;
18
13
  const SESSION_ID_WINDOW_SIZE = 16384;
19
14
  const PERMISSION_WINDOW_SIZE = 2000;
20
15
  const AUTO_APPROVE_DELAY_MS = 350;
@@ -835,16 +830,11 @@ export class ClaudePtyBridge extends EventEmitter {
835
830
  // ── Text Processing Utilities ──
836
831
  /**
837
832
  * Find the end index of the echoed user input in the PTY buffer.
838
- * The echo may contain ANSI codes between characters.
839
- * Returns the index after the last character of the echo.
833
+ * Returns 0 if the echo cannot be fully matched.
840
834
  *
841
- * Matching strategy:
842
- * - Keep every printable codepoint of `userInput` (anything that is not a
843
- * control char or whitespace) for comparison. The previous version dropped
844
- * common symbols like `/`, `(`, `:`, space — which made commands such as
845
- * `ls /tmp` mismatch and start parsing the chat response from a wrong offset.
846
- * - In the buffer, skip ANSI escape sequences entirely, and skip whitespace
847
- * so wrapped echoes (line continuation, padded columns) still align.
835
+ * Why: ANSI escapes and whitespace can interleave the echoed characters
836
+ * (line wrapping, padding, color codes), so matching skips them while
837
+ * comparing every printable codepoint of `userInput` in order.
848
838
  */
849
839
  findEchoEndIndex(buffer, userInput) {
850
840
  const inputChars = stripForEchoMatch(userInput);
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
2
  import process from "node:process";
3
- import { ensureConfig, hasConfigFile, isExecutionMode, resolveConfigPath, saveConfig } from "./config.js";
3
+ import { hasConfigFile, isPreferenceKey, loadConfigWithStorage, resolveConfigPath, saveConfig, writePreferenceToStorage, } from "./config.js";
4
4
  async function main() {
5
5
  const args = process.argv.slice(2);
6
6
  const command = args[0] || "help";
@@ -50,8 +50,18 @@ async function main() {
50
50
  break;
51
51
  }
52
52
  case "config:show": {
53
- const config = await ensureConfig(configPath);
54
- process.stdout.write(`${JSON.stringify(config, null, 2)}\n`);
53
+ // 展示合并后的视图(JSON 部署字段 + DB 偏好字段)。
54
+ const { ensureDatabaseFile, resolveDatabasePath, WandStorage } = await import("./storage.js");
55
+ const dbPath = resolveDatabasePath(configPath);
56
+ ensureDatabaseFile(dbPath);
57
+ const storage = new WandStorage(dbPath);
58
+ try {
59
+ const config = await loadConfigWithStorage(configPath, storage);
60
+ process.stdout.write(`${JSON.stringify(config, null, 2)}\n`);
61
+ }
62
+ finally {
63
+ storage.close();
64
+ }
55
65
  break;
56
66
  }
57
67
  case "config:set": {
@@ -60,10 +70,26 @@ async function main() {
60
70
  if (!key || typeof value === "undefined") {
61
71
  throw new Error("Usage: wand config:set <key> <value>");
62
72
  }
63
- const config = await ensureConfig(configPath);
64
- const nextConfig = setConfigValue(config, key, value);
65
- await saveConfig(configPath, nextConfig);
66
- process.stdout.write(`[wand] Updated ${key} in ${configPath}\n`);
73
+ const { ensureDatabaseFile, resolveDatabasePath, WandStorage } = await import("./storage.js");
74
+ const dbPath = resolveDatabasePath(configPath);
75
+ ensureDatabaseFile(dbPath);
76
+ const storage = new WandStorage(dbPath);
77
+ try {
78
+ const config = await loadConfigWithStorage(configPath, storage);
79
+ if (isPreferenceKey(key)) {
80
+ // 偏好字段写 DB,无需重启
81
+ writePreferenceToStorage(config, storage, key, value);
82
+ process.stdout.write(`[wand] Updated preference ${key} in ${dbPath}\n`);
83
+ }
84
+ else {
85
+ const nextConfig = setConfigValue(config, key, value);
86
+ await saveConfig(configPath, nextConfig);
87
+ process.stdout.write(`[wand] Updated ${key} in ${configPath}\n`);
88
+ }
89
+ }
90
+ finally {
91
+ storage.close();
92
+ }
67
93
  break;
68
94
  }
69
95
  case "help":
@@ -95,11 +121,19 @@ Options:
95
121
  `);
96
122
  }
97
123
  async function ensureRequiredFiles(configPath, opts = {}) {
98
- const { ensureDatabaseFile, resolveDatabasePath } = await import("./storage.js");
124
+ const { ensureDatabaseFile, resolveDatabasePath, WandStorage } = await import("./storage.js");
99
125
  const dbPath = resolveDatabasePath(configPath);
100
126
  const hadConfig = hasConfigFile(configPath);
101
- const config = await ensureConfig(configPath);
127
+ // 先建 DB 文件,再加载 config(loadConfigWithStorage 需要 storage 来迁移老 JSON 偏好字段并应用 DB 覆盖)。
102
128
  const createdDb = ensureDatabaseFile(dbPath);
129
+ const storage = new WandStorage(dbPath);
130
+ let config;
131
+ try {
132
+ config = await loadConfigWithStorage(configPath, storage);
133
+ }
134
+ finally {
135
+ storage.close();
136
+ }
103
137
  // 已存在的 ready 信息在 TUI 模式下由启动 banner 统一展示,此处静默;
104
138
  // 但 created 是首次创建事件,无论 TUI 与否都值得提示。
105
139
  if (!hadConfig) {
@@ -153,11 +187,11 @@ function printStartupBanner(handle) {
153
187
  process.stdout.write(lines.join("\n") + "\n");
154
188
  }
155
189
  function setConfigValue(config, key, value) {
190
+ // 偏好字段(defaultMode/defaultCwd/...)由调用方分流到 storage,这里只处理 JSON 字段。
156
191
  switch (key) {
157
192
  case "host":
158
193
  case "password":
159
194
  case "shell":
160
- case "defaultCwd":
161
195
  return {
162
196
  ...config,
163
197
  [key]: value
@@ -170,14 +204,6 @@ function setConfigValue(config, key, value) {
170
204
  ...config,
171
205
  port: Number(value)
172
206
  };
173
- case "defaultMode":
174
- if (!isExecutionMode(value)) {
175
- throw new Error(`defaultMode must be one of: assist, agent, agent-max, auto-edit, default, full-access, managed, native`);
176
- }
177
- return {
178
- ...config,
179
- defaultMode: value
180
- };
181
207
  case "https":
182
208
  if (value !== "true" && value !== "false") {
183
209
  throw new Error("https must be 'true' or 'false'");
package/dist/config.d.ts CHANGED
@@ -1,9 +1,43 @@
1
1
  import { CardExpandDefaults, ExecutionMode, WandConfig } from "./types.js";
2
+ import type { WandStorage } from "./storage.js";
3
+ /**
4
+ * 通过 UI 设置面板可改的"用户偏好"字段。这些字段从 SQLite app_config 表读取,
5
+ * 不再写入 ~/.wand/config.json。JSON 只保留服务部署/启动期参数(host/port/shell 等)。
6
+ *
7
+ * 升级路径:老 JSON 里仍存有这些字段时,首次启动会被搬到 DB(见 migrateLegacyPreferencesToDb),
8
+ * 然后下一次 saveConfig 写回 JSON 时它们会被剥离(见 stripPreferenceFields)。
9
+ */
10
+ export declare const PREFERENCE_KEYS: readonly ["defaultMode", "defaultCwd", "defaultModel", "structuredRunner", "language", "cardDefaults"];
11
+ export type PreferenceKey = (typeof PREFERENCE_KEYS)[number];
12
+ export declare function isPreferenceKey(key: string): key is PreferenceKey;
2
13
  export declare const defaultConfig: () => WandConfig;
3
14
  export declare function resolveConfigPath(inputPath?: string): string;
4
15
  export declare function resolveConfigDir(configPath: string): string;
5
16
  export declare function hasConfigFile(configPath: string): boolean;
6
17
  export declare function ensureConfig(configPath: string): Promise<WandConfig>;
18
+ /** saveConfig 写出时去掉偏好字段——这些已经移到 SQLite。 */
7
19
  export declare function saveConfig(configPath: string, config: WandConfig): Promise<void>;
20
+ /**
21
+ * 启动期合并 JSON + DB 偏好。语义:
22
+ * 1. 读 raw JSON
23
+ * 2. 把 JSON 中残留的偏好字段搬到 DB(只在 DB 没值时迁移)
24
+ * 3. mergeWithDefaults(raw) 得到 baseline
25
+ * 4. applyStoragePreferences 用 DB 覆盖 baseline 的偏好字段
26
+ * 5. 如果 JSON 里仍含偏好字段(来自 1),用 saveConfig 重写一次 JSON(剥离后)
27
+ */
28
+ export declare function loadConfigWithStorage(configPath: string, storage: WandStorage): Promise<WandConfig>;
29
+ /**
30
+ * 老版本 JSON 里如果还存有偏好字段,且 DB 里对应 key 没有写过,
31
+ * 把 JSON 的值搬到 DB。注意:必须在 mergeWithDefaults 之前操作 raw input,
32
+ * 这样能区分"用户显式写过 X" 和 "X 是 mergeWithDefaults 注入的默认值"。
33
+ */
34
+ export declare function migrateLegacyPreferencesToDb(rawJsonInput: Partial<WandConfig> | null | undefined, storage: WandStorage): void;
35
+ /**
36
+ * 用 DB 里的偏好覆盖 config 对象(in-place),返回同一个引用,
37
+ * 方便各 manager 持有引用后继续工作。DB 里没有的字段不动,保留 JSON 默认值。
38
+ */
39
+ export declare function applyStoragePreferences(config: WandConfig, storage: WandStorage): WandConfig;
40
+ /** Write a single preference value to DB and (in-place) update the live config object. */
41
+ export declare function writePreferenceToStorage(config: WandConfig, storage: WandStorage, key: PreferenceKey, value: unknown): void;
8
42
  export declare function normalizeCardDefaults(input: unknown): CardExpandDefaults;
9
43
  export declare function isExecutionMode(value: unknown): value is ExecutionMode;
package/dist/config.js CHANGED
@@ -5,6 +5,28 @@ import path from "node:path";
5
5
  import process from "node:process";
6
6
  const DEFAULT_CONFIG_DIR = ".wand";
7
7
  const DEFAULT_CONFIG_FILE = "config.json";
8
+ /**
9
+ * 通过 UI 设置面板可改的"用户偏好"字段。这些字段从 SQLite app_config 表读取,
10
+ * 不再写入 ~/.wand/config.json。JSON 只保留服务部署/启动期参数(host/port/shell 等)。
11
+ *
12
+ * 升级路径:老 JSON 里仍存有这些字段时,首次启动会被搬到 DB(见 migrateLegacyPreferencesToDb),
13
+ * 然后下一次 saveConfig 写回 JSON 时它们会被剥离(见 stripPreferenceFields)。
14
+ */
15
+ export const PREFERENCE_KEYS = [
16
+ "defaultMode",
17
+ "defaultCwd",
18
+ "defaultModel",
19
+ "structuredRunner",
20
+ "language",
21
+ "cardDefaults",
22
+ ];
23
+ const PREFERENCE_KEY_SET = new Set(PREFERENCE_KEYS);
24
+ function preferenceStorageKey(key) {
25
+ return `pref:${key}`;
26
+ }
27
+ export function isPreferenceKey(key) {
28
+ return PREFERENCE_KEY_SET.has(key);
29
+ }
8
30
  export const defaultConfig = () => ({
9
31
  host: "127.0.0.1",
10
32
  port: 8443,
@@ -20,6 +42,7 @@ export const defaultConfig = () => ({
20
42
  android: defaultAndroidApkConfig(),
21
43
  cardDefaults: defaultCardExpandDefaults(),
22
44
  defaultModel: "",
45
+ structuredRunner: "cli",
23
46
  commandPresets: [
24
47
  {
25
48
  label: "Claude",
@@ -79,9 +102,149 @@ export async function ensureConfig(configPath) {
79
102
  return config;
80
103
  }
81
104
  }
105
+ /** saveConfig 写出时去掉偏好字段——这些已经移到 SQLite。 */
82
106
  export async function saveConfig(configPath, config) {
83
107
  await mkdir(path.dirname(configPath), { recursive: true });
84
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
108
+ await writeFile(configPath, `${JSON.stringify(stripPreferenceFields(config), null, 2)}\n`, "utf8");
109
+ }
110
+ /**
111
+ * 启动期合并 JSON + DB 偏好。语义:
112
+ * 1. 读 raw JSON
113
+ * 2. 把 JSON 中残留的偏好字段搬到 DB(只在 DB 没值时迁移)
114
+ * 3. mergeWithDefaults(raw) 得到 baseline
115
+ * 4. applyStoragePreferences 用 DB 覆盖 baseline 的偏好字段
116
+ * 5. 如果 JSON 里仍含偏好字段(来自 1),用 saveConfig 重写一次 JSON(剥离后)
117
+ */
118
+ export async function loadConfigWithStorage(configPath, storage) {
119
+ const dir = path.dirname(configPath);
120
+ await mkdir(dir, { recursive: true });
121
+ let rawInput = {};
122
+ let hadFile = false;
123
+ try {
124
+ const raw = await readFile(configPath, "utf8");
125
+ rawInput = JSON.parse(raw);
126
+ hadFile = true;
127
+ }
128
+ catch {
129
+ rawInput = {};
130
+ }
131
+ migrateLegacyPreferencesToDb(rawInput, storage);
132
+ const config = mergeWithDefaults(rawInput);
133
+ applyStoragePreferences(config, storage);
134
+ // 如果 JSON 里有偏好字段(说明是老版本配置或刚迁移),重写一次干净版本
135
+ const hasLegacyPrefs = PREFERENCE_KEYS.some((key) => key in rawInput);
136
+ if (!hadFile || hasLegacyPrefs) {
137
+ await saveConfig(configPath, config);
138
+ }
139
+ return config;
140
+ }
141
+ /** Build a JSON-safe view of WandConfig that excludes preference fields (which live in DB). */
142
+ function stripPreferenceFields(config) {
143
+ const out = { ...config };
144
+ for (const key of PREFERENCE_KEYS) {
145
+ delete out[key];
146
+ }
147
+ return out;
148
+ }
149
+ /**
150
+ * 老版本 JSON 里如果还存有偏好字段,且 DB 里对应 key 没有写过,
151
+ * 把 JSON 的值搬到 DB。注意:必须在 mergeWithDefaults 之前操作 raw input,
152
+ * 这样能区分"用户显式写过 X" 和 "X 是 mergeWithDefaults 注入的默认值"。
153
+ */
154
+ export function migrateLegacyPreferencesToDb(rawJsonInput, storage) {
155
+ if (!rawJsonInput || typeof rawJsonInput !== "object")
156
+ return;
157
+ for (const key of PREFERENCE_KEYS) {
158
+ if (!(key in rawJsonInput))
159
+ continue;
160
+ const value = rawJsonInput[key];
161
+ if (value === undefined)
162
+ continue;
163
+ const dbKey = preferenceStorageKey(key);
164
+ if (storage.hasPreference(dbKey))
165
+ continue;
166
+ storage.setPreference(dbKey, value);
167
+ }
168
+ }
169
+ /**
170
+ * 用 DB 里的偏好覆盖 config 对象(in-place),返回同一个引用,
171
+ * 方便各 manager 持有引用后继续工作。DB 里没有的字段不动,保留 JSON 默认值。
172
+ */
173
+ export function applyStoragePreferences(config, storage) {
174
+ const defaults = defaultConfig();
175
+ if (storage.hasPreference(preferenceStorageKey("defaultMode"))) {
176
+ const v = storage.getPreference(preferenceStorageKey("defaultMode"), defaults.defaultMode);
177
+ if (isExecutionMode(v))
178
+ config.defaultMode = v;
179
+ }
180
+ if (storage.hasPreference(preferenceStorageKey("defaultCwd"))) {
181
+ const v = storage.getPreference(preferenceStorageKey("defaultCwd"), defaults.defaultCwd);
182
+ if (typeof v === "string" && v.trim())
183
+ config.defaultCwd = v;
184
+ }
185
+ if (storage.hasPreference(preferenceStorageKey("defaultModel"))) {
186
+ const v = storage.getPreference(preferenceStorageKey("defaultModel"), defaults.defaultModel ?? "");
187
+ if (typeof v === "string")
188
+ config.defaultModel = v.trim();
189
+ }
190
+ if (storage.hasPreference(preferenceStorageKey("structuredRunner"))) {
191
+ const v = storage.getPreference(preferenceStorageKey("structuredRunner"), defaults.structuredRunner ?? "sdk");
192
+ if (v === "cli" || v === "sdk")
193
+ config.structuredRunner = v;
194
+ }
195
+ if (storage.hasPreference(preferenceStorageKey("language"))) {
196
+ const v = storage.getPreference(preferenceStorageKey("language"), defaults.language ?? "");
197
+ if (typeof v === "string")
198
+ config.language = v.trim();
199
+ }
200
+ if (storage.hasPreference(preferenceStorageKey("cardDefaults"))) {
201
+ const v = storage.getPreference(preferenceStorageKey("cardDefaults"), defaults.cardDefaults);
202
+ config.cardDefaults = normalizeCardDefaults(v);
203
+ }
204
+ return config;
205
+ }
206
+ /** Write a single preference value to DB and (in-place) update the live config object. */
207
+ export function writePreferenceToStorage(config, storage, key, value) {
208
+ const dbKey = preferenceStorageKey(key);
209
+ switch (key) {
210
+ case "defaultMode": {
211
+ if (!isExecutionMode(value))
212
+ throw new Error(`无效执行模式: ${value}`);
213
+ storage.setPreference(dbKey, value);
214
+ config.defaultMode = value;
215
+ break;
216
+ }
217
+ case "defaultCwd": {
218
+ const v = typeof value === "string" ? value : "";
219
+ storage.setPreference(dbKey, v);
220
+ config.defaultCwd = v || defaultConfig().defaultCwd;
221
+ break;
222
+ }
223
+ case "defaultModel": {
224
+ const v = typeof value === "string" ? value.trim() : "";
225
+ storage.setPreference(dbKey, v);
226
+ config.defaultModel = v;
227
+ break;
228
+ }
229
+ case "structuredRunner": {
230
+ const v = value === "cli" ? "cli" : "sdk";
231
+ storage.setPreference(dbKey, v);
232
+ config.structuredRunner = v;
233
+ break;
234
+ }
235
+ case "language": {
236
+ const v = typeof value === "string" ? value.trim() : "";
237
+ storage.setPreference(dbKey, v);
238
+ config.language = v;
239
+ break;
240
+ }
241
+ case "cardDefaults": {
242
+ const normalized = normalizeCardDefaults(value);
243
+ storage.setPreference(dbKey, normalized);
244
+ config.cardDefaults = normalized;
245
+ break;
246
+ }
247
+ }
85
248
  }
86
249
  function defaultCardExpandDefaults() {
87
250
  return {
@@ -185,6 +348,7 @@ function mergeWithDefaults(input) {
185
348
  android: normalizeAndroidApkConfig(input.android) ?? defaults.android,
186
349
  cardDefaults: normalizeCardDefaults(input.cardDefaults),
187
350
  defaultModel: typeof input.defaultModel === "string" ? input.defaultModel.trim() : defaults.defaultModel,
351
+ structuredRunner: (input.structuredRunner === "sdk" || input.structuredRunner === "cli") ? input.structuredRunner : defaults.structuredRunner,
188
352
  };
189
353
  }
190
354
  export function isExecutionMode(value) {
@@ -8,7 +8,7 @@ import pty from "node-pty";
8
8
  import { SessionLogger } from "./session-logger.js";
9
9
  import { ClaudePtyBridge } from "./claude-pty-bridge.js";
10
10
  import { truncateMessagesForTransport } from "./message-truncator.js";
11
- import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
11
+ import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
12
12
  import { prepareSessionWorktree } from "./git-worktree.js";
13
13
  import { getResumeCommandSessionId } from "./resume-policy.js";
14
14
  function resolveProviderFromCommand(command) {
@@ -744,7 +744,7 @@ export class ProcessManager extends EventEmitter {
744
744
  rec.output = rec.ptyBridge.getRawOutput();
745
745
  }
746
746
  else {
747
- rec.output = appendWindow(rec.output, chunk, 200_000);
747
+ rec.output = appendWindow(rec.output, chunk, PTY_OUTPUT_MAX_SIZE);
748
748
  }
749
749
  this.logger.appendPtyOutput(id, chunk);
750
750
  if (!rec.ptyBridge) {
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Shared PTY text processing utilities for consistent ANSI stripping and noise filtering.
3
3
  */
4
+ /**
5
+ * Hard cap on the in-memory PTY replay buffer. Shared between ProcessManager
6
+ * and ClaudePtyBridge so a session keeps the same amount of history regardless
7
+ * of which capture path is active.
8
+ */
9
+ export declare const PTY_OUTPUT_MAX_SIZE = 200000;
4
10
  /** Strip ANSI escape sequences and control characters from raw PTY output. */
5
11
  export declare function stripAnsi(text: string): string;
6
12
  /** Lines considered as UI noise that should be excluded from chat view. */
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Shared PTY text processing utilities for consistent ANSI stripping and noise filtering.
3
3
  */
4
+ /**
5
+ * Hard cap on the in-memory PTY replay buffer. Shared between ProcessManager
6
+ * and ClaudePtyBridge so a session keeps the same amount of history regardless
7
+ * of which capture path is active.
8
+ */
9
+ export const PTY_OUTPUT_MAX_SIZE = 200_000;
4
10
  /** Strip ANSI escape sequences and control characters from raw PTY output. */
5
11
  export function stripAnsi(text) {
6
12
  return text
@@ -207,13 +207,19 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
207
207
  app.post("/api/structured-sessions/:id/messages", express.json(), async (req, res) => {
208
208
  const input = String(req.body?.input ?? "");
209
209
  const interrupt = !!req.body?.interrupt;
210
- console.log("[WAND] POST /api/structured-sessions/:id/messages id:", req.params.id, "input:", input.substring(0, 50), "interrupt:", interrupt);
210
+ const idempotencyKey = typeof req.body?.idempotencyKey === "string" ? req.body.idempotencyKey : undefined;
211
+ console.log("[WAND] POST /api/structured-sessions/:id/messages id:", req.params.id, "input:", input.substring(0, 50), "interrupt:", interrupt, "idempotencyKey:", idempotencyKey);
211
212
  try {
212
- const snapshot = await structured.sendMessage(req.params.id, input, { interrupt });
213
+ const snapshot = await structured.sendMessage(req.params.id, input, { interrupt, idempotencyKey });
213
214
  res.json(snapshot);
214
215
  }
215
216
  catch (error) {
216
- res.status(400).json({ error: getErrorMessage(error, "无法发送结构化消息。") });
217
+ const errorCode = error?.code;
218
+ const status = errorCode === "duplicate_idempotency_key" ? 409 : 400;
219
+ res.status(status).json({
220
+ error: getErrorMessage(error, "无法发送结构化消息。"),
221
+ errorCode,
222
+ });
217
223
  }
218
224
  });
219
225
  // ── Tool content lazy-load endpoint ──
package/dist/server.js CHANGED
@@ -13,7 +13,7 @@ import { WebSocketServer } from "ws";
13
13
  import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
14
14
  import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
15
15
  import { ensureCertificates } from "./cert.js";
16
- import { isExecutionMode, normalizeCardDefaults, resolveConfigDir, saveConfig } from "./config.js";
16
+ import { isExecutionMode, PREFERENCE_KEYS, resolveConfigDir, saveConfig, writePreferenceToStorage, } from "./config.js";
17
17
  import { getCachedModels, refreshModels } from "./models.js";
18
18
  import { ProcessManager } from "./process-manager.js";
19
19
  import { SessionLogger } from "./session-logger.js";
@@ -724,6 +724,7 @@ export async function startServer(config, configPath) {
724
724
  defaultMode: config.defaultMode,
725
725
  defaultCwd: config.defaultCwd,
726
726
  commandPresets: config.commandPresets,
727
+ structuredRunner: config.structuredRunner ?? "sdk",
727
728
  structuredRunners: [
728
729
  { label: "Claude Structured", runner: "claude-cli-print" },
729
730
  { label: "Codex Structured", runner: "codex-cli-exec" },
@@ -815,61 +816,61 @@ export async function startServer(config, configPath) {
815
816
  });
816
817
  app.post("/api/settings/config", async (req, res) => {
817
818
  const body = req.body;
818
- const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language", "defaultModel"];
819
- let changed = false;
820
- for (const field of allowedFields) {
821
- if (field in body && body[field] !== undefined) {
822
- if (field === "port") {
823
- const p = Number(body.port);
824
- if (!Number.isInteger(p) || p < 1 || p > 65535) {
825
- res.status(400).json({ error: `无效端口号: ${body.port}` });
826
- return;
827
- }
828
- config.port = p;
829
- }
830
- else if (field === "https") {
831
- config.https = body.https === true;
832
- }
833
- else if (field === "defaultMode") {
834
- if (!isExecutionMode(body.defaultMode)) {
835
- res.status(400).json({ error: `无效执行模式: ${body.defaultMode}` });
836
- return;
837
- }
838
- config.defaultMode = body.defaultMode;
839
- }
840
- else if (field === "host") {
841
- config.host = String(body.host);
842
- }
843
- else if (field === "defaultCwd") {
844
- config.defaultCwd = String(body.defaultCwd);
845
- }
846
- else if (field === "shell") {
847
- config.shell = String(body.shell);
848
- }
849
- else if (field === "language") {
850
- config.language = typeof body.language === "string" ? body.language.trim() : "";
851
- }
852
- else if (field === "defaultModel") {
853
- config.defaultModel = typeof body.defaultModel === "string" ? body.defaultModel.trim() : "";
819
+ // 部署字段:写 JSON,需要重启服务才生效(host/port/https 影响监听,shell 影响新 PTY)
820
+ const deployFields = ["host", "port", "https", "shell"];
821
+ let touchedDeployField = false;
822
+ let touchedPreferenceField = false;
823
+ for (const field of deployFields) {
824
+ if (!(field in body) || body[field] === undefined)
825
+ continue;
826
+ if (field === "port") {
827
+ const p = Number(body.port);
828
+ if (!Number.isInteger(p) || p < 1 || p > 65535) {
829
+ res.status(400).json({ error: `无效端口号: ${body.port}` });
830
+ return;
854
831
  }
855
- changed = true;
832
+ config.port = p;
856
833
  }
834
+ else if (field === "https") {
835
+ config.https = body.https === true;
836
+ }
837
+ else if (field === "host") {
838
+ config.host = String(body.host);
839
+ }
840
+ else if (field === "shell") {
841
+ config.shell = String(body.shell);
842
+ }
843
+ touchedDeployField = true;
857
844
  }
858
- // Handle cardDefaults separately (nested object, no restart needed)
859
- if (body.cardDefaults !== undefined) {
860
- config.cardDefaults = normalizeCardDefaults(body.cardDefaults);
861
- changed = true;
845
+ // 偏好字段:写 SQLite app_config,立即热生效(manager 持有 config 同一引用)。
846
+ // defaultMode 单独做严格校验以保留 400 错误响应,其余字段走 writePreferenceToStorage 的统一类型化处理。
847
+ if (body.defaultMode !== undefined && !isExecutionMode(body.defaultMode)) {
848
+ res.status(400).json({ error: `无效执行模式: ${body.defaultMode}` });
849
+ return;
862
850
  }
863
- if (!changed) {
851
+ for (const field of PREFERENCE_KEYS) {
852
+ if (!(field in body) || body[field] === undefined)
853
+ continue;
854
+ try {
855
+ writePreferenceToStorage(config, storage, field, body[field]);
856
+ }
857
+ catch (err) {
858
+ res.status(400).json({ error: getErrorMessage(err, `字段 ${field} 校验失败`) });
859
+ return;
860
+ }
861
+ touchedPreferenceField = true;
862
+ }
863
+ if (!touchedDeployField && !touchedPreferenceField) {
864
864
  res.status(400).json({ error: "没有可更新的配置字段。" });
865
865
  return;
866
866
  }
867
- // cardDefaults-only changes don't need restart
868
- const restartRequired = allowedFields.some((f) => f in body && body[f] !== undefined);
869
867
  try {
870
- await saveConfig(configPath, config);
868
+ if (touchedDeployField) {
869
+ await saveConfig(configPath, config);
870
+ }
871
871
  const { password: _pw, ...safeConfig } = config;
872
- res.json({ ok: true, config: safeConfig, restartRequired });
872
+ // 只有部署字段才需要重启;偏好字段已经热生效。
873
+ res.json({ ok: true, config: safeConfig, restartRequired: touchedDeployField });
873
874
  }
874
875
  catch (error) {
875
876
  res.status(500).json({ error: getErrorMessage(error, "保存配置失败。") });
@@ -35,6 +35,8 @@ export interface ShortcutLogContext {
35
35
  export declare class SessionLogger {
36
36
  private readonly baseDir;
37
37
  private readonly dirs;
38
+ /** Cached on-disk size of hot-path log files so we can rotate without stat'ing on every chunk. */
39
+ private readonly logSizes;
38
40
  private readonly shortcutLogMaxBytes;
39
41
  constructor(configDir: string, shortcutLogMaxBytes?: number);
40
42
  private ensureDir;
@@ -67,6 +69,6 @@ export declare class SessionLogger {
67
69
  deleteSession(sessionId: string): void;
68
70
  /** Append a shortcut key interaction log entry (for analyzing auto-confirm gaps) */
69
71
  appendShortcutLog(sessionId: string, shortcutKey: string, tailLines: string, ctx?: ShortcutLogContext): void;
70
- /** Truncate shortcut log by keeping only the most recent half of entries */
72
+ /** Truncate shortcut log by keeping only the most recent half of entries. Returns the new on-disk size. */
71
73
  private truncateShortcutLog;
72
74
  }