@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 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
- - **会话管理** — 创建、归档、恢复会话;支持从 Claude 原生历史记录恢复;会话列表显示摘要
209
+ - **会话管理** — 创建、归档并携带原生上下文恢复 Claude、Codex、OpenCode、Grok、Qoder 会话;支持恢复 Claude/Codex 原生历史记录;会话列表显示摘要
210
210
  - **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略
211
211
 
212
212
  #### 交互体验
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "86478ad79b23b328b962d8e7bc77ee3b4b021ef1",
3
- "builtAt": "2026-07-17T11:42:28.086Z",
4
- "version": "4.12.1",
2
+ "commit": "95fe372be4bebb2e489b287ddc8de993291d390c",
3
+ "builtAt": "2026-07-17T21:08:20.623Z",
4
+ "version": "4.13.0",
5
5
  "channel": "stable"
6
6
  }
@@ -1262,14 +1262,22 @@ async function runQuickCommitFallbackCli(opts, priorError) {
1262
1262
  });
1263
1263
  }
1264
1264
  else {
1265
- const args = ["-p", "--verbose", "--output-format", "stream-json"];
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(). */
@@ -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 { getCodexResumeCommandSessionId, getResumeCommandSessionId } from "./resume-policy.js";
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 getCodexResumeCommandSessionId(snapshot.command) ?? selectCodexSessionForTimeWindow(snapshot, sessions)?.claudeSessionId ?? null;
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 getResumeCommandSessionId(snapshot.command) ?? selectClaudeProjectSessionForTimeWindow(snapshot)?.id ?? null;
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 resumeCommandSessionId = isClaudeCmd
486
- ? getResumeCommandSessionId(snapshot.command)
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
- : null;
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
- const processedCommand = this.processCommandForMode(command, effectiveMode, provider, selectedModel, initialThinkingEffort);
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 codexResumeCommandSessionId = isCodexProvider
753
- ? getCodexResumeCommandSessionId(processedCommand) ?? getCodexResumeCommandSessionId(command)
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 && !codexResumeCommandSessionId
837
+ const knownCodexSessionMtimes = isCodexProvider && !existingProviderSessionId
758
838
  ? listCodexSessionMtimes(this.providerHistory.listCodexHistorySessions())
759
839
  : null;
760
- const initialClaudeSessionId = isClaudeProvider
761
- ? resumeCommandSessionId ?? null
762
- : codexResumeCommandSessionId ?? null;
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
- if (provider === "codex") {
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 (getResumeCommandSessionId(current.command)) {
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 (getCodexResumeCommandSessionId(current.command)) {
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;
@@ -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;
@@ -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
- const provider = snapshot.provider ?? (/^codex\b/.test(snapshot.command.trim()) ? "codex" : "claude");
243
- if (provider !== "claude" && provider !== "codex") {
244
- throw new Error("只有 Claude 或 Codex provider 支持恢复功能。");
245
- }
246
- return provider;
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 === "codex" ? "此会话没有 Codex thread ID,无法恢复。" : "此会话没有 Claude 会话 ID,无法恢复。");
260
+ throw new Error(`此会话没有 ${provider} 会话 ID,无法恢复。`);
257
261
  }
258
- if (provider === "claude" && !/^claude\b/.test(command)) {
259
- throw new Error("只有 Claude 命令支持恢复功能。");
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 === "codex"
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" || /^codex\b/.test(snapshot.command.trim()) || /^claude\b/.test(snapshot.command.trim()))
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
  }