@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.
- package/dist/auth.d.ts +19 -5
- package/dist/auth.js +83 -45
- package/dist/build-info.json +3 -3
- package/dist/cert.d.ts +1 -1
- package/dist/cert.js +124 -74
- package/dist/config.js +25 -8
- package/dist/express-async.d.ts +6 -0
- package/dist/express-async.js +28 -0
- package/dist/git-quick-commit.d.ts +2 -0
- package/dist/git-quick-commit.js +215 -76
- package/dist/git-utils.d.ts +4 -0
- package/dist/git-utils.js +60 -11
- package/dist/git-worktree.d.ts +8 -1
- package/dist/git-worktree.js +406 -41
- package/dist/models.d.ts +34 -4
- package/dist/models.js +334 -48
- package/dist/process-manager.d.ts +22 -30
- package/dist/process-manager.js +374 -441
- package/dist/provider-history-scanner.d.ts +54 -0
- package/dist/provider-history-scanner.js +354 -0
- package/dist/request-limits.d.ts +1 -0
- package/dist/request-limits.js +8 -0
- package/dist/resume-policy.d.ts +2 -0
- package/dist/resume-policy.js +5 -0
- package/dist/runtime-config.d.ts +16 -0
- package/dist/runtime-config.js +49 -0
- package/dist/server-file-routes.d.ts +17 -0
- package/dist/server-file-routes.js +653 -0
- package/dist/server-session-routes.d.ts +16 -3
- package/dist/server-session-routes.js +170 -149
- package/dist/server-settings-routes.d.ts +43 -0
- package/dist/server-settings-routes.js +225 -0
- package/dist/server-update-routes.d.ts +61 -0
- package/dist/server-update-routes.js +215 -0
- package/dist/server.d.ts +6 -4
- package/dist/server.js +350 -1313
- package/dist/session-logger.d.ts +32 -2
- package/dist/session-logger.js +145 -15
- package/dist/session-registry.d.ts +27 -0
- package/dist/session-registry.js +153 -0
- package/dist/session-transport.d.ts +31 -0
- package/dist/session-transport.js +82 -0
- package/dist/storage.d.ts +24 -6
- package/dist/storage.js +291 -44
- package/dist/structured-claude-adapter.d.ts +19 -0
- package/dist/structured-claude-adapter.js +117 -0
- package/dist/structured-codex-adapter.d.ts +3 -0
- package/dist/structured-codex-adapter.js +29 -0
- package/dist/structured-opencode-adapter.d.ts +11 -0
- package/dist/structured-opencode-adapter.js +115 -0
- package/dist/structured-provider-common.d.ts +11 -0
- package/dist/structured-provider-common.js +77 -0
- package/dist/structured-session-manager.d.ts +32 -35
- package/dist/structured-session-manager.js +551 -605
- package/dist/types.d.ts +10 -0
- package/dist/update-helper.js +5 -1
- package/dist/web-ui/content/scripts.js +32 -32
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.d.ts +16 -1
- package/dist/ws-broadcast.js +124 -58
- 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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
823
|
-
runner
|
|
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
|
|
832
|
-
runner
|
|
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.
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
786
|
+
const remaining = STREAM_SAVE_THROTTLE_MS - (now - last);
|
|
787
|
+
if (remaining <= 0) {
|
|
788
|
+
this.flushStreamingCheckpoint(snapshot.id);
|
|
881
789
|
return;
|
|
882
|
-
|
|
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.
|
|
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
|
|
929
|
-
|
|
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
|
|
1008
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1050
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1139
|
-
await this.
|
|
1140
|
+
if (provider === "codex") {
|
|
1141
|
+
await this.runCodexStreaming(id, updated, prompt, requestId);
|
|
1140
1142
|
}
|
|
1141
|
-
else if (
|
|
1142
|
-
await this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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:
|
|
1357
|
-
requestId:
|
|
1358
|
-
resolution
|
|
1381
|
+
lastEscalationResult: {
|
|
1382
|
+
requestId: pending.requestId,
|
|
1383
|
+
resolution,
|
|
1359
1384
|
reason: approved ? "user_approved" : "user_denied",
|
|
1360
|
-
}
|
|
1385
|
+
},
|
|
1361
1386
|
};
|
|
1362
1387
|
this.sessions.set(sessionId, updated);
|
|
1363
|
-
this.storage.
|
|
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
|
-
//
|
|
1376
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
1454
|
+
this.releasePendingSdkQuery(id, sdkQuery);
|
|
1427
1455
|
}
|
|
1428
|
-
const sdkAbort = this.pendingSdkAbort.get(id);
|
|
1429
1456
|
if (sdkAbort) {
|
|
1430
1457
|
sdkAbort.abort();
|
|
1431
|
-
this.
|
|
1458
|
+
this.releasePendingSdkAbort(id, sdkAbort);
|
|
1432
1459
|
}
|
|
1433
|
-
this.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
1625
|
+
this.clearStreamEmitTimer(emitTimer);
|
|
1607
1626
|
emitTimer = null;
|
|
1608
1627
|
}
|
|
1609
|
-
const current = this.
|
|
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.
|
|
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.
|
|
1738
|
-
|
|
1762
|
+
const released = this.releasePendingChild(sessionId, child);
|
|
1763
|
+
if (released)
|
|
1764
|
+
this.cancelStreamingCheckpointTimer(sessionId);
|
|
1739
1765
|
if (emitTimer)
|
|
1740
|
-
|
|
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.
|
|
1760
|
-
|
|
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.
|
|
1821
|
+
const current = this.currentSessionForRequest(sessionId, requestId);
|
|
1778
1822
|
if (!current) {
|
|
1779
|
-
|
|
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
|
|
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.
|
|
1842
|
+
this.saveAuthoritativeSession(failed);
|
|
1800
1843
|
this.emitStructuredSnapshot(failed);
|
|
1801
1844
|
this.emitStructuredSnapshot(failed, "ended");
|
|
1802
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
1959
|
+
this.clearStreamEmitTimer(emitTimer);
|
|
2022
1960
|
emitTimer = null;
|
|
2023
|
-
const current = this.
|
|
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 =
|
|
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.
|
|
2065
|
-
|
|
2008
|
+
const released = this.releasePendingChild(sessionId, child);
|
|
2009
|
+
if (released)
|
|
2010
|
+
this.cancelStreamingCheckpointTimer(sessionId);
|
|
2066
2011
|
if (emitTimer)
|
|
2067
|
-
|
|
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.
|
|
2076
|
-
|
|
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.
|
|
2043
|
+
const current = this.currentSessionForRequest(sessionId, requestId);
|
|
2081
2044
|
if (!current) {
|
|
2082
|
-
|
|
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
|
-
|
|
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.
|
|
2058
|
+
this.saveAuthoritativeSession(failed);
|
|
2096
2059
|
this.emitStructuredSnapshot(failed);
|
|
2097
2060
|
this.emitStructuredSnapshot(failed, "ended");
|
|
2098
|
-
|
|
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.
|
|
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
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
-
|
|
2322
|
+
this.clearStreamEmitTimer(emitTimer);
|
|
2385
2323
|
emitTimer = null;
|
|
2386
2324
|
}
|
|
2387
|
-
const current = this.
|
|
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.
|
|
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.
|
|
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.
|
|
2577
|
-
|
|
2520
|
+
const released = this.releasePendingChild(sessionId, child);
|
|
2521
|
+
if (released)
|
|
2522
|
+
this.cancelStreamingCheckpointTimer(sessionId);
|
|
2578
2523
|
if (emitTimer)
|
|
2579
|
-
|
|
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.
|
|
2596
|
-
|
|
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.
|
|
2576
|
+
const current = this.currentSessionForRequest(sessionId, requestId);
|
|
2614
2577
|
if (!current) {
|
|
2615
|
-
|
|
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 && !
|
|
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.
|
|
2627
|
+
this.saveAuthoritativeSession(failed);
|
|
2666
2628
|
this.emitStructuredSnapshot(failed);
|
|
2667
2629
|
this.emitStructuredSnapshot(failed, "ended");
|
|
2668
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
2834
|
+
this.clearStreamEmitTimer(emitTimer);
|
|
2887
2835
|
emitTimer = null;
|
|
2888
2836
|
}
|
|
2889
|
-
const current = this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
3149
|
-
this.
|
|
3150
|
-
|
|
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
|
-
|
|
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.
|
|
3164
|
-
this.
|
|
3165
|
-
|
|
3166
|
-
|
|
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
|
-
|
|
3126
|
+
this.clearStreamEmitTimer(emitTimer);
|
|
3127
|
+
if (!this.isCurrentRequest(sessionId, requestId))
|
|
3128
|
+
return;
|
|
3170
3129
|
flushEmit();
|
|
3171
|
-
const current = this.
|
|
3130
|
+
const current = this.currentSessionForRequest(sessionId, requestId);
|
|
3172
3131
|
if (!current)
|
|
3173
|
-
|
|
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.
|
|
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) {
|