@co0ontty/wand 4.12.1 → 4.13.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/README.md +2 -2
- package/dist/build-info.json +3 -3
- package/dist/git-quick-commit.js +10 -2
- package/dist/process-manager.d.ts +1 -0
- package/dist/process-manager.js +168 -30
- package/dist/resume-policy.d.ts +12 -0
- package/dist/resume-policy.js +53 -0
- package/dist/server-session-routes.js +23 -23
- package/dist/web-ui/content/scripts.js +39 -39
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ wand config:password
|
|
|
57
57
|
|
|
58
58
|
- **Dual view modes** — switch between raw terminal output and a structured conversation view for the same session
|
|
59
59
|
- **Multiple providers** — create PTY or structured sessions for Claude Code, Codex, OpenCode, Grok, and Qoder CLI
|
|
60
|
-
- **Session management** — create, archive, and resume sessions; restore Claude native history; show summaries in the session list
|
|
60
|
+
- **Session management** — create, archive, and resume Claude, Codex, OpenCode, Grok, and Qoder sessions with their provider-native context; restore Claude/Codex native history; show summaries in the session list
|
|
61
61
|
- **Permission control** — visual permission prompts with one-time approval, per-turn memory, and related policies
|
|
62
62
|
|
|
63
63
|
#### Experience
|
|
@@ -206,7 +206,7 @@ npm install -g opencode-ai@latest
|
|
|
206
206
|
|
|
207
207
|
- **双视图模式** — 终端原始输出和结构化对话视图可随时切换,同一会话两种呈现
|
|
208
208
|
- **多 Provider 支持** — Claude Code、Codex、OpenCode、Grok 和 Qoder CLI 均可创建 PTY 或结构化会话
|
|
209
|
-
- **会话管理** —
|
|
209
|
+
- **会话管理** — 创建、归档并携带原生上下文恢复 Claude、Codex、OpenCode、Grok、Qoder 会话;支持恢复 Claude/Codex 原生历史记录;会话列表显示摘要
|
|
210
210
|
- **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略
|
|
211
211
|
|
|
212
212
|
#### 交互体验
|
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-07-
|
|
4
|
-
"version": "4.
|
|
2
|
+
"commit": "95fe372be4bebb2e489b287ddc8de993291d390c",
|
|
3
|
+
"builtAt": "2026-07-17T21:08:20.623Z",
|
|
4
|
+
"version": "4.13.0",
|
|
5
5
|
"channel": "stable"
|
|
6
6
|
}
|
package/dist/git-quick-commit.js
CHANGED
|
@@ -1262,14 +1262,22 @@ async function runQuickCommitFallbackCli(opts, priorError) {
|
|
|
1262
1262
|
});
|
|
1263
1263
|
}
|
|
1264
1264
|
else {
|
|
1265
|
-
const args = [
|
|
1265
|
+
const args = [
|
|
1266
|
+
"-p",
|
|
1267
|
+
"--verbose",
|
|
1268
|
+
"--output-format",
|
|
1269
|
+
"stream-json",
|
|
1270
|
+
"--tools",
|
|
1271
|
+
"Bash",
|
|
1272
|
+
"--allowedTools",
|
|
1273
|
+
"Bash(git *)",
|
|
1274
|
+
];
|
|
1266
1275
|
const model = opts.model?.trim();
|
|
1267
1276
|
if (model && model !== "default")
|
|
1268
1277
|
args.push("--model", model);
|
|
1269
1278
|
const claudeEffort = thinkingEffortToClaudeCliEffort(opts.thinkingEffort ?? "off");
|
|
1270
1279
|
if (claudeEffort)
|
|
1271
1280
|
args.push("--effort", claudeEffort);
|
|
1272
|
-
args.push("--permission-mode", "bypassPermissions");
|
|
1273
1281
|
await runCliText("claude", args, prompt, {
|
|
1274
1282
|
cwd: opts.cwd,
|
|
1275
1283
|
timeoutMs: QUICK_COMMIT_CLI_TIMEOUT_MS,
|
|
@@ -65,6 +65,7 @@ export declare class ProcessManager extends EventEmitter {
|
|
|
65
65
|
hasCodexSessionFile(threadId: string): boolean;
|
|
66
66
|
deleteCodexHistoryFiles(threadIds: string[]): number;
|
|
67
67
|
private captureCodexSessionId;
|
|
68
|
+
private captureOpenCodeSessionId;
|
|
68
69
|
private captureClaudeSessionId;
|
|
69
70
|
get(id: string): SessionSnapshot | null;
|
|
70
71
|
/** Return only a session owned by this manager, without the SQLite fallback used by get(). */
|
package/dist/process-manager.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, unlinkSync, rmSync, readFileSync, readdirSync, statSync } f
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import os from "node:os";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
8
|
import pty from "node-pty";
|
|
8
9
|
import { SessionLogger } from "./session-logger.js";
|
|
9
10
|
import { ClaudePtyBridge } from "./claude-pty-bridge.js";
|
|
@@ -13,7 +14,7 @@ import { buildChildEnv, isRunningAsRoot } from "./env-utils.js";
|
|
|
13
14
|
import { ensureNodePtyHelperExecutable } from "./ensure-node-pty-helper.js";
|
|
14
15
|
import { buildLanguageDirective, buildManagedAutonomyDirective } from "./language-prompt.js";
|
|
15
16
|
import { prepareSessionWorktree } from "./git-worktree.js";
|
|
16
|
-
import {
|
|
17
|
+
import { getProviderCommandSessionId, getProviderResumeCommandSessionId } from "./resume-policy.js";
|
|
17
18
|
import { normalizeThinkingEffort, thinkingEffortToClaudeCliEffort, thinkingEffortToClaudeSlashEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant } from "./structured-provider-common.js";
|
|
18
19
|
import { generateSessionTopic } from "./session-topic.js";
|
|
19
20
|
import { getErrorMessage } from "./error-utils.js";
|
|
@@ -354,6 +355,72 @@ function selectCodexSessionForRecord(record, sessions) {
|
|
|
354
355
|
function getLatestCodexSessionId(record, sessions) {
|
|
355
356
|
return selectCodexSessionForRecord(record, sessions)?.claudeSessionId ?? null;
|
|
356
357
|
}
|
|
358
|
+
function getOpenCodeDatabasePath() {
|
|
359
|
+
const dataHome = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
|
|
360
|
+
return path.join(dataHome, "opencode", "opencode.db");
|
|
361
|
+
}
|
|
362
|
+
function listOpenCodeSessionCandidates() {
|
|
363
|
+
const dbPath = getOpenCodeDatabasePath();
|
|
364
|
+
if (!existsSync(dbPath))
|
|
365
|
+
return [];
|
|
366
|
+
let db = null;
|
|
367
|
+
try {
|
|
368
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
369
|
+
const rows = db.prepare("SELECT id, directory, time_created, time_updated FROM session ORDER BY time_updated DESC LIMIT 500").all();
|
|
370
|
+
return rows.flatMap((row) => {
|
|
371
|
+
if (typeof row.id !== "string" || typeof row.directory !== "string")
|
|
372
|
+
return [];
|
|
373
|
+
const createdAtMs = Number(row.time_created);
|
|
374
|
+
const updatedAtMs = Number(row.time_updated);
|
|
375
|
+
if (!Number.isFinite(createdAtMs) || !Number.isFinite(updatedAtMs))
|
|
376
|
+
return [];
|
|
377
|
+
return [{ id: row.id, cwd: row.directory, createdAtMs, updatedAtMs }];
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return [];
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
try {
|
|
385
|
+
db?.close();
|
|
386
|
+
}
|
|
387
|
+
catch { /* best-effort read-only probe */ }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function listOpenCodeSessionMtimes() {
|
|
391
|
+
return new Map(listOpenCodeSessionCandidates().map((session) => [session.id, session.updatedAtMs]));
|
|
392
|
+
}
|
|
393
|
+
function selectOpenCodeSessionForRecord(record) {
|
|
394
|
+
const known = record.knownOpenCodeSessionMtimes ?? new Map();
|
|
395
|
+
const startedAtMs = Date.parse(record.startedAt);
|
|
396
|
+
const candidates = listOpenCodeSessionCandidates()
|
|
397
|
+
.filter((session) => isSameResolvedPath(session.cwd, record.cwd))
|
|
398
|
+
.filter((session) => {
|
|
399
|
+
const previous = known.get(session.id);
|
|
400
|
+
return previous === undefined || session.updatedAtMs > previous;
|
|
401
|
+
})
|
|
402
|
+
.filter((session) => !Number.isFinite(startedAtMs) || session.updatedAtMs >= startedAtMs - START_TIME_SKEW_MS)
|
|
403
|
+
.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
|
404
|
+
if (candidates.length === 0)
|
|
405
|
+
return null;
|
|
406
|
+
const fresh = candidates.filter((session) => !known.has(session.id));
|
|
407
|
+
if (fresh.length === 1)
|
|
408
|
+
return fresh[0];
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
function selectOpenCodeSessionForTimeWindow(record) {
|
|
412
|
+
const startedAtMs = Date.parse(record.startedAt);
|
|
413
|
+
if (!Number.isFinite(startedAtMs))
|
|
414
|
+
return null;
|
|
415
|
+
const endedAtMs = Date.parse(record.endedAt ?? "");
|
|
416
|
+
const windowEnd = (Number.isFinite(endedAtMs) ? endedAtMs : Date.now()) + START_TIME_SKEW_MS;
|
|
417
|
+
const candidates = listOpenCodeSessionCandidates()
|
|
418
|
+
.filter((session) => isSameResolvedPath(session.cwd, record.cwd))
|
|
419
|
+
.filter((session) => session.updatedAtMs >= startedAtMs - START_TIME_SKEW_MS && session.updatedAtMs <= windowEnd)
|
|
420
|
+
.filter((session) => session.createdAtMs <= windowEnd)
|
|
421
|
+
.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
|
422
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
423
|
+
}
|
|
357
424
|
function selectCodexSessionForTimeWindow(record, sessions) {
|
|
358
425
|
const startedAtMs = parseTimeMs(record.startedAt);
|
|
359
426
|
if (startedAtMs === null)
|
|
@@ -382,13 +449,13 @@ function recoverCodexSessionIdFromHistory(snapshot, sessions) {
|
|
|
382
449
|
if (snapshot.provider !== "codex" || snapshot.claudeSessionId) {
|
|
383
450
|
return null;
|
|
384
451
|
}
|
|
385
|
-
return
|
|
452
|
+
return getProviderResumeCommandSessionId("codex", snapshot.command) ?? selectCodexSessionForTimeWindow(snapshot, sessions)?.claudeSessionId ?? null;
|
|
386
453
|
}
|
|
387
454
|
function recoverClaudeSessionIdFromHistory(snapshot) {
|
|
388
455
|
if (snapshot.provider !== "claude" || snapshot.claudeSessionId) {
|
|
389
456
|
return null;
|
|
390
457
|
}
|
|
391
|
-
return
|
|
458
|
+
return getProviderResumeCommandSessionId("claude", snapshot.command) ?? selectClaudeProjectSessionForTimeWindow(snapshot)?.id ?? null;
|
|
392
459
|
}
|
|
393
460
|
function snapshotMessages(record) {
|
|
394
461
|
return record.ptyBridge?.getMessages() ?? record.messages;
|
|
@@ -482,11 +549,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
482
549
|
const provider = snapshot.provider ?? resolveProviderFromCommand(snapshot.command);
|
|
483
550
|
const isClaudeCmd = provider === "claude";
|
|
484
551
|
const isCodexCmd = provider === "codex";
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
: isCodexCmd
|
|
488
|
-
? getCodexResumeCommandSessionId(snapshot.command)
|
|
489
|
-
: null;
|
|
552
|
+
const isOpenCodeCmd = provider === "opencode";
|
|
553
|
+
const resumeCommandSessionId = getProviderCommandSessionId(provider, snapshot.command);
|
|
490
554
|
const orphanEndedAt = snapshot.status === "running" ? new Date().toISOString() : null;
|
|
491
555
|
const sessionIdFromHistory = isClaudeCmd
|
|
492
556
|
? recoverClaudeSessionIdFromHistory({
|
|
@@ -500,7 +564,13 @@ export class ProcessManager extends EventEmitter {
|
|
|
500
564
|
provider: "codex",
|
|
501
565
|
endedAt: snapshot.endedAt ?? orphanEndedAt,
|
|
502
566
|
}, getStartupCodexHistory())
|
|
503
|
-
:
|
|
567
|
+
: isOpenCodeCmd
|
|
568
|
+
? selectOpenCodeSessionForTimeWindow({
|
|
569
|
+
cwd: snapshot.cwd,
|
|
570
|
+
startedAt: snapshot.startedAt,
|
|
571
|
+
endedAt: snapshot.endedAt ?? orphanEndedAt,
|
|
572
|
+
})?.id ?? null
|
|
573
|
+
: null;
|
|
504
574
|
const restoredSessionId = resumeCommandSessionId ?? snapshot.claudeSessionId ?? sessionIdFromHistory;
|
|
505
575
|
// Sessions restored from storage have ptyProcess: null — the old server's PTY
|
|
506
576
|
// belongs to a dead process. Mark running sessions as exited so the UI
|
|
@@ -517,7 +587,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
517
587
|
};
|
|
518
588
|
this.storage.saveSession(updated);
|
|
519
589
|
if (restoredSessionId && restoredSessionId !== snapshot.claudeSessionId) {
|
|
520
|
-
const label = isCodexCmd ? "Codex thread" : "Claude session";
|
|
590
|
+
const label = isCodexCmd ? "Codex thread" : isOpenCodeCmd ? "OpenCode session" : "Claude session";
|
|
521
591
|
process.stderr.write(`[wand] Recovered ${label} ID for orphan PTY ${snapshot.id}: ${restoredSessionId}\n`);
|
|
522
592
|
}
|
|
523
593
|
this.sessions.set(snapshot.id, {
|
|
@@ -550,6 +620,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
550
620
|
knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
|
|
551
621
|
knownCodexSessionMtimes: isCodexCmd ? listCodexSessionMtimes(getStartupCodexHistory()) : undefined,
|
|
552
622
|
codexSessionDiscoveryTimer: null,
|
|
623
|
+
knownOpenCodeSessionMtimes: isOpenCodeCmd ? listOpenCodeSessionMtimes() : undefined,
|
|
624
|
+
openCodeSessionDiscoveryTimer: null,
|
|
553
625
|
claudeSessionId: restoredSessionId ?? updated.claudeSessionId,
|
|
554
626
|
approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
|
|
555
627
|
ptyCols: snapshot.ptyCols ?? 120,
|
|
@@ -563,7 +635,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
563
635
|
: snapshot;
|
|
564
636
|
if (updated !== snapshot) {
|
|
565
637
|
this.storage.saveSessionMetadata(updated);
|
|
566
|
-
const label = isCodexCmd ? "Codex thread" : "Claude session";
|
|
638
|
+
const label = isCodexCmd ? "Codex thread" : isOpenCodeCmd ? "OpenCode session" : "Claude session";
|
|
567
639
|
process.stderr.write(`[wand] Recovered ${label} ID for saved PTY ${snapshot.id}: ${restoredSessionId}\n`);
|
|
568
640
|
}
|
|
569
641
|
this.sessions.set(snapshot.id, {
|
|
@@ -593,6 +665,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
593
665
|
knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
|
|
594
666
|
knownCodexSessionMtimes: isCodexCmd ? listCodexSessionMtimes(getStartupCodexHistory()) : undefined,
|
|
595
667
|
codexSessionDiscoveryTimer: null,
|
|
668
|
+
knownOpenCodeSessionMtimes: isOpenCodeCmd ? listOpenCodeSessionMtimes() : undefined,
|
|
669
|
+
openCodeSessionDiscoveryTimer: null,
|
|
596
670
|
claudeSessionId: restoredSessionId ?? updated.claudeSessionId,
|
|
597
671
|
approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
|
|
598
672
|
ptyCols: snapshot.ptyCols ?? 120,
|
|
@@ -744,22 +818,29 @@ export class ProcessManager extends EventEmitter {
|
|
|
744
818
|
const isClaudeProvider = provider === "claude";
|
|
745
819
|
const selectedModel = opts?.model?.trim() || undefined;
|
|
746
820
|
const initialThinkingEffort = normalizeThinkingEffort(opts?.thinkingEffort);
|
|
747
|
-
|
|
748
|
-
const resumeCommandSessionId = isClaudeProvider
|
|
749
|
-
? getResumeCommandSessionId(processedCommand) ?? getResumeCommandSessionId(command)
|
|
750
|
-
: null;
|
|
821
|
+
let processedCommand = this.processCommandForMode(command, effectiveMode, provider, selectedModel, initialThinkingEffort);
|
|
751
822
|
const isCodexProvider = provider === "codex";
|
|
752
|
-
const
|
|
753
|
-
|
|
823
|
+
const isOpenCodeProvider = provider === "opencode";
|
|
824
|
+
const existingProviderSessionId = getProviderCommandSessionId(provider, processedCommand)
|
|
825
|
+
?? getProviderCommandSessionId(provider, command);
|
|
826
|
+
// Grok and Qoder accept caller-selected IDs for new conversations. Assigning
|
|
827
|
+
// one up front avoids depending on provider-specific TUI rendering to learn
|
|
828
|
+
// the durable ID later.
|
|
829
|
+
const assignedProviderSessionId = !existingProviderSessionId && (provider === "grok" || provider === "qoder")
|
|
830
|
+
? randomUUID()
|
|
754
831
|
: null;
|
|
832
|
+
if (assignedProviderSessionId) {
|
|
833
|
+
processedCommand = `${processedCommand} --session-id ${assignedProviderSessionId}`;
|
|
834
|
+
}
|
|
755
835
|
const knownClaudeTaskIds = isClaudeProvider ? new Set(listRecentClaudeProjectSessionIds(resolvedCwd, new Date().toISOString())) : null;
|
|
756
836
|
const knownClaudeProjectMtimes = isClaudeProvider ? listClaudeProjectSessionMtimes(resolvedCwd) : null;
|
|
757
|
-
const knownCodexSessionMtimes = isCodexProvider && !
|
|
837
|
+
const knownCodexSessionMtimes = isCodexProvider && !existingProviderSessionId
|
|
758
838
|
? listCodexSessionMtimes(this.providerHistory.listCodexHistorySessions())
|
|
759
839
|
: null;
|
|
760
|
-
const
|
|
761
|
-
?
|
|
762
|
-
:
|
|
840
|
+
const knownOpenCodeSessionMtimes = isOpenCodeProvider && !existingProviderSessionId
|
|
841
|
+
? listOpenCodeSessionMtimes()
|
|
842
|
+
: null;
|
|
843
|
+
const initialClaudeSessionId = existingProviderSessionId ?? assignedProviderSessionId;
|
|
763
844
|
const startedAt = new Date().toISOString();
|
|
764
845
|
const record = {
|
|
765
846
|
id,
|
|
@@ -805,6 +886,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
805
886
|
knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
|
|
806
887
|
knownCodexSessionMtimes: knownCodexSessionMtimes ?? undefined,
|
|
807
888
|
codexSessionDiscoveryTimer: null,
|
|
889
|
+
knownOpenCodeSessionMtimes: knownOpenCodeSessionMtimes ?? undefined,
|
|
890
|
+
openCodeSessionDiscoveryTimer: null,
|
|
808
891
|
approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
|
|
809
892
|
selectedModel: selectedModel ?? null,
|
|
810
893
|
thinkingEffort: initialThinkingEffort,
|
|
@@ -829,13 +912,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
829
912
|
}
|
|
830
913
|
this.sessions.set(id, record);
|
|
831
914
|
this.persist(record, { forceFullSave: true });
|
|
832
|
-
if (initialClaudeSessionId) {
|
|
833
|
-
|
|
834
|
-
this.providerHistory.invalidate("codex");
|
|
835
|
-
}
|
|
836
|
-
else {
|
|
837
|
-
this.providerHistory.invalidate("claude");
|
|
838
|
-
}
|
|
915
|
+
if (initialClaudeSessionId && (provider === "claude" || provider === "codex")) {
|
|
916
|
+
this.providerHistory.invalidate(provider);
|
|
839
917
|
}
|
|
840
918
|
this.cleanupOldSessions();
|
|
841
919
|
const shellArgs = this.buildShellArgs(processedCommand);
|
|
@@ -886,6 +964,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
886
964
|
clearTimeout(current.codexSessionDiscoveryTimer);
|
|
887
965
|
current.codexSessionDiscoveryTimer = null;
|
|
888
966
|
}
|
|
967
|
+
if (current.openCodeSessionDiscoveryTimer) {
|
|
968
|
+
clearTimeout(current.openCodeSessionDiscoveryTimer);
|
|
969
|
+
current.openCodeSessionDiscoveryTimer = null;
|
|
970
|
+
}
|
|
889
971
|
if (current.initialInputTimer) {
|
|
890
972
|
clearTimeout(current.initialInputTimer);
|
|
891
973
|
current.initialInputTimer = null;
|
|
@@ -898,6 +980,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
898
980
|
current.ptyPermissionBlocked = false;
|
|
899
981
|
this.captureClaudeSessionId(current, { allowTimeWindowFallback: true });
|
|
900
982
|
this.captureCodexSessionId(current, { allowTimeWindowFallback: true });
|
|
983
|
+
this.captureOpenCodeSessionId(current, { allowTimeWindowFallback: true });
|
|
901
984
|
current.status = current.stopRequested ? "stopped" : exitCode === 0 ? "exited" : "failed";
|
|
902
985
|
current.exitCode = current.stopRequested ? null : exitCode;
|
|
903
986
|
current.endedAt = new Date().toISOString();
|
|
@@ -1003,7 +1086,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1003
1086
|
if (current !== record || current.ptyProcess !== child || current.status !== "running" || current.claudeSessionId || !current.knownClaudeTaskIds) {
|
|
1004
1087
|
return;
|
|
1005
1088
|
}
|
|
1006
|
-
if (
|
|
1089
|
+
if (getProviderResumeCommandSessionId("claude", current.command)) {
|
|
1007
1090
|
current.claudeTaskDiscoveryTimer = null;
|
|
1008
1091
|
return;
|
|
1009
1092
|
}
|
|
@@ -1034,7 +1117,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1034
1117
|
if (current !== record || current.ptyProcess !== child || current.status !== "running" || current.claudeSessionId || !current.knownCodexSessionMtimes) {
|
|
1035
1118
|
return;
|
|
1036
1119
|
}
|
|
1037
|
-
if (
|
|
1120
|
+
if (getProviderResumeCommandSessionId("codex", current.command)) {
|
|
1038
1121
|
current.codexSessionDiscoveryTimer = null;
|
|
1039
1122
|
return;
|
|
1040
1123
|
}
|
|
@@ -1047,6 +1130,29 @@ export class ProcessManager extends EventEmitter {
|
|
|
1047
1130
|
};
|
|
1048
1131
|
record.codexSessionDiscoveryTimer = setTimeout(tryDiscoverCodexSessionId, 500);
|
|
1049
1132
|
}
|
|
1133
|
+
if (record.knownOpenCodeSessionMtimes) {
|
|
1134
|
+
const tryDiscoverOpenCodeSessionId = () => {
|
|
1135
|
+
if (this.disposed)
|
|
1136
|
+
return;
|
|
1137
|
+
const current = this.sessions.get(id);
|
|
1138
|
+
if (current !== record || current.ptyProcess !== child || current.status !== "running" || current.claudeSessionId || !current.knownOpenCodeSessionMtimes) {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (getProviderResumeCommandSessionId("opencode", current.command)) {
|
|
1142
|
+
current.openCodeSessionDiscoveryTimer = null;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (this.captureOpenCodeSessionId(current)) {
|
|
1146
|
+
current.openCodeSessionDiscoveryTimer = null;
|
|
1147
|
+
this.persist(current);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
current.openCodeSessionDiscoveryTimer = setTimeout(tryDiscoverOpenCodeSessionId, 1000);
|
|
1151
|
+
current.openCodeSessionDiscoveryTimer.unref?.();
|
|
1152
|
+
};
|
|
1153
|
+
record.openCodeSessionDiscoveryTimer = setTimeout(tryDiscoverOpenCodeSessionId, 500);
|
|
1154
|
+
record.openCodeSessionDiscoveryTimer.unref?.();
|
|
1155
|
+
}
|
|
1050
1156
|
return this.snapshot(record);
|
|
1051
1157
|
}
|
|
1052
1158
|
list() {
|
|
@@ -1130,6 +1236,25 @@ export class ProcessManager extends EventEmitter {
|
|
|
1130
1236
|
process.stderr.write(`[wand] Captured Codex thread ID: ${threadId}\n`);
|
|
1131
1237
|
return true;
|
|
1132
1238
|
}
|
|
1239
|
+
captureOpenCodeSessionId(record, options) {
|
|
1240
|
+
if (record.provider !== "opencode" || record.claudeSessionId)
|
|
1241
|
+
return false;
|
|
1242
|
+
const discovered = record.knownOpenCodeSessionMtimes
|
|
1243
|
+
? selectOpenCodeSessionForRecord(record)
|
|
1244
|
+
: null;
|
|
1245
|
+
const fallback = discovered
|
|
1246
|
+
? null
|
|
1247
|
+
: options?.allowTimeWindowFallback
|
|
1248
|
+
? selectOpenCodeSessionForTimeWindow(record)
|
|
1249
|
+
: null;
|
|
1250
|
+
const providerSessionId = discovered?.id ?? fallback?.id ?? null;
|
|
1251
|
+
if (!providerSessionId)
|
|
1252
|
+
return false;
|
|
1253
|
+
record.claudeSessionId = providerSessionId;
|
|
1254
|
+
record.knownOpenCodeSessionMtimes?.set(providerSessionId, discovered?.updatedAtMs ?? fallback?.updatedAtMs ?? Date.now());
|
|
1255
|
+
process.stderr.write(`[wand] Captured OpenCode session ID: ${providerSessionId}\n`);
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1133
1258
|
captureClaudeSessionId(record, options) {
|
|
1134
1259
|
if (record.provider !== "claude" || record.claudeSessionId) {
|
|
1135
1260
|
return false;
|
|
@@ -1307,6 +1432,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
1307
1432
|
clearTimeout(record.codexSessionDiscoveryTimer);
|
|
1308
1433
|
record.codexSessionDiscoveryTimer = null;
|
|
1309
1434
|
}
|
|
1435
|
+
if (record.openCodeSessionDiscoveryTimer) {
|
|
1436
|
+
clearTimeout(record.openCodeSessionDiscoveryTimer);
|
|
1437
|
+
record.openCodeSessionDiscoveryTimer = null;
|
|
1438
|
+
}
|
|
1310
1439
|
if (record.initialInputTimer) {
|
|
1311
1440
|
clearTimeout(record.initialInputTimer);
|
|
1312
1441
|
record.initialInputTimer = null;
|
|
@@ -1333,6 +1462,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1333
1462
|
// discovery can still inspect the latest parsed turns.
|
|
1334
1463
|
this.captureClaudeSessionId(record, { allowTimeWindowFallback: true });
|
|
1335
1464
|
this.captureCodexSessionId(record, { allowTimeWindowFallback: true });
|
|
1465
|
+
this.captureOpenCodeSessionId(record, { allowTimeWindowFallback: true });
|
|
1336
1466
|
if (record.ptyBridge) {
|
|
1337
1467
|
record.messages = record.ptyBridge.getMessages();
|
|
1338
1468
|
record.ptyBridge.removeAllListeners();
|
|
@@ -1350,6 +1480,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
1350
1480
|
clearTimeout(record.codexSessionDiscoveryTimer);
|
|
1351
1481
|
record.codexSessionDiscoveryTimer = null;
|
|
1352
1482
|
}
|
|
1483
|
+
if (record.openCodeSessionDiscoveryTimer) {
|
|
1484
|
+
clearTimeout(record.openCodeSessionDiscoveryTimer);
|
|
1485
|
+
record.openCodeSessionDiscoveryTimer = null;
|
|
1486
|
+
}
|
|
1353
1487
|
if (record.initialInputTimer) {
|
|
1354
1488
|
clearTimeout(record.initialInputTimer);
|
|
1355
1489
|
record.initialInputTimer = null;
|
|
@@ -1388,6 +1522,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
1388
1522
|
clearTimeout(record.codexSessionDiscoveryTimer);
|
|
1389
1523
|
record.codexSessionDiscoveryTimer = null;
|
|
1390
1524
|
}
|
|
1525
|
+
if (record.openCodeSessionDiscoveryTimer) {
|
|
1526
|
+
clearTimeout(record.openCodeSessionDiscoveryTimer);
|
|
1527
|
+
record.openCodeSessionDiscoveryTimer = null;
|
|
1528
|
+
}
|
|
1391
1529
|
if (record.initialInputTimer) {
|
|
1392
1530
|
clearTimeout(record.initialInputTimer);
|
|
1393
1531
|
record.initialInputTimer = null;
|
package/dist/resume-policy.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
+
import type { SessionProvider } from "./types.js";
|
|
1
2
|
/** Claude session IDs and Codex thread IDs are UUID-shaped identifiers. */
|
|
2
3
|
export declare function isProviderSessionId(value: unknown): value is string;
|
|
3
4
|
export declare function getResumeCommandSessionId(command: string): string | null;
|
|
4
5
|
export declare function getCodexResumeCommandSessionId(command: string): string | null;
|
|
6
|
+
/**
|
|
7
|
+
* Provider-native IDs are not uniformly UUIDs: OpenCode uses `ses_*` and
|
|
8
|
+
* older Qoder releases also emitted `qs_*`. Keep the accepted alphabet shell
|
|
9
|
+
* inert before an ID is interpolated into a PTY command.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isSafeProviderSessionId(value: unknown): value is string;
|
|
12
|
+
export declare function getProviderResumeCommandSessionId(provider: SessionProvider, command: string): string | null;
|
|
13
|
+
/** Read either a resume ID or a caller-assigned ID from a provider command. */
|
|
14
|
+
export declare function getProviderCommandSessionId(provider: SessionProvider, command: string): string | null;
|
|
15
|
+
/** Build the interactive resume command used by PTY sessions. */
|
|
16
|
+
export declare function buildProviderResumeCommand(provider: SessionProvider, command: string, providerSessionId: string): string;
|
package/dist/resume-policy.js
CHANGED
|
@@ -2,6 +2,8 @@ const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{1
|
|
|
2
2
|
const PROVIDER_SESSION_ID_PATTERN = new RegExp(`^${UUID_PATTERN}$`, "i");
|
|
3
3
|
const RESUME_COMMAND_ID_PATTERN = new RegExp(`(?:^|\\s)--resume\\s+(${UUID_PATTERN})(?:\\s|$)`, "i");
|
|
4
4
|
const CODEX_RESUME_COMMAND_ID_PATTERN = new RegExp(`(?:^|\\s)resume\\s+(${UUID_PATTERN})(?:\\s|$)`, "i");
|
|
5
|
+
const SAFE_PROVIDER_SESSION_ID_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,199}$/i;
|
|
6
|
+
const SAFE_PROVIDER_SESSION_ID_SOURCE = "([a-z0-9][a-z0-9._:-]{0,199})";
|
|
5
7
|
/** Claude session IDs and Codex thread IDs are UUID-shaped identifiers. */
|
|
6
8
|
export function isProviderSessionId(value) {
|
|
7
9
|
return typeof value === "string" && PROVIDER_SESSION_ID_PATTERN.test(value);
|
|
@@ -14,3 +16,54 @@ export function getCodexResumeCommandSessionId(command) {
|
|
|
14
16
|
const match = CODEX_RESUME_COMMAND_ID_PATTERN.exec(command);
|
|
15
17
|
return match?.[1] ?? null;
|
|
16
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Provider-native IDs are not uniformly UUIDs: OpenCode uses `ses_*` and
|
|
21
|
+
* older Qoder releases also emitted `qs_*`. Keep the accepted alphabet shell
|
|
22
|
+
* inert before an ID is interpolated into a PTY command.
|
|
23
|
+
*/
|
|
24
|
+
export function isSafeProviderSessionId(value) {
|
|
25
|
+
return typeof value === "string" && SAFE_PROVIDER_SESSION_ID_PATTERN.test(value);
|
|
26
|
+
}
|
|
27
|
+
function resumeArgumentPattern(provider) {
|
|
28
|
+
if (provider === "codex") {
|
|
29
|
+
return new RegExp(`(?:^|\\s)resume\\s+${SAFE_PROVIDER_SESSION_ID_SOURCE}(?=\\s|$)`, "i");
|
|
30
|
+
}
|
|
31
|
+
if (provider === "opencode") {
|
|
32
|
+
return new RegExp(`(?:^|\\s)(?:--session|-s)\\s+${SAFE_PROVIDER_SESSION_ID_SOURCE}(?=\\s|$)`, "i");
|
|
33
|
+
}
|
|
34
|
+
return new RegExp(`(?:^|\\s)(?:--resume|-r)\\s+${SAFE_PROVIDER_SESSION_ID_SOURCE}(?=\\s|$)`, "i");
|
|
35
|
+
}
|
|
36
|
+
function assignedSessionIdPattern() {
|
|
37
|
+
return new RegExp(`(?:^|\\s)--session-id\\s+${SAFE_PROVIDER_SESSION_ID_SOURCE}(?=\\s|$)`, "i");
|
|
38
|
+
}
|
|
39
|
+
export function getProviderResumeCommandSessionId(provider, command) {
|
|
40
|
+
return resumeArgumentPattern(provider).exec(command)?.[1] ?? null;
|
|
41
|
+
}
|
|
42
|
+
/** Read either a resume ID or a caller-assigned ID from a provider command. */
|
|
43
|
+
export function getProviderCommandSessionId(provider, command) {
|
|
44
|
+
const resumed = getProviderResumeCommandSessionId(provider, command);
|
|
45
|
+
if (resumed)
|
|
46
|
+
return resumed;
|
|
47
|
+
if (provider === "codex" || provider === "opencode")
|
|
48
|
+
return null;
|
|
49
|
+
return assignedSessionIdPattern().exec(command)?.[1] ?? null;
|
|
50
|
+
}
|
|
51
|
+
function stripProviderResumeArgument(provider, command) {
|
|
52
|
+
const withoutResume = command.replace(resumeArgumentPattern(provider), " ");
|
|
53
|
+
const withoutAssignedId = provider === "codex" || provider === "opencode"
|
|
54
|
+
? withoutResume
|
|
55
|
+
: withoutResume.replace(assignedSessionIdPattern(), " ");
|
|
56
|
+
return withoutAssignedId.replace(/\s+/g, " ").trim();
|
|
57
|
+
}
|
|
58
|
+
/** Build the interactive resume command used by PTY sessions. */
|
|
59
|
+
export function buildProviderResumeCommand(provider, command, providerSessionId) {
|
|
60
|
+
if (!isSafeProviderSessionId(providerSessionId)) {
|
|
61
|
+
throw new Error("Provider 会话 ID 格式无效。");
|
|
62
|
+
}
|
|
63
|
+
const base = stripProviderResumeArgument(provider, command) || (provider === "qoder" ? "qodercli" : provider);
|
|
64
|
+
if (provider === "codex")
|
|
65
|
+
return `${base} resume ${providerSessionId}`;
|
|
66
|
+
if (provider === "opencode")
|
|
67
|
+
return `${base} --session ${providerSessionId}`;
|
|
68
|
+
return `${base} --resume ${providerSessionId}`;
|
|
69
|
+
}
|
|
@@ -7,7 +7,7 @@ import { resolveSessionCwd } from "./session-cwd.js";
|
|
|
7
7
|
import { resolveCommitAiContext } from "./session-ai-context.js";
|
|
8
8
|
import { getGitStatusAsync, QuickCommitError, runQuickCommitWithFallback, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
|
|
9
9
|
import { getErrorMessage } from "./error-utils.js";
|
|
10
|
-
import { isProviderSessionId } from "./resume-policy.js";
|
|
10
|
+
import { buildProviderResumeCommand, isProviderSessionId } from "./resume-policy.js";
|
|
11
11
|
import { parseBoundedInteger } from "./request-limits.js";
|
|
12
12
|
import { asyncRoute } from "./express-async.js";
|
|
13
13
|
import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
|
|
@@ -231,19 +231,23 @@ function getWorktreeMergePayload(error, fallback) {
|
|
|
231
231
|
result: null,
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
|
-
function buildCodexResumeCommand(command, threadId) {
|
|
235
|
-
const trimmed = command.trim();
|
|
236
|
-
const withoutExistingResume = trimmed
|
|
237
|
-
.replace(/\s+resume\s+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\s|$)/i, " ")
|
|
238
|
-
.trim();
|
|
239
|
-
return `${withoutExistingResume || "codex"} resume ${threadId}`;
|
|
240
|
-
}
|
|
241
234
|
function resolvePtyResumeProvider(snapshot) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
235
|
+
if (snapshot.provider)
|
|
236
|
+
return snapshot.provider;
|
|
237
|
+
const command = snapshot.command.trim();
|
|
238
|
+
if (/^codex\b/.test(command))
|
|
239
|
+
return "codex";
|
|
240
|
+
if (/^opencode\b/.test(command))
|
|
241
|
+
return "opencode";
|
|
242
|
+
if (/^grok\b/.test(command))
|
|
243
|
+
return "grok";
|
|
244
|
+
if (/^qodercli\b/.test(command))
|
|
245
|
+
return "qoder";
|
|
246
|
+
return "claude";
|
|
247
|
+
}
|
|
248
|
+
function isPtyProviderCommand(provider, command) {
|
|
249
|
+
const executable = provider === "qoder" ? "qodercli" : provider;
|
|
250
|
+
return new RegExp(`^${executable}\\b`, "i").test(command.trim());
|
|
247
251
|
}
|
|
248
252
|
function startResumedPtySession(processes, existingSession, sessionId, defaultMode, body, initialInput) {
|
|
249
253
|
if ((existingSession.sessionKind ?? "pty") !== "pty") {
|
|
@@ -253,23 +257,18 @@ function startResumedPtySession(processes, existingSession, sessionId, defaultMo
|
|
|
253
257
|
const provider = resolvePtyResumeProvider(existingSession);
|
|
254
258
|
const resumeSessionId = existingSession.claudeSessionId;
|
|
255
259
|
if (!resumeSessionId) {
|
|
256
|
-
throw new Error(provider
|
|
260
|
+
throw new Error(`此会话没有 ${provider} 会话 ID,无法恢复。`);
|
|
257
261
|
}
|
|
258
|
-
if (provider
|
|
259
|
-
throw new Error(
|
|
262
|
+
if (!isPtyProviderCommand(provider, command)) {
|
|
263
|
+
throw new Error(`当前命令不是 ${provider} CLI,无法恢复。`);
|
|
260
264
|
}
|
|
261
265
|
if (provider === "codex") {
|
|
262
|
-
if (!/^codex\b/.test(command)) {
|
|
263
|
-
throw new Error("只有 Codex 命令支持恢复功能。");
|
|
264
|
-
}
|
|
265
266
|
if (!processes.hasCodexSessionFile(resumeSessionId)) {
|
|
266
267
|
throw new Error("对应的 Codex 历史会话不存在,无法恢复。");
|
|
267
268
|
}
|
|
268
269
|
}
|
|
269
270
|
const newMode = parseExecutionMode(body.mode, parseExecutionMode(existingSession.mode, defaultMode));
|
|
270
|
-
const resumeCommand = provider
|
|
271
|
-
? buildCodexResumeCommand(command, resumeSessionId)
|
|
272
|
-
: `${command} --resume ${resumeSessionId}`;
|
|
271
|
+
const resumeCommand = buildProviderResumeCommand(provider, command, resumeSessionId);
|
|
273
272
|
const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
|
|
274
273
|
const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
|
|
275
274
|
return processes.start(resumeCommand, existingSession.cwd, newMode, initialInput, {
|
|
@@ -300,7 +299,8 @@ function canAutoResumePtyForInput(snapshot, input) {
|
|
|
300
299
|
return Boolean(snapshot
|
|
301
300
|
&& (snapshot.sessionKind ?? "pty") === "pty"
|
|
302
301
|
&& snapshot.status !== "running"
|
|
303
|
-
&& (snapshot.provider === "claude" || snapshot.provider === "codex" ||
|
|
302
|
+
&& (snapshot.provider === "claude" || snapshot.provider === "codex" || snapshot.provider === "opencode" || snapshot.provider === "grok" || snapshot.provider === "qoder"
|
|
303
|
+
|| /^(?:claude|codex|opencode|grok|qodercli)\b/.test(snapshot.command.trim()))
|
|
304
304
|
&& snapshot.claudeSessionId
|
|
305
305
|
&& input);
|
|
306
306
|
}
|