@co0ontty/wand 4.21.0 → 4.22.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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "79972abbe91abdb5e1627ebb9eaba7b2e1ef8118",
3
- "builtAt": "2026-07-22T05:37:40.056Z",
4
- "version": "4.21.0",
2
+ "commit": "3e46be813515b326cd1be393157864d189bf48fe",
3
+ "builtAt": "2026-07-22T14:22:25.841Z",
4
+ "version": "4.22.0",
5
5
  "channel": "stable"
6
6
  }
@@ -2,8 +2,8 @@ import { EventEmitter } from "node:events";
2
2
  import { WandStorage } from "./storage.js";
3
3
  import { ExecutionMode, ProcessEventHandler, SessionProvider, SessionSnapshot, SessionSource, WandConfig } from "./types.js";
4
4
  import { type PermissionResolution } from "./claude-pty-bridge.js";
5
- import { type ClaudeHistorySession, type CodexHistorySession } from "./provider-history-scanner.js";
6
- export type { ClaudeHistorySession, CodexHistorySession } from "./provider-history-scanner.js";
5
+ import { type ClaudeHistorySession, type CodexHistorySession, type OpenCodeHistorySession, type QoderHistorySession } from "./provider-history-scanner.js";
6
+ export type { ClaudeHistorySession, CodexHistorySession, OpenCodeHistorySession, QoderHistorySession, } from "./provider-history-scanner.js";
7
7
  /** Exported for focused policy tests. */
8
8
  export declare function isCommandAllowedByPrefixes(command: string, allowedPrefixes: readonly string[]): boolean;
9
9
  export type { ProcessEvent, ProcessEventHandler } from "./types.js";
