godot-daedalus_backend 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ npm install godot-daedalus_backend
40
40
  After installing the package, start it through the published bin command:
41
41
 
42
42
  ```powershell
43
- $env:PORT = "8080"
43
+ $env:PORT = "38180"
44
44
  godot-daedalus-backend
45
45
  ```
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "godot-daedalus_backend",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "TypeScript backend for the Godot Daedalus editor assistant plugin.",
5
5
  "main": "./src/main.ts",
6
6
  "bin": {
package/src/main.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from "./server/websocket-server.js";
2
2
  import { McpHost } from "./mcp/mcp-host.js";
3
3
 
4
- const DEFAULT_PORT: number = 8080;
4
+ const DEFAULT_PORT: number = 38180;
5
5
  const portText: string = process.env.PORT ?? String(DEFAULT_PORT);
6
6
  const port: number = Number.parseInt(portText, 10);
7
7
 
@@ -1,6 +1,6 @@
1
1
  import WebSocket from "ws";
2
2
 
3
- const url: string = process.env.WS_URL ?? "ws://localhost:8080";
3
+ const url: string = process.env.WS_URL ?? "ws://localhost:38180";
4
4
  const socket: WebSocket = new WebSocket(url);
5
5
 
6
6
  socket.on("open", (): void => {
@@ -0,0 +1,64 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const BACKEND_HEALTH_NAME: string = "godot-daedalus-backend";
6
+ const PACKAGE_ROOT: string = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
7
+ const PACKAGE_JSON_PATH: string = resolve(PACKAGE_ROOT, "package.json");
8
+
9
+ type PackageManifest = {
10
+ name?: unknown;
11
+ version?: unknown;
12
+ };
13
+
14
+ export type BackendHealthResult = {
15
+ name: string;
16
+ version: string;
17
+ pid: number;
18
+ mode: "development" | "runtime";
19
+ };
20
+
21
+ let cachedPackageVersion: string | null = null;
22
+
23
+ export function getBackendPackageVersion(): string {
24
+ if (cachedPackageVersion !== null) {
25
+ return cachedPackageVersion;
26
+ }
27
+
28
+ const envVersion: string | undefined = process.env.npm_package_version;
29
+ if (envVersion !== undefined && envVersion.trim() !== "") {
30
+ cachedPackageVersion = envVersion.trim();
31
+ return cachedPackageVersion;
32
+ }
33
+
34
+ try {
35
+ const manifestText: string = readFileSync(PACKAGE_JSON_PATH, "utf8");
36
+ const manifest: PackageManifest = JSON.parse(manifestText) as PackageManifest;
37
+ if (typeof manifest.version === "string" && manifest.version.trim() !== "") {
38
+ cachedPackageVersion = manifest.version.trim();
39
+ return cachedPackageVersion;
40
+ }
41
+ } catch {
42
+ // health 不能因为版本元数据不可读而阻断 WebSocket 启动。
43
+ }
44
+
45
+ cachedPackageVersion = "0.0.0";
46
+ return cachedPackageVersion;
47
+ }
48
+
49
+ export function getBackendRuntimeMode(): "development" | "runtime" {
50
+ if (process.env.NODE_ENV === "development" || process.env.npm_lifecycle_event === "dev") {
51
+ return "development";
52
+ }
53
+
54
+ return "runtime";
55
+ }
56
+
57
+ export function createBackendHealthResult(): BackendHealthResult {
58
+ return {
59
+ name: BACKEND_HEALTH_NAME,
60
+ version: getBackendPackageVersion(),
61
+ pid: process.pid,
62
+ mode: getBackendRuntimeMode()
63
+ };
64
+ }
@@ -10,7 +10,7 @@ import {
10
10
  type DeepSeekAgentContinuation,
11
11
  type DeepSeekAgentResult
12
12
  } from "../providers/deepseek-agent.js";
13
- import type { OnToolEvent } from "../tools/tool-dispatcher.js";
13
+ import type { OnToolEvent, ToolEvent } from "../tools/tool-dispatcher.js";
14
14
  import { chatWithDeepSeek, createDeepSeekClient, type DeepSeekChatOptions } from "../providers/deepseek-client.js";
15
15
  import { McpHost } from "../mcp/mcp-host.js";
16
16
  import type { CustomMcpServerRuntimeStatus } from "../mcp/mcp-host.js";
@@ -44,7 +44,7 @@ import {
44
44
  archiveSession, deleteArchivedSession, deleteSession, listArchivedSessions, renameSession, restoreArchivedSession,
45
45
  rewindSessionFromRequest,
46
46
  readSummary, writeSummary,
47
- appendSessionEvent, clearSessionEvents,
47
+ appendSessionEvent, appendApprovalEvent, clearSessionEvents, readApprovalEvents,
48
48
  openSessionRecentTimeline, openSessionTimelinePage,
49
49
  type SessionMetadata,
50
50
  type SessionSummary,
@@ -80,6 +80,18 @@ import {
80
80
  type ThinkingEventBuffer
81
81
  } from "./client-session.js";
82
82
  import { assertKnownRequestMethod } from "./request-dispatcher.js";
83
+ import { getToolPolicy } from "../tools/tool-policy.js";
84
+ import type { PendingApproval } from "../tools/approval-gateway.js";
85
+ import { getLlmToolExecutionIdentity } from "../tools/tool-idempotency.js";
86
+ import { resolveToolMapping } from "../tools/llm-tools.js";
87
+ import {
88
+ createPersistedApprovalRequestedData,
89
+ createRuntimePendingContinuation,
90
+ foldPendingApprovalStates,
91
+ serializePendingApprovalState,
92
+ type PendingApprovalState
93
+ } from "../session/approval-persistence.js";
94
+ import { createBackendHealthResult } from "./backend-health.js";
83
95
 
84
96
  const tokenCounterPromise: Promise<TokenCounter> = createTokenCounter();
85
97
  let sessionCompressorPromptCache: string | undefined;
@@ -98,6 +110,18 @@ const MAX_NEXT_STEP_HINT_COUNT: number = 5;
98
110
  const MAX_NEXT_STEP_HINT_MESSAGE_CHARS: number = 320;
99
111
  const MAX_GUIDE_TEXT_CHARS: number = 4000;
100
112
 
113
+ type WorkflowPhaseToolStats = {
114
+ toolEvents: number;
115
+ proposeToolEvents: number;
116
+ writeToolEvents: number;
117
+ approvalEvents: number;
118
+ };
119
+
120
+ type WorkflowPhaseRunResult = {
121
+ agentResult: DeepSeekAgentResult;
122
+ toolStats: WorkflowPhaseToolStats;
123
+ };
124
+
101
125
  function fingerprintText(text: string): string {
102
126
  if (text.length === 0) {
103
127
  return "empty";
@@ -1085,6 +1109,73 @@ function createToolEventForwarder(socket: WebSocket, requestId: string, session:
1085
1109
  };
1086
1110
  }
1087
1111
 
1112
+ function createEmptyWorkflowPhaseToolStats(): WorkflowPhaseToolStats {
1113
+ return {
1114
+ toolEvents: 0,
1115
+ proposeToolEvents: 0,
1116
+ writeToolEvents: 0,
1117
+ approvalEvents: 0
1118
+ };
1119
+ }
1120
+
1121
+ function updateWorkflowPhaseToolStats(stats: WorkflowPhaseToolStats, event: ToolEvent): void {
1122
+ if (!event.type.startsWith("tool.")) {
1123
+ return;
1124
+ }
1125
+
1126
+ stats.toolEvents += 1;
1127
+
1128
+ if (event.type === "tool.approval_required") {
1129
+ stats.approvalEvents += 1;
1130
+ }
1131
+
1132
+ const toolName: string | undefined = "toolName" in event ? event.toolName : undefined;
1133
+ if (toolName === undefined) {
1134
+ return;
1135
+ }
1136
+
1137
+ const policy = getToolPolicy(toolName);
1138
+ if (policy?.risk === "propose") {
1139
+ stats.proposeToolEvents += 1;
1140
+ }
1141
+ if (policy?.risk === "write" || policy?.risk === "destructive") {
1142
+ stats.writeToolEvents += 1;
1143
+ }
1144
+ }
1145
+
1146
+ function shouldRequireWorkflowWriteTool(phase: WorkflowPhase): boolean {
1147
+ return phase.toolGroup === "write";
1148
+ }
1149
+
1150
+ function didWorkflowWritePhaseExecute(phase: WorkflowPhase, stats: WorkflowPhaseToolStats): boolean {
1151
+ if (stats.writeToolEvents > 0 || stats.approvalEvents > 0) {
1152
+ return true;
1153
+ }
1154
+
1155
+ return isWorkflowProposalPhase(phase) && stats.proposeToolEvents > 0;
1156
+ }
1157
+
1158
+ function isWorkflowProposalPhase(phase: WorkflowPhase): boolean {
1159
+ const text: string = `${phase.id}\n${phase.title}\n${phase.instruction}`.toLowerCase();
1160
+ return text.includes("propose")
1161
+ || text.includes("preview")
1162
+ || text.includes("diff")
1163
+ || text.includes("预览")
1164
+ || text.includes("提案")
1165
+ || text.includes("方案");
1166
+ }
1167
+
1168
+ function createWorkflowWriteGuardRetryMessage(phaseMessage: string): string {
1169
+ return [
1170
+ phaseMessage,
1171
+ "",
1172
+ "## 后端执行守卫",
1173
+ "上一次候选回复没有实际调用当前阶段需要的 propose/write 工具,也没有触发审批,因此当前阶段还没有完成。",
1174
+ "如果当前阶段是预览/提案,请调用允许的 propose_* 工具;如果当前阶段是实际修改,请调用写入工具并按审批流程暂停。",
1175
+ "不要只描述计划、步骤或意图。"
1176
+ ].join("\n");
1177
+ }
1178
+
1088
1179
  function createPendingAiContinuation(
1089
1180
  params: AiChatParams,
1090
1181
  options: DeepSeekChatOptions,
@@ -1117,6 +1208,155 @@ function createPendingAiContinuation(
1117
1208
  return pendingContinuation;
1118
1209
  }
1119
1210
 
1211
+ async function persistApprovalRequested(
1212
+ session: ClientSession,
1213
+ mcpHost: McpHost,
1214
+ approvalId: string,
1215
+ pendingContinuation: PendingAiContinuation
1216
+ ): Promise<void> {
1217
+ if (session.sessionId === undefined) {
1218
+ return;
1219
+ }
1220
+
1221
+ const pendingApproval: PendingApproval | undefined = session.approvalGateway.getPending(approvalId);
1222
+ if (pendingApproval === undefined) {
1223
+ return;
1224
+ }
1225
+
1226
+ await appendApprovalEvent(
1227
+ session.sessionId,
1228
+ approvalId,
1229
+ pendingContinuation.requestId,
1230
+ "requested",
1231
+ createPersistedApprovalRequestedData(pendingApproval, pendingContinuation, mcpHost.getActiveWorkspaceId())
1232
+ );
1233
+ }
1234
+
1235
+ async function registerPendingApprovalContinuation(
1236
+ session: ClientSession,
1237
+ mcpHost: McpHost,
1238
+ approvalId: string,
1239
+ pendingContinuation: PendingAiContinuation
1240
+ ): Promise<void> {
1241
+ session.pendingAiContinuations.set(approvalId, pendingContinuation);
1242
+ await persistApprovalRequested(session, mcpHost, approvalId, pendingContinuation);
1243
+ }
1244
+
1245
+ async function loadHydratedPendingApprovalStates(
1246
+ session: ClientSession,
1247
+ apiKey?: string | undefined
1248
+ ): Promise<{ states: PendingApprovalState[]; hadEvents: boolean }> {
1249
+ if (session.sessionId === undefined) {
1250
+ return {
1251
+ states: createMemoryPendingApprovalStates(session),
1252
+ hadEvents: false
1253
+ };
1254
+ }
1255
+
1256
+ const approvalEvents = await readApprovalEvents(session.sessionId);
1257
+ if (approvalEvents.length === 0) {
1258
+ return {
1259
+ states: createMemoryPendingApprovalStates(session),
1260
+ hadEvents: false
1261
+ };
1262
+ }
1263
+
1264
+ const states: PendingApprovalState[] = foldPendingApprovalStates(approvalEvents);
1265
+ session.approvalGateway.replacePending(states.map((state: PendingApprovalState): PendingApproval => state.approval));
1266
+ const pendingIds: Set<string> = new Set(states.map((state: PendingApprovalState): string => state.approval.approvalId));
1267
+ for (const approvalId of session.pendingAiContinuations.keys()) {
1268
+ if (!pendingIds.has(approvalId)) {
1269
+ session.pendingAiContinuations.delete(approvalId);
1270
+ }
1271
+ }
1272
+
1273
+ if (apiKey !== undefined) {
1274
+ for (const state of states) {
1275
+ if (state.continuation !== undefined) {
1276
+ session.pendingAiContinuations.set(state.approval.approvalId, createRuntimePendingContinuation(state.continuation, apiKey));
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ return {
1282
+ states,
1283
+ hadEvents: true
1284
+ };
1285
+ }
1286
+
1287
+ function createMemoryPendingApprovalStates(session: ClientSession): PendingApprovalState[] {
1288
+ return session.approvalGateway.listPending().map((pendingApproval: PendingApproval): PendingApprovalState => {
1289
+ const timestamp: string = new Date(pendingApproval.createdAt).toISOString();
1290
+ return {
1291
+ approval: pendingApproval,
1292
+ status: "pending",
1293
+ restored: false,
1294
+ interrupted: false,
1295
+ requestId: "",
1296
+ createdAt: timestamp,
1297
+ updatedAt: timestamp
1298
+ };
1299
+ });
1300
+ }
1301
+
1302
+ function findPendingApprovalState(states: PendingApprovalState[], approvalId: string): PendingApprovalState | undefined {
1303
+ return states.find((state: PendingApprovalState): boolean => state.approval.approvalId === approvalId);
1304
+ }
1305
+
1306
+ async function restorePendingContinuationForApproval(
1307
+ session: ClientSession,
1308
+ state: PendingApprovalState | undefined,
1309
+ apiKey: string | undefined
1310
+ ): Promise<PendingAiContinuation | undefined> {
1311
+ const approvalId: string | undefined = state?.approval.approvalId;
1312
+ if (approvalId !== undefined) {
1313
+ const existingContinuation: PendingAiContinuation | undefined = session.pendingAiContinuations.get(approvalId);
1314
+ if (existingContinuation !== undefined) {
1315
+ return existingContinuation;
1316
+ }
1317
+ }
1318
+
1319
+ if (state?.continuation === undefined || apiKey === undefined) {
1320
+ return undefined;
1321
+ }
1322
+
1323
+ const restoredContinuation: PendingAiContinuation = createRuntimePendingContinuation(state.continuation, apiKey);
1324
+ session.pendingAiContinuations.set(state.approval.approvalId, restoredContinuation);
1325
+ return restoredContinuation;
1326
+ }
1327
+
1328
+ async function validatePendingApprovalBeforeExecution(
1329
+ session: ClientSession,
1330
+ mcpHost: McpHost,
1331
+ pendingApproval: PendingApproval
1332
+ ): Promise<string | null> {
1333
+ const decision = await session.approvalGateway.evaluate(pendingApproval.llmToolName, pendingApproval.args, pendingApproval.toolCallId);
1334
+ if (decision.action === "deny") {
1335
+ return decision.reason;
1336
+ }
1337
+
1338
+ try {
1339
+ resolveToolMapping(pendingApproval.llmToolName);
1340
+ } catch (error: unknown) {
1341
+ return error instanceof Error ? error.message : "审批工具当前不可用";
1342
+ }
1343
+
1344
+ const currentIdentity = getLlmToolExecutionIdentity(
1345
+ pendingApproval.llmToolName,
1346
+ pendingApproval.args,
1347
+ mcpHost.getActiveWorkspaceId()
1348
+ );
1349
+ if (
1350
+ pendingApproval.executionFingerprint !== undefined
1351
+ && currentIdentity !== undefined
1352
+ && currentIdentity.fingerprint !== pendingApproval.executionFingerprint
1353
+ ) {
1354
+ return "当前 workspace 与创建审批时不一致,不能执行该审批。";
1355
+ }
1356
+
1357
+ return null;
1358
+ }
1359
+
1120
1360
  function sendAiPaused(socket: WebSocket, requestId: string, agentResult: Extract<DeepSeekAgentResult, { status: "approval_required" }>): void {
1121
1361
  sendJson(socket, {
1122
1362
  type: "event",
@@ -1152,7 +1392,7 @@ async function sendContinuedAgentResult(
1152
1392
  pendingContinuation.stream,
1153
1393
  pendingContinuation.workflowState
1154
1394
  );
1155
- session.pendingAiContinuations.set(agentResult.approvalId, nextPendingContinuation);
1395
+ await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, nextPendingContinuation);
1156
1396
  sendAiPaused(socket, requestId, agentResult);
1157
1397
  return;
1158
1398
  }
@@ -1223,11 +1463,20 @@ async function runWorkflowPhase(
1223
1463
  persistRequestId: string,
1224
1464
  streamPhase: boolean,
1225
1465
  abortSignal?: AbortSignal | undefined
1226
- ): Promise<DeepSeekAgentResult> {
1227
- const onToolEvent: OnToolEvent = createToolEventForwarder(socket, requestId, session, persistRequestId);
1228
- return streamPhase
1466
+ ): Promise<WorkflowPhaseRunResult> {
1467
+ const toolStats: WorkflowPhaseToolStats = createEmptyWorkflowPhaseToolStats();
1468
+ const forwardToolEvent: OnToolEvent = createToolEventForwarder(socket, requestId, session, persistRequestId);
1469
+ const onToolEvent: OnToolEvent = (event: ToolEvent): void => {
1470
+ updateWorkflowPhaseToolStats(toolStats, event);
1471
+ forwardToolEvent(event);
1472
+ };
1473
+ const agentResult: DeepSeekAgentResult = streamPhase
1229
1474
  ? await runDeepSeekAgentStreaming(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, phase.allowedTools, onToolEvent, abortSignal)
1230
1475
  : await runDeepSeekAgent(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, phase.allowedTools, onToolEvent, abortSignal);
1476
+ return {
1477
+ agentResult,
1478
+ toolStats
1479
+ };
1231
1480
  }
1232
1481
 
1233
1482
  async function createWorkflowPhasePrompt(
@@ -1336,21 +1585,59 @@ async function continueWorkflowExecution(
1336
1585
  ].filter((section: string): boolean => section.length > 0).join("\n\n");
1337
1586
  const fullSystemPrompt: string = await createWorkflowPhasePrompt(phase, phaseParams, mcpHost, session, requestId, guidePromptSection);
1338
1587
  let agentResult: DeepSeekAgentResult;
1588
+ let phaseToolStats: WorkflowPhaseToolStats = createEmptyWorkflowPhaseToolStats();
1339
1589
  try {
1340
- agentResult = agentResultOverride ?? await runWorkflowPhase(
1341
- socket,
1342
- phaseParams,
1343
- options,
1344
- state.history,
1345
- fullSystemPrompt,
1346
- phase,
1347
- mcpHost,
1348
- session,
1349
- requestId,
1350
- persistRequestId,
1351
- streamPhase,
1352
- abortSignal
1353
- );
1590
+ if (agentResultOverride !== undefined) {
1591
+ agentResult = agentResultOverride;
1592
+ phaseToolStats.approvalEvents = 1;
1593
+ phaseToolStats.writeToolEvents = 1;
1594
+ } else {
1595
+ let phaseRunResult: WorkflowPhaseRunResult = await runWorkflowPhase(
1596
+ socket,
1597
+ phaseParams,
1598
+ options,
1599
+ state.history,
1600
+ fullSystemPrompt,
1601
+ phase,
1602
+ mcpHost,
1603
+ session,
1604
+ requestId,
1605
+ persistRequestId,
1606
+ streamPhase,
1607
+ abortSignal
1608
+ );
1609
+ agentResult = phaseRunResult.agentResult;
1610
+ phaseToolStats = phaseRunResult.toolStats;
1611
+
1612
+ if (
1613
+ agentResult.status === "completed"
1614
+ && shouldRequireWorkflowWriteTool(phase)
1615
+ && !didWorkflowWritePhaseExecute(phase, phaseToolStats)
1616
+ ) {
1617
+ const retryPhaseParams: AiChatParams = createPhaseParams(
1618
+ state.originalParams,
1619
+ phase,
1620
+ createWorkflowWriteGuardRetryMessage(phaseMessage),
1621
+ false
1622
+ );
1623
+ phaseRunResult = await runWorkflowPhase(
1624
+ socket,
1625
+ retryPhaseParams,
1626
+ options,
1627
+ state.history,
1628
+ fullSystemPrompt,
1629
+ phase,
1630
+ mcpHost,
1631
+ session,
1632
+ requestId,
1633
+ persistRequestId,
1634
+ false,
1635
+ abortSignal
1636
+ );
1637
+ agentResult = phaseRunResult.agentResult;
1638
+ phaseToolStats = phaseRunResult.toolStats;
1639
+ }
1640
+ }
1354
1641
  } catch (error: unknown) {
1355
1642
  throw new WorkflowExecutionError(error instanceof Error ? error.message : "Workflow phase failed", plan, error);
1356
1643
  }
@@ -1359,7 +1646,7 @@ async function continueWorkflowExecution(
1359
1646
  if (agentResult.status === "approval_required") {
1360
1647
  plan = updateWorkflowPhaseStatus(plan, phase.id, "paused");
1361
1648
  const pausedState: WorkflowRunState = { ...state, plan, phaseIndex: index, phaseOutputs };
1362
- session.pendingAiContinuations.set(agentResult.approvalId, createWorkflowPendingContinuation(
1649
+ const pendingContinuation: PendingAiContinuation = createWorkflowPendingContinuation(
1363
1650
  phaseParams,
1364
1651
  options,
1365
1652
  agentResult,
@@ -1368,12 +1655,22 @@ async function continueWorkflowExecution(
1368
1655
  persistRequestId,
1369
1656
  userCreatedAt,
1370
1657
  streamPhase
1371
- ));
1658
+ );
1659
+ await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
1372
1660
  sendWorkflowTodoSnapshot(socket, requestId, session, plan, persistRequestId);
1373
1661
  sendAiPaused(socket, requestId, agentResult);
1374
1662
  return;
1375
1663
  }
1376
1664
 
1665
+ if (shouldRequireWorkflowWriteTool(phase) && !didWorkflowWritePhaseExecute(phase, phaseToolStats)) {
1666
+ const guardMessage: string = `写入阶段「${phase.title}」没有实际调用写入工具或触发审批,已阻止将该 Todo 标记为完成。`;
1667
+ throw new WorkflowExecutionError(
1668
+ guardMessage,
1669
+ plan,
1670
+ new Error(guardMessage)
1671
+ );
1672
+ }
1673
+
1377
1674
  phaseOutputs = appendPhaseOutput(phaseOutputs, phase, agentResult.text);
1378
1675
  plan = updateWorkflowPhaseStatus(plan, phase.id, "done");
1379
1676
  state = { ...state, plan, phaseIndex: index + 1, phaseOutputs };
@@ -1996,12 +2293,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
1996
2293
  type: "response",
1997
2294
  id: request.id,
1998
2295
  ok: true,
1999
- result: {
2000
- name: "godot-daedalus-backend",
2001
- version: "1.0.1",
2002
- pid: process.pid,
2003
- mode: process.env.NODE_ENV === "development" || process.env.npm_lifecycle_event === "dev" ? "development" : "runtime"
2004
- }
2296
+ result: createBackendHealthResult()
2005
2297
  });
2006
2298
  break;
2007
2299
 
@@ -2273,7 +2565,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2273
2565
  const agentResult: DeepSeekAgentResult = await runDeepSeekAgentStreaming(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, allowedToolNames, onToolEvent, abortController.signal);
2274
2566
 
2275
2567
  if (agentResult.status === "approval_required") {
2276
- session.pendingAiContinuations.set(agentResult.approvalId, createPendingAiContinuation(
2568
+ const pendingContinuation: PendingAiContinuation = createPendingAiContinuation(
2277
2569
  params,
2278
2570
  options,
2279
2571
  agentResult.continuation,
@@ -2282,7 +2574,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2282
2574
  request.id,
2283
2575
  turnStartedAt,
2284
2576
  true
2285
- ));
2577
+ );
2578
+ await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
2286
2579
  sendAiPaused(socket, request.id, agentResult);
2287
2580
  break;
2288
2581
  }
@@ -2308,7 +2601,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2308
2601
  const agentResult: DeepSeekAgentResult = await runDeepSeekAgent(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, allowedToolNames, onToolEvent, abortController.signal);
2309
2602
 
2310
2603
  if (agentResult.status === "approval_required") {
2311
- session.pendingAiContinuations.set(agentResult.approvalId, createPendingAiContinuation(
2604
+ const pendingContinuation: PendingAiContinuation = createPendingAiContinuation(
2312
2605
  params,
2313
2606
  options,
2314
2607
  agentResult.continuation,
@@ -2317,7 +2610,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2317
2610
  request.id,
2318
2611
  turnStartedAt,
2319
2612
  false
2320
- ));
2613
+ );
2614
+ await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
2321
2615
  sendJson(socket, {
2322
2616
  type: "response",
2323
2617
  id: request.id,
@@ -2509,6 +2803,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2509
2803
 
2510
2804
  case "session.info":
2511
2805
  await waitForFullSessionLoad(session);
2806
+ await loadHydratedPendingApprovalStates(session);
2512
2807
  sendJson(socket, {
2513
2808
  type: "response",
2514
2809
  id: request.id,
@@ -3570,16 +3865,19 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3570
3865
  }
3571
3866
 
3572
3867
  case "approval.list":
3868
+ {
3869
+ const hydrated = await loadHydratedPendingApprovalStates(session);
3573
3870
  sendJson(socket, {
3574
3871
  type: "response",
3575
3872
  id: request.id,
3576
3873
  ok: true,
3577
3874
  result: {
3578
- pending: session.approvalGateway.listPending(),
3875
+ pending: hydrated.states.map(serializePendingApprovalState),
3579
3876
  mode: session.approvalGateway.getMode()
3580
3877
  }
3581
3878
  });
3582
3879
  break;
3880
+ }
3583
3881
 
3584
3882
  case "approval.mode.set":
3585
3883
  session.approvalGateway.setMode(request.params.mode);
@@ -3598,6 +3896,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3598
3896
  const abortController: AbortController = new AbortController();
3599
3897
  session.activeAbortControllers.set(request.id, abortController);
3600
3898
  try {
3899
+ const apiKey: string | undefined = await ensureProviderConfigured(session);
3900
+ const hydrated = await loadHydratedPendingApprovalStates(session, apiKey);
3601
3901
  const pending = session.approvalGateway.getPending(request.params.approvalId);
3602
3902
  if (!pending) {
3603
3903
  sendJson(socket, {
@@ -3609,8 +3909,54 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3609
3909
  break;
3610
3910
  }
3611
3911
 
3612
- const pendingContinuation: PendingAiContinuation | undefined = session.pendingAiContinuations.get(request.params.approvalId);
3912
+ const validationError: string | null = await validatePendingApprovalBeforeExecution(session, mcpHost, pending);
3913
+ if (validationError !== null) {
3914
+ if (session.sessionId !== undefined) {
3915
+ await appendApprovalEvent(session.sessionId, pending.approvalId, findPendingApprovalState(hydrated.states, pending.approvalId)?.requestId ?? request.id, "failed", {
3916
+ message: validationError
3917
+ });
3918
+ }
3919
+ sendJson(socket, {
3920
+ type: "response",
3921
+ id: request.id,
3922
+ ok: false,
3923
+ error: { code: "approval_validation_failed", message: validationError }
3924
+ });
3925
+ break;
3926
+ }
3927
+
3928
+ const pendingState: PendingApprovalState | undefined = findPendingApprovalState(hydrated.states, request.params.approvalId);
3929
+ const pendingContinuation: PendingAiContinuation | undefined = await restorePendingContinuationForApproval(session, pendingState, apiKey);
3930
+ if (pendingState?.continuation !== undefined && pendingContinuation === undefined) {
3931
+ const message: string = "当前没有可用的 DeepSeek API key,无法恢复审批后的 LLM continuation。请先配置 provider 后重试。";
3932
+ if (session.sessionId !== undefined) {
3933
+ await appendApprovalEvent(session.sessionId, pending.approvalId, pendingState.requestId, "failed", { message });
3934
+ }
3935
+ sendJson(socket, {
3936
+ type: "response",
3937
+ id: request.id,
3938
+ ok: false,
3939
+ error: { code: "provider_not_configured", message }
3940
+ });
3941
+ break;
3942
+ }
3943
+ const approvalPersistRequestId: string = pendingContinuation?.requestId ?? pendingState?.requestId ?? request.id;
3944
+ if (session.sessionId !== undefined) {
3945
+ await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "approved", {
3946
+ approvedAt: new Date().toISOString()
3947
+ });
3948
+ await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "executing", {
3949
+ startedAt: new Date().toISOString()
3950
+ });
3951
+ }
3613
3952
  const result = await session.approvalGateway.approve(request.params.approvalId, mcpHost);
3953
+ if (session.sessionId !== undefined) {
3954
+ await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "executed", {
3955
+ resultChars: result.content.length,
3956
+ cached: result.cached === true,
3957
+ executedAt: new Date().toISOString()
3958
+ });
3959
+ }
3614
3960
 
3615
3961
  sendJson(socket, {
3616
3962
  type: "response",
@@ -3707,6 +4053,11 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3707
4053
  sendAiCancelled(socket, request.id);
3708
4054
  break;
3709
4055
  }
4056
+ if (session.sessionId !== undefined) {
4057
+ await appendApprovalEvent(session.sessionId, request.params.approvalId, request.id, "failed", {
4058
+ message: error instanceof Error ? error.message : "Approval failed"
4059
+ });
4060
+ }
3710
4061
  sendJson(socket, {
3711
4062
  type: "response",
3712
4063
  id: request.id,
@@ -3724,7 +4075,15 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3724
4075
 
3725
4076
  case "approval.reject": {
3726
4077
  try {
4078
+ const hydrated = await loadHydratedPendingApprovalStates(session);
4079
+ const pendingState: PendingApprovalState | undefined = findPendingApprovalState(hydrated.states, request.params.approvalId);
3727
4080
  const rejected = session.approvalGateway.reject(request.params.approvalId);
4081
+ session.pendingAiContinuations.delete(request.params.approvalId);
4082
+ if (session.sessionId !== undefined) {
4083
+ await appendApprovalEvent(session.sessionId, request.params.approvalId, pendingState?.requestId ?? request.id, "rejected", {
4084
+ rejectedAt: new Date().toISOString()
4085
+ });
4086
+ }
3728
4087
  sendJson(socket, {
3729
4088
  type: "response",
3730
4089
  id: request.id,
@@ -0,0 +1,279 @@
1
+ import type { AiChatParams } from "../protocol/types.js";
2
+ import type { DeepSeekAgentContinuation } from "../providers/deepseek-agent.js";
3
+ import type { DeepSeekChatOptions } from "../providers/deepseek-client.js";
4
+ import type { PendingAiContinuation } from "../server/client-session.js";
5
+ import type { PendingApproval } from "../tools/approval-gateway.js";
6
+ import type { WorkflowRunState } from "../workflow/types.js";
7
+ import type { StoredApprovalEvent } from "./session-store.js";
8
+
9
+ export type PersistedDeepSeekChatOptions = {
10
+ model?: string | undefined;
11
+ baseUrl?: string | undefined;
12
+ };
13
+
14
+ export type PersistedPendingAiContinuation = {
15
+ params: AiChatParams;
16
+ options: PersistedDeepSeekChatOptions;
17
+ continuation: DeepSeekAgentContinuation;
18
+ allowedToolNames?: readonly string[] | undefined;
19
+ userMessage: string;
20
+ requestId: string;
21
+ userCreatedAt: string;
22
+ stream: boolean;
23
+ workflowState?: WorkflowRunState | undefined;
24
+ };
25
+
26
+ export type PersistedApprovalRequestedData = {
27
+ approval: PendingApproval;
28
+ continuation?: PersistedPendingAiContinuation | undefined;
29
+ workspaceId?: string | undefined;
30
+ createdAt: string;
31
+ };
32
+
33
+ export type PendingApprovalState = {
34
+ approval: PendingApproval;
35
+ status: "pending" | "interrupted";
36
+ restored: boolean;
37
+ interrupted: boolean;
38
+ requestId: string;
39
+ createdAt: string;
40
+ updatedAt: string;
41
+ continuation?: PersistedPendingAiContinuation | undefined;
42
+ workspaceId?: string | undefined;
43
+ lastError?: string | undefined;
44
+ };
45
+
46
+ export function createPersistedApprovalRequestedData(
47
+ approval: PendingApproval,
48
+ continuation: PendingAiContinuation | undefined,
49
+ workspaceId: string | undefined
50
+ ): PersistedApprovalRequestedData {
51
+ const data: PersistedApprovalRequestedData = {
52
+ approval,
53
+ createdAt: new Date(approval.createdAt).toISOString()
54
+ };
55
+
56
+ if (continuation !== undefined) {
57
+ data.continuation = createPersistedPendingContinuation(continuation);
58
+ }
59
+ if (workspaceId !== undefined) {
60
+ data.workspaceId = workspaceId;
61
+ }
62
+
63
+ return data;
64
+ }
65
+
66
+ export function createRuntimePendingContinuation(
67
+ persisted: PersistedPendingAiContinuation,
68
+ apiKey: string
69
+ ): PendingAiContinuation {
70
+ const options: DeepSeekChatOptions = { apiKey };
71
+ if (persisted.options.model !== undefined) {
72
+ options.model = persisted.options.model;
73
+ }
74
+ if (persisted.options.baseUrl !== undefined) {
75
+ options.baseUrl = persisted.options.baseUrl;
76
+ }
77
+
78
+ const continuation: PendingAiContinuation = {
79
+ params: persisted.params,
80
+ options,
81
+ continuation: persisted.continuation,
82
+ userMessage: persisted.userMessage,
83
+ requestId: persisted.requestId,
84
+ userCreatedAt: persisted.userCreatedAt,
85
+ stream: persisted.stream
86
+ };
87
+
88
+ if (persisted.allowedToolNames !== undefined) {
89
+ continuation.allowedToolNames = [...persisted.allowedToolNames];
90
+ }
91
+ if (persisted.workflowState !== undefined) {
92
+ continuation.workflowState = persisted.workflowState;
93
+ }
94
+
95
+ return continuation;
96
+ }
97
+
98
+ export function foldPendingApprovalStates(events: StoredApprovalEvent[]): PendingApprovalState[] {
99
+ const states: Map<string, PendingApprovalState> = new Map();
100
+ const sortedEvents: StoredApprovalEvent[] = [...events].sort((left: StoredApprovalEvent, right: StoredApprovalEvent): number => {
101
+ const timeComparison: number = left.createdAt.localeCompare(right.createdAt);
102
+ return timeComparison !== 0 ? timeComparison : left.id.localeCompare(right.id);
103
+ });
104
+
105
+ for (const event of sortedEvents) {
106
+ if (event.schemaVersion !== 1) {
107
+ continue;
108
+ }
109
+
110
+ if (event.event === "requested") {
111
+ const requestedData: PersistedApprovalRequestedData | null = parseRequestedData(event.data);
112
+ if (requestedData === null) {
113
+ continue;
114
+ }
115
+
116
+ states.set(event.approvalId, {
117
+ approval: requestedData.approval,
118
+ status: "pending",
119
+ restored: true,
120
+ interrupted: false,
121
+ requestId: event.requestId,
122
+ createdAt: event.createdAt,
123
+ updatedAt: event.createdAt,
124
+ continuation: requestedData.continuation,
125
+ workspaceId: requestedData.workspaceId
126
+ });
127
+ continue;
128
+ }
129
+
130
+ const existing: PendingApprovalState | undefined = states.get(event.approvalId);
131
+ if (existing === undefined) {
132
+ continue;
133
+ }
134
+
135
+ if (event.event === "rejected" || event.event === "executed" || event.event === "cancelled") {
136
+ states.delete(event.approvalId);
137
+ continue;
138
+ }
139
+
140
+ if (event.event === "executing") {
141
+ existing.status = "interrupted";
142
+ existing.interrupted = true;
143
+ existing.updatedAt = event.createdAt;
144
+ states.set(event.approvalId, existing);
145
+ continue;
146
+ }
147
+
148
+ if (event.event === "approved") {
149
+ existing.updatedAt = event.createdAt;
150
+ states.set(event.approvalId, existing);
151
+ continue;
152
+ }
153
+
154
+ if (event.event === "failed") {
155
+ existing.status = "pending";
156
+ existing.interrupted = false;
157
+ existing.updatedAt = event.createdAt;
158
+ existing.lastError = extractErrorMessage(event.data);
159
+ states.set(event.approvalId, existing);
160
+ }
161
+ }
162
+
163
+ return [...states.values()];
164
+ }
165
+
166
+ export function serializePendingApprovalState(state: PendingApprovalState): Record<string, unknown> {
167
+ const result: Record<string, unknown> = {
168
+ ...state.approval,
169
+ status: state.status,
170
+ restored: state.restored,
171
+ interrupted: state.interrupted,
172
+ requestId: state.requestId,
173
+ createdAt: state.createdAt,
174
+ updatedAt: state.updatedAt
175
+ };
176
+
177
+ if (state.lastError !== undefined) {
178
+ result.lastError = state.lastError;
179
+ }
180
+ if (state.workspaceId !== undefined) {
181
+ result.workspaceId = state.workspaceId;
182
+ }
183
+
184
+ return result;
185
+ }
186
+
187
+ function createPersistedPendingContinuation(continuation: PendingAiContinuation): PersistedPendingAiContinuation {
188
+ const options: PersistedDeepSeekChatOptions = {};
189
+ if (continuation.options.model !== undefined) {
190
+ options.model = continuation.options.model;
191
+ }
192
+ if (continuation.options.baseUrl !== undefined) {
193
+ options.baseUrl = continuation.options.baseUrl;
194
+ }
195
+
196
+ const persisted: PersistedPendingAiContinuation = {
197
+ params: continuation.params,
198
+ options,
199
+ continuation: continuation.continuation,
200
+ userMessage: continuation.userMessage,
201
+ requestId: continuation.requestId,
202
+ userCreatedAt: continuation.userCreatedAt,
203
+ stream: continuation.stream
204
+ };
205
+
206
+ if (continuation.allowedToolNames !== undefined) {
207
+ persisted.allowedToolNames = [...continuation.allowedToolNames];
208
+ }
209
+ if (continuation.workflowState !== undefined) {
210
+ persisted.workflowState = continuation.workflowState;
211
+ }
212
+
213
+ return persisted;
214
+ }
215
+
216
+ function parseRequestedData(value: unknown): PersistedApprovalRequestedData | null {
217
+ if (!isRecord(value)) {
218
+ return null;
219
+ }
220
+
221
+ const approvalValue: unknown = value.approval;
222
+ if (!isPendingApproval(approvalValue)) {
223
+ return null;
224
+ }
225
+
226
+ const data: PersistedApprovalRequestedData = {
227
+ approval: approvalValue,
228
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date(approvalValue.createdAt).toISOString()
229
+ };
230
+
231
+ if (isPersistedContinuation(value.continuation)) {
232
+ data.continuation = value.continuation;
233
+ }
234
+ if (typeof value.workspaceId === "string") {
235
+ data.workspaceId = value.workspaceId;
236
+ }
237
+
238
+ return data;
239
+ }
240
+
241
+ function isPersistedContinuation(value: unknown): value is PersistedPendingAiContinuation {
242
+ if (!isRecord(value)) {
243
+ return false;
244
+ }
245
+
246
+ return isRecord(value.params)
247
+ && isRecord(value.options)
248
+ && isRecord(value.continuation)
249
+ && typeof value.userMessage === "string"
250
+ && typeof value.requestId === "string"
251
+ && typeof value.userCreatedAt === "string"
252
+ && typeof value.stream === "boolean";
253
+ }
254
+
255
+ function isPendingApproval(value: unknown): value is PendingApproval {
256
+ if (!isRecord(value)) {
257
+ return false;
258
+ }
259
+
260
+ return typeof value.approvalId === "string"
261
+ && typeof value.toolCallId === "string"
262
+ && typeof value.toolName === "string"
263
+ && typeof value.llmToolName === "string"
264
+ && isRecord(value.args)
265
+ && typeof value.reason === "string"
266
+ && typeof value.createdAt === "number";
267
+ }
268
+
269
+ function extractErrorMessage(value: unknown): string | undefined {
270
+ if (!isRecord(value)) {
271
+ return undefined;
272
+ }
273
+
274
+ return typeof value.message === "string" ? value.message : undefined;
275
+ }
276
+
277
+ function isRecord(value: unknown): value is Record<string, unknown> {
278
+ return value !== null && typeof value === "object" && !Array.isArray(value);
279
+ }
@@ -36,6 +36,16 @@ export type StoredSessionEvent = {
36
36
  createdAt: string;
37
37
  };
38
38
 
39
+ export type StoredApprovalEvent = {
40
+ id: string;
41
+ schemaVersion: 1;
42
+ approvalId: string;
43
+ requestId: string;
44
+ event: string;
45
+ data: unknown;
46
+ createdAt: string;
47
+ };
48
+
39
49
  export type StoredSession = {
40
50
  metadata: SessionMetadata;
41
51
  messages: StoredMessage[];
@@ -68,7 +78,7 @@ function assertSafeSessionId(sessionId: string): string {
68
78
  return sessionId;
69
79
  }
70
80
 
71
- function getSessionDir(sessionId: string): string {
81
+ export function getSessionDir(sessionId: string): string {
72
82
  return join(SESSIONS_DIR, assertSafeSessionId(sessionId));
73
83
  }
74
84
 
@@ -119,6 +129,10 @@ function eventsPath(sessionId: string): string {
119
129
  return join(getSessionDir(sessionId), "events.jsonl");
120
130
  }
121
131
 
132
+ function approvalEventsPath(sessionId: string): string {
133
+ return join(getSessionDir(sessionId), "approval-events.jsonl");
134
+ }
135
+
122
136
  export async function createSession(title: string, workspaceId?: string, skillId?: string): Promise<SessionMetadata> {
123
137
  const timestamp: string = new Date().toISOString();
124
138
  const dateStr: string = timestamp.slice(0, 10).replace(/-/g, "");
@@ -137,6 +151,7 @@ export async function createSession(title: string, workspaceId?: string, skillId
137
151
  await writeFile(join(dir, "metadata.json"), JSON.stringify(metadata, null, 2), "utf8");
138
152
  await writeFile(join(dir, "messages.jsonl"), "", "utf8");
139
153
  await writeFile(join(dir, "events.jsonl"), "", "utf8");
154
+ await writeFile(join(dir, "approval-events.jsonl"), "", "utf8");
140
155
 
141
156
  return metadata;
142
157
  }
@@ -451,6 +466,18 @@ export async function rewindSessionFromRequest(sessionId: string, requestId: str
451
466
  keptEvents.map((event: StoredSessionEvent): string => JSON.stringify(event) + "\n").join(""),
452
467
  "utf8"
453
468
  );
469
+ try {
470
+ const rawApprovalEvents: string = await readFile(approvalEventsPath(sessionId), "utf8");
471
+ const keptApprovalEvents: StoredApprovalEvent[] = parseJsonLines<StoredApprovalEvent>(rawApprovalEvents)
472
+ .filter((event: StoredApprovalEvent): boolean => !removedRequestIds.has(event.requestId));
473
+ await writeFile(
474
+ approvalEventsPath(sessionId),
475
+ keptApprovalEvents.map((event: StoredApprovalEvent): string => JSON.stringify(event) + "\n").join(""),
476
+ "utf8"
477
+ );
478
+ } catch {
479
+ // Older sessions may not have approval persistence yet.
480
+ }
454
481
 
455
482
  return keptMessages;
456
483
  }
@@ -477,6 +504,33 @@ export async function appendSessionEvent(sessionId: string, requestId: string, e
477
504
  await writeFile(eventFile, line, { encoding: "utf8", flag: "a" });
478
505
  }
479
506
 
507
+ export async function appendApprovalEvent(sessionId: string, approvalId: string, requestId: string, event: string, data: unknown): Promise<void> {
508
+ await ensureSessionsDir();
509
+ const eventFile: string = approvalEventsPath(sessionId);
510
+ const timestamp: string = new Date().toISOString();
511
+ const record: StoredApprovalEvent = {
512
+ id: `approval-event-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
513
+ schemaVersion: 1,
514
+ approvalId,
515
+ requestId,
516
+ event,
517
+ data,
518
+ createdAt: timestamp
519
+ };
520
+ const line: string = JSON.stringify(record) + "\n";
521
+ await writeFile(eventFile, line, { encoding: "utf8", flag: "a" });
522
+ }
523
+
524
+ export async function readApprovalEvents(sessionId: string): Promise<StoredApprovalEvent[]> {
525
+ await ensureSessionsDir();
526
+ try {
527
+ const rawLines: string = await readFile(approvalEventsPath(sessionId), "utf8");
528
+ return parseJsonLines<StoredApprovalEvent>(rawLines);
529
+ } catch {
530
+ return [];
531
+ }
532
+ }
533
+
480
534
  export async function clearSessionEvents(sessionId: string): Promise<void> {
481
535
  await ensureSessionsDir();
482
536
  await writeFile(eventsPath(sessionId), "", "utf8");
@@ -56,6 +56,7 @@ const READ_TOOLS: string[] = [
56
56
  ];
57
57
 
58
58
  const FILE_CREATE_TOOLS: string[] = [
59
+ "mcp_godot_propose_create_text_file",
59
60
  "mcp_godot_create_text_file"
60
61
  ];
61
62
 
@@ -71,7 +72,9 @@ const TERMINAL_WRITE_TOOLS: string[] = [
71
72
  ];
72
73
 
73
74
  const FILE_EDIT_TOOLS: string[] = [
75
+ "mcp_godot_propose_overwrite_text_file",
74
76
  "mcp_godot_overwrite_text_file",
77
+ "mcp_godot_propose_replace_text_in_file",
75
78
  "mcp_godot_replace_text_in_file",
76
79
  "mcp_godot_propose_set_project_setting",
77
80
  "mcp_godot_set_project_setting",
@@ -80,10 +83,15 @@ const FILE_EDIT_TOOLS: string[] = [
80
83
  ];
81
84
 
82
85
  const SCENE_WRITE_TOOLS: string[] = [
86
+ "mcp_godot_propose_create_scene",
83
87
  "mcp_godot_create_scene",
88
+ "mcp_godot_propose_add_node_to_scene",
84
89
  "mcp_godot_add_node_to_scene",
90
+ "mcp_godot_propose_attach_script_to_node",
85
91
  "mcp_godot_attach_script_to_node",
92
+ "mcp_godot_propose_connect_signal_in_scene",
86
93
  "mcp_godot_connect_signal_in_scene",
94
+ "mcp_godot_propose_apply_scene_patch",
87
95
  "mcp_godot_apply_scene_patch",
88
96
  "mcp_godot_editor_apply_scene_patch"
89
97
  ];
@@ -20,7 +20,6 @@ export type ApprovalResult =
20
20
 
21
21
  export class ApprovalGateway {
22
22
  private pendingApprovals: Map<string, PendingApproval> = new Map();
23
- private approvalIdCounter: number = 0;
24
23
  private mode: ApprovalMode;
25
24
 
26
25
  constructor(mode: ApprovalMode = "manual") {
@@ -43,6 +42,26 @@ export class ApprovalGateway {
43
42
  return this.pendingApprovals.get(approvalId);
44
43
  }
45
44
 
45
+ replacePending(pendingApprovals: PendingApproval[]): void {
46
+ this.pendingApprovals.clear();
47
+ for (const pendingApproval of pendingApprovals) {
48
+ this.pendingApprovals.set(pendingApproval.approvalId, pendingApproval);
49
+ }
50
+ }
51
+
52
+ upsertPending(pendingApproval: PendingApproval): void {
53
+ this.pendingApprovals.set(pendingApproval.approvalId, pendingApproval);
54
+ }
55
+
56
+ removePending(approvalId: string): PendingApproval | undefined {
57
+ const pending: PendingApproval | undefined = this.pendingApprovals.get(approvalId);
58
+ if (pending !== undefined) {
59
+ this.pendingApprovals.delete(approvalId);
60
+ }
61
+
62
+ return pending;
63
+ }
64
+
46
65
  async evaluate(
47
66
  llmToolName: string,
48
67
  args: Record<string, unknown>,
@@ -67,8 +86,7 @@ export class ApprovalGateway {
67
86
  }
68
87
  }
69
88
 
70
- const approvalId: string = `approval-${this.approvalIdCounter}`;
71
- this.approvalIdCounter += 1;
89
+ const approvalId: string = `approval-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
72
90
 
73
91
  const pending: PendingApproval = {
74
92
  approvalId,
@@ -92,9 +110,8 @@ export class ApprovalGateway {
92
110
  throw new Error(`Approval not found: ${approvalId}`);
93
111
  }
94
112
 
95
- this.pendingApprovals.delete(approvalId);
96
-
97
113
  const result = await executeLlmToolWithIdempotency(mcpHost, pending.llmToolName, pending.args);
114
+ this.pendingApprovals.delete(approvalId);
98
115
  return { content: result.content, cached: result.reused };
99
116
  }
100
117
 
@@ -44,13 +44,21 @@ export const VERIFY_TOOLS: string[] = [
44
44
  ];
45
45
 
46
46
  export const WRITE_TOOLS: string[] = [
47
+ "mcp_godot_propose_create_text_file",
47
48
  "mcp_godot_create_text_file",
49
+ "mcp_godot_propose_overwrite_text_file",
48
50
  "mcp_godot_overwrite_text_file",
51
+ "mcp_godot_propose_replace_text_in_file",
49
52
  "mcp_godot_replace_text_in_file",
53
+ "mcp_godot_propose_create_scene",
50
54
  "mcp_godot_create_scene",
55
+ "mcp_godot_propose_add_node_to_scene",
51
56
  "mcp_godot_add_node_to_scene",
57
+ "mcp_godot_propose_attach_script_to_node",
52
58
  "mcp_godot_attach_script_to_node",
59
+ "mcp_godot_propose_connect_signal_in_scene",
53
60
  "mcp_godot_connect_signal_in_scene",
61
+ "mcp_godot_propose_apply_scene_patch",
54
62
  "mcp_godot_apply_scene_patch",
55
63
  "mcp_godot_editor_apply_scene_patch",
56
64
  "mcp_godot_propose_set_project_setting",
@@ -86,12 +86,14 @@ export function createPhaseParams(originalParams: AiChatParams, phase: WorkflowP
86
86
  }
87
87
 
88
88
  export function createPhasePrompt(phase: WorkflowPhase, skillPrompt: string, mcpSystemContext: string): string {
89
+ const toolGroupRules: string[] = createPhaseToolGroupRules(phase);
89
90
  return [
90
91
  "## 工作流阶段约束",
91
92
  `- 当前阶段:${phase.title}(${phase.id})`,
92
93
  `- 阶段目标:${phase.instruction}`,
93
94
  "- 只完成当前阶段,不要提前总结整个任务。",
94
95
  "- 如果需要写入或执行审批工具,按现有审批流程暂停。",
96
+ ...toolGroupRules,
95
97
  "- 当前阶段实际可用工具:",
96
98
  ...phase.allowedTools.map((toolName: string): string => ` - ${toolName}`),
97
99
  "",
@@ -100,6 +102,24 @@ export function createPhasePrompt(phase: WorkflowPhase, skillPrompt: string, mcp
100
102
  ].filter((part: string): boolean => part.length > 0).join("\n\n");
101
103
  }
102
104
 
105
+ function createPhaseToolGroupRules(phase: WorkflowPhase): string[] {
106
+ if (phase.toolGroup === "write") {
107
+ return [
108
+ "- 当前是写入/提案阶段:如果阶段目标是预览、提案或 diff,必须调用对应 propose_* 工具。",
109
+ "- 如果阶段目标是实际创建、修改、删除或应用补丁,必须调用实际写入工具;写入工具触发审批时按现有流程暂停。",
110
+ "- 不要只输出计划、意图或“稍后将执行”;后端会把未调用当前阶段所需工具的阶段视为未完成。"
111
+ ];
112
+ }
113
+
114
+ if (phase.toolGroup === "verify") {
115
+ return [
116
+ "- 当前是验证阶段:优先实际调用诊断或验证工具,不要只描述验证计划。"
117
+ ];
118
+ }
119
+
120
+ return [];
121
+ }
122
+
103
123
  export function appendPhaseOutput(outputs: WorkflowPhaseOutput[], phase: WorkflowPhase, text: string): WorkflowPhaseOutput[] {
104
124
  const clippedText: string = text.length > MAX_PHASE_OUTPUT_CHARS
105
125
  ? `${text.slice(0, MAX_PHASE_OUTPUT_CHARS)}\n\n[阶段输出已截断,原始长度 ${text.length} 字符]`