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.
Files changed (91) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/Analytics-B4OM8S8X.css +1 -0
  4. package/src/assets/web-panel/assets/Analytics-sBrYoc3A.js +3 -0
  5. package/src/assets/web-panel/assets/AppLayout-BhJ3YFWt.js +1 -0
  6. package/src/assets/web-panel/assets/AppLayout-Cr2lWhF-.css +1 -0
  7. package/src/assets/web-panel/assets/Backup-D68fenbD.js +1 -0
  8. package/src/assets/web-panel/assets/Backup-fZqtfC1m.css +1 -0
  9. package/src/assets/web-panel/assets/{Chat-DXtvKoM0.js → Chat-DaxTP3x8.js} +1 -1
  10. package/src/assets/web-panel/assets/{Cron-BJ4ODHOy.js → Cron-CNs03iHJ.js} +2 -2
  11. package/src/assets/web-panel/assets/{Dashboard-BZd4wDPQ.js → Dashboard-CjlX4CrX.js} +2 -2
  12. package/src/assets/web-panel/assets/Git-CCMVr3Y8.js +2 -0
  13. package/src/assets/web-panel/assets/Git-DGcuBXST.css +1 -0
  14. package/src/assets/web-panel/assets/{Logs-CSeKZEG_.js → Logs-BY6A0UNG.js} +2 -2
  15. package/src/assets/web-panel/assets/{McpTools-BYQAK11r.js → McpTools-CrBVYlg6.js} +2 -2
  16. package/src/assets/web-panel/assets/{Memory-gkUAPyuZ.js → Memory-CWx3SpUt.js} +2 -2
  17. package/src/assets/web-panel/assets/{Notes-bjNrQgAo.js → Notes-1LcGD49x.js} +2 -2
  18. package/src/assets/web-panel/assets/Organization-DdOOM4ic.css +1 -0
  19. package/src/assets/web-panel/assets/Organization-Dx2DhbkM.js +4 -0
  20. package/src/assets/web-panel/assets/P2P-B16fjqfJ.js +2 -0
  21. package/src/assets/web-panel/assets/P2P-OEzOeMZX.css +1 -0
  22. package/src/assets/web-panel/assets/Permissions-BQbC9FzG.js +4 -0
  23. package/src/assets/web-panel/assets/Permissions-C9WlkGl-.css +1 -0
  24. package/src/assets/web-panel/assets/Projects-CjhZbNYm.js +2 -0
  25. package/src/assets/web-panel/assets/Projects-DxKelI5h.css +1 -0
  26. package/src/assets/web-panel/assets/Providers-BEakqcO5.css +1 -0
  27. package/src/assets/web-panel/assets/Providers-ivOAQtHM.js +2 -0
  28. package/src/assets/web-panel/assets/RssFeed-BlFC20eg.css +1 -0
  29. package/src/assets/web-panel/assets/RssFeed-BrsErdrU.js +3 -0
  30. package/src/assets/web-panel/assets/Security-DnEvJU5h.js +4 -0
  31. package/src/assets/web-panel/assets/Security-Dwxw7rfP.css +1 -0
  32. package/src/assets/web-panel/assets/{Services-CS0oMdxh.js → Services-7jQywNbl.js} +2 -2
  33. package/src/assets/web-panel/assets/Skills-BCvgBkD3.js +1 -0
  34. package/src/assets/web-panel/assets/{Tasks-qULws8pc.js → Tasks-CmJBC1cf.js} +1 -1
  35. package/src/assets/web-panel/assets/Templates-DOY_oZnm.css +1 -0
  36. package/src/assets/web-panel/assets/Templates-RXT8-DNk.js +1 -0
  37. package/src/assets/web-panel/assets/Wallet-3iYASEx_.js +4 -0
  38. package/src/assets/web-panel/assets/Wallet-DnIumafl.css +1 -0
  39. package/src/assets/web-panel/assets/WebAuthn-CNPl2VQR.css +1 -0
  40. package/src/assets/web-panel/assets/WebAuthn-s3Hzd9db.js +5 -0
  41. package/src/assets/web-panel/assets/{antd-CJSBocer.js → antd-gZyc63Qr.js} +114 -114
  42. package/src/assets/web-panel/assets/chat-BmwHBi9M.js +1 -0
  43. package/src/assets/web-panel/assets/index-DrmEk9S3.js +2 -0
  44. package/src/assets/web-panel/assets/{markdown-Bo5cVN4u.js → markdown-Bv7nG63L.js} +1 -1
  45. package/src/assets/web-panel/assets/ws-CU7Gvoom.js +1 -0
  46. package/src/assets/web-panel/index.html +2 -2
  47. package/src/commands/doctor.js +33 -151
  48. package/src/commands/mcp.js +1 -1
  49. package/src/commands/plugin.js +1 -1
  50. package/src/commands/session.js +106 -7
  51. package/src/commands/status.js +39 -69
  52. package/src/gateways/ws/message-dispatcher.js +9 -0
  53. package/src/gateways/ws/session-protocol.js +368 -1
  54. package/src/gateways/ws/ws-agent-handler.js +484 -0
  55. package/src/gateways/ws/ws-server.js +758 -4
  56. package/src/gateways/ws/ws-session-gateway.js +1432 -1
  57. package/src/harness/mcp-client.js +417 -0
  58. package/src/harness/mock-llm-provider.js +167 -0
  59. package/src/harness/plugin-manager.js +434 -0
  60. package/src/lib/agent-core.js +25 -1902
  61. package/src/lib/hashline.js +208 -0
  62. package/src/lib/jsonl-session-store.js +11 -0
  63. package/src/lib/mcp-client.js +14 -412
  64. package/src/lib/plugin-manager.js +29 -428
  65. package/src/lib/prompt-compressor.js +11 -0
  66. package/src/lib/session-hooks.js +61 -0
  67. package/src/lib/skill-loader.js +4 -0
  68. package/src/lib/skill-mcp.js +190 -0
  69. package/src/lib/workflow-state-reader.js +94 -0
  70. package/src/lib/ws-agent-handler.js +8 -472
  71. package/src/lib/ws-server.js +12 -726
  72. package/src/lib/ws-session-manager.js +8 -1178
  73. package/src/repl/agent-repl.js +27 -3
  74. package/src/runtime/agent-core.js +1760 -0
  75. package/src/runtime/agent-runtime.js +3 -1
  76. package/src/runtime/coding-agent-contract-shared.cjs +496 -0
  77. package/src/runtime/coding-agent-contract.js +49 -229
  78. package/src/runtime/coding-agent-events.cjs +14 -0
  79. package/src/runtime/coding-agent-policy.cjs +54 -5
  80. package/src/runtime/diagnostics.js +317 -0
  81. package/src/runtime/index.js +3 -0
  82. package/src/tools/index.js +3 -0
  83. package/src/tools/legacy-agent-tools.js +5 -0
  84. package/src/assets/web-panel/assets/AppLayout-B_tkw3Pn.js +0 -1
  85. package/src/assets/web-panel/assets/AppLayout-CFP4dGIJ.css +0 -1
  86. package/src/assets/web-panel/assets/Providers-Brm-S_hS.css +0 -1
  87. package/src/assets/web-panel/assets/Providers-Dbf57Tbv.js +0 -1
  88. package/src/assets/web-panel/assets/Skills-B2fgruv8.js +0 -1
  89. package/src/assets/web-panel/assets/chat-DnH09sSR.js +0 -1
  90. package/src/assets/web-panel/assets/index-IK-oro0g.js +0 -2
  91. package/src/assets/web-panel/assets/ws-DjelKkD6.js +0 -1
@@ -1 +1,1432 @@
1
- export { WSSessionManager } from "../../lib/ws-session-manager.js";
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
+ }