@co0ontty/wand 1.21.4 → 1.21.5

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/config.js CHANGED
@@ -20,6 +20,7 @@ export const defaultConfig = () => ({
20
20
  android: defaultAndroidApkConfig(),
21
21
  cardDefaults: defaultCardExpandDefaults(),
22
22
  defaultModel: "",
23
+ structuredRunner: "cli",
23
24
  commandPresets: [
24
25
  {
25
26
  label: "Claude",
@@ -185,6 +186,7 @@ function mergeWithDefaults(input) {
185
186
  android: normalizeAndroidApkConfig(input.android) ?? defaults.android,
186
187
  cardDefaults: normalizeCardDefaults(input.cardDefaults),
187
188
  defaultModel: typeof input.defaultModel === "string" ? input.defaultModel.trim() : defaults.defaultModel,
189
+ structuredRunner: (input.structuredRunner === "sdk" || input.structuredRunner === "cli") ? input.structuredRunner : defaults.structuredRunner,
188
190
  };
189
191
  }
190
192
  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
@@ -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 ?? "cli",
727
728
  structuredRunners: [
728
729
  { label: "Claude Structured", runner: "claude-cli-print" },
729
730
  { label: "Codex Structured", runner: "codex-cli-exec" },
@@ -815,7 +816,7 @@ 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
+ const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language", "defaultModel", "structuredRunner"];
819
820
  let changed = false;
820
821
  for (const field of allowedFields) {
821
822
  if (field in body && body[field] !== undefined) {
@@ -852,6 +853,9 @@ export async function startServer(config, configPath) {
852
853
  else if (field === "defaultModel") {
853
854
  config.defaultModel = typeof body.defaultModel === "string" ? body.defaultModel.trim() : "";
854
855
  }
856
+ else if (field === "structuredRunner") {
857
+ config.structuredRunner = body.structuredRunner === "sdk" ? "sdk" : "cli";
858
+ }
855
859
  changed = true;
856
860
  }
857
861
  }
@@ -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
  }
@@ -23,6 +23,8 @@ const DEFAULT_SHORTCUT_LOG_MAX_BYTES = 10 * 1024 * 1024;
23
23
  export class SessionLogger {
24
24
  baseDir;
25
25
  dirs = new Map();
26
+ /** Cached on-disk size of hot-path log files so we can rotate without stat'ing on every chunk. */
27
+ logSizes = new Map();
26
28
  shortcutLogMaxBytes;
27
29
  constructor(configDir, shortcutLogMaxBytes) {
28
30
  this.baseDir = path.join(configDir, "sessions");
@@ -46,6 +48,10 @@ export class SessionLogger {
46
48
  // ignore
47
49
  }
48
50
  this.dirs.set(sessionId, dir);
51
+ // Seed the size cache from disk on first use; subsequent appends maintain
52
+ // the counter in memory so the hot path no longer touches stat/exists.
53
+ const sizes = { pty: tryStatSize(path.join(dir, "pty-output.log")), shortcut: tryStatSize(path.join(dir, "shortcut-interactions.jsonl")) };
54
+ this.logSizes.set(sessionId, sizes);
49
55
  return dir;
50
56
  }
51
57
  /**
@@ -78,15 +84,14 @@ export class SessionLogger {
78
84
  appendPtyOutput(sessionId, chunk) {
79
85
  try {
80
86
  const dir = this.ensureDir(sessionId);
81
- const logPath = path.join(dir, "pty-output.log");
82
- // Check size and rotate if needed
83
- if (existsSync(logPath)) {
84
- const stats = statSync(logPath);
85
- if (stats.size >= PTY_LOG_MAX_SIZE) {
86
- this.rotatePtyLog(dir);
87
- }
87
+ const sizes = this.logSizes.get(sessionId);
88
+ if (sizes.pty >= PTY_LOG_MAX_SIZE) {
89
+ this.rotatePtyLog(dir);
90
+ sizes.pty = 0;
88
91
  }
92
+ const logPath = path.join(dir, "pty-output.log");
89
93
  appendFileSync(logPath, chunk);
94
+ sizes.pty += Buffer.byteLength(chunk);
90
95
  }
91
96
  catch {
92
97
  // Non-critical — don't let logging failures affect main flow
@@ -200,6 +205,7 @@ export class SessionLogger {
200
205
  // Non-critical
201
206
  }
202
207
  this.dirs.delete(sessionId);
208
+ this.logSizes.delete(sessionId);
203
209
  }
204
210
  /** Append a shortcut key interaction log entry (for analyzing auto-confirm gaps) */
205
211
  appendShortcutLog(sessionId, shortcutKey, tailLines, ctx) {
@@ -207,6 +213,7 @@ export class SessionLogger {
207
213
  return;
208
214
  try {
209
215
  const dir = this.ensureDir(sessionId);
216
+ const sizes = this.logSizes.get(sessionId);
210
217
  const logPath = path.join(dir, "shortcut-interactions.jsonl");
211
218
  const entry = JSON.stringify({
212
219
  ts: new Date().toISOString(),
@@ -217,35 +224,41 @@ export class SessionLogger {
217
224
  input: ctx?.input,
218
225
  tail: tailLines,
219
226
  }) + "\n";
220
- // Check size and truncate if needed
221
- if (existsSync(logPath)) {
222
- const size = statSync(logPath).size;
223
- if (size + entry.length > this.shortcutLogMaxBytes) {
224
- this.truncateShortcutLog(logPath);
225
- }
227
+ const entryBytes = Buffer.byteLength(entry);
228
+ if (sizes.shortcut + entryBytes > this.shortcutLogMaxBytes) {
229
+ sizes.shortcut = this.truncateShortcutLog(logPath);
226
230
  }
227
231
  appendFileSync(logPath, entry);
232
+ sizes.shortcut += entryBytes;
228
233
  }
229
234
  catch {
230
235
  // Non-critical
231
236
  }
232
237
  }
233
- /** Truncate shortcut log by keeping only the most recent half of entries */
238
+ /** Truncate shortcut log by keeping only the most recent half of entries. Returns the new on-disk size. */
234
239
  truncateShortcutLog(logPath) {
235
240
  try {
236
241
  const content = readFileSync(logPath, "utf8");
237
242
  const lines = content.split("\n").filter(Boolean);
238
- // Keep the latter half
239
243
  const keepFrom = Math.floor(lines.length / 2);
240
244
  const trimmed = lines.slice(keepFrom).join("\n") + "\n";
241
245
  writeFileSync(logPath, trimmed);
246
+ return Buffer.byteLength(trimmed);
242
247
  }
243
248
  catch {
244
- // If truncation fails, delete the file to prevent unbounded growth
245
249
  try {
246
250
  unlinkSync(logPath);
247
251
  }
248
252
  catch { /* ignore */ }
253
+ return 0;
249
254
  }
250
255
  }
251
256
  }
257
+ function tryStatSize(filePath) {
258
+ try {
259
+ return existsSync(filePath) ? statSync(filePath).size : 0;
260
+ }
261
+ catch {
262
+ return 0;
263
+ }
264
+ }
@@ -17,12 +17,30 @@ export declare class StructuredSessionManager {
17
17
  private readonly logger;
18
18
  private readonly sessions;
19
19
  private readonly pendingChildren;
20
+ private readonly pendingSdkAbort;
20
21
  private readonly interruptedWith;
22
+ /** Last wall-clock time (ms) we did a full saveSession for a streaming session. */
23
+ private readonly lastStreamSaveAt;
24
+ /**
25
+ * Idempotency keys we've already accepted, mapped to their wall-clock timestamp.
26
+ * Android WebView 在进程恢复时偶尔会重发上一个未收到响应的 POST(HTTP/2 stream
27
+ * reset 等场景),客户端 JS 没有重试逻辑也拦不住。这里用 (sessionId, key) 永
28
+ * 久去重,重复就抛错让前端弹 toast 提示,**不**做任何处理。timestamp 仅用于
29
+ * map 大小溢出时按时间裁剪。
30
+ */
31
+ private readonly seenIdempotencyKeys;
21
32
  private emitEvent;
22
33
  private archiveTimer;
23
34
  constructor(storage: WandStorage, config: WandConfig, logger?: SessionLogger | null);
24
35
  private archiveExpiredSessions;
25
36
  setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
37
+ /**
38
+ * In-memory snapshot is updated unconditionally; the SQLite write is rate-
39
+ * limited to once per STREAM_SAVE_THROTTLE_MS. Caller must still invoke
40
+ * `storage.saveSession` directly at terminal events (close / failure) so the
41
+ * final state is durable.
42
+ */
43
+ private saveStreamingSnapshot;
26
44
  list(): SessionSnapshot[];
27
45
  /** Return lightweight snapshots for the session list (no output/messages). */
28
46
  listSlim(): SessionSnapshot[];
@@ -30,6 +48,7 @@ export declare class StructuredSessionManager {
30
48
  createSession(options: CreateStructuredSessionOptions): SessionSnapshot;
31
49
  sendMessage(id: string, input: string, opts?: {
32
50
  interrupt?: boolean;
51
+ idempotencyKey?: string;
33
52
  }): Promise<SessionSnapshot>;
34
53
  /** Approve a pending permission request. */
35
54
  approvePermission(sessionId: string): SessionSnapshot;
@@ -66,6 +85,18 @@ export declare class StructuredSessionManager {
66
85
  * outside CWD). stdin is always "ignore" — no ACP bidirectional control.
67
86
  */
68
87
  private runClaudeStreaming;
88
+ /**
89
+ * Use @anthropic-ai/claude-agent-sdk instead of spawning claude -p directly.
90
+ * The SDK still spawns the claude binary but provides typed AsyncGenerator<SDKMessage>
91
+ * messages, so we skip NDJSON parsing. Options are 1:1 with the CLI flags.
92
+ *
93
+ * Streaming is enabled via includePartialMessages: true — the SDK emits
94
+ * SDKPartialAssistantMessage (type: "stream_event") with BetaRawMessageStreamEvent
95
+ * payloads for incremental text/thinking/tool_use updates, followed by a final
96
+ * SDKAssistantMessage with the authoritative complete content.
97
+ */
98
+ private runClaudeSdkStreaming;
99
+ private _runClaudeSdkStreamingAsync;
69
100
  private extractAssistantMessage;
70
101
  private compactContentBlocks;
71
102
  private normalizeToolInput;
@@ -76,6 +107,8 @@ export declare class StructuredSessionManager {
76
107
  private finishStructuredFailure;
77
108
  private extractModelName;
78
109
  private extractUsage;
110
+ /** Extract usage from an SDKResultSuccess message (sdk runner). */
111
+ private extractSdkUsage;
79
112
  private extractCodexUsage;
80
113
  }
81
114
  export {};