@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.
@@ -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
+ }
package/dist/storage.d.ts CHANGED
@@ -16,6 +16,12 @@ export declare class WandStorage {
16
16
  setConfigValue(key: string, value: string): void;
17
17
  /** Delete a config value */
18
18
  deleteConfigValue(key: string): void;
19
+ /** 读取偏好。未设置或 JSON 解析失败时返回 fallback。 */
20
+ getPreference<T>(key: string, fallback: T): T;
21
+ /** 写入偏好。undefined / null 视为删除。 */
22
+ setPreference<T>(key: string, value: T | null | undefined): void;
23
+ /** 判断偏好是否在 DB 中存在(区别于值为 null/false/"")。 */
24
+ hasPreference(key: string): boolean;
19
25
  /** Get password from database */
20
26
  getPassword(): string | null;
21
27
  /** Set password in database */
package/dist/storage.js CHANGED
@@ -261,6 +261,35 @@ export class WandStorage {
261
261
  deleteConfigValue(key) {
262
262
  this.db.prepare("DELETE FROM app_config WHERE key = ?").run(key);
263
263
  }
264
+ // ============ Preference Methods ============
265
+ // Preferences 与 getConfigValue/setConfigValue 共用 app_config 表,
266
+ // 区别在于:preference 自动 JSON 序列化/反序列化,并按"未设置时返回 fallback"语义返回。
267
+ // 用于存放 UI 设置面板可改的用户偏好(defaultMode/defaultModel/cardDefaults 等),
268
+ // 与 JSON 配置中的部署期参数(host/port/shell 等)分开。
269
+ /** 读取偏好。未设置或 JSON 解析失败时返回 fallback。 */
270
+ getPreference(key, fallback) {
271
+ const raw = this.getConfigValue(key);
272
+ if (raw === null)
273
+ return fallback;
274
+ try {
275
+ return JSON.parse(raw);
276
+ }
277
+ catch {
278
+ return fallback;
279
+ }
280
+ }
281
+ /** 写入偏好。undefined / null 视为删除。 */
282
+ setPreference(key, value) {
283
+ if (value === undefined || value === null) {
284
+ this.deleteConfigValue(key);
285
+ return;
286
+ }
287
+ this.setConfigValue(key, JSON.stringify(value));
288
+ }
289
+ /** 判断偏好是否在 DB 中存在(区别于值为 null/false/"")。 */
290
+ hasPreference(key) {
291
+ return this.getConfigValue(key) !== null;
292
+ }
264
293
  /** Get password from database */
265
294
  getPassword() {
266
295
  return this.getConfigValue("password");
@@ -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 {};