@co0ontty/wand 3.1.1 → 4.0.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.
Files changed (62) hide show
  1. package/dist/auth.d.ts +19 -5
  2. package/dist/auth.js +83 -45
  3. package/dist/build-info.json +3 -3
  4. package/dist/cert.d.ts +1 -1
  5. package/dist/cert.js +124 -74
  6. package/dist/config.js +25 -8
  7. package/dist/express-async.d.ts +6 -0
  8. package/dist/express-async.js +28 -0
  9. package/dist/git-quick-commit.d.ts +2 -0
  10. package/dist/git-quick-commit.js +215 -76
  11. package/dist/git-utils.d.ts +4 -0
  12. package/dist/git-utils.js +60 -11
  13. package/dist/git-worktree.d.ts +8 -1
  14. package/dist/git-worktree.js +406 -41
  15. package/dist/models.d.ts +34 -4
  16. package/dist/models.js +334 -48
  17. package/dist/process-manager.d.ts +22 -30
  18. package/dist/process-manager.js +374 -441
  19. package/dist/provider-history-scanner.d.ts +54 -0
  20. package/dist/provider-history-scanner.js +354 -0
  21. package/dist/request-limits.d.ts +1 -0
  22. package/dist/request-limits.js +8 -0
  23. package/dist/resume-policy.d.ts +2 -0
  24. package/dist/resume-policy.js +5 -0
  25. package/dist/runtime-config.d.ts +16 -0
  26. package/dist/runtime-config.js +49 -0
  27. package/dist/server-file-routes.d.ts +17 -0
  28. package/dist/server-file-routes.js +653 -0
  29. package/dist/server-session-routes.d.ts +16 -3
  30. package/dist/server-session-routes.js +170 -149
  31. package/dist/server-settings-routes.d.ts +43 -0
  32. package/dist/server-settings-routes.js +225 -0
  33. package/dist/server-update-routes.d.ts +61 -0
  34. package/dist/server-update-routes.js +215 -0
  35. package/dist/server.d.ts +6 -4
  36. package/dist/server.js +350 -1313
  37. package/dist/session-logger.d.ts +32 -2
  38. package/dist/session-logger.js +145 -15
  39. package/dist/session-registry.d.ts +27 -0
  40. package/dist/session-registry.js +153 -0
  41. package/dist/session-transport.d.ts +31 -0
  42. package/dist/session-transport.js +82 -0
  43. package/dist/storage.d.ts +24 -6
  44. package/dist/storage.js +291 -44
  45. package/dist/structured-claude-adapter.d.ts +19 -0
  46. package/dist/structured-claude-adapter.js +117 -0
  47. package/dist/structured-codex-adapter.d.ts +3 -0
  48. package/dist/structured-codex-adapter.js +29 -0
  49. package/dist/structured-opencode-adapter.d.ts +11 -0
  50. package/dist/structured-opencode-adapter.js +115 -0
  51. package/dist/structured-provider-common.d.ts +11 -0
  52. package/dist/structured-provider-common.js +77 -0
  53. package/dist/structured-session-manager.d.ts +32 -35
  54. package/dist/structured-session-manager.js +551 -605
  55. package/dist/types.d.ts +10 -0
  56. package/dist/update-helper.js +5 -1
  57. package/dist/web-ui/content/scripts.js +32 -32
  58. package/dist/web-ui/embedded-assets.d.ts +1 -1
  59. package/dist/web-ui/embedded-assets.js +2 -2
  60. package/dist/ws-broadcast.d.ts +16 -1
  61. package/dist/ws-broadcast.js +124 -58
  62. package/package.json +2 -1
@@ -1,92 +1,27 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, readFileSync, statSync } from "node:fs";
4
- import { homedir } from "node:os";
5
4
  import path from "node:path";
6
5
  import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
7
6
  import { prepareSessionWorktree } from "./git-worktree.js";
8
7
  import { truncateMessagesForTransport } from "./message-truncator.js";
9
- import { buildChildEnv, isRunningAsRoot } from "./env-utils.js";
8
+ import { buildChildEnv } from "./env-utils.js";
10
9
  import { getErrorMessage } from "./error-utils.js";
11
10
  import { resolveSdkClaudeBinary } from "./claude-sdk-runner.js";
12
- import { buildLanguageDirective, buildManagedAutonomyDirective } from "./language-prompt.js";
13
11
  import { generateSessionTopic } from "./session-topic.js";
14
12
  import { resolveSessionCwd } from "./session-cwd.js";
15
- function defaultStructuredRunner(provider) {
16
- return provider === "codex" ? "codex-cli-exec" : provider === "opencode" ? "opencode-cli-run" : "claude-cli-print";
17
- }
18
- function defaultStructuredState(provider, runner = defaultStructuredRunner(provider)) {
19
- return {
20
- provider,
21
- runner,
22
- lastError: null,
23
- inFlight: false,
24
- activeRequestId: null,
25
- };
26
- }
27
- /**
28
- * 把任意外部输入收敛到合法的 thinkingEffort 枚举值。`null` / 非法值都视为
29
- * "未设置"——上层调用方再根据 provider 决定是否填默认值。
30
- */
31
- export function normalizeThinkingEffort(value) {
32
- if (typeof value !== "string")
33
- return null;
34
- const v = value.trim().toLowerCase();
35
- if (v === "off" || v === "standard" || v === "deep" || v === "max")
36
- return v;
37
- if (/^codex:[a-z0-9][a-z0-9_-]{0,31}$/.test(v))
38
- return v;
39
- return null;
40
- }
41
- /** Claude SDK 用:把 thinkingEffort 映射成 `thinking.budget_tokens`。off / 空 → 0(不启用)。 */
42
- export function thinkingEffortToSdkBudget(effort) {
43
- switch (effort) {
44
- case "standard": return 4096;
45
- case "deep": return 16000;
46
- case "max": return 31999;
47
- case "off":
48
- default: return 0;
13
+ import { buildCodexArgs } from "./structured-codex-adapter.js";
14
+ import { buildAppendSystemPromptParts, buildClaudeCliArgs, buildClaudeSdkThinking, derivePermissionPolicy, } from "./structured-claude-adapter.js";
15
+ import { applyOpenCodeEvent, buildOpenCodeArgs } from "./structured-opencode-adapter.js";
16
+ import { defaultStructuredRunner, defaultStructuredState, isStructuredRunnerForProvider, normalizeThinkingEffort, resolveStructuredRunner, } from "./structured-provider-common.js";
17
+ export { isStructuredRunnerForProvider, normalizeThinkingEffort, resolveStructuredRunner, thinkingEffortToClaudeCliEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant, thinkingEffortToSdkBudget, } from "./structured-provider-common.js";
18
+ /** The runner already persisted/emitted its detailed terminal snapshot. */
19
+ class PersistedStructuredRunnerError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "PersistedStructuredRunnerError";
49
23
  }
50
24
  }
