chainlesschain 0.45.67 → 0.45.74
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/package.json +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/Analytics-B4OM8S8X.css +1 -0
- package/src/assets/web-panel/assets/Analytics-sBrYoc3A.js +3 -0
- package/src/assets/web-panel/assets/AppLayout-BhJ3YFWt.js +1 -0
- package/src/assets/web-panel/assets/AppLayout-Cr2lWhF-.css +1 -0
- package/src/assets/web-panel/assets/Backup-D68fenbD.js +1 -0
- package/src/assets/web-panel/assets/Backup-fZqtfC1m.css +1 -0
- package/src/assets/web-panel/assets/{Chat-DXtvKoM0.js → Chat-DaxTP3x8.js} +1 -1
- package/src/assets/web-panel/assets/{Cron-BJ4ODHOy.js → Cron-CNs03iHJ.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-BZd4wDPQ.js → Dashboard-CjlX4CrX.js} +2 -2
- package/src/assets/web-panel/assets/Git-CCMVr3Y8.js +2 -0
- package/src/assets/web-panel/assets/Git-DGcuBXST.css +1 -0
- package/src/assets/web-panel/assets/{Logs-CSeKZEG_.js → Logs-BY6A0UNG.js} +2 -2
- package/src/assets/web-panel/assets/{McpTools-BYQAK11r.js → McpTools-CrBVYlg6.js} +2 -2
- package/src/assets/web-panel/assets/{Memory-gkUAPyuZ.js → Memory-CWx3SpUt.js} +2 -2
- package/src/assets/web-panel/assets/{Notes-bjNrQgAo.js → Notes-1LcGD49x.js} +2 -2
- package/src/assets/web-panel/assets/Organization-DdOOM4ic.css +1 -0
- package/src/assets/web-panel/assets/Organization-Dx2DhbkM.js +4 -0
- package/src/assets/web-panel/assets/P2P-B16fjqfJ.js +2 -0
- package/src/assets/web-panel/assets/P2P-OEzOeMZX.css +1 -0
- package/src/assets/web-panel/assets/Permissions-BQbC9FzG.js +4 -0
- package/src/assets/web-panel/assets/Permissions-C9WlkGl-.css +1 -0
- package/src/assets/web-panel/assets/Projects-CjhZbNYm.js +2 -0
- package/src/assets/web-panel/assets/Projects-DxKelI5h.css +1 -0
- package/src/assets/web-panel/assets/Providers-BEakqcO5.css +1 -0
- package/src/assets/web-panel/assets/Providers-ivOAQtHM.js +2 -0
- package/src/assets/web-panel/assets/RssFeed-BlFC20eg.css +1 -0
- package/src/assets/web-panel/assets/RssFeed-BrsErdrU.js +3 -0
- package/src/assets/web-panel/assets/Security-DnEvJU5h.js +4 -0
- package/src/assets/web-panel/assets/Security-Dwxw7rfP.css +1 -0
- package/src/assets/web-panel/assets/{Services-CS0oMdxh.js → Services-7jQywNbl.js} +2 -2
- package/src/assets/web-panel/assets/Skills-BCvgBkD3.js +1 -0
- package/src/assets/web-panel/assets/{Tasks-qULws8pc.js → Tasks-CmJBC1cf.js} +1 -1
- package/src/assets/web-panel/assets/Templates-DOY_oZnm.css +1 -0
- package/src/assets/web-panel/assets/Templates-RXT8-DNk.js +1 -0
- package/src/assets/web-panel/assets/Wallet-3iYASEx_.js +4 -0
- package/src/assets/web-panel/assets/Wallet-DnIumafl.css +1 -0
- package/src/assets/web-panel/assets/WebAuthn-CNPl2VQR.css +1 -0
- package/src/assets/web-panel/assets/WebAuthn-s3Hzd9db.js +5 -0
- package/src/assets/web-panel/assets/{antd-CJSBocer.js → antd-gZyc63Qr.js} +114 -114
- package/src/assets/web-panel/assets/chat-BmwHBi9M.js +1 -0
- package/src/assets/web-panel/assets/index-DrmEk9S3.js +2 -0
- package/src/assets/web-panel/assets/{markdown-Bo5cVN4u.js → markdown-Bv7nG63L.js} +1 -1
- package/src/assets/web-panel/assets/ws-CU7Gvoom.js +1 -0
- package/src/assets/web-panel/index.html +2 -2
- package/src/commands/doctor.js +33 -151
- package/src/commands/mcp.js +1 -1
- package/src/commands/plugin.js +1 -1
- package/src/commands/session.js +106 -7
- package/src/commands/status.js +39 -69
- package/src/gateways/ws/message-dispatcher.js +9 -0
- package/src/gateways/ws/session-protocol.js +368 -1
- package/src/gateways/ws/ws-agent-handler.js +484 -0
- package/src/gateways/ws/ws-server.js +758 -4
- package/src/gateways/ws/ws-session-gateway.js +1432 -1
- package/src/harness/mcp-client.js +417 -0
- package/src/harness/mock-llm-provider.js +167 -0
- package/src/harness/plugin-manager.js +434 -0
- package/src/lib/agent-core.js +25 -1902
- package/src/lib/hashline.js +208 -0
- package/src/lib/jsonl-session-store.js +11 -0
- package/src/lib/mcp-client.js +14 -412
- package/src/lib/plugin-manager.js +29 -428
- package/src/lib/prompt-compressor.js +11 -0
- package/src/lib/session-hooks.js +61 -0
- package/src/lib/skill-loader.js +4 -0
- package/src/lib/skill-mcp.js +190 -0
- package/src/lib/workflow-state-reader.js +94 -0
- package/src/lib/ws-agent-handler.js +8 -472
- package/src/lib/ws-server.js +12 -726
- package/src/lib/ws-session-manager.js +8 -1178
- package/src/repl/agent-repl.js +27 -3
- package/src/runtime/agent-core.js +1760 -0
- package/src/runtime/agent-runtime.js +3 -1
- package/src/runtime/coding-agent-contract-shared.cjs +496 -0
- package/src/runtime/coding-agent-contract.js +49 -229
- package/src/runtime/coding-agent-events.cjs +14 -0
- package/src/runtime/coding-agent-policy.cjs +54 -5
- package/src/runtime/diagnostics.js +317 -0
- package/src/runtime/index.js +3 -0
- package/src/tools/index.js +3 -0
- package/src/tools/legacy-agent-tools.js +5 -0
- package/src/assets/web-panel/assets/AppLayout-B_tkw3Pn.js +0 -1
- package/src/assets/web-panel/assets/AppLayout-CFP4dGIJ.css +0 -1
- package/src/assets/web-panel/assets/Providers-Brm-S_hS.css +0 -1
- package/src/assets/web-panel/assets/Providers-Dbf57Tbv.js +0 -1
- package/src/assets/web-panel/assets/Skills-B2fgruv8.js +0 -1
- package/src/assets/web-panel/assets/chat-DnH09sSR.js +0 -1
- package/src/assets/web-panel/assets/index-IK-oro0g.js +0 -2
- package/src/assets/web-panel/assets/ws-DjelKkD6.js +0 -1
|
@@ -1 +1,1432 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Session Manager
|
|
3
|
+
*
|
|
4
|
+
* Registry and lifecycle management for stateful agent/chat sessions
|
|
5
|
+
* accessed over WebSocket. Each session maintains its own message history,
|
|
6
|
+
* context engine, permanent memory, plan manager, and LLM configuration.
|
|
7
|
+
*
|
|
8
|
+
* Canonical location (moved from src/lib/ws-session-manager.js as part of
|
|
9
|
+
* the CLI Runtime Convergence roadmap, Phase 6a). src/lib/ws-session-manager.js
|
|
10
|
+
* is now a thin re-export shim for backwards compatibility.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from "crypto";
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import {
|
|
17
|
+
ExecutionPlan,
|
|
18
|
+
PlanModeManager,
|
|
19
|
+
PlanState,
|
|
20
|
+
} from "../../lib/plan-mode.js";
|
|
21
|
+
import { CLIContextEngineering } from "../../lib/cli-context-engineering.js";
|
|
22
|
+
import { CLIPermanentMemory } from "../../lib/permanent-memory.js";
|
|
23
|
+
import {
|
|
24
|
+
createTrustedMcpServerMap,
|
|
25
|
+
resolveMcpServerPolicy,
|
|
26
|
+
normalizeRiskLevel,
|
|
27
|
+
normalizeBoolean,
|
|
28
|
+
selectHigherRiskLevel,
|
|
29
|
+
} from "../../runtime/coding-agent-managed-tool-policy.cjs";
|
|
30
|
+
import {
|
|
31
|
+
createSession as dbCreateSession,
|
|
32
|
+
saveMessages as dbSaveMessages,
|
|
33
|
+
getSession as dbGetSession,
|
|
34
|
+
listSessions as dbListSessions,
|
|
35
|
+
updateSession as dbUpdateSession,
|
|
36
|
+
} from "../../lib/session-manager.js";
|
|
37
|
+
import { buildSystemPrompt } from "../../runtime/agent-core.js";
|
|
38
|
+
import { SubAgentRegistry } from "../../lib/sub-agent-registry.js";
|
|
39
|
+
import {
|
|
40
|
+
createWorktree,
|
|
41
|
+
removeWorktree,
|
|
42
|
+
} from "../../harness/worktree-isolator.js";
|
|
43
|
+
import { isGitRepo } from "../../lib/git-integration.js";
|
|
44
|
+
import {
|
|
45
|
+
CODING_AGENT_MVP_TOOL_NAMES,
|
|
46
|
+
listCodingAgentToolNames,
|
|
47
|
+
} from "../../runtime/coding-agent-contract.js";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {object} Session
|
|
51
|
+
* @property {string} id
|
|
52
|
+
* @property {"agent"|"chat"} type
|
|
53
|
+
* @property {"active"|"closed"} status
|
|
54
|
+
* @property {Array} messages
|
|
55
|
+
* @property {string} provider
|
|
56
|
+
* @property {string} model
|
|
57
|
+
* @property {string|null} apiKey
|
|
58
|
+
* @property {string|null} baseUrl
|
|
59
|
+
* @property {string} projectRoot
|
|
60
|
+
* @property {string} baseProjectRoot
|
|
61
|
+
* @property {string|null} rulesContent
|
|
62
|
+
* @property {string[]} enabledToolNames
|
|
63
|
+
* @property {object|null} hostManagedToolPolicy
|
|
64
|
+
* @property {Array<object>} externalToolDefinitions
|
|
65
|
+
* @property {object} externalToolDescriptors
|
|
66
|
+
* @property {object} externalToolExecutors
|
|
67
|
+
* @property {boolean} worktreeIsolation
|
|
68
|
+
* @property {object|null} worktree
|
|
69
|
+
* @property {PlanModeManager} planManager
|
|
70
|
+
* @property {CLIContextEngineering|null} contextEngine
|
|
71
|
+
* @property {CLIPermanentMemory|null} permanentMemory
|
|
72
|
+
* @property {import("./interaction-adapter.js").WebSocketInteractionAdapter|null} interaction
|
|
73
|
+
* @property {string} createdAt
|
|
74
|
+
* @property {string} lastActivity
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
export class WSSessionManager {
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} options
|
|
80
|
+
* @param {object} [options.db] - Database instance
|
|
81
|
+
* @param {object} [options.config] - Config object
|
|
82
|
+
* @param {string} [options.defaultProjectRoot] - Default project root
|
|
83
|
+
*/
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.db = options.db || null;
|
|
86
|
+
this.config = options.config || {};
|
|
87
|
+
this.defaultProjectRoot = options.defaultProjectRoot || process.cwd();
|
|
88
|
+
this.mcpClient = options.mcpClient || null;
|
|
89
|
+
this.allowedMcpServerNames = Array.isArray(options.allowedMcpServerNames)
|
|
90
|
+
? options.allowedMcpServerNames
|
|
91
|
+
: null;
|
|
92
|
+
this.allowHighRiskMcpServers = options.allowHighRiskMcpServers === true;
|
|
93
|
+
this.trustedMcpServers = createTrustedMcpServerMap(
|
|
94
|
+
options.mcpServerRegistry || null,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/** @type {Map<string, Session>} */
|
|
98
|
+
this.sessions = new Map();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_normalizeEnabledToolNames(enabledToolNames) {
|
|
102
|
+
const knownToolNames = new Set(listCodingAgentToolNames());
|
|
103
|
+
const requested = Array.isArray(enabledToolNames)
|
|
104
|
+
? enabledToolNames
|
|
105
|
+
.map((name) => String(name || "").trim())
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
: [];
|
|
108
|
+
|
|
109
|
+
const filtered = requested.filter((name) => knownToolNames.has(name));
|
|
110
|
+
if (filtered.length > 0) {
|
|
111
|
+
return [...new Set(filtered)];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [...CODING_AGENT_MVP_TOOL_NAMES];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_buildSessionExternalTools() {
|
|
118
|
+
if (
|
|
119
|
+
!this.mcpClient ||
|
|
120
|
+
!(this.mcpClient.servers instanceof Map) ||
|
|
121
|
+
typeof this.mcpClient.listTools !== "function"
|
|
122
|
+
) {
|
|
123
|
+
return {
|
|
124
|
+
definitions: [],
|
|
125
|
+
descriptors: {},
|
|
126
|
+
executors: {},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const definitions = [];
|
|
131
|
+
const descriptors = {};
|
|
132
|
+
const executors = {};
|
|
133
|
+
const seenNames = new Set();
|
|
134
|
+
|
|
135
|
+
for (const [serverName, serverState] of this.mcpClient.servers.entries()) {
|
|
136
|
+
const serverPolicy = resolveMcpServerPolicy(serverName, serverState, {
|
|
137
|
+
allowedMcpServerNames: this.allowedMcpServerNames,
|
|
138
|
+
trustedMcpServers: this.trustedMcpServers,
|
|
139
|
+
allowHighRiskMcpServers: this.allowHighRiskMcpServers,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!serverPolicy.allowed) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const serverTools = Array.isArray(serverState?.tools)
|
|
147
|
+
? serverState.tools
|
|
148
|
+
: this.mcpClient.listTools(serverName);
|
|
149
|
+
|
|
150
|
+
for (const mcpTool of Array.isArray(serverTools) ? serverTools : []) {
|
|
151
|
+
const parsedSchema = this._parseToolSchema(mcpTool?.inputSchema) ||
|
|
152
|
+
this._parseToolSchema(mcpTool?.input_schema) ||
|
|
153
|
+
this._parseToolSchema(mcpTool?.parameters_schema) || {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {},
|
|
156
|
+
};
|
|
157
|
+
const riskLevel = selectHigherRiskLevel(
|
|
158
|
+
serverPolicy.securityLevel,
|
|
159
|
+
normalizeRiskLevel(mcpTool?.risk_level, null),
|
|
160
|
+
);
|
|
161
|
+
const isReadOnly =
|
|
162
|
+
normalizeBoolean(mcpTool?.isReadOnly, false) ||
|
|
163
|
+
normalizeBoolean(mcpTool?.is_read_only, false) ||
|
|
164
|
+
riskLevel === "low";
|
|
165
|
+
|
|
166
|
+
let toolName = `mcp_${serverName}_${mcpTool?.name || "tool"}`;
|
|
167
|
+
if (seenNames.has(toolName)) {
|
|
168
|
+
let index = 2;
|
|
169
|
+
let candidate = `${toolName}_${index}`;
|
|
170
|
+
while (seenNames.has(candidate)) {
|
|
171
|
+
index += 1;
|
|
172
|
+
candidate = `${toolName}_${index}`;
|
|
173
|
+
}
|
|
174
|
+
toolName = candidate;
|
|
175
|
+
}
|
|
176
|
+
seenNames.add(toolName);
|
|
177
|
+
|
|
178
|
+
const descriptor = {
|
|
179
|
+
name: toolName,
|
|
180
|
+
description: mcpTool?.description || `MCP tool from ${serverName}.`,
|
|
181
|
+
inputSchema: parsedSchema,
|
|
182
|
+
isReadOnly,
|
|
183
|
+
riskLevel,
|
|
184
|
+
source: `mcp:${serverName}`,
|
|
185
|
+
mcpMetadata: {
|
|
186
|
+
serverName,
|
|
187
|
+
trusted: serverPolicy.trusted === true,
|
|
188
|
+
securityLevel: serverPolicy.securityLevel,
|
|
189
|
+
requiredPermissions: serverPolicy.requiredPermissions || [],
|
|
190
|
+
capabilities: serverPolicy.capabilities || [],
|
|
191
|
+
originalToolName: mcpTool?.name || null,
|
|
192
|
+
tool: mcpTool || null,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
definitions.push({
|
|
197
|
+
type: "function",
|
|
198
|
+
function: {
|
|
199
|
+
name: descriptor.name,
|
|
200
|
+
description: descriptor.description,
|
|
201
|
+
parameters: JSON.parse(JSON.stringify(descriptor.inputSchema)),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
descriptors[descriptor.name] = descriptor;
|
|
205
|
+
executors[descriptor.name] = {
|
|
206
|
+
kind: "mcp",
|
|
207
|
+
serverName,
|
|
208
|
+
toolName: mcpTool?.name || null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
definitions,
|
|
215
|
+
descriptors,
|
|
216
|
+
executors,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_parseToolSchema(value) {
|
|
221
|
+
if (!value) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (typeof value === "object") {
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (typeof value !== "string") {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
return JSON.parse(value);
|
|
235
|
+
} catch (_err) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generate a unique session ID
|
|
242
|
+
*/
|
|
243
|
+
_generateId() {
|
|
244
|
+
const hash = createHash("sha256")
|
|
245
|
+
.update(Math.random().toString() + Date.now().toString())
|
|
246
|
+
.digest("hex")
|
|
247
|
+
.slice(0, 8);
|
|
248
|
+
return `ws-session-${Date.now()}-${hash}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Create a new session.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} options
|
|
255
|
+
* @param {"agent"|"chat"} [options.type="agent"]
|
|
256
|
+
* @param {string} [options.projectRoot]
|
|
257
|
+
* @param {string} [options.provider="ollama"]
|
|
258
|
+
* @param {string} [options.model]
|
|
259
|
+
* @param {string} [options.apiKey]
|
|
260
|
+
* @param {string} [options.baseUrl]
|
|
261
|
+
* @param {object} [options.hostManagedToolPolicy]
|
|
262
|
+
* @returns {{ sessionId: string }}
|
|
263
|
+
*/
|
|
264
|
+
createSession(options = {}) {
|
|
265
|
+
const sessionId = this._generateId();
|
|
266
|
+
const type = options.type || "agent";
|
|
267
|
+
const baseProjectRoot = options.projectRoot || this.defaultProjectRoot;
|
|
268
|
+
const cfgLlm = this.config?.llm || {};
|
|
269
|
+
const provider = options.provider || cfgLlm.provider || "ollama";
|
|
270
|
+
const model =
|
|
271
|
+
options.model ||
|
|
272
|
+
cfgLlm.model ||
|
|
273
|
+
(provider === "ollama" ? "qwen2.5:7b" : null);
|
|
274
|
+
const baseUrl =
|
|
275
|
+
options.baseUrl || cfgLlm.baseUrl || "http://localhost:11434";
|
|
276
|
+
const apiKey = options.apiKey || cfgLlm.apiKey || null;
|
|
277
|
+
const worktreeIsolationRequested = options.worktreeIsolation === true;
|
|
278
|
+
const enabledToolNames = this._normalizeEnabledToolNames(
|
|
279
|
+
options.enabledToolNames,
|
|
280
|
+
);
|
|
281
|
+
const externalTools = this._buildSessionExternalTools();
|
|
282
|
+
const isolatedWorkspace = this._prepareSessionWorkspace(
|
|
283
|
+
baseProjectRoot,
|
|
284
|
+
sessionId,
|
|
285
|
+
{
|
|
286
|
+
worktreeIsolation: worktreeIsolationRequested,
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
const projectRoot = isolatedWorkspace.projectRoot;
|
|
290
|
+
const worktree = isolatedWorkspace.worktree;
|
|
291
|
+
|
|
292
|
+
// Project context (rules.md, persona) is now loaded by buildSystemPrompt()
|
|
293
|
+
|
|
294
|
+
// Create plan manager (non-singleton, per-session)
|
|
295
|
+
const planManager = new PlanModeManager();
|
|
296
|
+
|
|
297
|
+
// Create context engine
|
|
298
|
+
let contextEngine = null;
|
|
299
|
+
let permanentMemory = null;
|
|
300
|
+
try {
|
|
301
|
+
const memoryDir = path.join(projectRoot, "memory");
|
|
302
|
+
permanentMemory = new CLIPermanentMemory({
|
|
303
|
+
db: this.db,
|
|
304
|
+
memoryDir,
|
|
305
|
+
});
|
|
306
|
+
permanentMemory.initialize();
|
|
307
|
+
} catch (_err) {
|
|
308
|
+
// Non-critical
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
contextEngine = new CLIContextEngineering({
|
|
313
|
+
db: this.db,
|
|
314
|
+
permanentMemory,
|
|
315
|
+
});
|
|
316
|
+
} catch (_err) {
|
|
317
|
+
// Non-critical
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Build initial system prompt (includes persona + rules.md)
|
|
321
|
+
const systemPrompt = buildSystemPrompt(projectRoot);
|
|
322
|
+
|
|
323
|
+
const messages = [{ role: "system", content: systemPrompt }];
|
|
324
|
+
|
|
325
|
+
// Persist to DB
|
|
326
|
+
if (this.db) {
|
|
327
|
+
try {
|
|
328
|
+
dbCreateSession(this.db, {
|
|
329
|
+
id: sessionId,
|
|
330
|
+
title: `WS ${type} ${new Date().toISOString().slice(0, 10)}`,
|
|
331
|
+
provider,
|
|
332
|
+
model: model || "",
|
|
333
|
+
messages,
|
|
334
|
+
});
|
|
335
|
+
} catch (_err) {
|
|
336
|
+
// Non-critical
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const session = {
|
|
341
|
+
id: sessionId,
|
|
342
|
+
type,
|
|
343
|
+
status: "active",
|
|
344
|
+
messages,
|
|
345
|
+
provider,
|
|
346
|
+
model,
|
|
347
|
+
apiKey,
|
|
348
|
+
baseUrl,
|
|
349
|
+
mcpClient: this.mcpClient,
|
|
350
|
+
enabledToolNames,
|
|
351
|
+
hostManagedToolPolicy: options.hostManagedToolPolicy || null,
|
|
352
|
+
externalToolDefinitions: externalTools.definitions,
|
|
353
|
+
externalToolDescriptors: externalTools.descriptors,
|
|
354
|
+
externalToolExecutors: externalTools.executors,
|
|
355
|
+
projectRoot,
|
|
356
|
+
baseProjectRoot,
|
|
357
|
+
rulesContent: null,
|
|
358
|
+
worktreeIsolation: worktreeIsolationRequested,
|
|
359
|
+
worktree,
|
|
360
|
+
planManager,
|
|
361
|
+
contextEngine,
|
|
362
|
+
permanentMemory,
|
|
363
|
+
reviewState: null,
|
|
364
|
+
pendingPatches: new Map(),
|
|
365
|
+
patchHistory: [],
|
|
366
|
+
taskGraph: null,
|
|
367
|
+
interaction: null, // Set by ws-server after creation
|
|
368
|
+
createdAt: new Date().toISOString(),
|
|
369
|
+
lastActivity: new Date().toISOString(),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
if (this.db) {
|
|
373
|
+
try {
|
|
374
|
+
dbUpdateSession(this.db, sessionId, {
|
|
375
|
+
metadata: this._serializeSessionMetadata(session),
|
|
376
|
+
});
|
|
377
|
+
} catch (_err) {
|
|
378
|
+
// Non-critical
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
this._bindPlanManagerPersistence(session);
|
|
383
|
+
this.sessions.set(sessionId, session);
|
|
384
|
+
|
|
385
|
+
return { sessionId };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Resume an existing session from DB.
|
|
390
|
+
*
|
|
391
|
+
* @param {string} sessionId
|
|
392
|
+
* @returns {Session|null}
|
|
393
|
+
*/
|
|
394
|
+
resumeSession(sessionId) {
|
|
395
|
+
// Check in-memory first
|
|
396
|
+
if (this.sessions.has(sessionId)) {
|
|
397
|
+
const session = this.sessions.get(sessionId);
|
|
398
|
+
session.status = "active";
|
|
399
|
+
session.lastActivity = new Date().toISOString();
|
|
400
|
+
return session;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Try loading from DB
|
|
404
|
+
if (!this.db) return null;
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
const dbSession = dbGetSession(this.db, sessionId);
|
|
408
|
+
if (!dbSession) return null;
|
|
409
|
+
|
|
410
|
+
const messages =
|
|
411
|
+
typeof dbSession.messages === "string"
|
|
412
|
+
? JSON.parse(dbSession.messages)
|
|
413
|
+
: dbSession.messages || [];
|
|
414
|
+
const metadata = this._normalizeSessionMetadata(dbSession.metadata);
|
|
415
|
+
const baseProjectRoot =
|
|
416
|
+
metadata.baseProjectRoot ||
|
|
417
|
+
metadata.projectRoot ||
|
|
418
|
+
this.defaultProjectRoot;
|
|
419
|
+
const workspace = this._restoreSessionWorkspace(
|
|
420
|
+
dbSession.id,
|
|
421
|
+
baseProjectRoot,
|
|
422
|
+
metadata,
|
|
423
|
+
);
|
|
424
|
+
const planManager = this._hydratePlanManager(metadata.planSnapshot);
|
|
425
|
+
const externalTools = this._buildSessionExternalTools();
|
|
426
|
+
let contextEngine = null;
|
|
427
|
+
let permanentMemory = null;
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const memoryDir = path.join(workspace.projectRoot, "memory");
|
|
431
|
+
permanentMemory = new CLIPermanentMemory({
|
|
432
|
+
db: this.db,
|
|
433
|
+
memoryDir,
|
|
434
|
+
});
|
|
435
|
+
permanentMemory.initialize();
|
|
436
|
+
} catch (_err) {
|
|
437
|
+
// Non-critical
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
contextEngine = new CLIContextEngineering({
|
|
442
|
+
db: this.db,
|
|
443
|
+
permanentMemory,
|
|
444
|
+
});
|
|
445
|
+
} catch (_err) {
|
|
446
|
+
// Non-critical
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const session = {
|
|
450
|
+
id: dbSession.id,
|
|
451
|
+
type: metadata.sessionType || "agent",
|
|
452
|
+
status: "active",
|
|
453
|
+
messages,
|
|
454
|
+
provider: dbSession.provider || "ollama",
|
|
455
|
+
model: dbSession.model || null,
|
|
456
|
+
apiKey: null,
|
|
457
|
+
baseUrl: metadata.baseUrl || "http://localhost:11434",
|
|
458
|
+
mcpClient: this.mcpClient,
|
|
459
|
+
enabledToolNames: this._normalizeEnabledToolNames(
|
|
460
|
+
metadata.enabledToolNames,
|
|
461
|
+
),
|
|
462
|
+
hostManagedToolPolicy: metadata.hostManagedToolPolicy || null,
|
|
463
|
+
externalToolDefinitions: externalTools.definitions,
|
|
464
|
+
externalToolDescriptors: externalTools.descriptors,
|
|
465
|
+
externalToolExecutors: externalTools.executors,
|
|
466
|
+
projectRoot: workspace.projectRoot,
|
|
467
|
+
baseProjectRoot,
|
|
468
|
+
rulesContent: null,
|
|
469
|
+
worktreeIsolation: metadata.worktreeIsolation === true,
|
|
470
|
+
worktree: workspace.worktree,
|
|
471
|
+
planManager,
|
|
472
|
+
contextEngine,
|
|
473
|
+
permanentMemory,
|
|
474
|
+
reviewState: metadata.reviewState || null,
|
|
475
|
+
pendingPatches: this._hydratePendingPatches(metadata.pendingPatches),
|
|
476
|
+
patchHistory: Array.isArray(metadata.patchHistory)
|
|
477
|
+
? metadata.patchHistory
|
|
478
|
+
: [],
|
|
479
|
+
taskGraph: this._hydrateTaskGraph(metadata.taskGraph),
|
|
480
|
+
interaction: null,
|
|
481
|
+
createdAt: dbSession.created_at,
|
|
482
|
+
lastActivity: new Date().toISOString(),
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
this._bindPlanManagerPersistence(session);
|
|
486
|
+
this.sessions.set(session.id, session);
|
|
487
|
+
return session;
|
|
488
|
+
} catch (_err) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Close a session and persist final state.
|
|
495
|
+
*
|
|
496
|
+
* @param {string} sessionId
|
|
497
|
+
*/
|
|
498
|
+
closeSession(sessionId) {
|
|
499
|
+
const session = this.sessions.get(sessionId);
|
|
500
|
+
if (!session) return;
|
|
501
|
+
|
|
502
|
+
session.status = "closed";
|
|
503
|
+
|
|
504
|
+
// Persist messages to DB
|
|
505
|
+
this._persistSessionState(sessionId);
|
|
506
|
+
|
|
507
|
+
// Auto-summarize into permanent memory
|
|
508
|
+
if (session.permanentMemory && session.messages.length > 4) {
|
|
509
|
+
try {
|
|
510
|
+
session.permanentMemory.autoSummarize(session.messages);
|
|
511
|
+
} catch (_err) {
|
|
512
|
+
// Non-critical
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Force-complete any active sub-agents for this session
|
|
517
|
+
try {
|
|
518
|
+
SubAgentRegistry.getInstance().forceCompleteAll(sessionId);
|
|
519
|
+
} catch (_err) {
|
|
520
|
+
// Non-critical
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Clean up plan manager listeners
|
|
524
|
+
if (session.planManager) {
|
|
525
|
+
if (typeof session._planPersistenceCleanup === "function") {
|
|
526
|
+
try {
|
|
527
|
+
session._planPersistenceCleanup();
|
|
528
|
+
} catch (_err) {
|
|
529
|
+
// Non-critical.
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
session.planManager.removeAllListeners();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (session.worktree?.path && session.baseProjectRoot) {
|
|
536
|
+
try {
|
|
537
|
+
removeWorktree(session.baseProjectRoot, session.worktree.path, {
|
|
538
|
+
deleteBranch: true,
|
|
539
|
+
});
|
|
540
|
+
} catch (_err) {
|
|
541
|
+
// Best-effort cleanup.
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
this.sessions.delete(sessionId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* List all sessions (in-memory + DB).
|
|
550
|
+
*
|
|
551
|
+
* @returns {Array<{id, type, status, createdAt, lastActivity}>}
|
|
552
|
+
*/
|
|
553
|
+
listSessions() {
|
|
554
|
+
const results = [];
|
|
555
|
+
|
|
556
|
+
// In-memory active sessions
|
|
557
|
+
for (const [, session] of this.sessions) {
|
|
558
|
+
results.push({
|
|
559
|
+
id: session.id,
|
|
560
|
+
type: session.type,
|
|
561
|
+
status: session.status,
|
|
562
|
+
provider: session.provider,
|
|
563
|
+
model: session.model,
|
|
564
|
+
messageCount: session.messages.length,
|
|
565
|
+
enabledToolNames: session.enabledToolNames || [],
|
|
566
|
+
baseProjectRoot: session.baseProjectRoot,
|
|
567
|
+
worktreeIsolation: session.worktreeIsolation === true,
|
|
568
|
+
worktree: session.worktree || null,
|
|
569
|
+
createdAt: session.createdAt,
|
|
570
|
+
lastActivity: session.lastActivity,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// DB sessions (exclude already-listed in-memory ones)
|
|
575
|
+
if (this.db) {
|
|
576
|
+
try {
|
|
577
|
+
const dbSessions = dbListSessions(this.db, { limit: 20 });
|
|
578
|
+
const inMemoryIds = new Set(this.sessions.keys());
|
|
579
|
+
for (const dbs of dbSessions) {
|
|
580
|
+
const metadata = this._normalizeSessionMetadata(dbs.metadata);
|
|
581
|
+
if (!inMemoryIds.has(dbs.id)) {
|
|
582
|
+
results.push({
|
|
583
|
+
id: dbs.id,
|
|
584
|
+
type: metadata.sessionType || "unknown",
|
|
585
|
+
status: "persisted",
|
|
586
|
+
provider: dbs.provider,
|
|
587
|
+
model: dbs.model,
|
|
588
|
+
messageCount: dbs.message_count,
|
|
589
|
+
enabledToolNames: Array.isArray(metadata.enabledToolNames)
|
|
590
|
+
? metadata.enabledToolNames
|
|
591
|
+
: [],
|
|
592
|
+
baseProjectRoot: metadata.baseProjectRoot || null,
|
|
593
|
+
worktreeIsolation: metadata.worktreeIsolation === true,
|
|
594
|
+
worktree: metadata.worktree || null,
|
|
595
|
+
createdAt: dbs.created_at,
|
|
596
|
+
lastActivity: dbs.updated_at,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
} catch (_err) {
|
|
601
|
+
// Non-critical
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return results;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Get a session by ID.
|
|
610
|
+
*
|
|
611
|
+
* @param {string} sessionId
|
|
612
|
+
* @returns {Session|null}
|
|
613
|
+
*/
|
|
614
|
+
getSession(sessionId) {
|
|
615
|
+
return this.sessions.get(sessionId) || null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Update host-managed tool policy for an active session.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} sessionId
|
|
622
|
+
* @param {object|null} hostManagedToolPolicy
|
|
623
|
+
* @returns {Session|null}
|
|
624
|
+
*/
|
|
625
|
+
updateSessionPolicy(sessionId, hostManagedToolPolicy) {
|
|
626
|
+
const session = this.sessions.get(sessionId);
|
|
627
|
+
if (!session) return null;
|
|
628
|
+
|
|
629
|
+
session.hostManagedToolPolicy = hostManagedToolPolicy || null;
|
|
630
|
+
session.lastActivity = new Date().toISOString();
|
|
631
|
+
this._persistSessionState(sessionId);
|
|
632
|
+
return session;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Enter explicit review mode for a session. While in review, handlers
|
|
637
|
+
* MUST gate new sendMessage calls until the review is resolved. Reviewer
|
|
638
|
+
* sub-agents and human reviewers both feed into the same `comments` /
|
|
639
|
+
* `checklist` arrays.
|
|
640
|
+
*
|
|
641
|
+
* @param {string} sessionId
|
|
642
|
+
* @param {{
|
|
643
|
+
* reason?: string,
|
|
644
|
+
* requestedBy?: string,
|
|
645
|
+
* checklist?: Array<{ id?: string, title: string, note?: string }>,
|
|
646
|
+
* blocking?: boolean,
|
|
647
|
+
* }} [options]
|
|
648
|
+
*/
|
|
649
|
+
enterReview(sessionId, options = {}) {
|
|
650
|
+
const session = this.sessions.get(sessionId);
|
|
651
|
+
if (!session) return null;
|
|
652
|
+
|
|
653
|
+
// If already in pending review, return the existing state unchanged so
|
|
654
|
+
// callers can retry safely.
|
|
655
|
+
if (session.reviewState && session.reviewState.status === "pending") {
|
|
656
|
+
return session.reviewState;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const reviewId = `review-${this._generateId()}`;
|
|
660
|
+
const now = new Date().toISOString();
|
|
661
|
+
const checklist = Array.isArray(options.checklist)
|
|
662
|
+
? options.checklist.map((item, index) => ({
|
|
663
|
+
id: item.id || `chk-${index}-${Date.now()}`,
|
|
664
|
+
title: item.title || `Item ${index + 1}`,
|
|
665
|
+
note: item.note || null,
|
|
666
|
+
done: false,
|
|
667
|
+
}))
|
|
668
|
+
: [];
|
|
669
|
+
|
|
670
|
+
session.reviewState = {
|
|
671
|
+
reviewId,
|
|
672
|
+
status: "pending",
|
|
673
|
+
reason: options.reason || null,
|
|
674
|
+
requestedBy: options.requestedBy || "user",
|
|
675
|
+
requestedAt: now,
|
|
676
|
+
resolvedAt: null,
|
|
677
|
+
resolvedBy: null,
|
|
678
|
+
decision: null,
|
|
679
|
+
blocking: options.blocking !== false,
|
|
680
|
+
comments: [],
|
|
681
|
+
checklist,
|
|
682
|
+
};
|
|
683
|
+
session.lastActivity = now;
|
|
684
|
+
this._persistSessionState(sessionId);
|
|
685
|
+
return session.reviewState;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Submit an incremental update to the active review — append a comment
|
|
690
|
+
* and/or toggle a checklist item. Returns the updated reviewState, or null
|
|
691
|
+
* if the session has no active review.
|
|
692
|
+
*
|
|
693
|
+
* @param {string} sessionId
|
|
694
|
+
* @param {{
|
|
695
|
+
* comment?: { author?: string, content: string },
|
|
696
|
+
* checklistItemId?: string,
|
|
697
|
+
* checklistItemDone?: boolean,
|
|
698
|
+
* checklistItemNote?: string,
|
|
699
|
+
* }} update
|
|
700
|
+
*/
|
|
701
|
+
submitReviewComment(sessionId, update = {}) {
|
|
702
|
+
const session = this.sessions.get(sessionId);
|
|
703
|
+
if (!session || !session.reviewState) return null;
|
|
704
|
+
if (session.reviewState.status !== "pending") return null;
|
|
705
|
+
|
|
706
|
+
const now = new Date().toISOString();
|
|
707
|
+
|
|
708
|
+
if (update.comment && update.comment.content) {
|
|
709
|
+
session.reviewState.comments.push({
|
|
710
|
+
id: `cmt-${session.reviewState.comments.length}-${Date.now()}`,
|
|
711
|
+
author: update.comment.author || "user",
|
|
712
|
+
content: String(update.comment.content),
|
|
713
|
+
timestamp: now,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (update.checklistItemId) {
|
|
718
|
+
const item = session.reviewState.checklist.find(
|
|
719
|
+
(c) => c.id === update.checklistItemId,
|
|
720
|
+
);
|
|
721
|
+
if (item) {
|
|
722
|
+
if (typeof update.checklistItemDone === "boolean") {
|
|
723
|
+
item.done = update.checklistItemDone;
|
|
724
|
+
}
|
|
725
|
+
if (typeof update.checklistItemNote === "string") {
|
|
726
|
+
item.note = update.checklistItemNote;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
session.lastActivity = now;
|
|
732
|
+
this._persistSessionState(sessionId);
|
|
733
|
+
return session.reviewState;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Resolve the active review with an approved/rejected decision. After
|
|
738
|
+
* resolve the session can accept new messages again (reviewState becomes
|
|
739
|
+
* non-blocking but is retained for audit).
|
|
740
|
+
*
|
|
741
|
+
* @param {string} sessionId
|
|
742
|
+
* @param {{ decision: "approved"|"rejected", resolvedBy?: string, summary?: string }} payload
|
|
743
|
+
*/
|
|
744
|
+
resolveReview(sessionId, payload = {}) {
|
|
745
|
+
const session = this.sessions.get(sessionId);
|
|
746
|
+
if (!session || !session.reviewState) return null;
|
|
747
|
+
if (session.reviewState.status !== "pending") {
|
|
748
|
+
return session.reviewState;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const decision =
|
|
752
|
+
payload.decision === "approved" || payload.decision === "rejected"
|
|
753
|
+
? payload.decision
|
|
754
|
+
: "approved";
|
|
755
|
+
|
|
756
|
+
session.reviewState.status = decision;
|
|
757
|
+
session.reviewState.decision = decision;
|
|
758
|
+
session.reviewState.resolvedAt = new Date().toISOString();
|
|
759
|
+
session.reviewState.resolvedBy = payload.resolvedBy || "user";
|
|
760
|
+
session.reviewState.blocking = false;
|
|
761
|
+
if (payload.summary) {
|
|
762
|
+
session.reviewState.summary = String(payload.summary);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
session.lastActivity = session.reviewState.resolvedAt;
|
|
766
|
+
this._persistSessionState(sessionId);
|
|
767
|
+
return session.reviewState;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Returns true when the session currently has a blocking review gate
|
|
772
|
+
* open. Callers (e.g. handleSessionMessage) should short-circuit with a
|
|
773
|
+
* REVIEW_BLOCKING error instead of running the agent turn.
|
|
774
|
+
*/
|
|
775
|
+
isReviewBlocking(sessionId) {
|
|
776
|
+
const session = this.sessions.get(sessionId);
|
|
777
|
+
if (!session || !session.reviewState) return false;
|
|
778
|
+
return (
|
|
779
|
+
session.reviewState.status === "pending" &&
|
|
780
|
+
session.reviewState.blocking === true
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
getReviewState(sessionId) {
|
|
785
|
+
const session = this.sessions.get(sessionId);
|
|
786
|
+
return session ? session.reviewState || null : null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Record a proposed patch on the session. Accepts one or more file hunks
|
|
791
|
+
* that a tool wanted to write but should be previewed before they land.
|
|
792
|
+
*
|
|
793
|
+
* @param {string} sessionId
|
|
794
|
+
* @param {{
|
|
795
|
+
* files: Array<{
|
|
796
|
+
* path: string,
|
|
797
|
+
* op?: "create"|"modify"|"delete",
|
|
798
|
+
* before?: string|null,
|
|
799
|
+
* after?: string|null,
|
|
800
|
+
* diff?: string|null,
|
|
801
|
+
* stats?: { added?: number, removed?: number }
|
|
802
|
+
* }>,
|
|
803
|
+
* origin?: string,
|
|
804
|
+
* reason?: string,
|
|
805
|
+
* requestId?: string|null
|
|
806
|
+
* }} payload
|
|
807
|
+
* @returns {object|null} patch record, or null if the session is missing
|
|
808
|
+
*/
|
|
809
|
+
proposePatch(sessionId, payload = {}) {
|
|
810
|
+
const session = this.sessions.get(sessionId);
|
|
811
|
+
if (!session) return null;
|
|
812
|
+
|
|
813
|
+
const files = Array.isArray(payload.files) ? payload.files : [];
|
|
814
|
+
if (files.length === 0) return null;
|
|
815
|
+
|
|
816
|
+
const patchId = `patch-${this._generateId()}`;
|
|
817
|
+
const now = new Date().toISOString();
|
|
818
|
+
const normalizedFiles = files.map((file, index) => {
|
|
819
|
+
const op = file.op || (file.before == null ? "create" : "modify");
|
|
820
|
+
const stats = this._computePatchStats(file);
|
|
821
|
+
return {
|
|
822
|
+
index,
|
|
823
|
+
path: file.path || `unknown-${index}`,
|
|
824
|
+
op,
|
|
825
|
+
before: file.before == null ? null : String(file.before),
|
|
826
|
+
after: file.after == null ? null : String(file.after),
|
|
827
|
+
diff: file.diff == null ? null : String(file.diff),
|
|
828
|
+
stats,
|
|
829
|
+
};
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const totalStats = normalizedFiles.reduce(
|
|
833
|
+
(acc, file) => ({
|
|
834
|
+
added: acc.added + (file.stats.added || 0),
|
|
835
|
+
removed: acc.removed + (file.stats.removed || 0),
|
|
836
|
+
}),
|
|
837
|
+
{ added: 0, removed: 0 },
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
const patch = {
|
|
841
|
+
patchId,
|
|
842
|
+
status: "pending",
|
|
843
|
+
origin: payload.origin || "tool",
|
|
844
|
+
reason: payload.reason || null,
|
|
845
|
+
requestId: payload.requestId || null,
|
|
846
|
+
proposedAt: now,
|
|
847
|
+
resolvedAt: null,
|
|
848
|
+
resolvedBy: null,
|
|
849
|
+
files: normalizedFiles,
|
|
850
|
+
stats: {
|
|
851
|
+
fileCount: normalizedFiles.length,
|
|
852
|
+
added: totalStats.added,
|
|
853
|
+
removed: totalStats.removed,
|
|
854
|
+
},
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
if (!(session.pendingPatches instanceof Map)) {
|
|
858
|
+
session.pendingPatches = new Map();
|
|
859
|
+
}
|
|
860
|
+
session.pendingPatches.set(patchId, patch);
|
|
861
|
+
session.lastActivity = now;
|
|
862
|
+
this._persistSessionState(sessionId);
|
|
863
|
+
return patch;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Mark a pending patch as applied. Moves the record to patchHistory so it
|
|
868
|
+
* is still visible in the summary view but no longer counts as pending.
|
|
869
|
+
*/
|
|
870
|
+
applyPatch(sessionId, patchId, options = {}) {
|
|
871
|
+
const session = this.sessions.get(sessionId);
|
|
872
|
+
if (!session || !(session.pendingPatches instanceof Map)) return null;
|
|
873
|
+
const patch = session.pendingPatches.get(patchId);
|
|
874
|
+
if (!patch) return null;
|
|
875
|
+
|
|
876
|
+
patch.status = "applied";
|
|
877
|
+
patch.resolvedAt = new Date().toISOString();
|
|
878
|
+
patch.resolvedBy = options.resolvedBy || "user";
|
|
879
|
+
if (options.note) {
|
|
880
|
+
patch.note = String(options.note);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
session.pendingPatches.delete(patchId);
|
|
884
|
+
if (!Array.isArray(session.patchHistory)) {
|
|
885
|
+
session.patchHistory = [];
|
|
886
|
+
}
|
|
887
|
+
session.patchHistory.push(patch);
|
|
888
|
+
session.lastActivity = patch.resolvedAt;
|
|
889
|
+
this._persistSessionState(sessionId);
|
|
890
|
+
return patch;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Discard a pending patch. Same bookkeeping as applyPatch but records a
|
|
895
|
+
* "rejected" decision instead.
|
|
896
|
+
*/
|
|
897
|
+
rejectPatch(sessionId, patchId, options = {}) {
|
|
898
|
+
const session = this.sessions.get(sessionId);
|
|
899
|
+
if (!session || !(session.pendingPatches instanceof Map)) return null;
|
|
900
|
+
const patch = session.pendingPatches.get(patchId);
|
|
901
|
+
if (!patch) return null;
|
|
902
|
+
|
|
903
|
+
patch.status = "rejected";
|
|
904
|
+
patch.resolvedAt = new Date().toISOString();
|
|
905
|
+
patch.resolvedBy = options.resolvedBy || "user";
|
|
906
|
+
if (options.reason) {
|
|
907
|
+
patch.rejectionReason = String(options.reason);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
session.pendingPatches.delete(patchId);
|
|
911
|
+
if (!Array.isArray(session.patchHistory)) {
|
|
912
|
+
session.patchHistory = [];
|
|
913
|
+
}
|
|
914
|
+
session.patchHistory.push(patch);
|
|
915
|
+
session.lastActivity = patch.resolvedAt;
|
|
916
|
+
this._persistSessionState(sessionId);
|
|
917
|
+
return patch;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Return a flattened summary of all pending + resolved patches on the
|
|
922
|
+
* session. Shape matches what the renderer strip consumes:
|
|
923
|
+
* { pending: [...], history: [...], totals: { added, removed, fileCount } }
|
|
924
|
+
*/
|
|
925
|
+
getPatchSummary(sessionId) {
|
|
926
|
+
const session = this.sessions.get(sessionId);
|
|
927
|
+
if (!session) return null;
|
|
928
|
+
|
|
929
|
+
const pending =
|
|
930
|
+
session.pendingPatches instanceof Map
|
|
931
|
+
? Array.from(session.pendingPatches.values())
|
|
932
|
+
: [];
|
|
933
|
+
const history = Array.isArray(session.patchHistory)
|
|
934
|
+
? session.patchHistory
|
|
935
|
+
: [];
|
|
936
|
+
|
|
937
|
+
const totals = [...pending, ...history].reduce(
|
|
938
|
+
(acc, patch) => ({
|
|
939
|
+
fileCount: acc.fileCount + (patch.stats?.fileCount || 0),
|
|
940
|
+
added: acc.added + (patch.stats?.added || 0),
|
|
941
|
+
removed: acc.removed + (patch.stats?.removed || 0),
|
|
942
|
+
}),
|
|
943
|
+
{ fileCount: 0, added: 0, removed: 0 },
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
return { pending, history, totals };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
hasPendingPatches(sessionId) {
|
|
950
|
+
const session = this.sessions.get(sessionId);
|
|
951
|
+
if (!session || !(session.pendingPatches instanceof Map)) return false;
|
|
952
|
+
return session.pendingPatches.size > 0;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
_computePatchStats(file) {
|
|
956
|
+
if (file && file.stats && typeof file.stats === "object") {
|
|
957
|
+
return {
|
|
958
|
+
added: Number(file.stats.added) || 0,
|
|
959
|
+
removed: Number(file.stats.removed) || 0,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
const before = file && typeof file.before === "string" ? file.before : "";
|
|
963
|
+
const after = file && typeof file.after === "string" ? file.after : "";
|
|
964
|
+
const beforeLines = before ? before.split(/\r?\n/).length : 0;
|
|
965
|
+
const afterLines = after ? after.split(/\r?\n/).length : 0;
|
|
966
|
+
// Rough heuristic when no explicit diff is provided: full replace counts
|
|
967
|
+
// the entire file as added/removed.
|
|
968
|
+
if (!before && after) return { added: afterLines, removed: 0 };
|
|
969
|
+
if (before && !after) return { added: 0, removed: beforeLines };
|
|
970
|
+
return {
|
|
971
|
+
added: Math.max(0, afterLines - beforeLines),
|
|
972
|
+
removed: Math.max(0, beforeLines - afterLines),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Create or replace the task graph for a session. A graph is a DAG of
|
|
978
|
+
* `nodes` keyed by id; each node has `{ id, title, status, dependsOn[],
|
|
979
|
+
* metadata }`. Returns the serialized graph.
|
|
980
|
+
*/
|
|
981
|
+
createTaskGraph(sessionId, payload = {}) {
|
|
982
|
+
const session = this.sessions.get(sessionId);
|
|
983
|
+
if (!session) return null;
|
|
984
|
+
|
|
985
|
+
const graphId = payload.graphId || `graph-${this._generateId()}`;
|
|
986
|
+
const now = new Date().toISOString();
|
|
987
|
+
const nodes = {};
|
|
988
|
+
const incomingNodes = Array.isArray(payload.nodes) ? payload.nodes : [];
|
|
989
|
+
for (const raw of incomingNodes) {
|
|
990
|
+
if (!raw || !raw.id) continue;
|
|
991
|
+
nodes[raw.id] = this._normalizeTaskNode(raw, now);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const graph = {
|
|
995
|
+
graphId,
|
|
996
|
+
title: payload.title || null,
|
|
997
|
+
description: payload.description || null,
|
|
998
|
+
status: "active",
|
|
999
|
+
createdAt: now,
|
|
1000
|
+
updatedAt: now,
|
|
1001
|
+
completedAt: null,
|
|
1002
|
+
nodes,
|
|
1003
|
+
order: Object.keys(nodes),
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
session.taskGraph = graph;
|
|
1007
|
+
session.lastActivity = now;
|
|
1008
|
+
this._persistSessionState(sessionId);
|
|
1009
|
+
return this._cloneTaskGraph(graph);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Add a node to the existing task graph. Fails if no graph exists or if
|
|
1014
|
+
* the node id already exists.
|
|
1015
|
+
*/
|
|
1016
|
+
addTaskNode(sessionId, payload = {}) {
|
|
1017
|
+
const session = this.sessions.get(sessionId);
|
|
1018
|
+
if (!session || !session.taskGraph) return null;
|
|
1019
|
+
if (!payload || !payload.id) return null;
|
|
1020
|
+
const graph = session.taskGraph;
|
|
1021
|
+
if (graph.nodes[payload.id]) return null;
|
|
1022
|
+
|
|
1023
|
+
const now = new Date().toISOString();
|
|
1024
|
+
graph.nodes[payload.id] = this._normalizeTaskNode(payload, now);
|
|
1025
|
+
graph.order = [...(graph.order || []), payload.id];
|
|
1026
|
+
graph.updatedAt = now;
|
|
1027
|
+
session.lastActivity = now;
|
|
1028
|
+
this._persistSessionState(sessionId);
|
|
1029
|
+
return this._cloneTaskGraph(graph);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Update a node's status / metadata. Valid statuses: pending, ready,
|
|
1034
|
+
* running, completed, failed, skipped.
|
|
1035
|
+
*/
|
|
1036
|
+
updateTaskNode(sessionId, nodeId, updates = {}) {
|
|
1037
|
+
const session = this.sessions.get(sessionId);
|
|
1038
|
+
if (!session || !session.taskGraph) return null;
|
|
1039
|
+
const graph = session.taskGraph;
|
|
1040
|
+
const node = graph.nodes[nodeId];
|
|
1041
|
+
if (!node) return null;
|
|
1042
|
+
|
|
1043
|
+
const now = new Date().toISOString();
|
|
1044
|
+
if (updates.status) {
|
|
1045
|
+
node.status = String(updates.status);
|
|
1046
|
+
if (node.status === "running" && !node.startedAt) {
|
|
1047
|
+
node.startedAt = now;
|
|
1048
|
+
}
|
|
1049
|
+
if (
|
|
1050
|
+
node.status === "completed" ||
|
|
1051
|
+
node.status === "failed" ||
|
|
1052
|
+
node.status === "skipped"
|
|
1053
|
+
) {
|
|
1054
|
+
node.completedAt = now;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (updates.title !== undefined) node.title = updates.title;
|
|
1058
|
+
if (updates.result !== undefined) node.result = updates.result;
|
|
1059
|
+
if (updates.error !== undefined) node.error = updates.error;
|
|
1060
|
+
if (updates.metadata !== undefined) {
|
|
1061
|
+
node.metadata = { ...(node.metadata || {}), ...(updates.metadata || {}) };
|
|
1062
|
+
}
|
|
1063
|
+
node.updatedAt = now;
|
|
1064
|
+
graph.updatedAt = now;
|
|
1065
|
+
|
|
1066
|
+
// Check graph completion
|
|
1067
|
+
const allDone = Object.values(graph.nodes).every((n) =>
|
|
1068
|
+
["completed", "failed", "skipped"].includes(n.status),
|
|
1069
|
+
);
|
|
1070
|
+
if (allDone) {
|
|
1071
|
+
graph.status = Object.values(graph.nodes).some(
|
|
1072
|
+
(n) => n.status === "failed",
|
|
1073
|
+
)
|
|
1074
|
+
? "failed"
|
|
1075
|
+
: "completed";
|
|
1076
|
+
graph.completedAt = now;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
session.lastActivity = now;
|
|
1080
|
+
this._persistSessionState(sessionId);
|
|
1081
|
+
return this._cloneTaskGraph(graph);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Advance the task graph: mark any `pending` node whose dependencies are
|
|
1086
|
+
* all `completed` (or `skipped`) as `ready`. Returns the list of node ids
|
|
1087
|
+
* that became ready and the updated graph snapshot.
|
|
1088
|
+
*/
|
|
1089
|
+
advanceTaskGraph(sessionId) {
|
|
1090
|
+
const session = this.sessions.get(sessionId);
|
|
1091
|
+
if (!session || !session.taskGraph) return null;
|
|
1092
|
+
const graph = session.taskGraph;
|
|
1093
|
+
|
|
1094
|
+
const becameReady = [];
|
|
1095
|
+
for (const node of Object.values(graph.nodes)) {
|
|
1096
|
+
if (node.status !== "pending") continue;
|
|
1097
|
+
const deps = Array.isArray(node.dependsOn) ? node.dependsOn : [];
|
|
1098
|
+
const blocked = deps.some((depId) => {
|
|
1099
|
+
const dep = graph.nodes[depId];
|
|
1100
|
+
if (!dep) return true;
|
|
1101
|
+
return dep.status !== "completed" && dep.status !== "skipped";
|
|
1102
|
+
});
|
|
1103
|
+
if (!blocked) {
|
|
1104
|
+
node.status = "ready";
|
|
1105
|
+
node.updatedAt = new Date().toISOString();
|
|
1106
|
+
becameReady.push(node.id);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
if (becameReady.length > 0) {
|
|
1111
|
+
graph.updatedAt = new Date().toISOString();
|
|
1112
|
+
session.lastActivity = graph.updatedAt;
|
|
1113
|
+
this._persistSessionState(sessionId);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
graph: this._cloneTaskGraph(graph),
|
|
1118
|
+
becameReady,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
getTaskGraph(sessionId) {
|
|
1123
|
+
const session = this.sessions.get(sessionId);
|
|
1124
|
+
if (!session || !session.taskGraph) return null;
|
|
1125
|
+
return this._cloneTaskGraph(session.taskGraph);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
clearTaskGraph(sessionId) {
|
|
1129
|
+
const session = this.sessions.get(sessionId);
|
|
1130
|
+
if (!session) return false;
|
|
1131
|
+
session.taskGraph = null;
|
|
1132
|
+
session.lastActivity = new Date().toISOString();
|
|
1133
|
+
this._persistSessionState(sessionId);
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
_normalizeTaskNode(raw, now) {
|
|
1138
|
+
const status = raw.status || "pending";
|
|
1139
|
+
return {
|
|
1140
|
+
id: raw.id,
|
|
1141
|
+
title: raw.title || raw.id,
|
|
1142
|
+
description: raw.description || null,
|
|
1143
|
+
status,
|
|
1144
|
+
dependsOn: Array.isArray(raw.dependsOn)
|
|
1145
|
+
? raw.dependsOn.filter((x) => typeof x === "string")
|
|
1146
|
+
: [],
|
|
1147
|
+
metadata:
|
|
1148
|
+
raw.metadata && typeof raw.metadata === "object" ? raw.metadata : {},
|
|
1149
|
+
createdAt: raw.createdAt || now,
|
|
1150
|
+
updatedAt: raw.updatedAt || now,
|
|
1151
|
+
startedAt: raw.startedAt || null,
|
|
1152
|
+
completedAt: raw.completedAt || null,
|
|
1153
|
+
result: raw.result || null,
|
|
1154
|
+
error: raw.error || null,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
_cloneTaskGraph(graph) {
|
|
1159
|
+
if (!graph) return null;
|
|
1160
|
+
return {
|
|
1161
|
+
graphId: graph.graphId,
|
|
1162
|
+
title: graph.title,
|
|
1163
|
+
description: graph.description,
|
|
1164
|
+
status: graph.status,
|
|
1165
|
+
createdAt: graph.createdAt,
|
|
1166
|
+
updatedAt: graph.updatedAt,
|
|
1167
|
+
completedAt: graph.completedAt,
|
|
1168
|
+
order: Array.isArray(graph.order)
|
|
1169
|
+
? [...graph.order]
|
|
1170
|
+
: Object.keys(graph.nodes || {}),
|
|
1171
|
+
nodes: Object.fromEntries(
|
|
1172
|
+
Object.entries(graph.nodes || {}).map(([id, node]) => [
|
|
1173
|
+
id,
|
|
1174
|
+
{
|
|
1175
|
+
...node,
|
|
1176
|
+
dependsOn: [...(node.dependsOn || [])],
|
|
1177
|
+
metadata: { ...(node.metadata || {}) },
|
|
1178
|
+
},
|
|
1179
|
+
]),
|
|
1180
|
+
),
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
_hydrateTaskGraph(data) {
|
|
1185
|
+
if (!data || typeof data !== "object") return null;
|
|
1186
|
+
if (!data.graphId || !data.nodes) return null;
|
|
1187
|
+
const nodes = {};
|
|
1188
|
+
for (const [id, node] of Object.entries(data.nodes)) {
|
|
1189
|
+
nodes[id] = this._normalizeTaskNode(
|
|
1190
|
+
{ ...node, id },
|
|
1191
|
+
node.createdAt || new Date().toISOString(),
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
return {
|
|
1195
|
+
graphId: data.graphId,
|
|
1196
|
+
title: data.title || null,
|
|
1197
|
+
description: data.description || null,
|
|
1198
|
+
status: data.status || "active",
|
|
1199
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
1200
|
+
updatedAt: data.updatedAt || new Date().toISOString(),
|
|
1201
|
+
completedAt: data.completedAt || null,
|
|
1202
|
+
order: Array.isArray(data.order) ? data.order : Object.keys(nodes),
|
|
1203
|
+
nodes,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
_serializeTaskGraph(graph) {
|
|
1208
|
+
if (!graph) return null;
|
|
1209
|
+
return this._cloneTaskGraph(graph);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Persist current messages for a session.
|
|
1214
|
+
*/
|
|
1215
|
+
persistMessages(sessionId) {
|
|
1216
|
+
const session = this.sessions.get(sessionId);
|
|
1217
|
+
if (!session || !this.db) return;
|
|
1218
|
+
|
|
1219
|
+
try {
|
|
1220
|
+
dbSaveMessages(
|
|
1221
|
+
this.db,
|
|
1222
|
+
sessionId,
|
|
1223
|
+
session.messages,
|
|
1224
|
+
this._serializeSessionMetadata(session),
|
|
1225
|
+
);
|
|
1226
|
+
} catch (_err) {
|
|
1227
|
+
// Non-critical
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
session.lastActivity = new Date().toISOString();
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
_prepareSessionWorkspace(projectRoot, sessionId, options = {}) {
|
|
1234
|
+
if (options.worktreeIsolation !== true) {
|
|
1235
|
+
return {
|
|
1236
|
+
projectRoot,
|
|
1237
|
+
worktree: null,
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (!isGitRepo(projectRoot)) {
|
|
1242
|
+
throw new Error(
|
|
1243
|
+
`Worktree isolation requires a git repository: ${projectRoot}`,
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const branchName = `coding-agent/${sessionId}`;
|
|
1248
|
+
const worktree = createWorktree(projectRoot, branchName);
|
|
1249
|
+
|
|
1250
|
+
return {
|
|
1251
|
+
projectRoot: worktree.path,
|
|
1252
|
+
worktree: {
|
|
1253
|
+
branch: worktree.branch,
|
|
1254
|
+
path: worktree.path,
|
|
1255
|
+
baseProjectRoot: projectRoot,
|
|
1256
|
+
},
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
_persistSessionState(sessionId) {
|
|
1261
|
+
const session = this.sessions.get(sessionId);
|
|
1262
|
+
if (!session || !this.db) return;
|
|
1263
|
+
|
|
1264
|
+
try {
|
|
1265
|
+
dbSaveMessages(
|
|
1266
|
+
this.db,
|
|
1267
|
+
sessionId,
|
|
1268
|
+
session.messages,
|
|
1269
|
+
this._serializeSessionMetadata(session),
|
|
1270
|
+
);
|
|
1271
|
+
} catch (_err) {
|
|
1272
|
+
// Non-critical
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
session.lastActivity = new Date().toISOString();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
_serializeSessionMetadata(session) {
|
|
1279
|
+
return {
|
|
1280
|
+
version: 1,
|
|
1281
|
+
sessionType: session.type || "agent",
|
|
1282
|
+
projectRoot: session.projectRoot || null,
|
|
1283
|
+
baseProjectRoot: session.baseProjectRoot || session.projectRoot || null,
|
|
1284
|
+
baseUrl: session.baseUrl || null,
|
|
1285
|
+
hostManagedToolPolicy: session.hostManagedToolPolicy || null,
|
|
1286
|
+
enabledToolNames: session.enabledToolNames || [],
|
|
1287
|
+
worktreeIsolation: session.worktreeIsolation === true,
|
|
1288
|
+
worktree: session.worktree || null,
|
|
1289
|
+
planSnapshot: this._serializePlanManager(session.planManager),
|
|
1290
|
+
reviewState: session.reviewState || null,
|
|
1291
|
+
pendingPatches:
|
|
1292
|
+
session.pendingPatches instanceof Map
|
|
1293
|
+
? Array.from(session.pendingPatches.values())
|
|
1294
|
+
: [],
|
|
1295
|
+
patchHistory: Array.isArray(session.patchHistory)
|
|
1296
|
+
? session.patchHistory
|
|
1297
|
+
: [],
|
|
1298
|
+
taskGraph: this._serializeTaskGraph(session.taskGraph),
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
_hydratePendingPatches(list) {
|
|
1303
|
+
const map = new Map();
|
|
1304
|
+
if (Array.isArray(list)) {
|
|
1305
|
+
for (const patch of list) {
|
|
1306
|
+
if (patch && patch.patchId) {
|
|
1307
|
+
map.set(patch.patchId, patch);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return map;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
_serializePlanManager(planManager) {
|
|
1315
|
+
if (!planManager) {
|
|
1316
|
+
return null;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
return {
|
|
1320
|
+
state: planManager.state || PlanState.INACTIVE,
|
|
1321
|
+
currentPlan: planManager.currentPlan || null,
|
|
1322
|
+
history: Array.isArray(planManager.history) ? planManager.history : [],
|
|
1323
|
+
blockedToolLog: Array.isArray(planManager.blockedToolLog)
|
|
1324
|
+
? planManager.blockedToolLog
|
|
1325
|
+
: [],
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
_normalizeSessionMetadata(metadata) {
|
|
1330
|
+
if (!metadata) {
|
|
1331
|
+
return {};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (typeof metadata === "string") {
|
|
1335
|
+
try {
|
|
1336
|
+
return JSON.parse(metadata);
|
|
1337
|
+
} catch (_err) {
|
|
1338
|
+
return {};
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
return typeof metadata === "object" ? metadata : {};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
_hydratePlanManager(snapshot) {
|
|
1346
|
+
const planManager = new PlanModeManager();
|
|
1347
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
1348
|
+
return planManager;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
planManager.state = snapshot.state || PlanState.INACTIVE;
|
|
1352
|
+
planManager.currentPlan = snapshot.currentPlan
|
|
1353
|
+
? new ExecutionPlan(snapshot.currentPlan)
|
|
1354
|
+
: null;
|
|
1355
|
+
planManager.history = Array.isArray(snapshot.history)
|
|
1356
|
+
? snapshot.history.map((plan) => new ExecutionPlan(plan))
|
|
1357
|
+
: [];
|
|
1358
|
+
planManager.blockedToolLog = Array.isArray(snapshot.blockedToolLog)
|
|
1359
|
+
? [...snapshot.blockedToolLog]
|
|
1360
|
+
: [];
|
|
1361
|
+
return planManager;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
_restoreSessionWorkspace(sessionId, baseProjectRoot, metadata = {}) {
|
|
1365
|
+
const requestedWorktreeIsolation = metadata.worktreeIsolation === true;
|
|
1366
|
+
const persistedWorktreePath = metadata.worktree?.path || null;
|
|
1367
|
+
|
|
1368
|
+
if (!requestedWorktreeIsolation) {
|
|
1369
|
+
return {
|
|
1370
|
+
projectRoot: metadata.projectRoot || baseProjectRoot,
|
|
1371
|
+
worktree: null,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (persistedWorktreePath && fs.existsSync(persistedWorktreePath)) {
|
|
1376
|
+
return {
|
|
1377
|
+
projectRoot: persistedWorktreePath,
|
|
1378
|
+
worktree: {
|
|
1379
|
+
...(metadata.worktree || {}),
|
|
1380
|
+
baseProjectRoot,
|
|
1381
|
+
},
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
try {
|
|
1386
|
+
return this._prepareSessionWorkspace(baseProjectRoot, sessionId, {
|
|
1387
|
+
worktreeIsolation: true,
|
|
1388
|
+
});
|
|
1389
|
+
} catch (_err) {
|
|
1390
|
+
return {
|
|
1391
|
+
projectRoot: baseProjectRoot,
|
|
1392
|
+
worktree: null,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
_bindPlanManagerPersistence(session) {
|
|
1398
|
+
if (
|
|
1399
|
+
!session?.id ||
|
|
1400
|
+
!session.planManager ||
|
|
1401
|
+
typeof session.planManager.on !== "function"
|
|
1402
|
+
) {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
if (typeof session._planPersistenceCleanup === "function") {
|
|
1407
|
+
session._planPersistenceCleanup();
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const persist = () => this._persistSessionState(session.id);
|
|
1411
|
+
const events = [
|
|
1412
|
+
"enter",
|
|
1413
|
+
"exit",
|
|
1414
|
+
"item-added",
|
|
1415
|
+
"plan-ready",
|
|
1416
|
+
"plan-approved",
|
|
1417
|
+
"tool-blocked",
|
|
1418
|
+
];
|
|
1419
|
+
|
|
1420
|
+
for (const eventName of events) {
|
|
1421
|
+
session.planManager.on(eventName, persist);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
session._planPersistenceCleanup = () => {
|
|
1425
|
+
if (typeof session.planManager.off === "function") {
|
|
1426
|
+
for (const eventName of events) {
|
|
1427
|
+
session.planManager.off(eventName, persist);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
}
|