@@ -64,6 +64,10 @@ export declare class ProcessManager extends EventEmitter {
64
64
  listCodexHistorySessions(): CodexHistorySession[];
65
65
  hasCodexSessionFile(threadId: string): boolean;
66
66
  deleteCodexHistoryFiles(threadIds: string[]): number;
67
+ listOpenCodeHistorySessions(): OpenCodeHistorySession[];
68
+ deleteOpenCodeHistorySessions(sessionIds: string[]): number;
69
+ listQoderHistorySessions(): QoderHistorySession[];
70
+ deleteQoderHistoryFiles(sessionIds: string[]): number;
67
71
  private captureCodexSessionId;
68
72
  private captureOpenCodeSessionId;
69
73
  private captureClaudeSessionId;
@@ -356,12 +356,9 @@ function selectCodexSessionForRecord(record, sessions) {
356
356
  function getLatestCodexSessionId(record, sessions) {
357
357
  return selectCodexSessionForRecord(record, sessions)?.claudeSessionId ?? null;
358
358
  }
359
- function getOpenCodeDatabasePath() {
360
- const dataHome = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
361
- return path.join(dataHome, "opencode", "opencode.db");
362
- }
363
359
  function listOpenCodeSessionCandidates() {
364
- const dbPath = getOpenCodeDatabasePath();
360
+ const dataHome = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
361
+ const dbPath = path.join(dataHome, "opencode", "opencode.db");
365
362
  if (!existsSync(dbPath))
366
363
  return [];
367
364
  let db = null;
@@ -1213,6 +1210,18 @@ export class ProcessManager extends EventEmitter {
1213
1210
  deleteCodexHistoryFiles(threadIds) {
1214
1211
  return this.providerHistory.deleteCodexHistoryFiles(threadIds);
1215
1212
  }
1213
+ listOpenCodeHistorySessions() {
1214
+ return this.providerHistory.listOpenCodeHistorySessions();
1215
+ }
1216
+ deleteOpenCodeHistorySessions(sessionIds) {
1217
+ return this.providerHistory.deleteOpenCodeHistorySessions(sessionIds);
1218
+ }
1219
+ listQoderHistorySessions() {
1220
+ return this.providerHistory.listQoderHistorySessions();
1221
+ }
1222
+ deleteQoderHistoryFiles(sessionIds) {
1223
+ return this.providerHistory.deleteQoderHistoryFiles(sessionIds);
1224
+ }
1216
1225
  captureCodexSessionId(record, options) {
1217
1226
  if (record.provider !== "codex" || record.claudeSessionId) {
1218
1227
  return false;
@@ -20,9 +20,34 @@ export interface CodexHistorySession {
20
20
  managedByWand: boolean;
21
21
  provider: "codex";
22
22
  }
23
+ /** OpenCode persists session metadata and messages in its local SQLite database. */
24
+ export interface OpenCodeHistorySession {
25
+ claudeSessionId: string;
26
+ cwd: string;
27
+ firstUserMessage: string;
28
+ timestamp: string;
29
+ mtimeMs: number;
30
+ hasConversation: boolean;
31
+ managedByWand: boolean;
32
+ provider: "opencode";
33
+ }
34
+ /** Qoder CLI keeps one JSONL transcript per project-local native session. */
35
+ export interface QoderHistorySession {
36
+ claudeSessionId: string;
37
+ cwd: string;
38
+ firstUserMessage: string;
39
+ timestamp: string;
40
+ mtimeMs: number;
41
+ hasConversation: boolean;
42
+ managedByWand: boolean;
43
+ provider: "qoder";
44
+ }
23
45
  export interface ProviderHistoryScannerOptions {
24
46
  claudeHome?: string;
25
47
  codexSessionsDir?: string;
48
+ openCodeDatabasePath?: string;
49
+ /** Qoder and Qoder CN use separate config roots; callers may override both for tests. */
50
+ qoderProjectsDirs?: string[];
26
51
  }
27
52
  /**
28
53
  * Incremental provider-history index. Directory entries are refreshed on each
@@ -32,23 +57,35 @@ export declare class ProviderHistoryScanner {
32
57
  private readonly claudeHome;
33
58
  private readonly claudeProjectsDir;
34
59
  private readonly codexSessionsDir;
60
+ private readonly openCodeDatabasePath;
61
+ private readonly qoderProjectsDirs;
35
62
  private readonly claudeIndex;
36
63
  private readonly codexIndex;
64
+ private readonly qoderIndex;
65
+ private openCodeFingerprint;
66
+ private openCodeSessions;
37
67
  private parsedFiles;
38
68
  constructor(options?: ProviderHistoryScannerOptions);
39
69
  getDiagnostics(): {
40
70
  parsedFiles: number;
41
71
  claudeEntries: number;
42
72
  codexEntries: number;
73
+ openCodeEntries: number;
74
+ qoderEntries: number;
43
75
  };
44
- invalidate(provider?: "claude" | "codex"): void;
76
+ invalidate(provider?: "claude" | "codex" | "opencode" | "qoder"): void;
45
77
  listClaudeHistorySessions(): ClaudeHistorySession[];
46
78
  private listCodexRolloutFiles;
47
79
  listCodexHistorySessions(): CodexHistorySession[];
48
80
  hasCodexSessionFile(threadId: string): boolean;
81
+ listOpenCodeHistorySessions(): OpenCodeHistorySession[];
82
+ private listQoderTranscriptFiles;
83
+ listQoderHistorySessions(): QoderHistorySession[];
49
84
  deleteClaudeHistoryFiles(sessions: Array<{
50
85
  claudeSessionId: string;
51
86
  cwd: string;
52
87
  }>): number;
53
88
  deleteCodexHistoryFiles(threadIds: string[]): number;
89
+ deleteOpenCodeHistorySessions(sessionIds: string[]): number;
90
+ deleteQoderHistoryFiles(sessionIds: string[]): number;
54
91
  }
@@ -1,7 +1,9 @@
1
1
  import { closeSync, existsSync, openSync, readSync, readdirSync, rmSync, statSync, unlinkSync, } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
4
5
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ const PROVIDER_SESSION_ID_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,199}$/i;
5
7
  const WORKTREE_DIR_PATTERN = /--?\.?(?:wand-worktrees|claude-worktrees)-/;
6
8
  function readHead(filePath, maxBytes) {
7
9
  let fd = null;
@@ -102,6 +104,74 @@ function isCodexSystemInjectedText(text) {
102
104
  const trimmed = text.trimStart();
103
105
  return trimmed.startsWith("#") || trimmed.startsWith("<");
104
106
  }
107
+ function qoderMessageText(content) {
108
+ if (typeof content === "string")
109
+ return content;
110
+ if (!Array.isArray(content))
111
+ return "";
112
+ return content
113
+ .filter((block) => Boolean(block && typeof block === "object"))
114
+ .filter((block) => block.type === "text" && typeof block.text === "string")
115
+ .map((block) => block.text)
116
+ .join("\n");
117
+ }
118
+ function isQoderGeneratedMessage(text) {
119
+ const trimmed = text.trimStart();
120
+ return trimmed.startsWith("<local-command-caveat>") || trimmed.startsWith("<command-message>");
121
+ }
122
+ function parseQoderSummary(id, head) {
123
+ let cwd = "";
124
+ let timestamp = "";
125
+ let firstUserMessage = "";
126
+ let aiTitle = "";
127
+ let hasUser = false;
128
+ let hasAssistant = false;
129
+ for (const line of head.text.split("\n")) {
130
+ if (!line.trim())
131
+ continue;
132
+ let parsed;
133
+ try {
134
+ parsed = JSON.parse(line);
135
+ }
136
+ catch {
137
+ continue;
138
+ }
139
+ if (!timestamp && typeof parsed.timestamp === "string")
140
+ timestamp = parsed.timestamp;
141
+ if (!cwd && typeof parsed.cwd === "string")
142
+ cwd = parsed.cwd;
143
+ if (!cwd && parsed.type === "workspace-directories" && Array.isArray(parsed.directories)) {
144
+ const directory = parsed.directories.find((value) => typeof value === "string" && value.trim().length > 0);
145
+ if (directory)
146
+ cwd = directory;
147
+ }
148
+ if (parsed.type === "ai-title" && typeof parsed.aiTitle === "string" && parsed.aiTitle.trim()) {
149
+ aiTitle = parsed.aiTitle.trim();
150
+ }
151
+ const role = parsed.message?.role;
152
+ if (parsed.type === "user" && role === "user") {
153
+ const text = qoderMessageText(parsed.message?.content).trim();
154
+ if (text && parsed.isMeta !== true && !isQoderGeneratedMessage(text)) {
155
+ hasUser = true;
156
+ if (!firstUserMessage)
157
+ firstUserMessage = text.slice(0, 120);
158
+ }
159
+ }
160
+ else if (parsed.type === "assistant" && role === "assistant") {
161
+ hasAssistant = true;
162
+ }
163
+ }
164
+ return {
165
+ claudeSessionId: id,
166
+ cwd,
167
+ firstUserMessage: aiTitle || firstUserMessage,
168
+ timestamp: timestamp || new Date(head.mtimeMs).toISOString(),
169
+ mtimeMs: head.mtimeMs,
170
+ hasConversation: hasUser && hasAssistant,
171
+ managedByWand: false,
172
+ provider: "qoder",
173
+ };
174
+ }
105
175
  /**
106
176
  * Codex versions used before quick commit enabled `--ephemeral` persisted these
107
177
  * internal one-shot prompts as ordinary sessions. Hide those legacy artifacts
@@ -192,22 +262,45 @@ export class ProviderHistoryScanner {
192
262
  claudeHome;
193
263
  claudeProjectsDir;
194
264
  codexSessionsDir;
265
+ openCodeDatabasePath;
266
+ qoderProjectsDirs;
195
267
  claudeIndex = new Map();
196
268
  codexIndex = new Map();
269
+ qoderIndex = new Map();
270
+ openCodeFingerprint = null;
271
+ openCodeSessions = [];
197
272
  parsedFiles = 0;
198
273
  constructor(options = {}) {
199
274
  this.claudeHome = options.claudeHome ?? path.join(os.homedir(), ".claude");
200
275
  this.claudeProjectsDir = path.join(this.claudeHome, "projects");
201
276
  this.codexSessionsDir = options.codexSessionsDir ?? path.join(os.homedir(), ".codex", "sessions");
277
+ const dataHome = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
278
+ this.openCodeDatabasePath = options.openCodeDatabasePath ?? path.join(dataHome, "opencode", "opencode.db");
279
+ this.qoderProjectsDirs = options.qoderProjectsDirs ?? [
280
+ path.join(os.homedir(), ".qoder", "projects"),
281
+ path.join(os.homedir(), ".qoder-cn", "projects"),
282
+ ];
202
283
  }
203
284
  getDiagnostics() {
204
- return { parsedFiles: this.parsedFiles, claudeEntries: this.claudeIndex.size, codexEntries: this.codexIndex.size };
285
+ return {
286
+ parsedFiles: this.parsedFiles,
287
+ claudeEntries: this.claudeIndex.size,
288
+ codexEntries: this.codexIndex.size,
289
+ openCodeEntries: this.openCodeSessions.length,
290
+ qoderEntries: this.qoderIndex.size,
291
+ };
205
292
  }
206
293
  invalidate(provider) {
207
294
  if (!provider || provider === "claude")
208
295
  this.claudeIndex.clear();
209
296
  if (!provider || provider === "codex")
210
297
  this.codexIndex.clear();
298
+ if (!provider || provider === "opencode") {
299
+ this.openCodeFingerprint = null;
300
+ this.openCodeSessions = [];
301
+ }
302
+ if (!provider || provider === "qoder")
303
+ this.qoderIndex.clear();
211
304
  }
212
305
  listClaudeHistorySessions() {
213
306
  const observed = new Set();
@@ -319,6 +412,143 @@ export class ProviderHistoryScanner {
319
412
  return UUID_PATTERN.test(threadId)
320
413
  && this.listCodexHistorySessions().some((session) => session.claudeSessionId === threadId);
321
414
  }
415
+ listOpenCodeHistorySessions() {
416
+ const databasePaths = [this.openCodeDatabasePath, `${this.openCodeDatabasePath}-wal`];
417
+ const fingerprint = databasePaths.map((filePath) => {
418
+ try {
419
+ const stats = statSync(filePath);
420
+ return `${filePath}:${stats.mtimeMs}:${stats.size}`;
421
+ }
422
+ catch {
423
+ return `${filePath}:missing`;
424
+ }
425
+ }).join("|");
426
+ if (fingerprint === this.openCodeFingerprint) {
427
+ return this.openCodeSessions.map((session) => ({ ...session, managedByWand: false }));
428
+ }
429
+ this.openCodeFingerprint = fingerprint;
430
+ if (!existsSync(this.openCodeDatabasePath)) {
431
+ this.openCodeSessions = [];
432
+ return [];
433
+ }
434
+ let database = null;
435
+ try {
436
+ database = new DatabaseSync(this.openCodeDatabasePath, { readOnly: true });
437
+ const rows = database.prepare(`
438
+ SELECT
439
+ session.id AS id,
440
+ session.directory AS cwd,
441
+ session.title AS title,
442
+ session.time_created AS time_created,
443
+ session.time_updated AS time_updated,
444
+ SUM(CASE WHEN json_extract(message.data, '$.role') = 'user' THEN 1 ELSE 0 END) AS user_count,
445
+ SUM(CASE WHEN json_extract(message.data, '$.role') = 'assistant' THEN 1 ELSE 0 END) AS assistant_count
446
+ FROM session
447
+ LEFT JOIN message ON message.session_id = session.id
448
+ GROUP BY session.id
449
+ ORDER BY session.time_updated DESC
450
+ LIMIT 1000
451
+ `).all();
452
+ this.openCodeSessions = rows.flatMap((row) => {
453
+ if (typeof row.id !== "string" || !PROVIDER_SESSION_ID_PATTERN.test(row.id))
454
+ return [];
455
+ if (typeof row.cwd !== "string" || !row.cwd.trim())
456
+ return [];
457
+ const mtimeMs = Number(row.time_updated);
458
+ const createdMs = Number(row.time_created);
459
+ if (!Number.isFinite(mtimeMs) || !Number.isFinite(createdMs))
460
+ return [];
461
+ return [{
462
+ claudeSessionId: row.id,
463
+ cwd: row.cwd,
464
+ firstUserMessage: typeof row.title === "string" ? row.title.trim().slice(0, 120) : "",
465
+ timestamp: new Date(createdMs).toISOString(),
466
+ mtimeMs,
467
+ hasConversation: Number(row.user_count) > 0 && Number(row.assistant_count) > 0,
468
+ managedByWand: false,
469
+ provider: "opencode",
470
+ }];
471
+ });
472
+ return this.openCodeSessions.map((session) => ({ ...session }));
473
+ }
474
+ catch {
475
+ this.openCodeSessions = [];
476
+ return [];
477
+ }
478
+ finally {
479
+ try {
480
+ database?.close();
481
+ }
482
+ catch { /* best-effort read-only probe */ }
483
+ }
484
+ }
485
+ listQoderTranscriptFiles() {
486
+ const files = [];
487
+ for (const projectsDir of this.qoderProjectsDirs) {
488
+ let projects;
489
+ try {
490
+ projects = readdirSync(projectsDir, { withFileTypes: true });
491
+ }
492
+ catch {
493
+ continue;
494
+ }
495
+ for (const project of projects) {
496
+ if (!project.isDirectory())
497
+ continue;
498
+ const projectDir = path.join(projectsDir, project.name);
499
+ let entries;
500
+ try {
501
+ entries = readdirSync(projectDir, { withFileTypes: true });
502
+ }
503
+ catch {
504
+ continue;
505
+ }
506
+ for (const entry of entries) {
507
+ if (entry.isFile() && entry.name.endsWith(".jsonl"))
508
+ files.push(path.join(projectDir, entry.name));
509
+ }
510
+ }
511
+ }
512
+ return files;
513
+ }
514
+ listQoderHistorySessions() {
515
+ const observed = new Set();
516
+ const bySessionId = new Map();
517
+ for (const filePath of this.listQoderTranscriptFiles()) {
518
+ const id = path.basename(filePath, ".jsonl");
519
+ if (!PROVIDER_SESSION_ID_PATTERN.test(id))
520
+ continue;
521
+ observed.add(filePath);
522
+ let stats;
523
+ try {
524
+ stats = statSync(filePath);
525
+ }
526
+ catch {
527
+ continue;
528
+ }
529
+ let cached = this.qoderIndex.get(filePath);
530
+ if (!cached || cached.mtimeMs !== stats.mtimeMs || cached.size !== stats.size) {
531
+ const head = readHead(filePath, 65536);
532
+ if (!head)
533
+ continue;
534
+ cached = { mtimeMs: head.mtimeMs, size: head.size, summary: parseQoderSummary(id, head) };
535
+ this.qoderIndex.set(filePath, cached);
536
+ this.parsedFiles += 1;
537
+ }
538
+ const summary = cached.summary;
539
+ if (!summary || !summary.cwd)
540
+ continue;
541
+ const existing = bySessionId.get(summary.claudeSessionId);
542
+ if (!existing || summary.mtimeMs > existing.mtimeMs) {
543
+ bySessionId.set(summary.claudeSessionId, { ...summary, managedByWand: false });
544
+ }
545
+ }
546
+ for (const filePath of this.qoderIndex.keys()) {
547
+ if (!observed.has(filePath))
548
+ this.qoderIndex.delete(filePath);
549
+ }
550
+ return Array.from(bySessionId.values()).sort((left, right) => right.mtimeMs - left.mtimeMs);
551
+ }
322
552
  deleteClaudeHistoryFiles(sessions) {
323
553
  let deleted = 0;
324
554
  for (const { claudeSessionId, cwd } of sessions) {
@@ -369,4 +599,47 @@ export class ProviderHistoryScanner {
369
599
  }
370
600
  return deleted;
371
601
  }
602
+ deleteOpenCodeHistorySessions(sessionIds) {
603
+ const ids = sessionIds.filter((id) => PROVIDER_SESSION_ID_PATTERN.test(id));
604
+ if (ids.length === 0 || !existsSync(this.openCodeDatabasePath))
605
+ return 0;
606
+ let database = null;
607
+ try {
608
+ database = new DatabaseSync(this.openCodeDatabasePath);
609
+ database.exec("PRAGMA foreign_keys = ON");
610
+ const remove = database.prepare("DELETE FROM session WHERE id = ?");
611
+ let deleted = 0;
612
+ for (const id of ids)
613
+ deleted += Number(remove.run(id).changes ?? 0);
614
+ this.invalidate("opencode");
615
+ return deleted;
616
+ }
617
+ catch {
618
+ return 0;
619
+ }
620
+ finally {
621
+ try {
622
+ database?.close();
623
+ }
624
+ catch { /* best effort */ }
625
+ }
626
+ }
627
+ deleteQoderHistoryFiles(sessionIds) {
628
+ const ids = new Set(sessionIds.filter((id) => PROVIDER_SESSION_ID_PATTERN.test(id)));
629
+ if (ids.size === 0)
630
+ return 0;
631
+ let deleted = 0;
632
+ for (const filePath of this.listQoderTranscriptFiles()) {
633
+ const id = path.basename(filePath, ".jsonl");
634
+ if (!ids.has(id))
635
+ continue;
636
+ try {
637
+ unlinkSync(filePath);
638
+ deleted += 1;
639
+ }
640
+ catch { /* already absent */ }
641
+ this.qoderIndex.delete(filePath);
642
+ }
643
+ return deleted;
644
+ }
372
645
  }
@@ -13,7 +13,7 @@ export declare function parseSessionCreationOrigin(body: {
13
13
  sessionSource: SessionSource;
14
14
  automationId?: string;
15
15
  };
16
- type SessionDeletionProcesses = Pick<ProcessManager, "get" | "delete" | "deleteClaudeHistoryFiles" | "deleteCodexHistoryFiles">;
16
+ type SessionDeletionProcesses = Pick<ProcessManager, "get" | "delete" | "deleteClaudeHistoryFiles" | "deleteCodexHistoryFiles" | "deleteOpenCodeHistorySessions" | "deleteQoderHistoryFiles">;
17
17
  type SessionDeletionStructured = Pick<StructuredSessionManager, "get" | "delete">;
18
18
  /**
19
19
  * A Wand-managed session and its provider-native history represent the same
@@ -29,7 +29,7 @@ type ProviderHistorySession = {
29
29
  mtimeMs: number;
30
30
  hasConversation: boolean;
31
31
  managedByWand: boolean;
32
- provider?: "claude" | "codex";
32
+ provider?: "claude" | "codex" | "opencode" | "qoder";
33
33
  };
34
34
  export type SessionListPageEntry = {
35
35
  type: "managed";
@@ -41,7 +41,7 @@ export type SessionListPageEntry = {
41
41
  key: string;
42
42
  sortTimestamp: number;
43
43
  history: ProviderHistorySession & {
44
- provider: "claude" | "codex";
44
+ provider: "claude" | "codex" | "opencode" | "qoder";
45
45
  };
46
46
  };
47
47
  export interface SessionListPage {
@@ -50,14 +50,14 @@ export interface SessionListPage {
50
50
  total: number;
51
51
  revision: string;
52
52
  }
53
- export declare function buildSessionListPage(sessions: SessionSnapshot[], claudeHistory: ProviderHistorySession[], codexHistory: ProviderHistorySession[], hiddenHistoryIds: Set<string>, offset: number, limit: number): SessionListPage;
53
+ export declare function buildSessionListPage(sessions: SessionSnapshot[], claudeHistory: ProviderHistorySession[], codexHistory: ProviderHistorySession[], hiddenHistoryIds: Set<string>, offset: number, limit: number, openCodeHistory?: ProviderHistorySession[], qoderHistory?: ProviderHistorySession[]): SessionListPage;
54
54
  /**
55
55
  * Provider history is scanned by ProcessManager, but structured sessions live
56
56
  * in StructuredSessionManager. Annotate against the combined session list so a
57
57
  * structured conversation is not also exposed as a recoverable native history
58
58
  * entry. Return copies for matches because ProcessManager caches scan results.
59
59
  */
60
- export declare function markManagedProviderHistory<T extends ProviderHistorySession>(history: T[], sessions: SessionSnapshot[], provider: "claude" | "codex"): T[];
60
+ export declare function markManagedProviderHistory<T extends ProviderHistorySession>(history: T[], sessions: SessionSnapshot[], provider: "claude" | "codex" | "opencode" | "qoder"): T[];
61
61
  export declare function registerSessionRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, defaultMode: ExecutionMode, config: WandConfig, sessions: SessionRegistry, onSessionCreated?: (cwd: string | undefined | null) => void): void;
62
62
  export declare function registerClaudeHistoryRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, sessionRegistry: SessionRegistry): void;
63
63
  export {};
@@ -8,7 +8,7 @@ import { resolveSessionCwd } from "./session-cwd.js";
8
8
  import { resolveCommitAiContext } from "./session-ai-context.js";
9
9
  import { getGitStatusAsync, QuickCommitError, runQuickCommitWithFallback, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
10
10
  import { getErrorMessage } from "./error-utils.js";
11
- import { buildProviderResumeCommand, isProviderSessionId } from "./resume-policy.js";
11
+ import { buildProviderResumeCommand, isProviderSessionId, isSafeProviderSessionId } from "./resume-policy.js";
12
12
  import { parseBoundedInteger } from "./request-limits.js";
13
13
  import { asyncRoute } from "./express-async.js";
14
14
  import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
@@ -179,6 +179,12 @@ export function deleteSessionWithProviderHistory(processes, structured, storage,
179
179
  else if (provider === "codex") {
180
180
  processes.deleteCodexHistoryFiles([providerSessionId]);
181
181
  }
182
+ else if (provider === "opencode") {
183
+ processes.deleteOpenCodeHistorySessions([providerSessionId]);
184
+ }
185
+ else if (provider === "qoder") {
186
+ processes.deleteQoderHistoryFiles([providerSessionId]);
187
+ }
182
188
  else {
183
189
  return;
184
190
  }
@@ -190,7 +196,7 @@ function sessionSortTimestamp(snapshot) {
190
196
  const timestamp = Date.parse(snapshot.startedAt);
191
197
  return Number.isFinite(timestamp) ? timestamp : 0;
192
198
  }
193
- export function buildSessionListPage(sessions, claudeHistory, codexHistory, hiddenHistoryIds, offset, limit) {
199
+ export function buildSessionListPage(sessions, claudeHistory, codexHistory, hiddenHistoryIds, offset, limit, openCodeHistory = [], qoderHistory = []) {
194
200
  const managed = sessions.map((session) => ({
195
201
  type: "managed",
196
202
  key: `session-${session.id}`,
@@ -200,8 +206,12 @@ export function buildSessionListPage(sessions, claudeHistory, codexHistory, hidd
200
206
  const recoverable = [
201
207
  ...markManagedProviderHistory(claudeHistory, sessions, "claude"),
202
208
  ...markManagedProviderHistory(codexHistory, sessions, "codex"),
209
+ ...markManagedProviderHistory(openCodeHistory, sessions, "opencode"),
210
+ ...markManagedProviderHistory(qoderHistory, sessions, "qoder"),
203
211
  ].flatMap((history) => {
204
- const provider = history.provider === "codex" ? "codex" : "claude";
212
+ const provider = history.provider === "codex" || history.provider === "opencode" || history.provider === "qoder"
213
+ ? history.provider
214
+ : "claude";
205
215
  if (!history.hasConversation || history.managedByWand || hiddenHistoryIds.has(history.claudeSessionId)) {
206
216
  return [];
207
217
  }
@@ -238,7 +248,13 @@ export function markManagedProviderHistory(history, sessions, provider) {
238
248
  for (const session of sessions) {
239
249
  const sessionProvider = session.provider
240
250
  ?? session.structuredState?.provider
241
- ?? (/^codex\b/i.test(session.command.trim()) ? "codex" : "claude");
251
+ ?? (/^codex\b/i.test(session.command.trim())
252
+ ? "codex"
253
+ : /^opencode\b/i.test(session.command.trim())
254
+ ? "opencode"
255
+ : /^qodercli\b/i.test(session.command.trim())
256
+ ? "qoder"
257
+ : "claude");
242
258
  const providerSessionId = session.claudeSessionId?.trim();
243
259
  if (sessionProvider === provider && providerSessionId) {
244
260
  managedIds.add(providerSessionId);
@@ -385,7 +401,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
385
401
  const limit = parseBoundedInteger(req.query.limit, 40, 1, 200);
386
402
  const requestedRevision = typeof req.query.revision === "string" ? req.query.revision : "";
387
403
  const currentSessions = sessions.listSlim();
388
- const page = buildSessionListPage(currentSessions, processes.listClaudeHistorySessions(), processes.listCodexHistorySessions(), getHiddenClaudeSessionIds(storage), offset, limit);
404
+ const page = buildSessionListPage(currentSessions, processes.listClaudeHistorySessions(), processes.listCodexHistorySessions(), getHiddenClaudeSessionIds(storage), offset, limit, processes.listOpenCodeHistorySessions(), processes.listQoderHistorySessions());
389
405
  if (offset > 0 && requestedRevision !== page.revision) {
390
406
  res.status(409).json({ error: "会话列表已更新,请重新加载。", revision: page.revision });
391
407
  return;
@@ -1136,6 +1152,80 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1136
1152
  res.status(400).json({ error: getErrorMessage(error, "无法按 Codex 会话 ID 恢复会话。") });
1137
1153
  }
1138
1154
  }));
1155
+ /**
1156
+ * OpenCode and Qoder own their native transcript storage. Restoring an
1157
+ * external transcript creates a Wand structured shell carrying the native
1158
+ * ID; each provider runner adds its own resume flag when the next input is
1159
+ * sent, preserving the original context without importing the full history.
1160
+ */
1161
+ app.post("/api/opencode-sessions/:sessionId/resume", (req, res) => {
1162
+ const sessionId = String(req.params.sessionId || "").trim();
1163
+ const body = req.body;
1164
+ try {
1165
+ if (!isSafeProviderSessionId(sessionId)) {
1166
+ res.status(400).json({ error: "OpenCode 会话 ID 格式无效。" });
1167
+ return;
1168
+ }
1169
+ const history = processes.listOpenCodeHistorySessions().find((session) => session.claudeSessionId === sessionId);
1170
+ if (!history) {
1171
+ res.status(400).json({ error: "对应的 OpenCode 历史会话不存在,无法恢复。" });
1172
+ return;
1173
+ }
1174
+ const cwd = body.cwd?.trim() || history.cwd;
1175
+ if (!cwd) {
1176
+ res.status(400).json({ error: "无法确定工作目录 (cwd),无法恢复。" });
1177
+ return;
1178
+ }
1179
+ const snapshot = structured.createSession({
1180
+ cwd,
1181
+ mode: parseExecutionMode(body.mode, defaultMode),
1182
+ provider: "opencode",
1183
+ runner: "opencode-cli-run",
1184
+ worktreeEnabled: body.worktreeEnabled === true,
1185
+ claudeSessionId: sessionId,
1186
+ ...parseSessionCreationOrigin(body),
1187
+ });
1188
+ onSessionCreated?.(cwd);
1189
+ res.status(201).json({ resumedClaudeSessionId: sessionId, ...sessionResponseDTO(snapshot) });
1190
+ }
1191
+ catch (error) {
1192
+ res.status(400).json({ error: getErrorMessage(error, "无法按 OpenCode 会话 ID 恢复会话。") });
1193
+ }
1194
+ });
1195
+ app.post("/api/qoder-sessions/:sessionId/resume", (req, res) => {
1196
+ const sessionId = String(req.params.sessionId || "").trim();
1197
+ const body = req.body;
1198
+ try {
1199
+ if (!isSafeProviderSessionId(sessionId)) {
1200
+ res.status(400).json({ error: "Qoder 会话 ID 格式无效。" });
1201
+ return;
1202
+ }
1203
+ const history = processes.listQoderHistorySessions().find((session) => session.claudeSessionId === sessionId);
1204
+ if (!history) {
1205
+ res.status(400).json({ error: "对应的 Qoder 历史会话不存在,无法恢复。" });
1206
+ return;
1207
+ }
1208
+ const cwd = body.cwd?.trim() || history.cwd;
1209
+ if (!cwd) {
1210
+ res.status(400).json({ error: "无法确定工作目录 (cwd),无法恢复。" });
1211
+ return;
1212
+ }
1213
+ const snapshot = structured.createSession({
1214
+ cwd,
1215
+ mode: parseExecutionMode(body.mode, defaultMode),
1216
+ provider: "qoder",
1217
+ runner: "qoder-cli-print",
1218
+ worktreeEnabled: body.worktreeEnabled === true,
1219
+ claudeSessionId: sessionId,
1220
+ ...parseSessionCreationOrigin(body),
1221
+ });
1222
+ onSessionCreated?.(cwd);
1223
+ res.status(201).json({ resumedClaudeSessionId: sessionId, ...sessionResponseDTO(snapshot) });
1224
+ }
1225
+ catch (error) {
1226
+ res.status(400).json({ error: getErrorMessage(error, "无法按 Qoder 会话 ID 恢复会话。") });
1227
+ }
1228
+ });
1139
1229
  app.post("/api/sessions/:id/resize", (req, res) => {
1140
1230
  const body = req.body;
1141
1231
  try {
@@ -1419,4 +1509,72 @@ export function registerClaudeHistoryRoutes(app, processes, structured, storage,
1419
1509
  res.status(500).json({ error: getErrorMessage(error, "无法批量删除历史会话。") });
1420
1510
  }
1421
1511
  });
1512
+ const externalHistoryProviders = [
1513
+ {
1514
+ provider: "opencode",
1515
+ label: "OpenCode",
1516
+ list: () => processes.listOpenCodeHistorySessions(),
1517
+ remove: (ids) => processes.deleteOpenCodeHistorySessions(ids),
1518
+ },
1519
+ {
1520
+ provider: "qoder",
1521
+ label: "Qoder",
1522
+ list: () => processes.listQoderHistorySessions(),
1523
+ remove: (ids) => processes.deleteQoderHistoryFiles(ids),
1524
+ },
1525
+ ];
1526
+ for (const config of externalHistoryProviders) {
1527
+ app.get(`/api/${config.provider}-history`, (_req, res) => {
1528
+ try {
1529
+ const history = markManagedProviderHistory(config.list(), sessionRegistry.listSlim(), config.provider);
1530
+ const hidden = getHiddenClaudeSessionIds(storage);
1531
+ res.json(hidden.size > 0 ? history.filter((session) => !hidden.has(session.claudeSessionId)) : history);
1532
+ }
1533
+ catch (error) {
1534
+ res.status(500).json({ error: getErrorMessage(error, `无法扫描 ${config.label} 历史会话。`) });
1535
+ }
1536
+ });
1537
+ app.delete(`/api/${config.provider}-history/:sessionId`, (req, res) => {
1538
+ const sessionId = req.params.sessionId?.trim();
1539
+ if (!sessionId) {
1540
+ res.status(400).json({ error: "会话 ID 不能为空。" });
1541
+ return;
1542
+ }
1543
+ try {
1544
+ const exists = config.list().some((session) => session.claudeSessionId === sessionId);
1545
+ if (exists) {
1546
+ config.remove([sessionId]);
1547
+ removeFromHiddenClaudeSessionIds(storage, [sessionId]);
1548
+ }
1549
+ else {
1550
+ addToHiddenClaudeSessionIds(storage, [sessionId]);
1551
+ }
1552
+ res.json({ ok: true });
1553
+ }
1554
+ catch (error) {
1555
+ res.status(500).json({ error: getErrorMessage(error, `无法删除 ${config.label} 历史会话。`) });
1556
+ }
1557
+ });
1558
+ app.post(`/api/${config.provider}-history/batch-delete`, (req, res) => {
1559
+ const sessionIds = Array.isArray(req.body?.claudeSessionIds)
1560
+ ? req.body.claudeSessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
1561
+ : [];
1562
+ if (sessionIds.length === 0) {
1563
+ res.status(400).json({ error: "至少提供一个历史会话 ID。" });
1564
+ return;
1565
+ }
1566
+ try {
1567
+ const existing = new Set(config.list().map((session) => session.claudeSessionId));
1568
+ const toDelete = sessionIds.filter((id) => existing.has(id));
1569
+ const toHide = sessionIds.filter((id) => !existing.has(id));
1570
+ const deleted = config.remove(toDelete);
1571
+ removeFromHiddenClaudeSessionIds(storage, toDelete);
1572
+ addToHiddenClaudeSessionIds(storage, toHide);
1573
+ res.json({ ok: true, deleted: deleted + toHide.length });
1574
+ }
1575
+ catch (error) {
1576
+ res.status(500).json({ error: getErrorMessage(error, `无法批量删除 ${config.label} 历史会话。`) });
1577
+ }
1578
+ });
1579
+ }
1422
1580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "4.21.0",
3
+ "version": "4.22.0",
4
4
  "description": "A web console for Claude Code, Codex, OpenCode, and other local CLI tools.",
5
5
  "type": "module",
6
6
  "bin": {