51
- /** Claude CLI 用:把 thinkingEffort 映射到 `--effort` / `/effort` 支持的等级。off → 不覆盖默认值。 */
52
- export function thinkingEffortToClaudeCliEffort(effort) {
53
- switch (effort) {
54
- case "standard": return "low";
55
- case "deep": return "medium";
56
- case "max": return "max";
57
- case "off":
58
- default: return null;
59
- }
60
- }
61
- /** Claude PTY slash-command 用:off 表示恢复模型默认 effort。 */
62
- export function thinkingEffortToClaudeSlashEffort(effort) {
63
- return thinkingEffortToClaudeCliEffort(effort) ?? "auto";
64
- }
65
- /** Codex CLI 用:把 thinkingEffort 映射到 model_reasoning_effort 配置。off → 不覆盖 Codex 默认。 */
66
- export function thinkingEffortToCodexReasoningEffort(effort) {
67
- if (typeof effort === "string" && effort.startsWith("codex:")) {
68
- return effort.slice("codex:".length) || null;
69
- }
70
- switch (effort) {
71
- case "standard": return "low";
72
- case "deep": return "medium";
73
- case "max": return "xhigh";
74
- case "off": return null;
75
- default: return null;
76
- }
77
- }
78
- /** OpenCode exposes provider-specific reasoning presets through `--variant`. */
79
- export function thinkingEffortToOpenCodeVariant(effort) {
80
- if (!effort || effort === "off")
81
- return null;
82
- if (effort === "standard")
83
- return "low";
84
- if (effort === "deep")
85
- return "high";
86
- if (effort === "max")
87
- return "max";
88
- return effort.startsWith("codex:") ? effort.slice("codex:".length) || null : null;
89
- }
90
25
  function asRecord(value) {
91
26
  return value && typeof value === "object" && !Array.isArray(value)
92
27
  ? value
@@ -516,7 +451,11 @@ const STREAM_EMIT_DEBOUNCE_MS = 16;
516
451
  * saveSession serializes the entire messages array, so doing it on every NDJSON
517
452
  * event is N². close-path always calls saveSession unconditionally to take the
518
453
  * authoritative final snapshot. */
519
- const STREAM_SAVE_THROTTLE_MS = 200;
454
+ // Full message snapshots become increasingly expensive during long turns.
455
+ // Terminal paths always force an authoritative save, so a one-second crash
456
+ // checkpoint keeps recovery useful without rewriting megabytes five times a
457
+ // second on the event loop.
458
+ const STREAM_SAVE_THROTTLE_MS = 1_000;
520
459
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
521
460
  /**
522
461
  * 找出最后一条 assistant turn 中尚未配对 tool_result 的 AskUserQuestion tool_use。
@@ -573,132 +512,6 @@ function withSummary(snapshot) {
573
512
  function shouldAutoApproveForMode(mode) {
574
513
  return mode === "full-access" || mode === "managed" || mode === "auto-edit";
575
514
  }
576
- /**
577
- * Root 模式下绕过权限的工具白名单。Claude CLI 拒绝以 root 身份用 bypassPermissions,
578
- * 退而求其次用 acceptEdits + 显式 allowedTools 覆盖 CWD 之外的路径。
579
- */
580
- const ROOT_FALLBACK_ALLOWED_TOOLS = [
581
- "Bash", "Edit", "Write", "Read", "Glob", "Grep", "NotebookEdit", "WebFetch", "WebSearch",
582
- ];
583
- /**
584
- * 收集当前会话可见的 MCP server 名字。
585
- * claude -p / SDK runner 没有交互式权限弹窗,碰到 mcp__* 工具会直接 fail with
586
- * "haven't granted"。用户已经在 claude 这边配过的 MCP server 视为可信,
587
- * 在 --allowedTools 里加 `mcp__<server>` 放行整台 server 的所有工具。
588
- *
589
- * 来源(取并集):
590
- * - ~/.claude.json 顶层 mcpServers
591
- * - ~/.claude.json projects[<cwd>].mcpServers(仅当前 cwd 精确匹配)
592
- * - <cwd>/.mcp.json mcpServers
593
- *
594
- * 结果按 (cwd, 各文件 mtime) 缓存,避免每次 spawn 都重读。
595
- */
596
- const mcpServerCache = new Map();
597
- function readJsonSafe(filePath) {
598
- try {
599
- const raw = readFileSync(filePath, "utf-8");
600
- const parsed = JSON.parse(raw);
601
- if (parsed && typeof parsed === "object")
602
- return parsed;
603
- }
604
- catch { /* missing/invalid — return null */ }
605
- return null;
606
- }
607
- function mtimeOf(filePath) {
608
- try {
609
- return statSync(filePath).mtimeMs;
610
- }
611
- catch {
612
- return 0;
613
- }
614
- }
615
- function extractMcpServerKeys(node) {
616
- if (!node || typeof node !== "object")
617
- return [];
618
- const mcpServers = node.mcpServers;
619
- if (!mcpServers || typeof mcpServers !== "object")
620
- return [];
621
- return Object.keys(mcpServers);
622
- }
623
- function collectMcpServerNames(cwd) {
624
- const userConfigPath = path.join(homedir(), ".claude.json");
625
- const projectMcpPath = path.join(cwd, ".mcp.json");
626
- const fingerprint = `${mtimeOf(userConfigPath)}:${mtimeOf(projectMcpPath)}`;
627
- const cached = mcpServerCache.get(cwd);
628
- if (cached && cached.mtimeFingerprint === fingerprint)
629
- return cached.names;
630
- const names = new Set();
631
- const userConfig = readJsonSafe(userConfigPath);
632
- if (userConfig) {
633
- for (const k of extractMcpServerKeys(userConfig))
634
- names.add(k);
635
- const projects = userConfig.projects;
636
- if (projects && typeof projects === "object") {
637
- const entry = projects[cwd];
638
- for (const k of extractMcpServerKeys(entry))
639
- names.add(k);
640
- }
641
- }
642
- const projectMcp = readJsonSafe(projectMcpPath);
643
- for (const k of extractMcpServerKeys(projectMcp))
644
- names.add(k);
645
- const result = Array.from(names);
646
- mcpServerCache.set(cwd, { mtimeFingerprint: fingerprint, names: result });
647
- return result;
648
- }
649
- function mcpAllowEntries(cwd) {
650
- // `mcp__<server>` 形式放行该 server 的所有工具,等价于 `mcp__<server>__*`。
651
- return collectMcpServerNames(cwd).map((name) => `mcp__${name}`);
652
- }
653
- /**
654
- * 把 (执行模式, 自动批准开关) 映射成 Claude CLI / SDK 的权限决策。
655
- * CLI runner 把它转成 --permission-mode / --allowedTools flag,
656
- * SDK runner 直接塞进 Options。两边的决策规则保持一字不差。
657
- *
658
- * cwd 用来枚举该会话能看到的 MCP server,把 `mcp__<server>` 加进 allowedTools;
659
- * bypassPermissions 模式下整个白名单都没意义,不附加。
660
- */
661
- function derivePermissionPolicy(mode, autoApprove, cwd) {
662
- const shouldBypass = autoApprove || mode === "full-access" || mode === "managed";
663
- const shouldAcceptEdits = mode === "auto-edit";
664
- const mcpAllow = shouldBypass ? [] : mcpAllowEntries(cwd);
665
- const withMcp = (base) => {
666
- if (!mcpAllow.length)
667
- return base;
668
- return base ? [...base, ...mcpAllow] : [...mcpAllow];
669
- };
670
- if (!isRunningAsRoot()) {
671
- if (shouldBypass)
672
- return { permissionMode: "bypassPermissions", allowedTools: undefined };
673
- if (shouldAcceptEdits)
674
- return { permissionMode: "acceptEdits", allowedTools: withMcp(undefined) };
675
- return { permissionMode: "default", allowedTools: withMcp(undefined) };
676
- }
677
- if (shouldBypass || shouldAcceptEdits) {
678
- return { permissionMode: "acceptEdits", allowedTools: withMcp(ROOT_FALLBACK_ALLOWED_TOOLS) };
679
- }
680
- return { permissionMode: "default", allowedTools: withMcp(undefined) };
681
- }
682
- /**
683
- * 拼装要追加到系统提示词里的片段:托管模式的自主决策提示 + 用户配置的语言偏好。
684
- * CLI runner 每段单独 push 一对 `--append-system-prompt <part>` flag,
685
- * SDK runner 用 "\n\n" 串成一个 appendSystemPrompt 字符串塞 Options。
686
- * 文本统一到这里维护,避免两个 runner 各抄一份导致漂移。
687
- */
688
- function buildAppendSystemPromptParts(language, mode) {
689
- const trimmedLanguage = language?.trim();
690
- const isChinese = trimmedLanguage === "中文";
691
- const parts = [];
692
- if (mode === "managed") {
693
- parts.push(buildManagedAutonomyDirective(isChinese));
694
- }
695
- if (trimmedLanguage) {
696
- const directive = buildLanguageDirective(trimmedLanguage);
697
- if (directive)
698
- parts.push(directive);
699
- }
700
- return parts;
701
- }
702
515
  function buildStructuredOutputPayload(snapshot) {
703
516
  return {
704
517
  output: snapshot.output,
@@ -769,6 +582,7 @@ export class StructuredSessionManager {
769
582
  storage;
770
583
  config;
771
584
  logger;
585
+ sdkQueryFactory;
772
586
  sessions = new Map();
773
587
  pendingChildren = new Map();
774
588
  pendingSdkAbort = new Map();
@@ -779,12 +593,6 @@ export class StructuredSessionManager {
779
593
  */
780
594
  pendingSdkQueries = new Map();
781
595
  interruptedWith = new Map();
782
- /**
783
- * 用户主动点了「停止」的会话。异步收尾(claude -p / codex 的 close、SDK 的 abort)
784
- * 据此跳过"结构化会话执行失败"路径——主动停止不是失败,按正常 idle 收尾、保留历史内容。
785
- * 用 Set.delete 消费:读取的同时清除,下一轮真失败不会被旧标记误抑制。
786
- */
787
- userStopped = new Set();
788
596
  /**
789
597
  * Sessions where the current interrupt is a "queue promote" (用户从排队条点了「立即」
790
598
  * 把队首插队到 now)。退出处理三个分支默认会把 queuedMessages 清空——因为常规的
@@ -793,8 +601,10 @@ export class StructuredSessionManager {
793
601
  * 收到后必须 delete 掉,避免下一次普通 interrupt 误带 flag。
794
602
  */
795
603
  preserveQueueOnInterrupt = new Set();
796
- /** Last wall-clock time (ms) we did a full saveSession for a streaming session. */
604
+ /** Last wall-clock time (ms) a streaming checkpoint reached SQLite. */
797
605
  lastStreamSaveAt = new Map();
606
+ streamCheckpointTimers = new Map();
607
+ streamCheckpointDirty = new Map();
798
608
  /**
799
609
  * Idempotency keys we've already accepted, mapped to their wall-clock timestamp.
800
610
  * Android WebView 在进程恢复时偶尔会重发上一个未收到响应的 POST(HTTP/2 stream
@@ -806,21 +616,34 @@ export class StructuredSessionManager {
806
616
  emitEvent = null;
807
617
  archiveTimer = null;
808
618
  topicRequests = new Set();
809
- constructor(storage, config, logger = null) {
619
+ streamEmitTimers = new Set();
620
+ disposed = false;
621
+ constructor(storage, config, logger = null, sdkQueryFactory = sdkQuery) {
810
622
  this.storage = storage;
811
623
  this.config = config;
812
624
  this.logger = logger;
625
+ this.sdkQueryFactory = sdkQueryFactory;
813
626
  for (const snapshot of this.storage.loadSessions()) {
814
627
  if ((snapshot.sessionKind ?? "pty") !== "structured")
815
628
  continue;
816
629
  const restoredStatus = snapshot.status === "running" ? "idle" : snapshot.status;
630
+ const storedProvider = snapshot.provider ?? snapshot.structuredState?.provider;
631
+ const provider = storedProvider === "codex" || storedProvider === "opencode"
632
+ ? storedProvider
633
+ : "claude";
634
+ const storedRunner = snapshot.runner ?? snapshot.structuredState?.runner;
635
+ // Legacy/corrupt snapshots are normalized on restore so send dispatch can
636
+ // rely on the provider/runner invariant without making startup fail.
637
+ const runner = isStructuredRunnerForProvider(provider, storedRunner)
638
+ ? storedRunner
639
+ : defaultStructuredRunner(provider, this.config.structuredRunner);
817
640
  const restored = {
818
641
  ...snapshot,
819
642
  sessionKind: "structured",
820
643
  sessionSource: snapshot.sessionSource ?? "interactive",
821
644
  automationId: snapshot.automationId,
822
- provider: snapshot.provider ?? snapshot.structuredState?.provider ?? "claude",
823
- runner: snapshot.runner ?? snapshot.structuredState?.runner ?? defaultStructuredRunner(snapshot.provider ?? snapshot.structuredState?.provider ?? "claude"),
645
+ provider,
646
+ runner,
824
647
  status: restoredStatus,
825
648
  autoApprovePermissions: snapshot.autoApprovePermissions ?? shouldAutoApproveForMode(snapshot.mode),
826
649
  approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
@@ -828,8 +651,8 @@ export class StructuredSessionManager {
828
651
  pendingEscalation: null,
829
652
  permissionBlocked: false,
830
653
  structuredState: {
831
- provider: snapshot.structuredState?.provider ?? snapshot.provider ?? "claude",
832
- runner: snapshot.runner ?? snapshot.structuredState?.runner ?? defaultStructuredRunner(snapshot.structuredState?.provider ?? snapshot.provider ?? "claude"),
654
+ provider,
655
+ runner,
833
656
  model: snapshot.structuredState?.model ?? snapshot.selectedModel ?? undefined,
834
657
  lastError: snapshot.structuredState?.lastError ?? null,
835
658
  inFlight: false,
@@ -862,25 +685,160 @@ export class StructuredSessionManager {
862
685
  continue;
863
686
  session.archived = true;
864
687
  session.archivedAt = new Date(now).toISOString();
865
- this.storage.saveSession(session);
688
+ this.storage.updateSessionRuntimeMetadata(session);
866
689
  }
867
690
  }
868
691
  setEventEmitter(emitEvent) {
692
+ if (this.disposed)
693
+ return;
869
694
  this.emitEvent = emitEvent;
870
695
  }
871
- /**
872
- * In-memory snapshot is updated unconditionally; the SQLite write is rate-
873
- * limited to once per STREAM_SAVE_THROTTLE_MS. Caller must still invoke
874
- * `storage.saveSession` directly at terminal events (close / failure) so the
875
- * final state is durable.
876
- */
877
- saveStreamingSnapshot(snapshot) {
696
+ /** Stop every runner and flush terminal state before storage is closed. */
697
+ dispose() {
698
+ if (this.disposed)
699
+ return;
700
+ this.disposed = true;
701
+ if (this.archiveTimer) {
702
+ clearInterval(this.archiveTimer);
703
+ this.archiveTimer = null;
704
+ }
705
+ for (const timer of this.streamEmitTimers)
706
+ clearTimeout(timer);
707
+ this.streamEmitTimers.clear();
708
+ const activeSessionIds = new Set([
709
+ ...this.pendingChildren.keys(),
710
+ ...this.pendingSdkQueries.keys(),
711
+ ...this.pendingSdkAbort.keys(),
712
+ ...Array.from(this.sessions.values())
713
+ .filter((session) => session.structuredState?.inFlight)
714
+ .map((session) => session.id),
715
+ ]);
716
+ for (const id of activeSessionIds) {
717
+ const session = this.sessions.get(id);
718
+ if (!session)
719
+ continue;
720
+ const cancelled = {
721
+ ...session,
722
+ status: "idle",
723
+ exitCode: null,
724
+ endedAt: null,
725
+ pendingEscalation: null,
726
+ permissionBlocked: false,
727
+ structuredState: {
728
+ ...(session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
729
+ inFlight: false,
730
+ activeRequestId: null,
731
+ lastError: null,
732
+ },
733
+ };
734
+ this.sessions.set(id, cancelled);
735
+ try {
736
+ this.saveAuthoritativeSession(cancelled);
737
+ }
738
+ catch { /* best-effort shutdown flush */ }
739
+ }
740
+ for (const child of this.pendingChildren.values()) {
741
+ try {
742
+ child.kill();
743
+ }
744
+ catch { /* ignore */ }
745
+ }
746
+ for (const query of this.pendingSdkQueries.values()) {
747
+ void query.interrupt().catch(() => { });
748
+ }
749
+ for (const controller of this.pendingSdkAbort.values())
750
+ controller.abort();
751
+ this.pendingChildren.clear();
752
+ this.pendingSdkQueries.clear();
753
+ this.pendingSdkAbort.clear();
754
+ this.interruptedWith.clear();
755
+ this.preserveQueueOnInterrupt.clear();
756
+ for (const timer of this.streamCheckpointTimers.values())
757
+ clearTimeout(timer);
758
+ this.streamCheckpointTimers.clear();
759
+ this.streamCheckpointDirty.clear();
760
+ this.lastStreamSaveAt.clear();
761
+ this.topicRequests.clear();
762
+ this.emitEvent = null;
763
+ }
764
+ trackStreamEmitTimer(timer) {
765
+ this.streamEmitTimers.add(timer);
766
+ return timer;
767
+ }
768
+ clearStreamEmitTimer(timer) {
769
+ clearTimeout(timer);
770
+ this.streamEmitTimers.delete(timer);
771
+ }
772
+ /** Mark streaming payload dirty and enforce both leading and trailing checkpoints. */
773
+ saveStreamingSnapshot(snapshot, changed = { messages: true, output: true }) {
774
+ if (this.disposed)
775
+ return;
776
+ const dirty = this.streamCheckpointDirty.get(snapshot.id) ?? { metadata: false, output: false, messages: false };
777
+ if (changed.metadata)
778
+ dirty.metadata = true;
779
+ if (changed.output)
780
+ dirty.output = true;
781
+ if (changed.messages)
782
+ dirty.messages = true;
783
+ this.streamCheckpointDirty.set(snapshot.id, dirty);
878
784
  const now = Date.now();
879
785
  const last = this.lastStreamSaveAt.get(snapshot.id) ?? 0;
880
- if (now - last < STREAM_SAVE_THROTTLE_MS)
786
+ const remaining = STREAM_SAVE_THROTTLE_MS - (now - last);
787
+ if (remaining <= 0) {
788
+ this.flushStreamingCheckpoint(snapshot.id);
881
789
  return;
882
- this.lastStreamSaveAt.set(snapshot.id, now);
790
+ }
791
+ if (this.streamCheckpointTimers.has(snapshot.id))
792
+ return;
793
+ const timer = setTimeout(() => {
794
+ this.streamCheckpointTimers.delete(snapshot.id);
795
+ if (!this.disposed)
796
+ this.flushStreamingCheckpoint(snapshot.id);
797
+ }, remaining);
798
+ timer.unref?.();
799
+ this.streamCheckpointTimers.set(snapshot.id, timer);
800
+ }
801
+ flushStreamingCheckpoint(sessionId) {
802
+ const timer = this.streamCheckpointTimers.get(sessionId);
803
+ if (timer) {
804
+ clearTimeout(timer);
805
+ this.streamCheckpointTimers.delete(sessionId);
806
+ }
807
+ const dirty = this.streamCheckpointDirty.get(sessionId);
808
+ const snapshot = this.sessions.get(sessionId);
809
+ if (!dirty || !snapshot) {
810
+ this.streamCheckpointDirty.delete(sessionId);
811
+ return;
812
+ }
813
+ if (dirty.metadata)
814
+ this.storage.updateSessionRuntimeMetadata(snapshot);
815
+ if (dirty.messages) {
816
+ this.storage.checkpointSessionMessages(sessionId, snapshot.messages ?? [], snapshot.structuredState, dirty.output ? snapshot.output : undefined);
817
+ }
818
+ else if (dirty.output) {
819
+ this.storage.checkpointSessionOutput(sessionId, snapshot.output);
820
+ }
821
+ this.streamCheckpointDirty.delete(sessionId);
822
+ this.lastStreamSaveAt.set(sessionId, Date.now());
823
+ }
824
+ clearStreamingCheckpoint(sessionId) {
825
+ this.cancelStreamingCheckpointTimer(sessionId);
826
+ this.streamCheckpointDirty.delete(sessionId);
827
+ this.lastStreamSaveAt.delete(sessionId);
828
+ }
829
+ cancelStreamingCheckpointTimer(sessionId) {
830
+ const timer = this.streamCheckpointTimers.get(sessionId);
831
+ if (timer)
832
+ clearTimeout(timer);
833
+ this.streamCheckpointTimers.delete(sessionId);
834
+ }
835
+ saveAuthoritativeSession(snapshot) {
883
836
  this.storage.saveSession(snapshot);
837
+ this.clearStreamingCheckpoint(snapshot.id);
838
+ }
839
+ checkpointSessionMessages(snapshot, includeOutput = false) {
840
+ this.storage.updateSessionRuntimeMetadata(snapshot);
841
+ this.storage.checkpointSessionMessages(snapshot.id, snapshot.messages ?? [], snapshot.structuredState, includeOutput ? snapshot.output : undefined);
884
842
  }
885
843
  list() {
886
844
  return Array.from(this.sessions.values())
@@ -905,28 +863,60 @@ export class StructuredSessionManager {
905
863
  const current = this.requireSession(id);
906
864
  const updated = { ...current, title, description, summary: description };
907
865
  this.sessions.set(id, updated);
908
- this.storage.saveSessionMetadata(updated);
866
+ this.storage.updateSessionRuntimeMetadata(updated);
909
867
  this.emitStructuredSnapshot(updated);
910
868
  return updated;
911
869
  }
870
+ /**
871
+ * Update worktree merge progress on the canonical in-memory snapshot before
872
+ * persisting it. A null result means this manager does not own the session.
873
+ */
874
+ setWorktreeMergeState(id, status, info) {
875
+ const current = this.sessions.get(id);
876
+ if (!current)
877
+ return null;
878
+ const updated = {
879
+ ...current,
880
+ worktreeMergeStatus: status,
881
+ worktreeMergeInfo: info ?? null,
882
+ };
883
+ this.sessions.set(id, updated);
884
+ this.storage.updateSessionRuntimeMetadata(updated);
885
+ this.emit({
886
+ type: "status",
887
+ sessionId: id,
888
+ data: {
889
+ sessionKind: "structured",
890
+ worktreeMergeStatus: status,
891
+ worktreeMergeInfo: updated.worktreeMergeInfo,
892
+ },
893
+ });
894
+ return updated;
895
+ }
912
896
  maybeGenerateSessionTopic(id, input) {
913
897
  const session = this.sessions.get(id);
914
- if (!session || session.title || this.topicRequests.has(id))
898
+ if (this.disposed || !session || session.title || this.topicRequests.has(id))
915
899
  return;
916
900
  this.topicRequests.add(id);
917
901
  void generateSessionTopic(input, session.cwd, this.config.language)
918
902
  .then(({ title, description }) => {
919
- if (this.sessions.has(id))
903
+ if (!this.disposed && this.sessions.has(id))
920
904
  this.setSessionTopic(id, title, description);
921
905
  })
922
906
  .catch((error) => console.error(`[StructuredSessionManager] Failed to generate session topic ${id}:`, getErrorMessage(error)))
923
907
  .finally(() => this.topicRequests.delete(id));
924
908
  }
925
909
  createSession(options) {
910
+ if (this.disposed)
911
+ throw new Error("StructuredSessionManager has been disposed.");
926
912
  const id = randomUUID();
927
913
  const startedAt = new Date().toISOString();
928
- const provider = options.provider === "codex" || options.provider === "opencode" ? options.provider : "claude";
929
- const runner = options.runner ?? defaultStructuredRunner(provider);
914
+ const requestedProvider = options.provider ?? "claude";
915
+ if (requestedProvider !== "claude" && requestedProvider !== "codex" && requestedProvider !== "opencode") {
916
+ throw new Error(`不支持的结构化 provider: ${String(requestedProvider)}`);
917
+ }
918
+ const provider = requestedProvider;
919
+ const runner = resolveStructuredRunner(provider, options.runner, this.config.structuredRunner);
930
920
  const baseCwd = resolveSessionCwd(options.cwd, this.config.defaultCwd);
931
921
  const worktreeSetup = options.worktreeEnabled
932
922
  ? prepareSessionWorktree({ cwd: baseCwd, sessionId: id })
@@ -981,6 +971,8 @@ export class StructuredSessionManager {
981
971
  return snapshot;
982
972
  }
983
973
  async sendMessage(id, input, opts) {
974
+ if (this.disposed)
975
+ throw new Error("StructuredSessionManager has been disposed.");
984
976
  let session = this.requireSession(id);
985
977
  const prompt = input.trim();
986
978
  if (!prompt)
@@ -1004,10 +996,18 @@ export class StructuredSessionManager {
1004
996
  }
1005
997
  if (session.structuredState?.inFlight) {
1006
998
  const child = this.pendingChildren.get(id);
1007
- const childAlive = child && !child.killed && child.exitCode === null;
1008
- if (!childAlive) {
999
+ const sdkAbort = this.pendingSdkAbort.get(id);
1000
+ const sdkQueryHandle = this.pendingSdkQueries.get(id);
1001
+ // ChildProcess.killed only means kill() successfully sent a signal; the
1002
+ // process can keep running until its close/error callback releases this
1003
+ // exact handle. Treat map ownership as the authoritative in-flight state.
1004
+ const childActive = Boolean(child);
1005
+ const sdkAlive = Boolean(sdkQueryHandle || (sdkAbort && !sdkAbort.signal.aborted));
1006
+ if (!childActive && !sdkAlive) {
1009
1007
  if (child)
1010
- this.pendingChildren.delete(id);
1008
+ this.releasePendingChild(id, child);
1009
+ if (sdkAbort)
1010
+ this.releasePendingSdkAbort(id, sdkAbort);
1011
1011
  const recovered = {
1012
1012
  ...session,
1013
1013
  status: "idle",
@@ -1019,7 +1019,7 @@ export class StructuredSessionManager {
1019
1019
  },
1020
1020
  };
1021
1021
  this.sessions.set(id, recovered);
1022
- this.storage.saveSession(recovered);
1022
+ this.storage.updateSessionRuntimeMetadata(recovered);
1023
1023
  session = recovered;
1024
1024
  }
1025
1025
  else if (opts?.interrupt) {
@@ -1038,7 +1038,7 @@ export class StructuredSessionManager {
1038
1038
  const trimmedQueue = queue.slice(0, removeAt).concat(queue.slice(removeAt + 1));
1039
1039
  session = { ...session, queuedMessages: trimmedQueue };
1040
1040
  this.sessions.set(id, session);
1041
- this.storage.saveSession(session);
1041
+ this.storage.updateSessionRuntimeMetadata(session);
1042
1042
  this.emitStructuredSnapshot(session);
1043
1043
  }
1044
1044
  }
@@ -1046,15 +1046,15 @@ export class StructuredSessionManager {
1046
1046
  else {
1047
1047
  this.preserveQueueOnInterrupt.delete(id);
1048
1048
  }
1049
- try {
1050
- child.kill("SIGTERM");
1049
+ if (childActive && child) {
1050
+ try {
1051
+ child.kill("SIGTERM");
1052
+ }
1053
+ catch (_err) { /* ignore */ }
1051
1054
  }
1052
- catch (_err) { /* ignore */ }
1053
- const sdkQueryHandle = this.pendingSdkQueries.get(id);
1054
1055
  if (sdkQueryHandle) {
1055
1056
  void sdkQueryHandle.interrupt().catch(() => { });
1056
1057
  }
1057
- const sdkAbort = this.pendingSdkAbort.get(id);
1058
1058
  if (sdkAbort)
1059
1059
  sdkAbort.abort();
1060
1060
  return session;
@@ -1074,7 +1074,7 @@ export class StructuredSessionManager {
1074
1074
  queuedMessages: [...queue, prompt],
1075
1075
  };
1076
1076
  this.sessions.set(id, queued);
1077
- this.storage.saveSession(queued);
1077
+ this.storage.updateSessionRuntimeMetadata(queued);
1078
1078
  this.emitStructuredSnapshot(queued);
1079
1079
  return queued;
1080
1080
  }
@@ -1114,7 +1114,7 @@ export class StructuredSessionManager {
1114
1114
  },
1115
1115
  };
1116
1116
  this.sessions.set(id, updated);
1117
- this.storage.saveSession(updated);
1117
+ this.checkpointSessionMessages(updated);
1118
1118
  this.emitStructuredSnapshot(updated);
1119
1119
  this.emit({
1120
1120
  type: "status",
@@ -1132,26 +1132,41 @@ export class StructuredSessionManager {
1132
1132
  ? `[对刚才 AskUserQuestion 工具的回答 — 结构化模式不支持工具结果回传,下面是用户从选项中的选择]\n${prompt}`
1133
1133
  : prompt;
1134
1134
  try {
1135
- if ((updated.provider ?? "claude") === "codex") {
1136
- await this.runCodexStreaming(id, updated, prompt);
1135
+ const provider = updated.provider ?? updated.structuredState?.provider ?? "claude";
1136
+ const runner = updated.runner ?? updated.structuredState?.runner;
1137
+ if (!isStructuredRunnerForProvider(provider, runner)) {
1138
+ throw new Error(`会话 runner ${String(runner)} 与 provider ${provider} 不匹配。`);
1137
1139
  }
1138
- else if ((updated.provider ?? "claude") === "opencode") {
1139
- await this.runOpenCodeStreaming(id, updated, prompt);
1140
+ if (provider === "codex") {
1141
+ await this.runCodexStreaming(id, updated, prompt, requestId);
1140
1142
  }
1141
- else if (this.config.structuredRunner === "sdk") {
1142
- await this.runClaudeSdkStreaming(id, updated, prompt);
1143
+ else if (provider === "opencode") {
1144
+ await this.runOpenCodeStreaming(id, updated, prompt, requestId);
1145
+ }
1146
+ else if (runner === "claude-sdk") {
1147
+ await this.runClaudeSdkStreaming(id, updated, prompt, requestId);
1143
1148
  }
1144
1149
  else {
1145
- await this.runClaudeStreaming(id, updated, cliClaudePrompt);
1150
+ await this.runClaudeStreaming(id, updated, cliClaudePrompt, requestId);
1146
1151
  }
1147
1152
  const finished = this.requireSession(id);
1148
1153
  return finished;
1149
1154
  }
1150
1155
  catch (error) {
1151
1156
  const message = getErrorMessage(error);
1157
+ // Close handlers use this tagged error after they have already persisted
1158
+ // the detailed failure. Re-throw even if an ended-event listener removed
1159
+ // the session synchronously; there is no request-id marker to leak.
1160
+ if (error instanceof PersistedStructuredRunnerError)
1161
+ throw error;
1152
1162
  const current = this.sessions.get(id);
1153
1163
  if (!current)
1154
1164
  throw error;
1165
+ // stop() or a newer turn may have invalidated this execution while its
1166
+ // runner was unwinding. A stale rejection must never fail the new turn.
1167
+ if (!this.isCurrentRequest(id, requestId)) {
1168
+ return current;
1169
+ }
1155
1170
  const failed = {
1156
1171
  ...current,
1157
1172
  status: "failed",
@@ -1165,7 +1180,7 @@ export class StructuredSessionManager {
1165
1180
  },
1166
1181
  };
1167
1182
  this.sessions.set(id, failed);
1168
- this.storage.saveSession(failed);
1183
+ this.saveAuthoritativeSession(failed);
1169
1184
  this.emit({
1170
1185
  type: "status",
1171
1186
  sessionId: id,
@@ -1199,7 +1214,7 @@ export class StructuredSessionManager {
1199
1214
  const reordered = order.map((idx) => queue[idx]);
1200
1215
  const updated = { ...session, queuedMessages: reordered };
1201
1216
  this.sessions.set(sessionId, updated);
1202
- this.storage.saveSession(updated);
1217
+ this.storage.updateSessionRuntimeMetadata(updated);
1203
1218
  this.emitStructuredSnapshot(updated);
1204
1219
  return updated;
1205
1220
  }
@@ -1213,7 +1228,7 @@ export class StructuredSessionManager {
1213
1228
  const next = queue.slice(0, index).concat(queue.slice(index + 1));
1214
1229
  const updated = { ...session, queuedMessages: next };
1215
1230
  this.sessions.set(sessionId, updated);
1216
- this.storage.saveSession(updated);
1231
+ this.storage.updateSessionRuntimeMetadata(updated);
1217
1232
  this.emitStructuredSnapshot(updated);
1218
1233
  return updated;
1219
1234
  }
@@ -1239,7 +1254,7 @@ export class StructuredSessionManager {
1239
1254
  const inFlight = session.status === "running" && session.structuredState?.inFlight === true;
1240
1255
  const updated = { ...session, queuedMessages: remaining };
1241
1256
  this.sessions.set(sessionId, updated);
1242
- this.storage.saveSession(updated);
1257
+ this.storage.updateSessionRuntimeMetadata(updated);
1243
1258
  this.emitStructuredSnapshot(updated);
1244
1259
  try {
1245
1260
  return await this.sendMessage(sessionId, prompt, {
@@ -1263,7 +1278,7 @@ export class StructuredSessionManager {
1263
1278
  }
1264
1279
  const updated = { ...session, queuedMessages: [] };
1265
1280
  this.sessions.set(sessionId, updated);
1266
- this.storage.saveSession(updated);
1281
+ this.storage.updateSessionRuntimeMetadata(updated);
1267
1282
  this.emitStructuredSnapshot(updated);
1268
1283
  return updated;
1269
1284
  }
@@ -1280,7 +1295,7 @@ export class StructuredSessionManager {
1280
1295
  },
1281
1296
  };
1282
1297
  this.sessions.set(sessionId, updated);
1283
- this.storage.saveSession(updated);
1298
+ this.storage.updateSessionRuntimeMetadata(updated);
1284
1299
  this.emit({
1285
1300
  type: "status",
1286
1301
  sessionId,
@@ -1301,7 +1316,7 @@ export class StructuredSessionManager {
1301
1316
  thinkingEffort: normalized,
1302
1317
  };
1303
1318
  this.sessions.set(sessionId, updated);
1304
- this.storage.saveSession(updated);
1319
+ this.storage.updateSessionRuntimeMetadata(updated);
1305
1320
  this.emit({
1306
1321
  type: "status",
1307
1322
  sessionId,
@@ -1324,7 +1339,7 @@ export class StructuredSessionManager {
1324
1339
  autoApprovePermissions: autoApprove,
1325
1340
  };
1326
1341
  this.sessions.set(sessionId, updated);
1327
- this.storage.saveSession(updated);
1342
+ this.storage.updateSessionRuntimeMetadata(updated);
1328
1343
  this.emit({
1329
1344
  type: "status",
1330
1345
  sessionId,
@@ -1338,14 +1353,24 @@ export class StructuredSessionManager {
1338
1353
  const newVal = !session.autoApprovePermissions;
1339
1354
  const updated = { ...session, autoApprovePermissions: newVal };
1340
1355
  this.sessions.set(sessionId, updated);
1341
- this.storage.saveSession(updated);
1356
+ this.storage.updateSessionRuntimeMetadata(updated);
1342
1357
  return updated;
1343
1358
  }
1344
1359
  /** Resolve a specific escalation by requestId. */
1345
1360
  resolveEscalation(sessionId, requestId, resolution) {
1346
- const approved = resolution !== "deny";
1347
1361
  const session = this.requireSession(sessionId);
1348
- const scope = session.pendingEscalation?.scope;
1362
+ const pending = session.pendingEscalation;
1363
+ if (!pending) {
1364
+ throw new Error("当前会话没有待处理的授权请求。");
1365
+ }
1366
+ if (pending.requestId !== requestId) {
1367
+ throw new Error("授权请求已失效,请刷新后重试。");
1368
+ }
1369
+ if (resolution !== "approve_once" && resolution !== "approve_turn" && resolution !== "deny") {
1370
+ throw new Error("resolution 必须是 approve_once、approve_turn 或 deny。");
1371
+ }
1372
+ const approved = resolution !== "deny";
1373
+ const scope = pending.scope;
1349
1374
  if (approved && scope) {
1350
1375
  this.incrementApprovalStats(session, scope);
1351
1376
  }
@@ -1353,14 +1378,14 @@ export class StructuredSessionManager {
1353
1378
  ...session,
1354
1379
  pendingEscalation: null,
1355
1380
  permissionBlocked: false,
1356
- lastEscalationResult: session.pendingEscalation ? {
1357
- requestId: session.pendingEscalation.requestId,
1358
- resolution: approved ? "approve_once" : "deny",
1381
+ lastEscalationResult: {
1382
+ requestId: pending.requestId,
1383
+ resolution,
1359
1384
  reason: approved ? "user_approved" : "user_denied",
1360
- } : session.lastEscalationResult ?? null,
1385
+ },
1361
1386
  };
1362
1387
  this.sessions.set(sessionId, updated);
1363
- this.storage.saveSession(updated);
1388
+ this.storage.updateSessionRuntimeMetadata(updated);
1364
1389
  this.emit({
1365
1390
  type: "status",
1366
1391
  sessionId,
@@ -1372,26 +1397,8 @@ export class StructuredSessionManager {
1372
1397
  const session = this.requireSession(id);
1373
1398
  this.interruptedWith.delete(id);
1374
1399
  this.preserveQueueOnInterrupt.delete(id);
1375
- // 标记本会话为「用户主动停止」,让被 kill 的子进程 close / SDK abort 收尾时
1376
- // 走正常 idle 收尾而不是失败路径。
1377
- this.userStopped.add(id);
1378
- const child = this.pendingChildren.get(id);
1379
- if (child) {
1380
- child.kill();
1381
- this.pendingChildren.delete(id);
1382
- }
1383
- // SDK runner:先尝试 query.interrupt() 优雅停止,失败再走 abort。
1384
- // 两个都清掉避免后续重复操作。
1385
- const sdkQuery = this.pendingSdkQueries.get(id);
1386
- if (sdkQuery) {
1387
- void sdkQuery.interrupt().catch(() => { });
1388
- this.pendingSdkQueries.delete(id);
1389
- }
1390
- const sdkAbort = this.pendingSdkAbort.get(id);
1391
- if (sdkAbort) {
1392
- sdkAbort.abort();
1393
- this.pendingSdkAbort.delete(id);
1394
- }
1400
+ // Clearing activeRequestId is the generation barrier: late data/close callbacks
1401
+ // from the cancelled runner can no longer mutate this session or a replacement turn.
1395
1402
  // 主动停止只是取消「当前回合」,结构化会话本身并没有结束——置为 idle 而非 stopped。
1396
1403
  // 这样前端不会进入"会话已结束/恢复会话"终止态,输入框保持可用,直接展示历史内容。
1397
1404
  const cancelled = {
@@ -1409,29 +1416,48 @@ export class StructuredSessionManager {
1409
1416
  },
1410
1417
  };
1411
1418
  this.sessions.set(id, cancelled);
1412
- this.storage.saveSession(cancelled);
1419
+ const child = this.pendingChildren.get(id);
1420
+ if (child) {
1421
+ child.kill();
1422
+ this.releasePendingChild(id, child);
1423
+ }
1424
+ // SDK runner:先尝试 query.interrupt() 优雅停止,失败再走 abort。
1425
+ // 两个都清掉避免后续重复操作。
1426
+ const sdkQuery = this.pendingSdkQueries.get(id);
1427
+ if (sdkQuery) {
1428
+ void sdkQuery.interrupt().catch(() => { });
1429
+ this.releasePendingSdkQuery(id, sdkQuery);
1430
+ }
1431
+ const sdkAbort = this.pendingSdkAbort.get(id);
1432
+ if (sdkAbort) {
1433
+ sdkAbort.abort();
1434
+ this.releasePendingSdkAbort(id, sdkAbort);
1435
+ }
1436
+ this.saveAuthoritativeSession(cancelled);
1413
1437
  // 仍发 "ended" 事件让各端停掉"回复中"指示 / 灵动岛,但携带的 status 是 idle。
1414
1438
  this.emitStructuredSnapshot(cancelled, "ended");
1415
1439
  return cancelled;
1416
1440
  }
1417
1441
  delete(id) {
1418
1442
  const child = this.pendingChildren.get(id);
1443
+ const sdkQuery = this.pendingSdkQueries.get(id);
1444
+ const sdkAbort = this.pendingSdkAbort.get(id);
1445
+ // Invalidate callback ownership before signalling the runner. Abort/kill can
1446
+ // synchronously wake listeners in some SDK/ChildProcess implementations.
1447
+ this.sessions.delete(id);
1419
1448
  if (child) {
1420
1449
  child.kill();
1421
- this.pendingChildren.delete(id);
1450
+ this.releasePendingChild(id, child);
1422
1451
  }
1423
- const sdkQuery = this.pendingSdkQueries.get(id);
1424
1452
  if (sdkQuery) {
1425
1453
  void sdkQuery.interrupt().catch(() => { });
1426
- this.pendingSdkQueries.delete(id);
1454
+ this.releasePendingSdkQuery(id, sdkQuery);
1427
1455
  }
1428
- const sdkAbort = this.pendingSdkAbort.get(id);
1429
1456
  if (sdkAbort) {
1430
1457
  sdkAbort.abort();
1431
- this.pendingSdkAbort.delete(id);
1458
+ this.releasePendingSdkAbort(id, sdkAbort);
1432
1459
  }
1433
- this.sessions.delete(id);
1434
- this.lastStreamSaveAt.delete(id);
1460
+ this.clearStreamingCheckpoint(id);
1435
1461
  this.interruptedWith.delete(id);
1436
1462
  this.preserveQueueOnInterrupt.delete(id);
1437
1463
  this.storage.deleteSession(id);
@@ -1447,6 +1473,34 @@ export class StructuredSessionManager {
1447
1473
  }
1448
1474
  return session;
1449
1475
  }
1476
+ /** True only while this exact turn still owns the session's mutable state. */
1477
+ isCurrentRequest(sessionId, requestId) {
1478
+ return this.sessions.get(sessionId)?.structuredState?.activeRequestId === requestId;
1479
+ }
1480
+ currentSessionForRequest(sessionId, requestId) {
1481
+ if (!this.isCurrentRequest(sessionId, requestId))
1482
+ return null;
1483
+ return this.sessions.get(sessionId) ?? null;
1484
+ }
1485
+ /** Delete a handle only if it still belongs to the execution doing cleanup. */
1486
+ releasePendingChild(sessionId, child) {
1487
+ if (this.pendingChildren.get(sessionId) !== child)
1488
+ return false;
1489
+ this.pendingChildren.delete(sessionId);
1490
+ return true;
1491
+ }
1492
+ releasePendingSdkAbort(sessionId, controller) {
1493
+ if (this.pendingSdkAbort.get(sessionId) !== controller)
1494
+ return false;
1495
+ this.pendingSdkAbort.delete(sessionId);
1496
+ return true;
1497
+ }
1498
+ releasePendingSdkQuery(sessionId, query) {
1499
+ if (this.pendingSdkQueries.get(sessionId) !== query)
1500
+ return false;
1501
+ this.pendingSdkQueries.delete(sessionId);
1502
+ return true;
1503
+ }
1450
1504
  emitStructuredSnapshot(session, eventType = "output") {
1451
1505
  // 排队消息只通过 payload.queuedMessages 单独下发,由各端在消息卡片外的「排队条」
1452
1506
  // 里纵向渲染——绝不再把它们当成 __queued 占位 turn 混进 messages 消息流里,否则会
@@ -1464,6 +1518,8 @@ export class StructuredSessionManager {
1464
1518
  });
1465
1519
  }
1466
1520
  async flushNextQueuedMessage(sessionId) {
1521
+ if (this.disposed)
1522
+ return;
1467
1523
  const current = this.sessions.get(sessionId);
1468
1524
  if (!current || (current.queuedMessages?.length ?? 0) === 0) {
1469
1525
  return;
@@ -1480,7 +1536,7 @@ export class StructuredSessionManager {
1480
1536
  queuedMessages: restQueue,
1481
1537
  };
1482
1538
  this.sessions.set(sessionId, nextSession);
1483
- this.storage.saveSession(nextSession);
1539
+ this.storage.updateSessionRuntimeMetadata(nextSession);
1484
1540
  this.emitStructuredSnapshot(nextSession);
1485
1541
  try {
1486
1542
  await this.sendMessage(sessionId, nextInput);
@@ -1495,13 +1551,13 @@ export class StructuredSessionManager {
1495
1551
  queuedMessages: [nextInput, ...(afterFail.queuedMessages ?? [])],
1496
1552
  };
1497
1553
  this.sessions.set(sessionId, rescued);
1498
- this.storage.saveSession(rescued);
1554
+ this.storage.updateSessionRuntimeMetadata(rescued);
1499
1555
  this.emitStructuredSnapshot(rescued);
1500
1556
  }
1501
1557
  }
1502
1558
  }
1503
1559
  emit(event) {
1504
- if (this.emitEvent) {
1560
+ if (!this.disposed && this.emitEvent) {
1505
1561
  this.emitEvent(event);
1506
1562
  }
1507
1563
  }
@@ -1521,49 +1577,11 @@ export class StructuredSessionManager {
1521
1577
  session.approvalStats = stats;
1522
1578
  }
1523
1579
  // ---------------------------------------------------------------------------
1524
- // CLI argument construction
1525
- // ---------------------------------------------------------------------------
1526
- // claude CLI 的权限/系统提示 flag 由模块级 derivePermissionPolicy() +
1527
- // buildAppendSystemPromptParts() 派生,定义在文件顶部,与 SDK runner 共用。
1528
- buildCodexArgs(session) {
1529
- const args = ["exec", "--json", "--color", "never"];
1530
- const shouldBypass = session.autoApprovePermissions === true || session.mode === "full-access" || session.mode === "managed";
1531
- if (shouldBypass) {
1532
- args.push("--dangerously-bypass-approvals-and-sandbox");
1533
- }
1534
- else if (session.mode === "auto-edit" || session.mode === "agent" || session.mode === "agent-max") {
1535
- args.push("--sandbox", "workspace-write");
1536
- }
1537
- else {
1538
- args.push("--sandbox", "read-only");
1539
- }
1540
- args.push("--skip-git-repo-check");
1541
- const modelChoice = session.selectedModel?.trim();
1542
- if (modelChoice && modelChoice !== "default") {
1543
- args.push("--model", modelChoice);
1544
- }
1545
- // 思考深度 → model_reasoning_effort(off → 不覆盖,standard → low,deep → medium,max → xhigh)
1546
- // Newer Codex CLI versions removed the old dedicated exec flag, but still
1547
- // accept config overrides through `-c`.
1548
- const reasoningEffort = thinkingEffortToCodexReasoningEffort(session.thinkingEffort);
1549
- if (reasoningEffort) {
1550
- args.push("-c", `model_reasoning_effort=${reasoningEffort}`);
1551
- }
1552
- if (session.claudeSessionId) {
1553
- args.push("resume", session.claudeSessionId, "-");
1554
- }
1555
- else {
1556
- args.push("-");
1557
- }
1558
- return args;
1559
- }
1560
- // ---------------------------------------------------------------------------
1561
1580
  // Streaming codex exec --json execution
1562
1581
  // ---------------------------------------------------------------------------
1563
- runCodexStreaming(sessionId, session, prompt) {
1564
- this.userStopped.delete(sessionId);
1582
+ runCodexStreaming(sessionId, session, prompt, requestId) {
1565
1583
  return new Promise((resolve, reject) => {
1566
- const args = this.buildCodexArgs(session);
1584
+ const args = buildCodexArgs(session);
1567
1585
  const spawnedAt = new Date().toISOString();
1568
1586
  const child = spawn("codex", args, {
1569
1587
  cwd: session.cwd,
@@ -1596,6 +1614,7 @@ export class StructuredSessionManager {
1596
1614
  let lineBuf = "";
1597
1615
  let stderr = "";
1598
1616
  let emitTimer = null;
1617
+ let settled = false;
1599
1618
  // codex 把所有错误(包括重试日志和最终失败原因)都通过 stdout 的 NDJSON 事件
1600
1619
  // 输出,stderr 通常是空的。我们在 processLine 里收集这些,然后在 close 中
1601
1620
  // 决定真正的报错文本。
@@ -1603,20 +1622,20 @@ export class StructuredSessionManager {
1603
1622
  let codexTurnFailed = null;
1604
1623
  const flushEmit = () => {
1605
1624
  if (emitTimer) {
1606
- clearTimeout(emitTimer);
1625
+ this.clearStreamEmitTimer(emitTimer);
1607
1626
  emitTimer = null;
1608
1627
  }
1609
- const current = this.sessions.get(sessionId);
1628
+ const current = this.currentSessionForRequest(sessionId, requestId);
1610
1629
  if (!current)
1611
1630
  return;
1612
1631
  this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
1613
1632
  };
1614
1633
  const scheduleEmit = () => {
1615
1634
  if (!emitTimer)
1616
- emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
1635
+ emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
1617
1636
  };
1618
1637
  const syncSnapshot = () => {
1619
- const current = this.sessions.get(sessionId);
1638
+ const current = this.currentSessionForRequest(sessionId, requestId);
1620
1639
  if (!current)
1621
1640
  return;
1622
1641
  refreshEstimatedCodexUsage(turnState);
@@ -1647,6 +1666,8 @@ export class StructuredSessionManager {
1647
1666
  this.saveStreamingSnapshot(patched);
1648
1667
  };
1649
1668
  const processLine = (line) => {
1669
+ if (!this.isCurrentRequest(sessionId, requestId))
1670
+ return;
1650
1671
  const trimmed = line.trim();
1651
1672
  if (!trimmed)
1652
1673
  return;
@@ -1720,6 +1741,8 @@ export class StructuredSessionManager {
1720
1741
  }
1721
1742
  };
1722
1743
  child.stdout?.on("data", (chunk) => {
1744
+ if (!this.isCurrentRequest(sessionId, requestId))
1745
+ return;
1723
1746
  const text = chunk.toString();
1724
1747
  this.logger?.appendStructuredStdout(sessionId, text);
1725
1748
  lineBuf += text;
@@ -1729,15 +1752,26 @@ export class StructuredSessionManager {
1729
1752
  processLine(line);
1730
1753
  });
1731
1754
  child.stderr?.on("data", (chunk) => {
1755
+ if (!this.isCurrentRequest(sessionId, requestId))
1756
+ return;
1732
1757
  const text = chunk.toString();
1733
1758
  this.logger?.appendStructuredStderr(sessionId, text);
1734
1759
  stderr += text;
1735
1760
  });
1736
1761
  child.on("error", (error) => {
1737
- this.pendingChildren.delete(sessionId);
1738
- this.lastStreamSaveAt.delete(sessionId);
1762
+ const released = this.releasePendingChild(sessionId, child);
1763
+ if (released)
1764
+ this.cancelStreamingCheckpointTimer(sessionId);
1739
1765
  if (emitTimer)
1740
- clearTimeout(emitTimer);
1766
+ this.clearStreamEmitTimer(emitTimer);
1767
+ if (settled)
1768
+ return;
1769
+ if (!this.isCurrentRequest(sessionId, requestId)) {
1770
+ settled = true;
1771
+ resolve();
1772
+ return;
1773
+ }
1774
+ settled = true;
1741
1775
  this.logger?.appendStructuredSpawn(sessionId, {
1742
1776
  kind: "codex-exec-error",
1743
1777
  pid: child.pid ?? null,
@@ -1756,8 +1790,18 @@ export class StructuredSessionManager {
1756
1790
  reject(new Error(`codex exec 启动失败:${error.message}${hint}`));
1757
1791
  });
1758
1792
  child.on("close", (code, signal) => {
1759
- this.pendingChildren.delete(sessionId);
1760
- this.lastStreamSaveAt.delete(sessionId);
1793
+ const released = this.releasePendingChild(sessionId, child);
1794
+ if (released)
1795
+ this.cancelStreamingCheckpointTimer(sessionId);
1796
+ if (settled)
1797
+ return;
1798
+ if (!this.isCurrentRequest(sessionId, requestId)) {
1799
+ if (emitTimer)
1800
+ this.clearStreamEmitTimer(emitTimer);
1801
+ settled = true;
1802
+ resolve();
1803
+ return;
1804
+ }
1761
1805
  if (lineBuf.trim()) {
1762
1806
  processLine(lineBuf);
1763
1807
  lineBuf = "";
@@ -1774,20 +1818,19 @@ export class StructuredSessionManager {
1774
1818
  codexErrors,
1775
1819
  codexTurnFailed,
1776
1820
  });
1777
- const current = this.sessions.get(sessionId);
1821
+ const current = this.currentSessionForRequest(sessionId, requestId);
1778
1822
  if (!current) {
1779
- reject(new Error("Session removed during execution."));
1823
+ settled = true;
1824
+ resolve();
1780
1825
  return;
1781
1826
  }
1782
1827
  // 主动中断时(interruptedWith 里有新消息),不走失败路径
1783
1828
  const interruptedByUser = this.interruptedWith.has(sessionId);
1784
1829
  const interruptPrompt = this.interruptedWith.get(sessionId);
1785
- // 用户点「停止」kill 掉 codex 后会非零退出,但这不是失败——按正常 idle 收尾。
1786
- const userStopped = this.userStopped.delete(sessionId);
1787
1830
  // codex 把模型/网络/沙箱等错误写到 stdout 的 NDJSON 流(type: error / turn.failed),
1788
1831
  // 而不是 stderr。我们以 turn.failed 的 message 为准,其次是最后一个 error 事件。
1789
1832
  const codexFailed = codexTurnFailed !== null;
1790
- if ((codexFailed || (code !== 0 && code !== null) || signal) && !interruptedByUser && !userStopped) {
1833
+ if ((codexFailed || (code !== 0 && code !== null) || signal) && !interruptedByUser) {
1791
1834
  const errorText = this.formatStructuredExitError("codex exec", code, signal, {
1792
1835
  stderr,
1793
1836
  primary: codexTurnFailed,
@@ -1796,10 +1839,11 @@ export class StructuredSessionManager {
1796
1839
  const exitForSnapshot = typeof code === "number" ? code : 1;
1797
1840
  const failed = this.finishStructuredFailure(current, exitForSnapshot, errorText, turnState);
1798
1841
  this.sessions.set(sessionId, failed);
1799
- this.storage.saveSession(failed);
1842
+ this.saveAuthoritativeSession(failed);
1800
1843
  this.emitStructuredSnapshot(failed);
1801
1844
  this.emitStructuredSnapshot(failed, "ended");
1802
- reject(new Error(errorText));
1845
+ settled = true;
1846
+ reject(new PersistedStructuredRunnerError(errorText));
1803
1847
  return;
1804
1848
  }
1805
1849
  const msgs = this.buildCompletedAssistantMessages(current, turnState);
@@ -1824,7 +1868,7 @@ export class StructuredSessionManager {
1824
1868
  },
1825
1869
  };
1826
1870
  this.sessions.set(sessionId, finished);
1827
- this.storage.saveSession(finished);
1871
+ this.saveAuthoritativeSession(finished);
1828
1872
  this.emitStructuredSnapshot(finished);
1829
1873
  if (!keepRunning) {
1830
1874
  this.emitStructuredSnapshot(finished, "ended");
@@ -1836,131 +1880,24 @@ export class StructuredSessionManager {
1836
1880
  // 注意:被保留的 queuedMessages 不需要在这里主动 flush,重发的
1837
1881
  // interruptPrompt 跑完会自然触发 flushNextQueuedMessage。
1838
1882
  this.preserveQueueOnInterrupt.delete(sessionId);
1883
+ settled = true;
1839
1884
  resolve();
1840
1885
  setImmediate(() => {
1841
1886
  this.sendMessage(sessionId, interruptPrompt).catch((err) => {
1842
1887
  console.error("[WAND] codex interrupt-and-send failed:", err);
1843
- const afterFail = this.sessions.get(sessionId);
1844
- if (afterFail) {
1845
- const recovered = {
1846
- ...afterFail,
1847
- status: "idle",
1848
- exitCode: 0,
1849
- endedAt: new Date().toISOString(),
1850
- structuredState: {
1851
- ...afterFail.structuredState,
1852
- inFlight: false,
1853
- activeRequestId: null,
1854
- },
1855
- };
1856
- this.sessions.set(sessionId, recovered);
1857
- this.storage.saveSession(recovered);
1858
- this.emitStructuredSnapshot(recovered);
1859
- }
1860
1888
  });
1861
1889
  });
1862
1890
  return;
1863
1891
  }
1892
+ settled = true;
1864
1893
  resolve();
1865
1894
  setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
1866
1895
  });
1867
1896
  });
1868
1897
  }
1869
- buildOpenCodeArgs(session) {
1870
- const args = ["run", "--format", "json", "--thinking"];
1871
- const modelChoice = session.selectedModel?.trim();
1872
- if (modelChoice && modelChoice !== "default") {
1873
- args.push("--model", modelChoice);
1874
- }
1875
- const variant = thinkingEffortToOpenCodeVariant(session.thinkingEffort);
1876
- if (variant)
1877
- args.push("--variant", variant);
1878
- if (session.autoApprovePermissions === true || session.mode === "full-access" || session.mode === "managed" || session.mode === "auto-edit") {
1879
- args.push("--dangerously-skip-permissions");
1880
- }
1881
- if (session.claudeSessionId) {
1882
- args.push("--session", session.claudeSessionId);
1883
- }
1884
- return args;
1885
- }
1886
- openCodeToolName(name) {
1887
- const mapped = {
1888
- bash: "Bash",
1889
- shell: "Bash",
1890
- read: "Read",
1891
- edit: "Edit",
1892
- write: "Write",
1893
- glob: "Glob",
1894
- grep: "Grep",
1895
- webfetch: "WebFetch",
1896
- websearch: "WebSearch",
1897
- todowrite: "TodoWrite",
1898
- task: "Task",
1899
- skill: "Skill",
1900
- };
1901
- return mapped[name.toLowerCase()] ?? `OpenCode/${name}`;
1902
- }
1903
- applyOpenCodeEvent(turnState, event) {
1904
- if (typeof event.sessionID === "string" && event.sessionID)
1905
- turnState.sessionId = event.sessionID;
1906
- const type = typeof event.type === "string" ? event.type : "";
1907
- const part = asRecord(event.part);
1908
- if (!part) {
1909
- if (type === "error")
1910
- return this.extractCodexText(event.error) || "OpenCode run failed";
1911
- return null;
1912
- }
1913
- if (type === "text" && typeof part.text === "string" && part.text.trim()) {
1914
- turnState.blocks.push({ type: "text", text: part.text });
1915
- turnState.result += (turnState.result ? "\n" : "") + part.text;
1916
- return null;
1917
- }
1918
- if (type === "reasoning" && typeof part.text === "string" && part.text.trim()) {
1919
- turnState.blocks.push({ type: "thinking", thinking: part.text });
1920
- return null;
1921
- }
1922
- if (type === "tool_use") {
1923
- const state = asRecord(part.state) ?? {};
1924
- const tool = typeof part.tool === "string" && part.tool ? part.tool : "tool";
1925
- const toolId = typeof part.callID === "string" && part.callID
1926
- ? part.callID
1927
- : typeof part.id === "string" && part.id
1928
- ? part.id
1929
- : randomUUID();
1930
- const input = asRecord(state.input) ?? {};
1931
- turnState.blocks.push({
1932
- type: "tool_use",
1933
- id: toolId,
1934
- name: this.openCodeToolName(tool),
1935
- description: typeof state.title === "string" ? state.title : undefined,
1936
- input,
1937
- });
1938
- const failed = state.status === "error";
1939
- const content = failed
1940
- ? (typeof state.error === "string" ? state.error : "OpenCode tool failed")
1941
- : (typeof state.output === "string" ? state.output : "");
1942
- turnState.blocks.push({ type: "tool_result", tool_use_id: toolId, content, is_error: failed });
1943
- return null;
1944
- }
1945
- if (type === "step_finish") {
1946
- const tokens = asRecord(part.tokens);
1947
- const cache = asRecord(tokens?.cache);
1948
- const previous = turnState.usage ?? {};
1949
- turnState.usage = {
1950
- inputTokens: (previous.inputTokens ?? 0) + (typeof tokens?.input === "number" ? tokens.input : 0),
1951
- outputTokens: (previous.outputTokens ?? 0) + (typeof tokens?.output === "number" ? tokens.output : 0),
1952
- reasoningOutputTokens: (previous.reasoningOutputTokens ?? 0) + (typeof tokens?.reasoning === "number" ? tokens.reasoning : 0),
1953
- cacheReadInputTokens: (previous.cacheReadInputTokens ?? 0) + (typeof cache?.read === "number" ? cache.read : 0),
1954
- cacheCreationInputTokens: (previous.cacheCreationInputTokens ?? 0) + (typeof cache?.write === "number" ? cache.write : 0),
1955
- totalCostUsd: (previous.totalCostUsd ?? 0) + (typeof part.cost === "number" ? part.cost : 0),
1956
- };
1957
- }
1958
- return null;
1959
- }
1960
- runOpenCodeStreaming(sessionId, session, prompt) {
1961
- this.userStopped.delete(sessionId);
1898
+ runOpenCodeStreaming(sessionId, session, prompt, requestId) {
1962
1899
  return new Promise((resolve, reject) => {
1963
- const args = this.buildOpenCodeArgs(session);
1900
+ const args = buildOpenCodeArgs(session);
1964
1901
  const spawnedAt = new Date().toISOString();
1965
1902
  const child = spawn("opencode", args, {
1966
1903
  cwd: session.cwd,
@@ -1993,8 +1930,9 @@ export class StructuredSessionManager {
1993
1930
  let stderr = "";
1994
1931
  let primaryError = null;
1995
1932
  let emitTimer = null;
1933
+ let settled = false;
1996
1934
  const syncSnapshot = () => {
1997
- const current = this.sessions.get(sessionId);
1935
+ const current = this.currentSessionForRequest(sessionId, requestId);
1998
1936
  if (!current)
1999
1937
  return;
2000
1938
  const turn = {
@@ -2018,17 +1956,19 @@ export class StructuredSessionManager {
2018
1956
  };
2019
1957
  const flushEmit = () => {
2020
1958
  if (emitTimer)
2021
- clearTimeout(emitTimer);
1959
+ this.clearStreamEmitTimer(emitTimer);
2022
1960
  emitTimer = null;
2023
- const current = this.sessions.get(sessionId);
1961
+ const current = this.currentSessionForRequest(sessionId, requestId);
2024
1962
  if (current)
2025
1963
  this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
2026
1964
  };
2027
1965
  const scheduleEmit = () => {
2028
1966
  if (!emitTimer)
2029
- emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
1967
+ emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
2030
1968
  };
2031
1969
  const processLine = (line) => {
1970
+ if (!this.isCurrentRequest(sessionId, requestId))
1971
+ return;
2032
1972
  const trimmed = line.trim();
2033
1973
  if (!trimmed)
2034
1974
  return;
@@ -2040,13 +1980,15 @@ export class StructuredSessionManager {
2040
1980
  return;
2041
1981
  }
2042
1982
  this.logger?.appendStreamEvent(sessionId, event);
2043
- const error = this.applyOpenCodeEvent(turnState, event);
1983
+ const error = applyOpenCodeEvent(turnState, event);
2044
1984
  if (error)
2045
1985
  primaryError = error;
2046
1986
  syncSnapshot();
2047
1987
  scheduleEmit();
2048
1988
  };
2049
1989
  child.stdout?.on("data", (chunk) => {
1990
+ if (!this.isCurrentRequest(sessionId, requestId))
1991
+ return;
2050
1992
  const text = chunk.toString();
2051
1993
  this.logger?.appendStructuredStdout(sessionId, text);
2052
1994
  lineBuf += text;
@@ -2056,15 +1998,26 @@ export class StructuredSessionManager {
2056
1998
  processLine(line);
2057
1999
  });
2058
2000
  child.stderr?.on("data", (chunk) => {
2001
+ if (!this.isCurrentRequest(sessionId, requestId))
2002
+ return;
2059
2003
  const text = chunk.toString();
2060
2004
  this.logger?.appendStructuredStderr(sessionId, text);
2061
2005
  stderr += text;
2062
2006
  });
2063
2007
  child.on("error", (error) => {
2064
- this.pendingChildren.delete(sessionId);
2065
- this.lastStreamSaveAt.delete(sessionId);
2008
+ const released = this.releasePendingChild(sessionId, child);
2009
+ if (released)
2010
+ this.cancelStreamingCheckpointTimer(sessionId);
2066
2011
  if (emitTimer)
2067
- clearTimeout(emitTimer);
2012
+ this.clearStreamEmitTimer(emitTimer);
2013
+ if (settled)
2014
+ return;
2015
+ if (!this.isCurrentRequest(sessionId, requestId)) {
2016
+ settled = true;
2017
+ resolve();
2018
+ return;
2019
+ }
2020
+ settled = true;
2068
2021
  const nodeError = error;
2069
2022
  const hint = nodeError.code === "ENOENT"
2070
2023
  ? "(PATH 中找不到 opencode;请安装 opencode-ai,或重跑 `wand service:install` 刷新服务 PATH)"
@@ -2072,30 +2025,41 @@ export class StructuredSessionManager {
2072
2025
  reject(new Error(`opencode run 启动失败:${error.message}${hint}`));
2073
2026
  });
2074
2027
  child.on("close", (code, signal) => {
2075
- this.pendingChildren.delete(sessionId);
2076
- this.lastStreamSaveAt.delete(sessionId);
2028
+ const released = this.releasePendingChild(sessionId, child);
2029
+ if (released)
2030
+ this.cancelStreamingCheckpointTimer(sessionId);
2031
+ if (settled)
2032
+ return;
2033
+ if (!this.isCurrentRequest(sessionId, requestId)) {
2034
+ if (emitTimer)
2035
+ this.clearStreamEmitTimer(emitTimer);
2036
+ settled = true;
2037
+ resolve();
2038
+ return;
2039
+ }
2077
2040
  if (lineBuf.trim())
2078
2041
  processLine(lineBuf);
2079
2042
  flushEmit();
2080
- const current = this.sessions.get(sessionId);
2043
+ const current = this.currentSessionForRequest(sessionId, requestId);
2081
2044
  if (!current) {
2082
- reject(new Error("Session removed during execution."));
2045
+ settled = true;
2046
+ resolve();
2083
2047
  return;
2084
2048
  }
2085
2049
  const interruptedByUser = this.interruptedWith.has(sessionId);
2086
2050
  const interruptPrompt = this.interruptedWith.get(sessionId);
2087
- const userStopped = this.userStopped.delete(sessionId);
2088
- if ((primaryError || (code !== 0 && code !== null) || signal) && !interruptedByUser && !userStopped) {
2051
+ if ((primaryError || (code !== 0 && code !== null) || signal) && !interruptedByUser) {
2089
2052
  const legacyHint = /unknown command|unknown flag|No help topic for 'run'/i.test(stderr)
2090
2053
  ? "\n检测到旧版 OpenCode CLI;请卸载 0.0.x 旧包并安装 `opencode-ai@latest`。"
2091
2054
  : "";
2092
2055
  const errorText = this.formatStructuredExitError("opencode run", code, signal, { stderr, primary: primaryError }) + legacyHint;
2093
2056
  const failed = this.finishStructuredFailure(current, typeof code === "number" ? code : 1, errorText, turnState);
2094
2057
  this.sessions.set(sessionId, failed);
2095
- this.storage.saveSession(failed);
2058
+ this.saveAuthoritativeSession(failed);
2096
2059
  this.emitStructuredSnapshot(failed);
2097
2060
  this.emitStructuredSnapshot(failed, "ended");
2098
- reject(new Error(errorText));
2061
+ settled = true;
2062
+ reject(new PersistedStructuredRunnerError(errorText));
2099
2063
  return;
2100
2064
  }
2101
2065
  const messages = this.buildCompletedAssistantMessages(current, turnState);
@@ -2120,13 +2084,14 @@ export class StructuredSessionManager {
2120
2084
  },
2121
2085
  };
2122
2086
  this.sessions.set(sessionId, finished);
2123
- this.storage.saveSession(finished);
2087
+ this.saveAuthoritativeSession(finished);
2124
2088
  this.emitStructuredSnapshot(finished);
2125
2089
  if (!keepRunning)
2126
2090
  this.emitStructuredSnapshot(finished, "ended");
2127
2091
  if (interruptPrompt) {
2128
2092
  this.interruptedWith.delete(sessionId);
2129
2093
  this.preserveQueueOnInterrupt.delete(sessionId);
2094
+ settled = true;
2130
2095
  resolve();
2131
2096
  setImmediate(() => {
2132
2097
  this.sendMessage(sessionId, interruptPrompt).catch((error) => {
@@ -2135,6 +2100,7 @@ export class StructuredSessionManager {
2135
2100
  });
2136
2101
  return;
2137
2102
  }
2103
+ settled = true;
2138
2104
  resolve();
2139
2105
  setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
2140
2106
  });
@@ -2154,43 +2120,14 @@ export class StructuredSessionManager {
2154
2120
  * - Root: --permission-mode acceptEdits + --allowedTools (extends approval
2155
2121
  * outside CWD). stdin is always "ignore" — no ACP bidirectional control.
2156
2122
  */
2157
- runClaudeStreaming(sessionId, session, prompt) {
2158
- this.userStopped.delete(sessionId);
2123
+ runClaudeStreaming(sessionId, session, prompt, requestId) {
2159
2124
  return new Promise((resolve, reject) => {
2160
- const args = ["-p", "--verbose", "--output-format", "stream-json"];
2161
- // 权限策略:决策规则与 SDK runner 共享 derivePermissionPolicy(),CLI 这边把
2162
- // 结果转成对应的 flag。--allowedTools 是 commander 的 variadic(<tools...>),
2163
- // 紧跟其后的所有非 flag 形 token 都会被吞进工具列表,因此后面任何位置参数
2164
- // 都得是 -- 开头的 flag——下面追加 --append-system-prompt / --model / --resume
2165
- // 都满足这个条件。
2166
2125
  const permPolicy = derivePermissionPolicy(session.mode, session.autoApprovePermissions ?? false, session.cwd);
2167
- if (permPolicy.permissionMode !== "default") {
2168
- args.push("--permission-mode", permPolicy.permissionMode);
2169
- }
2170
- if (permPolicy.allowedTools) {
2171
- args.push("--allowedTools", ...permPolicy.allowedTools);
2172
- }
2173
- // 追加系统提示词(托管模式自主决策 + 语言偏好),文本与 SDK runner 共享。
2174
- for (const part of buildAppendSystemPromptParts(this.config.language, session.mode)) {
2175
- args.push("--append-system-prompt", part);
2176
- }
2177
- const modelChoice = session.selectedModel?.trim();
2178
- if (modelChoice && modelChoice !== "default") {
2179
- args.push("--model", modelChoice);
2180
- }
2181
- const claudeEffort = thinkingEffortToClaudeCliEffort(session.thinkingEffort);
2182
- if (claudeEffort) {
2183
- args.push("--effort", claudeEffort);
2184
- }
2185
- // 托管模式:禁用 AskUserQuestion,让 agent 自己拍板,不要等用户决策。
2186
- // 非托管模式:保留工具,靠 processLine 检测后主动 kill child 触发"中断+续接"流程。
2187
2126
  const isManaged = session.mode === "managed";
2188
- if (isManaged) {
2189
- args.push("--disallowedTools", "AskUserQuestion");
2190
- }
2191
- if (session.claudeSessionId) {
2192
- args.push("--resume", session.claudeSessionId);
2193
- }
2127
+ const args = buildClaudeCliArgs(session, {
2128
+ permissionPolicy: permPolicy,
2129
+ systemPromptParts: buildAppendSystemPromptParts(this.config.language, session.mode),
2130
+ });
2194
2131
  // 通过 stdin 传 prompt,避免被 --allowedTools / --disallowedTools 这类
2195
2132
  // variadic 参数贪婪吞掉(commander 的 <tools...> 会一直吃 positional 直到
2196
2133
  // 下一个 flag)。表现为 claude 报 "Input must be provided either through
@@ -2375,16 +2312,17 @@ export class StructuredSessionManager {
2375
2312
  let lineBuf = "";
2376
2313
  // Debounce output events to avoid flooding the WebSocket.
2377
2314
  let emitTimer = null;
2315
+ let settled = false;
2378
2316
  // 当 Claude 在非托管模式调用 AskUserQuestion 时,stdin 关闭导致它会 hang 等
2379
2317
  // tool_result。我们检测到后主动 kill child,让它顺利退出,UI 把 tool_use 卡片
2380
2318
  // 渲染成可交互选项;用户提交后由 sendMessage() 通过 --resume 续接。
2381
2319
  let killedForAskUserQuestion = false;
2382
2320
  const flushEmit = () => {
2383
2321
  if (emitTimer) {
2384
- clearTimeout(emitTimer);
2322
+ this.clearStreamEmitTimer(emitTimer);
2385
2323
  emitTimer = null;
2386
2324
  }
2387
- const current = this.sessions.get(sessionId);
2325
+ const current = this.currentSessionForRequest(sessionId, requestId);
2388
2326
  if (!current)
2389
2327
  return;
2390
2328
  this.emit({
@@ -2395,12 +2333,12 @@ export class StructuredSessionManager {
2395
2333
  };
2396
2334
  const scheduleEmit = () => {
2397
2335
  if (!emitTimer) {
2398
- emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
2336
+ emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
2399
2337
  }
2400
2338
  };
2401
2339
  /** Update the session snapshot with the current in-progress assistant turn. */
2402
2340
  const syncSnapshot = () => {
2403
- const current = this.sessions.get(sessionId);
2341
+ const current = this.currentSessionForRequest(sessionId, requestId);
2404
2342
  if (!current)
2405
2343
  return;
2406
2344
  const inProgressTurn = {
@@ -2445,14 +2383,16 @@ export class StructuredSessionManager {
2445
2383
  if (turnState.sessionId === sid)
2446
2384
  return;
2447
2385
  turnState.sessionId = sid;
2448
- const current = this.sessions.get(sessionId);
2386
+ const current = this.currentSessionForRequest(sessionId, requestId);
2449
2387
  if (current && current.claudeSessionId !== sid) {
2450
2388
  const patched = { ...current, claudeSessionId: sid };
2451
2389
  this.sessions.set(sessionId, patched);
2452
- this.saveStreamingSnapshot(patched);
2390
+ this.saveStreamingSnapshot(patched, { metadata: true });
2453
2391
  }
2454
2392
  };
2455
2393
  const processLine = (line) => {
2394
+ if (!this.isCurrentRequest(sessionId, requestId))
2395
+ return;
2456
2396
  const trimmed = line.trim();
2457
2397
  if (!trimmed)
2458
2398
  return;
@@ -2554,6 +2494,8 @@ export class StructuredSessionManager {
2554
2494
  // 纯文本(非 JSON)打到 stdout 然后非零退出,之前的实现会丢掉这部分。
2555
2495
  let lastRawStdoutChunk = "";
2556
2496
  child.stdout?.on("data", (chunk) => {
2497
+ if (!this.isCurrentRequest(sessionId, requestId))
2498
+ return;
2557
2499
  const text = chunk.toString();
2558
2500
  this.logger?.appendStructuredStdout(sessionId, text);
2559
2501
  const trimmed = text.trim();
@@ -2568,15 +2510,26 @@ export class StructuredSessionManager {
2568
2510
  }
2569
2511
  });
2570
2512
  child.stderr?.on("data", (chunk) => {
2513
+ if (!this.isCurrentRequest(sessionId, requestId))
2514
+ return;
2571
2515
  const text = chunk.toString();
2572
2516
  this.logger?.appendStructuredStderr(sessionId, text);
2573
2517
  stderr += text;
2574
2518
  });
2575
2519
  child.on("error", (error) => {
2576
- this.pendingChildren.delete(sessionId);
2577
- this.lastStreamSaveAt.delete(sessionId);
2520
+ const released = this.releasePendingChild(sessionId, child);
2521
+ if (released)
2522
+ this.cancelStreamingCheckpointTimer(sessionId);
2578
2523
  if (emitTimer)
2579
- clearTimeout(emitTimer);
2524
+ this.clearStreamEmitTimer(emitTimer);
2525
+ if (settled)
2526
+ return;
2527
+ if (!this.isCurrentRequest(sessionId, requestId)) {
2528
+ settled = true;
2529
+ resolve();
2530
+ return;
2531
+ }
2532
+ settled = true;
2580
2533
  this.logger?.appendStructuredSpawn(sessionId, {
2581
2534
  kind: "claude-print-error",
2582
2535
  pid: child.pid ?? null,
@@ -2592,8 +2545,18 @@ export class StructuredSessionManager {
2592
2545
  reject(new Error(`claude -p 启动失败:${error.message}${hint}`));
2593
2546
  });
2594
2547
  child.on("close", (code, signal) => {
2595
- this.pendingChildren.delete(sessionId);
2596
- this.lastStreamSaveAt.delete(sessionId);
2548
+ const released = this.releasePendingChild(sessionId, child);
2549
+ if (released)
2550
+ this.cancelStreamingCheckpointTimer(sessionId);
2551
+ if (settled)
2552
+ return;
2553
+ if (!this.isCurrentRequest(sessionId, requestId)) {
2554
+ if (emitTimer)
2555
+ this.clearStreamEmitTimer(emitTimer);
2556
+ settled = true;
2557
+ resolve();
2558
+ return;
2559
+ }
2597
2560
  this.logger?.appendStructuredSpawn(sessionId, {
2598
2561
  kind: "claude-print-close",
2599
2562
  pid: child.pid ?? null,
@@ -2610,19 +2573,18 @@ export class StructuredSessionManager {
2610
2573
  // Flush any pending debounced emit before finalizing.
2611
2574
  flushEmit();
2612
2575
  // Finalize the session snapshot.
2613
- const current = this.sessions.get(sessionId);
2576
+ const current = this.currentSessionForRequest(sessionId, requestId);
2614
2577
  if (!current) {
2615
- reject(new Error("Session removed during execution."));
2578
+ settled = true;
2579
+ resolve();
2616
2580
  return;
2617
2581
  }
2618
2582
  // 如果是用户主动中断(interruptedWith 里有新消息),claude -p 收到 SIGTERM 后
2619
2583
  // 可能以非零 exit code 退出(内部 handler 调了 exit(1))。这种情况属于正常
2620
2584
  // 中断流程,不应走失败路径——后续 interruptedWith 逻辑会发送新消息。
2621
2585
  const interruptedByUser = this.interruptedWith.has(sessionId);
2622
- // 用户点「停止」kill 掉 claude -p 后会非零退出,但这不是失败——按正常 idle 收尾。
2623
- const userStopped = this.userStopped.delete(sessionId);
2624
2586
  const failedExit = (code !== null && code !== 0) || signal !== null;
2625
- if (failedExit && !interruptedByUser && !userStopped) {
2587
+ if (failedExit && !interruptedByUser && !killedForAskUserQuestion) {
2626
2588
  const errorText = this.formatStructuredExitError("claude -p", code, signal, {
2627
2589
  stderr,
2628
2590
  // claude -p 没有 codex 那种独立的 turn.failed 事件,所以 primary 留空;
@@ -2662,10 +2624,11 @@ export class StructuredSessionManager {
2662
2624
  },
2663
2625
  };
2664
2626
  this.sessions.set(sessionId, failed);
2665
- this.storage.saveSession(failed);
2627
+ this.saveAuthoritativeSession(failed);
2666
2628
  this.emitStructuredSnapshot(failed);
2667
2629
  this.emitStructuredSnapshot(failed, "ended");
2668
- reject(new Error(errorText));
2630
+ settled = true;
2631
+ reject(new PersistedStructuredRunnerError(errorText));
2669
2632
  return;
2670
2633
  }
2671
2634
  const msgs = this.buildCompletedAssistantMessages(current, turnState);
@@ -2693,16 +2656,11 @@ export class StructuredSessionManager {
2693
2656
  },
2694
2657
  };
2695
2658
  this.sessions.set(sessionId, finished);
2696
- this.storage.saveSession(finished);
2659
+ this.saveAuthoritativeSession(finished);
2697
2660
  this.emitStructuredSnapshot(finished);
2698
2661
  if (!keepRunning) {
2699
2662
  this.emitStructuredSnapshot(finished, "ended");
2700
2663
  }
2701
- // 等待用户回答 AskUserQuestion 时,跳过后续自续接和队列推进。
2702
- if (killedForAskUserQuestion) {
2703
- resolve();
2704
- return;
2705
- }
2706
2664
  // 用户中断当前回复:保存部分回复后立即发送新消息。
2707
2665
  if (interruptPrompt) {
2708
2666
  this.interruptedWith.delete(sessionId);
@@ -2711,38 +2669,33 @@ export class StructuredSessionManager {
2711
2669
  // 注意:被保留的 queuedMessages 不需要在这里主动 flush,重发的
2712
2670
  // interruptPrompt 跑完会自然触发 flushNextQueuedMessage。
2713
2671
  this.preserveQueueOnInterrupt.delete(sessionId);
2672
+ settled = true;
2714
2673
  resolve();
2715
2674
  setImmediate(() => {
2716
2675
  this.sendMessage(sessionId, interruptPrompt).catch((err) => {
2717
2676
  console.error("[WAND] interrupt-and-send failed:", err);
2718
- // 续接失败:把状态回滚到 idle,让用户可以重新输入而不是卡在 running 状态
2719
- const afterFail = this.sessions.get(sessionId);
2720
- if (afterFail) {
2721
- const recovered = {
2722
- ...afterFail,
2723
- status: "idle",
2724
- exitCode: 0,
2725
- endedAt: new Date().toISOString(),
2726
- structuredState: {
2727
- ...afterFail.structuredState,
2728
- inFlight: false,
2729
- activeRequestId: null,
2730
- },
2731
- };
2732
- this.sessions.set(sessionId, recovered);
2733
- this.storage.saveSession(recovered);
2734
- this.emitStructuredSnapshot(recovered);
2735
- }
2736
2677
  });
2737
2678
  });
2738
2679
  return;
2739
2680
  }
2681
+ if (killedForAskUserQuestion) {
2682
+ settled = true;
2683
+ resolve();
2684
+ // An answer can arrive after AskUserQuestion triggered SIGTERM but
2685
+ // before close finalized the turn. It was queued while inFlight; now
2686
+ // advance it normally so it becomes the matching tool_result.
2687
+ if ((finished.queuedMessages?.length ?? 0) > 0) {
2688
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
2689
+ }
2690
+ return;
2691
+ }
2740
2692
  // Auto-continue after plan mode exit: when Claude calls ExitPlanMode,
2741
2693
  // the `-p` process exits because stdin is "ignore" and it cannot get
2742
2694
  // user confirmation. Detect this and automatically resume execution
2743
2695
  // so the plan is actually carried out.
2744
2696
  const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
2745
2697
  if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
2698
+ settled = true;
2746
2699
  resolve();
2747
2700
  setImmediate(() => {
2748
2701
  this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
@@ -2751,6 +2704,7 @@ export class StructuredSessionManager {
2751
2704
  });
2752
2705
  return;
2753
2706
  }
2707
+ settled = true;
2754
2708
  resolve();
2755
2709
  setImmediate(() => {
2756
2710
  void this.flushNextQueuedMessage(sessionId);
@@ -2771,8 +2725,7 @@ export class StructuredSessionManager {
2771
2725
  * payloads for incremental text/thinking/tool_use updates, followed by a final
2772
2726
  * SDKAssistantMessage with the authoritative complete content.
2773
2727
  */
2774
- async runClaudeSdkStreaming(sessionId, session, prompt) {
2775
- this.userStopped.delete(sessionId);
2728
+ async runClaudeSdkStreaming(sessionId, session, prompt, requestId) {
2776
2729
  const abortController = new AbortController();
2777
2730
  this.pendingSdkAbort.set(sessionId, abortController);
2778
2731
  const isManaged = session.mode === "managed";
@@ -2784,12 +2737,7 @@ export class StructuredSessionManager {
2784
2737
  // SDK 默认会把整个 process.env 透传给 claude 子进程;这里显式按 inheritEnv 配置组装,
2785
2738
  // 否则关闭"继承环境变量"开关时 SDK 路径会被静默忽略。
2786
2739
  const sdkEnv = buildChildEnv(this.config.inheritEnv !== false);
2787
- // 思考深度:off 显式禁用 thinking,其他 → 给一个固定 budget。
2788
- // SDK 类型用驼峰 budgetTokens(API 层是 budget_tokens,SDK 内部已做转换)。
2789
- const sdkThinkingBudget = thinkingEffortToSdkBudget(session.thinkingEffort);
2790
- const sdkThinking = sdkThinkingBudget > 0
2791
- ? { type: "enabled", budgetTokens: sdkThinkingBudget }
2792
- : { type: "disabled" };
2740
+ const sdkThinking = buildClaudeSdkThinking(session.thinkingEffort);
2793
2741
  const sdkOptions = {
2794
2742
  cwd: session.cwd,
2795
2743
  abortController,
@@ -2883,17 +2831,17 @@ export class StructuredSessionManager {
2883
2831
  let emitTimer = null;
2884
2832
  const flushEmit = () => {
2885
2833
  if (emitTimer) {
2886
- clearTimeout(emitTimer);
2834
+ this.clearStreamEmitTimer(emitTimer);
2887
2835
  emitTimer = null;
2888
2836
  }
2889
- const current = this.sessions.get(sessionId);
2837
+ const current = this.currentSessionForRequest(sessionId, requestId);
2890
2838
  if (!current)
2891
2839
  return;
2892
2840
  this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
2893
2841
  };
2894
2842
  const scheduleEmit = () => {
2895
2843
  if (!emitTimer)
2896
- emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
2844
+ emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
2897
2845
  };
2898
2846
  // Rebuild ContentBlock[] from finalized history + the in-progress streaming map.
2899
2847
  // Returning only the streaming blocks would drop every prior parent/subagent
@@ -2938,7 +2886,7 @@ export class StructuredSessionManager {
2938
2886
  return [...finalizedBlocks, ...stampedStreaming];
2939
2887
  };
2940
2888
  const syncSnapshot = () => {
2941
- const current = this.sessions.get(sessionId);
2889
+ const current = this.currentSessionForRequest(sessionId, requestId);
2942
2890
  if (!current)
2943
2891
  return;
2944
2892
  const inProgressTurn = {
@@ -2976,11 +2924,18 @@ export class StructuredSessionManager {
2976
2924
  claudeSessionId: session.claudeSessionId,
2977
2925
  spawnedAt,
2978
2926
  });
2979
- const queryHandle = sdkQuery({ prompt: singleShotPrompt(), options: sdkOptions });
2927
+ let queryHandle;
2928
+ try {
2929
+ queryHandle = this.sdkQueryFactory({ prompt: singleShotPrompt(), options: sdkOptions });
2930
+ }
2931
+ catch (error) {
2932
+ this.releasePendingSdkAbort(sessionId, abortController);
2933
+ throw error;
2934
+ }
2980
2935
  this.pendingSdkQueries.set(sessionId, queryHandle);
2981
2936
  try {
2982
2937
  for await (const msg of queryHandle) {
2983
- if (abortController.signal.aborted)
2938
+ if (abortController.signal.aborted || !this.isCurrentRequest(sessionId, requestId))
2984
2939
  break;
2985
2940
  // 同 CLI runner 的关键修复:从任何带 session_id 的 SDK 消息(system / assistant /
2986
2941
  // user / result)即时捕获并落库。AskUserQuestion 的 interrupt 发生在 assistant
@@ -2989,11 +2944,11 @@ export class StructuredSessionManager {
2989
2944
  const msgSessionId = msg.session_id;
2990
2945
  if (typeof msgSessionId === "string" && msgSessionId && turnState.sessionId !== msgSessionId) {
2991
2946
  turnState.sessionId = msgSessionId;
2992
- const cur = this.sessions.get(sessionId);
2947
+ const cur = this.currentSessionForRequest(sessionId, requestId);
2993
2948
  if (cur && cur.claudeSessionId !== msgSessionId) {
2994
2949
  const patched = { ...cur, claudeSessionId: msgSessionId };
2995
2950
  this.sessions.set(sessionId, patched);
2996
- this.saveStreamingSnapshot(patched);
2951
+ this.saveStreamingSnapshot(patched, { metadata: true });
2997
2952
  }
2998
2953
  }
2999
2954
  // Incremental streaming events (opt-in via includePartialMessages: true)
@@ -3145,11 +3100,14 @@ export class StructuredSessionManager {
3145
3100
  // AbortError from abortController.abort() is intentional — fall through to finish logic
3146
3101
  const isAbort = abortController.signal.aborted || (err instanceof Error && err.name === "AbortError");
3147
3102
  if (!isAbort) {
3148
- this.pendingSdkAbort.delete(sessionId);
3149
- this.pendingSdkQueries.delete(sessionId);
3150
- this.lastStreamSaveAt.delete(sessionId);
3103
+ const releasedAbort = this.releasePendingSdkAbort(sessionId, abortController);
3104
+ const releasedQuery = this.releasePendingSdkQuery(sessionId, queryHandle);
3105
+ if (releasedAbort || releasedQuery)
3106
+ this.cancelStreamingCheckpointTimer(sessionId);
3151
3107
  if (emitTimer)
3152
- clearTimeout(emitTimer);
3108
+ this.clearStreamEmitTimer(emitTimer);
3109
+ if (!this.isCurrentRequest(sessionId, requestId))
3110
+ return;
3153
3111
  this.logger?.appendStructuredSpawn(sessionId, {
3154
3112
  kind: "claude-sdk-error",
3155
3113
  spawnedAt,
@@ -3160,17 +3118,18 @@ export class StructuredSessionManager {
3160
3118
  }
3161
3119
  }
3162
3120
  // Cleanup
3163
- this.pendingSdkAbort.delete(sessionId);
3164
- this.pendingSdkQueries.delete(sessionId);
3165
- this.lastStreamSaveAt.delete(sessionId);
3166
- // SDK abort 收尾本就走 idle(不产生失败串),这里只是顺手清掉用户停止标记避免泄漏。
3167
- this.userStopped.delete(sessionId);
3121
+ const releasedAbort = this.releasePendingSdkAbort(sessionId, abortController);
3122
+ const releasedQuery = this.releasePendingSdkQuery(sessionId, queryHandle);
3123
+ if (releasedAbort || releasedQuery)
3124
+ this.cancelStreamingCheckpointTimer(sessionId);
3168
3125
  if (emitTimer)
3169
- clearTimeout(emitTimer);
3126
+ this.clearStreamEmitTimer(emitTimer);
3127
+ if (!this.isCurrentRequest(sessionId, requestId))
3128
+ return;
3170
3129
  flushEmit();
3171
- const current = this.sessions.get(sessionId);
3130
+ const current = this.currentSessionForRequest(sessionId, requestId);
3172
3131
  if (!current)
3173
- throw new Error("Session removed during execution.");
3132
+ return;
3174
3133
  this.logger?.appendStructuredSpawn(sessionId, {
3175
3134
  kind: "claude-sdk-close",
3176
3135
  spawnedAt,
@@ -3201,12 +3160,10 @@ export class StructuredSessionManager {
3201
3160
  },
3202
3161
  };
3203
3162
  this.sessions.set(sessionId, finished);
3204
- this.storage.saveSession(finished);
3163
+ this.saveAuthoritativeSession(finished);
3205
3164
  this.emitStructuredSnapshot(finished);
3206
3165
  if (!keepRunning)
3207
3166
  this.emitStructuredSnapshot(finished, "ended");
3208
- if (killedForAskUserQuestion)
3209
- return;
3210
3167
  if (interruptPrompt) {
3211
3168
  this.interruptedWith.delete(sessionId);
3212
3169
  // 与 codex/cli runner 对齐:清掉"保留队列"标记,避免 stale flag 影响下一次普通 interrupt。
@@ -3214,27 +3171,16 @@ export class StructuredSessionManager {
3214
3171
  setImmediate(() => {
3215
3172
  this.sendMessage(sessionId, interruptPrompt).catch((err) => {
3216
3173
  console.error("[WAND] sdk interrupt-and-send failed:", err);
3217
- const afterFail = this.sessions.get(sessionId);
3218
- if (afterFail) {
3219
- const recovered = {
3220
- ...afterFail,
3221
- status: "idle",
3222
- exitCode: 0,
3223
- endedAt: new Date().toISOString(),
3224
- structuredState: {
3225
- ...afterFail.structuredState,
3226
- inFlight: false,
3227
- activeRequestId: null,
3228
- },
3229
- };
3230
- this.sessions.set(sessionId, recovered);
3231
- this.storage.saveSession(recovered);
3232
- this.emitStructuredSnapshot(recovered);
3233
- }
3234
3174
  });
3235
3175
  });
3236
3176
  return;
3237
3177
  }
3178
+ if (killedForAskUserQuestion) {
3179
+ if ((finished.queuedMessages?.length ?? 0) > 0) {
3180
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
3181
+ }
3182
+ return;
3183
+ }
3238
3184
  // Auto-continue after ExitPlanMode (same as CLI runner)
3239
3185
  const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
3240
3186
  if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {