godot-daedalus_backend 1.0.0 → 1.0.2

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
@@ -37,6 +37,8 @@ npm install godot-daedalus_backend
37
37
 
38
38
  ## Run The WebSocket Backend
39
39
 
40
+ After installing the package, start it through the published bin command:
41
+
40
42
  ```powershell
41
43
  $env:PORT = "8080"
42
44
  godot-daedalus-backend
@@ -44,6 +46,31 @@ godot-daedalus-backend
44
46
 
45
47
  The Godot plugin connects to this backend over WebSocket. Provider configuration, including the DeepSeek API key, is normally saved from the plugin settings UI.
46
48
 
49
+ If the package is installed locally in another project, use npm exec or the local `.bin` command:
50
+
51
+ ```powershell
52
+ npm exec godot-daedalus-backend
53
+ .\node_modules\.bin\godot-daedalus-backend.cmd
54
+ ```
55
+
56
+ Do not use `npm run dev` from the consuming Godot project unless that project's own `package.json` defines a `dev` script. `npm run dev` is only a source-repository development command.
57
+
58
+ To add a convenient script in the consuming project:
59
+
60
+ ```json
61
+ {
62
+ "scripts": {
63
+ "daedalus": "godot-daedalus-backend"
64
+ }
65
+ }
66
+ ```
67
+
68
+ Then run:
69
+
70
+ ```powershell
71
+ npm run daedalus
72
+ ```
73
+
47
74
  ## Run The Godot MCP Server
48
75
 
49
76
  The standalone Godot MCP server requires a Godot project path:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "godot-daedalus_backend",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "TypeScript backend for the Godot Daedalus editor assistant plugin.",
5
5
  "main": "./src/main.ts",
6
6
  "bin": {
@@ -79,6 +79,12 @@ export const clientRequestSchema = z.discriminatedUnion("method", [
79
79
  method: z.literal("ping"),
80
80
  params: z.object({}).optional(),
81
81
  }),
82
+ z.object({
83
+ type: z.literal("request"),
84
+ id: z.string(),
85
+ method: z.literal("backend.health"),
86
+ params: z.object({}).optional(),
87
+ }),
82
88
  z.object({
83
89
  type: z.literal("request"),
84
90
  id: z.string(),
@@ -12,6 +12,7 @@ export type RequestHandler = (
12
12
 
13
13
  export const REQUEST_HANDLER_METHODS: readonly ClientRequest["method"][] = [
14
14
  "ping",
15
+ "backend.health",
15
16
  "provider.configure",
16
17
  "provider.config.get",
17
18
  "provider.config.set",
@@ -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,17 @@ 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";
83
94
 
84
95
  const tokenCounterPromise: Promise<TokenCounter> = createTokenCounter();
85
96
  let sessionCompressorPromptCache: string | undefined;
@@ -98,6 +109,18 @@ const MAX_NEXT_STEP_HINT_COUNT: number = 5;
98
109
  const MAX_NEXT_STEP_HINT_MESSAGE_CHARS: number = 320;
99
110
  const MAX_GUIDE_TEXT_CHARS: number = 4000;
100
111
 
112
+ type WorkflowPhaseToolStats = {
113
+ toolEvents: number;
114
+ proposeToolEvents: number;
115
+ writeToolEvents: number;
116
+ approvalEvents: number;
117
+ };
118
+
119
+ type WorkflowPhaseRunResult = {
120
+ agentResult: DeepSeekAgentResult;
121
+ toolStats: WorkflowPhaseToolStats;
122
+ };
123
+
101
124
  function fingerprintText(text: string): string {
102
125
  if (text.length === 0) {
103
126
  return "empty";
@@ -1085,6 +1108,73 @@ function createToolEventForwarder(socket: WebSocket, requestId: string, session:
1085
1108
  };
1086
1109
  }
1087
1110
 
1111
+ function createEmptyWorkflowPhaseToolStats(): WorkflowPhaseToolStats {
1112
+ return {
1113
+ toolEvents: 0,
1114
+ proposeToolEvents: 0,
1115
+ writeToolEvents: 0,
1116
+ approvalEvents: 0
1117
+ };
1118
+ }
1119
+
1120
+ function updateWorkflowPhaseToolStats(stats: WorkflowPhaseToolStats, event: ToolEvent): void {
1121
+ if (!event.type.startsWith("tool.")) {
1122
+ return;
1123
+ }
1124
+
1125
+ stats.toolEvents += 1;
1126
+
1127
+ if (event.type === "tool.approval_required") {
1128
+ stats.approvalEvents += 1;
1129
+ }
1130
+
1131
+ const toolName: string | undefined = "toolName" in event ? event.toolName : undefined;
1132
+ if (toolName === undefined) {
1133
+ return;
1134
+ }
1135
+
1136
+ const policy = getToolPolicy(toolName);
1137
+ if (policy?.risk === "propose") {
1138
+ stats.proposeToolEvents += 1;
1139
+ }
1140
+ if (policy?.risk === "write" || policy?.risk === "destructive") {
1141
+ stats.writeToolEvents += 1;
1142
+ }
1143
+ }
1144
+
1145
+ function shouldRequireWorkflowWriteTool(phase: WorkflowPhase): boolean {
1146
+ return phase.toolGroup === "write";
1147
+ }
1148
+
1149
+ function didWorkflowWritePhaseExecute(phase: WorkflowPhase, stats: WorkflowPhaseToolStats): boolean {
1150
+ if (stats.writeToolEvents > 0 || stats.approvalEvents > 0) {
1151
+ return true;
1152
+ }
1153
+
1154
+ return isWorkflowProposalPhase(phase) && stats.proposeToolEvents > 0;
1155
+ }
1156
+
1157
+ function isWorkflowProposalPhase(phase: WorkflowPhase): boolean {
1158
+ const text: string = `${phase.id}\n${phase.title}\n${phase.instruction}`.toLowerCase();
1159
+ return text.includes("propose")
1160
+ || text.includes("preview")
1161
+ || text.includes("diff")
1162
+ || text.includes("预览")
1163
+ || text.includes("提案")
1164
+ || text.includes("方案");
1165
+ }
1166
+
1167
+ function createWorkflowWriteGuardRetryMessage(phaseMessage: string): string {
1168
+ return [
1169
+ phaseMessage,
1170
+ "",
1171
+ "## 后端执行守卫",
1172
+ "上一次候选回复没有实际调用当前阶段需要的 propose/write 工具,也没有触发审批,因此当前阶段还没有完成。",
1173
+ "如果当前阶段是预览/提案,请调用允许的 propose_* 工具;如果当前阶段是实际修改,请调用写入工具并按审批流程暂停。",
1174
+ "不要只描述计划、步骤或意图。"
1175
+ ].join("\n");
1176
+ }
1177
+
1088
1178
  function createPendingAiContinuation(
1089
1179
  params: AiChatParams,
1090
1180
  options: DeepSeekChatOptions,
@@ -1117,6 +1207,155 @@ function createPendingAiContinuation(
1117
1207
  return pendingContinuation;
1118
1208
  }
1119
1209
 
1210
+ async function persistApprovalRequested(
1211
+ session: ClientSession,
1212
+ mcpHost: McpHost,
1213
+ approvalId: string,
1214
+ pendingContinuation: PendingAiContinuation
1215
+ ): Promise<void> {
1216
+ if (session.sessionId === undefined) {
1217
+ return;
1218
+ }
1219
+
1220
+ const pendingApproval: PendingApproval | undefined = session.approvalGateway.getPending(approvalId);
1221
+ if (pendingApproval === undefined) {
1222
+ return;
1223
+ }
1224
+
1225
+ await appendApprovalEvent(
1226
+ session.sessionId,
1227
+ approvalId,
1228
+ pendingContinuation.requestId,
1229
+ "requested",
1230
+ createPersistedApprovalRequestedData(pendingApproval, pendingContinuation, mcpHost.getActiveWorkspaceId())
1231
+ );
1232
+ }
1233
+
1234
+ async function registerPendingApprovalContinuation(
1235
+ session: ClientSession,
1236
+ mcpHost: McpHost,
1237
+ approvalId: string,
1238
+ pendingContinuation: PendingAiContinuation
1239
+ ): Promise<void> {
1240
+ session.pendingAiContinuations.set(approvalId, pendingContinuation);
1241
+ await persistApprovalRequested(session, mcpHost, approvalId, pendingContinuation);
1242
+ }
1243
+
1244
+ async function loadHydratedPendingApprovalStates(
1245
+ session: ClientSession,
1246
+ apiKey?: string | undefined
1247
+ ): Promise<{ states: PendingApprovalState[]; hadEvents: boolean }> {
1248
+ if (session.sessionId === undefined) {
1249
+ return {
1250
+ states: createMemoryPendingApprovalStates(session),
1251
+ hadEvents: false
1252
+ };
1253
+ }
1254
+
1255
+ const approvalEvents = await readApprovalEvents(session.sessionId);
1256
+ if (approvalEvents.length === 0) {
1257
+ return {
1258
+ states: createMemoryPendingApprovalStates(session),
1259
+ hadEvents: false
1260
+ };
1261
+ }
1262
+
1263
+ const states: PendingApprovalState[] = foldPendingApprovalStates(approvalEvents);
1264
+ session.approvalGateway.replacePending(states.map((state: PendingApprovalState): PendingApproval => state.approval));
1265
+ const pendingIds: Set<string> = new Set(states.map((state: PendingApprovalState): string => state.approval.approvalId));
1266
+ for (const approvalId of session.pendingAiContinuations.keys()) {
1267
+ if (!pendingIds.has(approvalId)) {
1268
+ session.pendingAiContinuations.delete(approvalId);
1269
+ }
1270
+ }
1271
+
1272
+ if (apiKey !== undefined) {
1273
+ for (const state of states) {
1274
+ if (state.continuation !== undefined) {
1275
+ session.pendingAiContinuations.set(state.approval.approvalId, createRuntimePendingContinuation(state.continuation, apiKey));
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ return {
1281
+ states,
1282
+ hadEvents: true
1283
+ };
1284
+ }
1285
+
1286
+ function createMemoryPendingApprovalStates(session: ClientSession): PendingApprovalState[] {
1287
+ return session.approvalGateway.listPending().map((pendingApproval: PendingApproval): PendingApprovalState => {
1288
+ const timestamp: string = new Date(pendingApproval.createdAt).toISOString();
1289
+ return {
1290
+ approval: pendingApproval,
1291
+ status: "pending",
1292
+ restored: false,
1293
+ interrupted: false,
1294
+ requestId: "",
1295
+ createdAt: timestamp,
1296
+ updatedAt: timestamp
1297
+ };
1298
+ });
1299
+ }
1300
+
1301
+ function findPendingApprovalState(states: PendingApprovalState[], approvalId: string): PendingApprovalState | undefined {
1302
+ return states.find((state: PendingApprovalState): boolean => state.approval.approvalId === approvalId);
1303
+ }
1304
+
1305
+ async function restorePendingContinuationForApproval(
1306
+ session: ClientSession,
1307
+ state: PendingApprovalState | undefined,
1308
+ apiKey: string | undefined
1309
+ ): Promise<PendingAiContinuation | undefined> {
1310
+ const approvalId: string | undefined = state?.approval.approvalId;
1311
+ if (approvalId !== undefined) {
1312
+ const existingContinuation: PendingAiContinuation | undefined = session.pendingAiContinuations.get(approvalId);
1313
+ if (existingContinuation !== undefined) {
1314
+ return existingContinuation;
1315
+ }
1316
+ }
1317
+
1318
+ if (state?.continuation === undefined || apiKey === undefined) {
1319
+ return undefined;
1320
+ }
1321
+
1322
+ const restoredContinuation: PendingAiContinuation = createRuntimePendingContinuation(state.continuation, apiKey);
1323
+ session.pendingAiContinuations.set(state.approval.approvalId, restoredContinuation);
1324
+ return restoredContinuation;
1325
+ }
1326
+
1327
+ async function validatePendingApprovalBeforeExecution(
1328
+ session: ClientSession,
1329
+ mcpHost: McpHost,
1330
+ pendingApproval: PendingApproval
1331
+ ): Promise<string | null> {
1332
+ const decision = await session.approvalGateway.evaluate(pendingApproval.llmToolName, pendingApproval.args, pendingApproval.toolCallId);
1333
+ if (decision.action === "deny") {
1334
+ return decision.reason;
1335
+ }
1336
+
1337
+ try {
1338
+ resolveToolMapping(pendingApproval.llmToolName);
1339
+ } catch (error: unknown) {
1340
+ return error instanceof Error ? error.message : "审批工具当前不可用";
1341
+ }
1342
+
1343
+ const currentIdentity = getLlmToolExecutionIdentity(
1344
+ pendingApproval.llmToolName,
1345
+ pendingApproval.args,
1346
+ mcpHost.getActiveWorkspaceId()
1347
+ );
1348
+ if (
1349
+ pendingApproval.executionFingerprint !== undefined
1350
+ && currentIdentity !== undefined
1351
+ && currentIdentity.fingerprint !== pendingApproval.executionFingerprint
1352
+ ) {
1353
+ return "当前 workspace 与创建审批时不一致,不能执行该审批。";
1354
+ }
1355
+
1356
+ return null;
1357
+ }
1358
+
1120
1359
  function sendAiPaused(socket: WebSocket, requestId: string, agentResult: Extract<DeepSeekAgentResult, { status: "approval_required" }>): void {
1121
1360
  sendJson(socket, {
1122
1361
  type: "event",
@@ -1152,7 +1391,7 @@ async function sendContinuedAgentResult(
1152
1391
  pendingContinuation.stream,
1153
1392
  pendingContinuation.workflowState
1154
1393
  );
1155
- session.pendingAiContinuations.set(agentResult.approvalId, nextPendingContinuation);
1394
+ await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, nextPendingContinuation);
1156
1395
  sendAiPaused(socket, requestId, agentResult);
1157
1396
  return;
1158
1397
  }
@@ -1223,11 +1462,20 @@ async function runWorkflowPhase(
1223
1462
  persistRequestId: string,
1224
1463
  streamPhase: boolean,
1225
1464
  abortSignal?: AbortSignal | undefined
1226
- ): Promise<DeepSeekAgentResult> {
1227
- const onToolEvent: OnToolEvent = createToolEventForwarder(socket, requestId, session, persistRequestId);
1228
- return streamPhase
1465
+ ): Promise<WorkflowPhaseRunResult> {
1466
+ const toolStats: WorkflowPhaseToolStats = createEmptyWorkflowPhaseToolStats();
1467
+ const forwardToolEvent: OnToolEvent = createToolEventForwarder(socket, requestId, session, persistRequestId);
1468
+ const onToolEvent: OnToolEvent = (event: ToolEvent): void => {
1469
+ updateWorkflowPhaseToolStats(toolStats, event);
1470
+ forwardToolEvent(event);
1471
+ };
1472
+ const agentResult: DeepSeekAgentResult = streamPhase
1229
1473
  ? await runDeepSeekAgentStreaming(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, phase.allowedTools, onToolEvent, abortSignal)
1230
1474
  : await runDeepSeekAgent(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, phase.allowedTools, onToolEvent, abortSignal);
1475
+ return {
1476
+ agentResult,
1477
+ toolStats
1478
+ };
1231
1479
  }
1232
1480
 
1233
1481
  async function createWorkflowPhasePrompt(
@@ -1336,21 +1584,59 @@ async function continueWorkflowExecution(
1336
1584
  ].filter((section: string): boolean => section.length > 0).join("\n\n");
1337
1585
  const fullSystemPrompt: string = await createWorkflowPhasePrompt(phase, phaseParams, mcpHost, session, requestId, guidePromptSection);
1338
1586
  let agentResult: DeepSeekAgentResult;
1587
+ let phaseToolStats: WorkflowPhaseToolStats = createEmptyWorkflowPhaseToolStats();
1339
1588
  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
- );
1589
+ if (agentResultOverride !== undefined) {
1590
+ agentResult = agentResultOverride;
1591
+ phaseToolStats.approvalEvents = 1;
1592
+ phaseToolStats.writeToolEvents = 1;
1593
+ } else {
1594
+ let phaseRunResult: WorkflowPhaseRunResult = await runWorkflowPhase(
1595
+ socket,
1596
+ phaseParams,
1597
+ options,
1598
+ state.history,
1599
+ fullSystemPrompt,
1600
+ phase,
1601
+ mcpHost,
1602
+ session,
1603
+ requestId,
1604
+ persistRequestId,
1605
+ streamPhase,
1606
+ abortSignal
1607
+ );
1608
+ agentResult = phaseRunResult.agentResult;
1609
+ phaseToolStats = phaseRunResult.toolStats;
1610
+
1611
+ if (
1612
+ agentResult.status === "completed"
1613
+ && shouldRequireWorkflowWriteTool(phase)
1614
+ && !didWorkflowWritePhaseExecute(phase, phaseToolStats)
1615
+ ) {
1616
+ const retryPhaseParams: AiChatParams = createPhaseParams(
1617
+ state.originalParams,
1618
+ phase,
1619
+ createWorkflowWriteGuardRetryMessage(phaseMessage),
1620
+ false
1621
+ );
1622
+ phaseRunResult = await runWorkflowPhase(
1623
+ socket,
1624
+ retryPhaseParams,
1625
+ options,
1626
+ state.history,
1627
+ fullSystemPrompt,
1628
+ phase,
1629
+ mcpHost,
1630
+ session,
1631
+ requestId,
1632
+ persistRequestId,
1633
+ false,
1634
+ abortSignal
1635
+ );
1636
+ agentResult = phaseRunResult.agentResult;
1637
+ phaseToolStats = phaseRunResult.toolStats;
1638
+ }
1639
+ }
1354
1640
  } catch (error: unknown) {
1355
1641
  throw new WorkflowExecutionError(error instanceof Error ? error.message : "Workflow phase failed", plan, error);
1356
1642
  }
@@ -1359,7 +1645,7 @@ async function continueWorkflowExecution(
1359
1645
  if (agentResult.status === "approval_required") {
1360
1646
  plan = updateWorkflowPhaseStatus(plan, phase.id, "paused");
1361
1647
  const pausedState: WorkflowRunState = { ...state, plan, phaseIndex: index, phaseOutputs };
1362
- session.pendingAiContinuations.set(agentResult.approvalId, createWorkflowPendingContinuation(
1648
+ const pendingContinuation: PendingAiContinuation = createWorkflowPendingContinuation(
1363
1649
  phaseParams,
1364
1650
  options,
1365
1651
  agentResult,
@@ -1368,12 +1654,22 @@ async function continueWorkflowExecution(
1368
1654
  persistRequestId,
1369
1655
  userCreatedAt,
1370
1656
  streamPhase
1371
- ));
1657
+ );
1658
+ await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
1372
1659
  sendWorkflowTodoSnapshot(socket, requestId, session, plan, persistRequestId);
1373
1660
  sendAiPaused(socket, requestId, agentResult);
1374
1661
  return;
1375
1662
  }
1376
1663
 
1664
+ if (shouldRequireWorkflowWriteTool(phase) && !didWorkflowWritePhaseExecute(phase, phaseToolStats)) {
1665
+ const guardMessage: string = `写入阶段「${phase.title}」没有实际调用写入工具或触发审批,已阻止将该 Todo 标记为完成。`;
1666
+ throw new WorkflowExecutionError(
1667
+ guardMessage,
1668
+ plan,
1669
+ new Error(guardMessage)
1670
+ );
1671
+ }
1672
+
1377
1673
  phaseOutputs = appendPhaseOutput(phaseOutputs, phase, agentResult.text);
1378
1674
  plan = updateWorkflowPhaseStatus(plan, phase.id, "done");
1379
1675
  state = { ...state, plan, phaseIndex: index + 1, phaseOutputs };
@@ -1991,6 +2287,20 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
1991
2287
  });
1992
2288
  break;
1993
2289
 
2290
+ case "backend.health":
2291
+ sendJson(socket, {
2292
+ type: "response",
2293
+ id: request.id,
2294
+ ok: true,
2295
+ result: {
2296
+ name: "godot-daedalus-backend",
2297
+ version: "1.0.1",
2298
+ pid: process.pid,
2299
+ mode: process.env.NODE_ENV === "development" || process.env.npm_lifecycle_event === "dev" ? "development" : "runtime"
2300
+ }
2301
+ });
2302
+ break;
2303
+
1994
2304
  case "provider.configure":
1995
2305
  session.deepseekApiKey = request.params.apiKey;
1996
2306
  session.deepseekModel = request.params.model;
@@ -2259,7 +2569,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2259
2569
  const agentResult: DeepSeekAgentResult = await runDeepSeekAgentStreaming(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, allowedToolNames, onToolEvent, abortController.signal);
2260
2570
 
2261
2571
  if (agentResult.status === "approval_required") {
2262
- session.pendingAiContinuations.set(agentResult.approvalId, createPendingAiContinuation(
2572
+ const pendingContinuation: PendingAiContinuation = createPendingAiContinuation(
2263
2573
  params,
2264
2574
  options,
2265
2575
  agentResult.continuation,
@@ -2268,7 +2578,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2268
2578
  request.id,
2269
2579
  turnStartedAt,
2270
2580
  true
2271
- ));
2581
+ );
2582
+ await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
2272
2583
  sendAiPaused(socket, request.id, agentResult);
2273
2584
  break;
2274
2585
  }
@@ -2294,7 +2605,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2294
2605
  const agentResult: DeepSeekAgentResult = await runDeepSeekAgent(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, allowedToolNames, onToolEvent, abortController.signal);
2295
2606
 
2296
2607
  if (agentResult.status === "approval_required") {
2297
- session.pendingAiContinuations.set(agentResult.approvalId, createPendingAiContinuation(
2608
+ const pendingContinuation: PendingAiContinuation = createPendingAiContinuation(
2298
2609
  params,
2299
2610
  options,
2300
2611
  agentResult.continuation,
@@ -2303,7 +2614,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2303
2614
  request.id,
2304
2615
  turnStartedAt,
2305
2616
  false
2306
- ));
2617
+ );
2618
+ await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
2307
2619
  sendJson(socket, {
2308
2620
  type: "response",
2309
2621
  id: request.id,
@@ -2495,6 +2807,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
2495
2807
 
2496
2808
  case "session.info":
2497
2809
  await waitForFullSessionLoad(session);
2810
+ await loadHydratedPendingApprovalStates(session);
2498
2811
  sendJson(socket, {
2499
2812
  type: "response",
2500
2813
  id: request.id,
@@ -3556,16 +3869,19 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3556
3869
  }
3557
3870
 
3558
3871
  case "approval.list":
3872
+ {
3873
+ const hydrated = await loadHydratedPendingApprovalStates(session);
3559
3874
  sendJson(socket, {
3560
3875
  type: "response",
3561
3876
  id: request.id,
3562
3877
  ok: true,
3563
3878
  result: {
3564
- pending: session.approvalGateway.listPending(),
3879
+ pending: hydrated.states.map(serializePendingApprovalState),
3565
3880
  mode: session.approvalGateway.getMode()
3566
3881
  }
3567
3882
  });
3568
3883
  break;
3884
+ }
3569
3885
 
3570
3886
  case "approval.mode.set":
3571
3887
  session.approvalGateway.setMode(request.params.mode);
@@ -3584,6 +3900,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3584
3900
  const abortController: AbortController = new AbortController();
3585
3901
  session.activeAbortControllers.set(request.id, abortController);
3586
3902
  try {
3903
+ const apiKey: string | undefined = await ensureProviderConfigured(session);
3904
+ const hydrated = await loadHydratedPendingApprovalStates(session, apiKey);
3587
3905
  const pending = session.approvalGateway.getPending(request.params.approvalId);
3588
3906
  if (!pending) {
3589
3907
  sendJson(socket, {
@@ -3595,8 +3913,54 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3595
3913
  break;
3596
3914
  }
3597
3915
 
3598
- const pendingContinuation: PendingAiContinuation | undefined = session.pendingAiContinuations.get(request.params.approvalId);
3916
+ const validationError: string | null = await validatePendingApprovalBeforeExecution(session, mcpHost, pending);
3917
+ if (validationError !== null) {
3918
+ if (session.sessionId !== undefined) {
3919
+ await appendApprovalEvent(session.sessionId, pending.approvalId, findPendingApprovalState(hydrated.states, pending.approvalId)?.requestId ?? request.id, "failed", {
3920
+ message: validationError
3921
+ });
3922
+ }
3923
+ sendJson(socket, {
3924
+ type: "response",
3925
+ id: request.id,
3926
+ ok: false,
3927
+ error: { code: "approval_validation_failed", message: validationError }
3928
+ });
3929
+ break;
3930
+ }
3931
+
3932
+ const pendingState: PendingApprovalState | undefined = findPendingApprovalState(hydrated.states, request.params.approvalId);
3933
+ const pendingContinuation: PendingAiContinuation | undefined = await restorePendingContinuationForApproval(session, pendingState, apiKey);
3934
+ if (pendingState?.continuation !== undefined && pendingContinuation === undefined) {
3935
+ const message: string = "当前没有可用的 DeepSeek API key,无法恢复审批后的 LLM continuation。请先配置 provider 后重试。";
3936
+ if (session.sessionId !== undefined) {
3937
+ await appendApprovalEvent(session.sessionId, pending.approvalId, pendingState.requestId, "failed", { message });
3938
+ }
3939
+ sendJson(socket, {
3940
+ type: "response",
3941
+ id: request.id,
3942
+ ok: false,
3943
+ error: { code: "provider_not_configured", message }
3944
+ });
3945
+ break;
3946
+ }
3947
+ const approvalPersistRequestId: string = pendingContinuation?.requestId ?? pendingState?.requestId ?? request.id;
3948
+ if (session.sessionId !== undefined) {
3949
+ await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "approved", {
3950
+ approvedAt: new Date().toISOString()
3951
+ });
3952
+ await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "executing", {
3953
+ startedAt: new Date().toISOString()
3954
+ });
3955
+ }
3599
3956
  const result = await session.approvalGateway.approve(request.params.approvalId, mcpHost);
3957
+ if (session.sessionId !== undefined) {
3958
+ await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "executed", {
3959
+ resultChars: result.content.length,
3960
+ cached: result.cached === true,
3961
+ executedAt: new Date().toISOString()
3962
+ });
3963
+ }
3600
3964
 
3601
3965
  sendJson(socket, {
3602
3966
  type: "response",
@@ -3693,6 +4057,11 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3693
4057
  sendAiCancelled(socket, request.id);
3694
4058
  break;
3695
4059
  }
4060
+ if (session.sessionId !== undefined) {
4061
+ await appendApprovalEvent(session.sessionId, request.params.approvalId, request.id, "failed", {
4062
+ message: error instanceof Error ? error.message : "Approval failed"
4063
+ });
4064
+ }
3696
4065
  sendJson(socket, {
3697
4066
  type: "response",
3698
4067
  id: request.id,
@@ -3710,7 +4079,15 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3710
4079
 
3711
4080
  case "approval.reject": {
3712
4081
  try {
4082
+ const hydrated = await loadHydratedPendingApprovalStates(session);
4083
+ const pendingState: PendingApprovalState | undefined = findPendingApprovalState(hydrated.states, request.params.approvalId);
3713
4084
  const rejected = session.approvalGateway.reject(request.params.approvalId);
4085
+ session.pendingAiContinuations.delete(request.params.approvalId);
4086
+ if (session.sessionId !== undefined) {
4087
+ await appendApprovalEvent(session.sessionId, request.params.approvalId, pendingState?.requestId ?? request.id, "rejected", {
4088
+ rejectedAt: new Date().toISOString()
4089
+ });
4090
+ }
3714
4091
  sendJson(socket, {
3715
4092
  type: "response",
3716
4093
  id: request.id,
@@ -3899,6 +4276,10 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
3899
4276
  export function createServer(port: number, mcpHost: McpHost): WebSocketServer {
3900
4277
  const server: WebSocketServer = new WebSocketServer({ port });
3901
4278
 
4279
+ server.on("headers", (headers: string[]): void => {
4280
+ headers.push("X-Godot-Daedalus: websocket");
4281
+ });
4282
+
3902
4283
  server.on("connection", (socket: WebSocket, request): void => {
3903
4284
  const session: ClientSession = createClientSession(getDefaultWorkspace());
3904
4285
  const remoteAddress: string = request.socket.remoteAddress ?? "unknown";
@@ -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} 字符]`