@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.
- package/dist/build-info.json +3 -3
- package/dist/process-manager.d.ts +6 -2
- package/dist/process-manager.js +14 -5
- package/dist/provider-history-scanner.d.ts +38 -1
- package/dist/provider-history-scanner.js +274 -1
- package/dist/server-session-routes.d.ts +5 -5
- package/dist/server-session-routes.js +163 -5
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-07-
|
|
4
|
-
"version": "4.
|
|
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;
|
package/dist/process-manager.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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"
|
|
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())
|
|
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
|
}
|