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,1182 +1,12 @@
1
1
  /**
2
- * WebSocket Session Manager
2
+ * @deprecated canonical implementation lives in
3
+ * `../gateways/ws/ws-session-gateway.js` as of the CLI Runtime
4
+ * Convergence roadmap (Phase 6a, 2026-04-09). This file is retained
5
+ * as a re-export shim for backwards compatibility and will be
6
+ * removed once all external consumers have migrated.
3
7
  *
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.
8
+ * Please import `WSSessionManager` directly from
9
+ * `packages/cli/src/gateways/ws/ws-session-gateway.js` in new code.
7
10
  */
8
11
 
9
- import { createHash } from "crypto";
10
- import fs from "fs";
11
- import path from "path";
12
- import { ExecutionPlan, PlanModeManager, PlanState } from "./plan-mode.js";
13
- import { CLIContextEngineering } from "./cli-context-engineering.js";
14
- import { CLIPermanentMemory } from "./permanent-memory.js";
15
- import {
16
- createTrustedMcpServerMap,
17
- resolveMcpServerPolicy,
18
- normalizeRiskLevel,
19
- normalizeBoolean,
20
- selectHigherRiskLevel,
21
- } from "../runtime/coding-agent-managed-tool-policy.cjs";
22
- import {
23
- createSession as dbCreateSession,
24
- saveMessages as dbSaveMessages,
25
- getSession as dbGetSession,
26
- listSessions as dbListSessions,
27
- updateSession as dbUpdateSession,
28
- } from "./session-manager.js";
29
- import { buildSystemPrompt } from "./agent-core.js";
30
- import { SubAgentRegistry } from "./sub-agent-registry.js";
31
- import { createWorktree, removeWorktree } from "./worktree-isolator.js";
32
- import { isGitRepo } from "./git-integration.js";
33
- import {
34
- CODING_AGENT_MVP_TOOL_NAMES,
35
- listCodingAgentToolNames,
36
- } from "../runtime/coding-agent-contract.js";
37
-
38
- /**
39
- * @typedef {object} Session
40
- * @property {string} id
41
- * @property {"agent"|"chat"} type
42
- * @property {"active"|"closed"} status
43
- * @property {Array} messages
44
- * @property {string} provider
45
- * @property {string} model
46
- * @property {string|null} apiKey
47
- * @property {string|null} baseUrl
48
- * @property {string} projectRoot
49
- * @property {string} baseProjectRoot
50
- * @property {string|null} rulesContent
51
- * @property {string[]} enabledToolNames
52
- * @property {object|null} hostManagedToolPolicy
53
- * @property {Array<object>} externalToolDefinitions
54
- * @property {object} externalToolDescriptors
55
- * @property {object} externalToolExecutors
56
- * @property {boolean} worktreeIsolation
57
- * @property {object|null} worktree
58
- * @property {PlanModeManager} planManager
59
- * @property {CLIContextEngineering|null} contextEngine
60
- * @property {CLIPermanentMemory|null} permanentMemory
61
- * @property {import("./interaction-adapter.js").WebSocketInteractionAdapter|null} interaction
62
- * @property {string} createdAt
63
- * @property {string} lastActivity
64
- */
65
-
66
- export class WSSessionManager {
67
- /**
68
- * @param {object} options
69
- * @param {object} [options.db] - Database instance
70
- * @param {object} [options.config] - Config object
71
- * @param {string} [options.defaultProjectRoot] - Default project root
72
- */
73
- constructor(options = {}) {
74
- this.db = options.db || null;
75
- this.config = options.config || {};
76
- this.defaultProjectRoot = options.defaultProjectRoot || process.cwd();
77
- this.mcpClient = options.mcpClient || null;
78
- this.allowedMcpServerNames = Array.isArray(options.allowedMcpServerNames)
79
- ? options.allowedMcpServerNames
80
- : null;
81
- this.allowHighRiskMcpServers = options.allowHighRiskMcpServers === true;
82
- this.trustedMcpServers = createTrustedMcpServerMap(
83
- options.mcpServerRegistry || null,
84
- );
85
-
86
- /** @type {Map<string, Session>} */
87
- this.sessions = new Map();
88
- }
89
-
90
- _normalizeEnabledToolNames(enabledToolNames) {
91
- const knownToolNames = new Set(listCodingAgentToolNames());
92
- const requested = Array.isArray(enabledToolNames)
93
- ? enabledToolNames
94
- .map((name) => String(name || "").trim())
95
- .filter(Boolean)
96
- : [];
97
-
98
- const filtered = requested.filter((name) => knownToolNames.has(name));
99
- if (filtered.length > 0) {
100
- return [...new Set(filtered)];
101
- }
102
-
103
- return [...CODING_AGENT_MVP_TOOL_NAMES];
104
- }
105
-
106
- _buildSessionExternalTools() {
107
- if (
108
- !this.mcpClient ||
109
- !(this.mcpClient.servers instanceof Map) ||
110
- typeof this.mcpClient.listTools !== "function"
111
- ) {
112
- return {
113
- definitions: [],
114
- descriptors: {},
115
- executors: {},
116
- };
117
- }
118
-
119
- const definitions = [];
120
- const descriptors = {};
121
- const executors = {};
122
- const seenNames = new Set();
123
-
124
- for (const [serverName, serverState] of this.mcpClient.servers.entries()) {
125
- const serverPolicy = resolveMcpServerPolicy(serverName, serverState, {
126
- allowedMcpServerNames: this.allowedMcpServerNames,
127
- trustedMcpServers: this.trustedMcpServers,
128
- allowHighRiskMcpServers: this.allowHighRiskMcpServers,
129
- });
130
-
131
- if (!serverPolicy.allowed) {
132
- continue;
133
- }
134
-
135
- const serverTools = Array.isArray(serverState?.tools)
136
- ? serverState.tools
137
- : this.mcpClient.listTools(serverName);
138
-
139
- for (const mcpTool of Array.isArray(serverTools) ? serverTools : []) {
140
- const parsedSchema = this._parseToolSchema(mcpTool?.inputSchema) ||
141
- this._parseToolSchema(mcpTool?.input_schema) ||
142
- this._parseToolSchema(mcpTool?.parameters_schema) || {
143
- type: "object",
144
- properties: {},
145
- };
146
- const riskLevel = selectHigherRiskLevel(
147
- serverPolicy.securityLevel,
148
- normalizeRiskLevel(mcpTool?.risk_level, null),
149
- );
150
- const isReadOnly =
151
- normalizeBoolean(mcpTool?.isReadOnly, false) ||
152
- normalizeBoolean(mcpTool?.is_read_only, false) ||
153
- riskLevel === "low";
154
-
155
- let toolName = `mcp_${serverName}_${mcpTool?.name || "tool"}`;
156
- if (seenNames.has(toolName)) {
157
- let index = 2;
158
- let candidate = `${toolName}_${index}`;
159
- while (seenNames.has(candidate)) {
160
- index += 1;
161
- candidate = `${toolName}_${index}`;
162
- }
163
- toolName = candidate;
164
- }
165
- seenNames.add(toolName);
166
-
167
- const descriptor = {
168
- name: toolName,
169
- description: mcpTool?.description || `MCP tool from ${serverName}.`,
170
- inputSchema: parsedSchema,
171
- isReadOnly,
172
- riskLevel,
173
- source: `mcp:${serverName}`,
174
- mcpMetadata: {
175
- serverName,
176
- trusted: serverPolicy.trusted === true,
177
- securityLevel: serverPolicy.securityLevel,
178
- requiredPermissions: serverPolicy.requiredPermissions || [],
179
- capabilities: serverPolicy.capabilities || [],
180
- originalToolName: mcpTool?.name || null,
181
- tool: mcpTool || null,
182
- },
183
- };
184
-
185
- definitions.push({
186
- type: "function",
187
- function: {
188
- name: descriptor.name,
189
- description: descriptor.description,
190
- parameters: JSON.parse(JSON.stringify(descriptor.inputSchema)),
191
- },
192
- });
193
- descriptors[descriptor.name] = descriptor;
194
- executors[descriptor.name] = {
195
- kind: "mcp",
196
- serverName,
197
- toolName: mcpTool?.name || null,
198
- };
199
- }
200
- }
201
-
202
- return {
203
- definitions,
204
- descriptors,
205
- executors,
206
- };
207
- }
208
-
209
- _parseToolSchema(value) {
210
- if (!value) {
211
- return null;
212
- }
213
-
214
- if (typeof value === "object") {
215
- return value;
216
- }
217
-
218
- if (typeof value !== "string") {
219
- return null;
220
- }
221
-
222
- try {
223
- return JSON.parse(value);
224
- } catch (_err) {
225
- return null;
226
- }
227
- }
228
-
229
- /**
230
- * Generate a unique session ID
231
- */
232
- _generateId() {
233
- const hash = createHash("sha256")
234
- .update(Math.random().toString() + Date.now().toString())
235
- .digest("hex")
236
- .slice(0, 8);
237
- return `ws-session-${Date.now()}-${hash}`;
238
- }
239
-
240
- /**
241
- * Create a new session.
242
- *
243
- * @param {object} options
244
- * @param {"agent"|"chat"} [options.type="agent"]
245
- * @param {string} [options.projectRoot]
246
- * @param {string} [options.provider="ollama"]
247
- * @param {string} [options.model]
248
- * @param {string} [options.apiKey]
249
- * @param {string} [options.baseUrl]
250
- * @param {object} [options.hostManagedToolPolicy]
251
- * @returns {{ sessionId: string }}
252
- */
253
- createSession(options = {}) {
254
- const sessionId = this._generateId();
255
- const type = options.type || "agent";
256
- const baseProjectRoot = options.projectRoot || this.defaultProjectRoot;
257
- const cfgLlm = this.config?.llm || {};
258
- const provider = options.provider || cfgLlm.provider || "ollama";
259
- const model =
260
- options.model ||
261
- cfgLlm.model ||
262
- (provider === "ollama" ? "qwen2.5:7b" : null);
263
- const baseUrl =
264
- options.baseUrl || cfgLlm.baseUrl || "http://localhost:11434";
265
- const apiKey = options.apiKey || cfgLlm.apiKey || null;
266
- const worktreeIsolationRequested = options.worktreeIsolation === true;
267
- const enabledToolNames = this._normalizeEnabledToolNames(
268
- options.enabledToolNames,
269
- );
270
- const externalTools = this._buildSessionExternalTools();
271
- const isolatedWorkspace = this._prepareSessionWorkspace(
272
- baseProjectRoot,
273
- sessionId,
274
- {
275
- worktreeIsolation: worktreeIsolationRequested,
276
- },
277
- );
278
- const projectRoot = isolatedWorkspace.projectRoot;
279
- const worktree = isolatedWorkspace.worktree;
280
-
281
- // Project context (rules.md, persona) is now loaded by buildSystemPrompt()
282
-
283
- // Create plan manager (non-singleton, per-session)
284
- const planManager = new PlanModeManager();
285
-
286
- // Create context engine
287
- let contextEngine = null;
288
- let permanentMemory = null;
289
- try {
290
- const memoryDir = path.join(projectRoot, "memory");
291
- permanentMemory = new CLIPermanentMemory({
292
- db: this.db,
293
- memoryDir,
294
- });
295
- permanentMemory.initialize();
296
- } catch (_err) {
297
- // Non-critical
298
- }
299
-
300
- try {
301
- contextEngine = new CLIContextEngineering({
302
- db: this.db,
303
- permanentMemory,
304
- });
305
- } catch (_err) {
306
- // Non-critical
307
- }
308
-
309
- // Build initial system prompt (includes persona + rules.md)
310
- const systemPrompt = buildSystemPrompt(projectRoot);
311
-
312
- const messages = [{ role: "system", content: systemPrompt }];
313
-
314
- // Persist to DB
315
- if (this.db) {
316
- try {
317
- dbCreateSession(this.db, {
318
- id: sessionId,
319
- title: `WS ${type} ${new Date().toISOString().slice(0, 10)}`,
320
- provider,
321
- model: model || "",
322
- messages,
323
- });
324
- } catch (_err) {
325
- // Non-critical
326
- }
327
- }
328
-
329
- const session = {
330
- id: sessionId,
331
- type,
332
- status: "active",
333
- messages,
334
- provider,
335
- model,
336
- apiKey,
337
- baseUrl,
338
- mcpClient: this.mcpClient,
339
- enabledToolNames,
340
- hostManagedToolPolicy: options.hostManagedToolPolicy || null,
341
- externalToolDefinitions: externalTools.definitions,
342
- externalToolDescriptors: externalTools.descriptors,
343
- externalToolExecutors: externalTools.executors,
344
- projectRoot,
345
- baseProjectRoot,
346
- rulesContent: null,
347
- worktreeIsolation: worktreeIsolationRequested,
348
- worktree,
349
- planManager,
350
- contextEngine,
351
- permanentMemory,
352
- reviewState: null,
353
- pendingPatches: new Map(),
354
- patchHistory: [],
355
- interaction: null, // Set by ws-server after creation
356
- createdAt: new Date().toISOString(),
357
- lastActivity: new Date().toISOString(),
358
- };
359
-
360
- if (this.db) {
361
- try {
362
- dbUpdateSession(this.db, sessionId, {
363
- metadata: this._serializeSessionMetadata(session),
364
- });
365
- } catch (_err) {
366
- // Non-critical
367
- }
368
- }
369
-
370
- this._bindPlanManagerPersistence(session);
371
- this.sessions.set(sessionId, session);
372
-
373
- return { sessionId };
374
- }
375
-
376
- /**
377
- * Resume an existing session from DB.
378
- *
379
- * @param {string} sessionId
380
- * @returns {Session|null}
381
- */
382
- resumeSession(sessionId) {
383
- // Check in-memory first
384
- if (this.sessions.has(sessionId)) {
385
- const session = this.sessions.get(sessionId);
386
- session.status = "active";
387
- session.lastActivity = new Date().toISOString();
388
- return session;
389
- }
390
-
391
- // Try loading from DB
392
- if (!this.db) return null;
393
-
394
- try {
395
- const dbSession = dbGetSession(this.db, sessionId);
396
- if (!dbSession) return null;
397
-
398
- const messages =
399
- typeof dbSession.messages === "string"
400
- ? JSON.parse(dbSession.messages)
401
- : dbSession.messages || [];
402
- const metadata = this._normalizeSessionMetadata(dbSession.metadata);
403
- const baseProjectRoot =
404
- metadata.baseProjectRoot ||
405
- metadata.projectRoot ||
406
- this.defaultProjectRoot;
407
- const workspace = this._restoreSessionWorkspace(
408
- dbSession.id,
409
- baseProjectRoot,
410
- metadata,
411
- );
412
- const planManager = this._hydratePlanManager(metadata.planSnapshot);
413
- const externalTools = this._buildSessionExternalTools();
414
- let contextEngine = null;
415
- let permanentMemory = null;
416
-
417
- try {
418
- const memoryDir = path.join(workspace.projectRoot, "memory");
419
- permanentMemory = new CLIPermanentMemory({
420
- db: this.db,
421
- memoryDir,
422
- });
423
- permanentMemory.initialize();
424
- } catch (_err) {
425
- // Non-critical
426
- }
427
-
428
- try {
429
- contextEngine = new CLIContextEngineering({
430
- db: this.db,
431
- permanentMemory,
432
- });
433
- } catch (_err) {
434
- // Non-critical
435
- }
436
-
437
- const session = {
438
- id: dbSession.id,
439
- type: metadata.sessionType || "agent",
440
- status: "active",
441
- messages,
442
- provider: dbSession.provider || "ollama",
443
- model: dbSession.model || null,
444
- apiKey: null,
445
- baseUrl: metadata.baseUrl || "http://localhost:11434",
446
- mcpClient: this.mcpClient,
447
- enabledToolNames: this._normalizeEnabledToolNames(
448
- metadata.enabledToolNames,
449
- ),
450
- hostManagedToolPolicy: metadata.hostManagedToolPolicy || null,
451
- externalToolDefinitions: externalTools.definitions,
452
- externalToolDescriptors: externalTools.descriptors,
453
- externalToolExecutors: externalTools.executors,
454
- projectRoot: workspace.projectRoot,
455
- baseProjectRoot,
456
- rulesContent: null,
457
- worktreeIsolation: metadata.worktreeIsolation === true,
458
- worktree: workspace.worktree,
459
- planManager,
460
- contextEngine,
461
- permanentMemory,
462
- reviewState: metadata.reviewState || null,
463
- pendingPatches: this._hydratePendingPatches(metadata.pendingPatches),
464
- patchHistory: Array.isArray(metadata.patchHistory)
465
- ? metadata.patchHistory
466
- : [],
467
- interaction: null,
468
- createdAt: dbSession.created_at,
469
- lastActivity: new Date().toISOString(),
470
- };
471
-
472
- this._bindPlanManagerPersistence(session);
473
- this.sessions.set(session.id, session);
474
- return session;
475
- } catch (_err) {
476
- return null;
477
- }
478
- }
479
-
480
- /**
481
- * Close a session and persist final state.
482
- *
483
- * @param {string} sessionId
484
- */
485
- closeSession(sessionId) {
486
- const session = this.sessions.get(sessionId);
487
- if (!session) return;
488
-
489
- session.status = "closed";
490
-
491
- // Persist messages to DB
492
- this._persistSessionState(sessionId);
493
-
494
- // Auto-summarize into permanent memory
495
- if (session.permanentMemory && session.messages.length > 4) {
496
- try {
497
- session.permanentMemory.autoSummarize(session.messages);
498
- } catch (_err) {
499
- // Non-critical
500
- }
501
- }
502
-
503
- // Force-complete any active sub-agents for this session
504
- try {
505
- SubAgentRegistry.getInstance().forceCompleteAll(sessionId);
506
- } catch (_err) {
507
- // Non-critical
508
- }
509
-
510
- // Clean up plan manager listeners
511
- if (session.planManager) {
512
- if (typeof session._planPersistenceCleanup === "function") {
513
- try {
514
- session._planPersistenceCleanup();
515
- } catch (_err) {
516
- // Non-critical.
517
- }
518
- }
519
- session.planManager.removeAllListeners();
520
- }
521
-
522
- if (session.worktree?.path && session.baseProjectRoot) {
523
- try {
524
- removeWorktree(session.baseProjectRoot, session.worktree.path, {
525
- deleteBranch: true,
526
- });
527
- } catch (_err) {
528
- // Best-effort cleanup.
529
- }
530
- }
531
-
532
- this.sessions.delete(sessionId);
533
- }
534
-
535
- /**
536
- * List all sessions (in-memory + DB).
537
- *
538
- * @returns {Array<{id, type, status, createdAt, lastActivity}>}
539
- */
540
- listSessions() {
541
- const results = [];
542
-
543
- // In-memory active sessions
544
- for (const [, session] of this.sessions) {
545
- results.push({
546
- id: session.id,
547
- type: session.type,
548
- status: session.status,
549
- provider: session.provider,
550
- model: session.model,
551
- messageCount: session.messages.length,
552
- enabledToolNames: session.enabledToolNames || [],
553
- baseProjectRoot: session.baseProjectRoot,
554
- worktreeIsolation: session.worktreeIsolation === true,
555
- worktree: session.worktree || null,
556
- createdAt: session.createdAt,
557
- lastActivity: session.lastActivity,
558
- });
559
- }
560
-
561
- // DB sessions (exclude already-listed in-memory ones)
562
- if (this.db) {
563
- try {
564
- const dbSessions = dbListSessions(this.db, { limit: 20 });
565
- const inMemoryIds = new Set(this.sessions.keys());
566
- for (const dbs of dbSessions) {
567
- const metadata = this._normalizeSessionMetadata(dbs.metadata);
568
- if (!inMemoryIds.has(dbs.id)) {
569
- results.push({
570
- id: dbs.id,
571
- type: metadata.sessionType || "unknown",
572
- status: "persisted",
573
- provider: dbs.provider,
574
- model: dbs.model,
575
- messageCount: dbs.message_count,
576
- enabledToolNames: Array.isArray(metadata.enabledToolNames)
577
- ? metadata.enabledToolNames
578
- : [],
579
- baseProjectRoot: metadata.baseProjectRoot || null,
580
- worktreeIsolation: metadata.worktreeIsolation === true,
581
- worktree: metadata.worktree || null,
582
- createdAt: dbs.created_at,
583
- lastActivity: dbs.updated_at,
584
- });
585
- }
586
- }
587
- } catch (_err) {
588
- // Non-critical
589
- }
590
- }
591
-
592
- return results;
593
- }
594
-
595
- /**
596
- * Get a session by ID.
597
- *
598
- * @param {string} sessionId
599
- * @returns {Session|null}
600
- */
601
- getSession(sessionId) {
602
- return this.sessions.get(sessionId) || null;
603
- }
604
-
605
- /**
606
- * Update host-managed tool policy for an active session.
607
- *
608
- * @param {string} sessionId
609
- * @param {object|null} hostManagedToolPolicy
610
- * @returns {Session|null}
611
- */
612
- updateSessionPolicy(sessionId, hostManagedToolPolicy) {
613
- const session = this.sessions.get(sessionId);
614
- if (!session) return null;
615
-
616
- session.hostManagedToolPolicy = hostManagedToolPolicy || null;
617
- session.lastActivity = new Date().toISOString();
618
- this._persistSessionState(sessionId);
619
- return session;
620
- }
621
-
622
- /**
623
- * Enter explicit review mode for a session. While in review, handlers
624
- * MUST gate new sendMessage calls until the review is resolved. Reviewer
625
- * sub-agents and human reviewers both feed into the same `comments` /
626
- * `checklist` arrays.
627
- *
628
- * @param {string} sessionId
629
- * @param {{
630
- * reason?: string,
631
- * requestedBy?: string,
632
- * checklist?: Array<{ id?: string, title: string, note?: string }>,
633
- * blocking?: boolean,
634
- * }} [options]
635
- */
636
- enterReview(sessionId, options = {}) {
637
- const session = this.sessions.get(sessionId);
638
- if (!session) return null;
639
-
640
- // If already in pending review, return the existing state unchanged so
641
- // callers can retry safely.
642
- if (session.reviewState && session.reviewState.status === "pending") {
643
- return session.reviewState;
644
- }
645
-
646
- const reviewId = `review-${this._generateId()}`;
647
- const now = new Date().toISOString();
648
- const checklist = Array.isArray(options.checklist)
649
- ? options.checklist.map((item, index) => ({
650
- id: item.id || `chk-${index}-${Date.now()}`,
651
- title: item.title || `Item ${index + 1}`,
652
- note: item.note || null,
653
- done: false,
654
- }))
655
- : [];
656
-
657
- session.reviewState = {
658
- reviewId,
659
- status: "pending",
660
- reason: options.reason || null,
661
- requestedBy: options.requestedBy || "user",
662
- requestedAt: now,
663
- resolvedAt: null,
664
- resolvedBy: null,
665
- decision: null,
666
- blocking: options.blocking !== false,
667
- comments: [],
668
- checklist,
669
- };
670
- session.lastActivity = now;
671
- this._persistSessionState(sessionId);
672
- return session.reviewState;
673
- }
674
-
675
- /**
676
- * Submit an incremental update to the active review — append a comment
677
- * and/or toggle a checklist item. Returns the updated reviewState, or null
678
- * if the session has no active review.
679
- *
680
- * @param {string} sessionId
681
- * @param {{
682
- * comment?: { author?: string, content: string },
683
- * checklistItemId?: string,
684
- * checklistItemDone?: boolean,
685
- * checklistItemNote?: string,
686
- * }} update
687
- */
688
- submitReviewComment(sessionId, update = {}) {
689
- const session = this.sessions.get(sessionId);
690
- if (!session || !session.reviewState) return null;
691
- if (session.reviewState.status !== "pending") return null;
692
-
693
- const now = new Date().toISOString();
694
-
695
- if (update.comment && update.comment.content) {
696
- session.reviewState.comments.push({
697
- id: `cmt-${session.reviewState.comments.length}-${Date.now()}`,
698
- author: update.comment.author || "user",
699
- content: String(update.comment.content),
700
- timestamp: now,
701
- });
702
- }
703
-
704
- if (update.checklistItemId) {
705
- const item = session.reviewState.checklist.find(
706
- (c) => c.id === update.checklistItemId,
707
- );
708
- if (item) {
709
- if (typeof update.checklistItemDone === "boolean") {
710
- item.done = update.checklistItemDone;
711
- }
712
- if (typeof update.checklistItemNote === "string") {
713
- item.note = update.checklistItemNote;
714
- }
715
- }
716
- }
717
-
718
- session.lastActivity = now;
719
- this._persistSessionState(sessionId);
720
- return session.reviewState;
721
- }
722
-
723
- /**
724
- * Resolve the active review with an approved/rejected decision. After
725
- * resolve the session can accept new messages again (reviewState becomes
726
- * non-blocking but is retained for audit).
727
- *
728
- * @param {string} sessionId
729
- * @param {{ decision: "approved"|"rejected", resolvedBy?: string, summary?: string }} payload
730
- */
731
- resolveReview(sessionId, payload = {}) {
732
- const session = this.sessions.get(sessionId);
733
- if (!session || !session.reviewState) return null;
734
- if (session.reviewState.status !== "pending") {
735
- return session.reviewState;
736
- }
737
-
738
- const decision =
739
- payload.decision === "approved" || payload.decision === "rejected"
740
- ? payload.decision
741
- : "approved";
742
-
743
- session.reviewState.status = decision;
744
- session.reviewState.decision = decision;
745
- session.reviewState.resolvedAt = new Date().toISOString();
746
- session.reviewState.resolvedBy = payload.resolvedBy || "user";
747
- session.reviewState.blocking = false;
748
- if (payload.summary) {
749
- session.reviewState.summary = String(payload.summary);
750
- }
751
-
752
- session.lastActivity = session.reviewState.resolvedAt;
753
- this._persistSessionState(sessionId);
754
- return session.reviewState;
755
- }
756
-
757
- /**
758
- * Returns true when the session currently has a blocking review gate
759
- * open. Callers (e.g. handleSessionMessage) should short-circuit with a
760
- * REVIEW_BLOCKING error instead of running the agent turn.
761
- */
762
- isReviewBlocking(sessionId) {
763
- const session = this.sessions.get(sessionId);
764
- if (!session || !session.reviewState) return false;
765
- return (
766
- session.reviewState.status === "pending" &&
767
- session.reviewState.blocking === true
768
- );
769
- }
770
-
771
- getReviewState(sessionId) {
772
- const session = this.sessions.get(sessionId);
773
- return session ? session.reviewState || null : null;
774
- }
775
-
776
- /**
777
- * Record a proposed patch on the session. Accepts one or more file hunks
778
- * that a tool wanted to write but should be previewed before they land.
779
- *
780
- * @param {string} sessionId
781
- * @param {{
782
- * files: Array<{
783
- * path: string,
784
- * op?: "create"|"modify"|"delete",
785
- * before?: string|null,
786
- * after?: string|null,
787
- * diff?: string|null,
788
- * stats?: { added?: number, removed?: number }
789
- * }>,
790
- * origin?: string,
791
- * reason?: string,
792
- * requestId?: string|null
793
- * }} payload
794
- * @returns {object|null} patch record, or null if the session is missing
795
- */
796
- proposePatch(sessionId, payload = {}) {
797
- const session = this.sessions.get(sessionId);
798
- if (!session) return null;
799
-
800
- const files = Array.isArray(payload.files) ? payload.files : [];
801
- if (files.length === 0) return null;
802
-
803
- const patchId = `patch-${this._generateId()}`;
804
- const now = new Date().toISOString();
805
- const normalizedFiles = files.map((file, index) => {
806
- const op = file.op || (file.before == null ? "create" : "modify");
807
- const stats = this._computePatchStats(file);
808
- return {
809
- index,
810
- path: file.path || `unknown-${index}`,
811
- op,
812
- before: file.before == null ? null : String(file.before),
813
- after: file.after == null ? null : String(file.after),
814
- diff: file.diff == null ? null : String(file.diff),
815
- stats,
816
- };
817
- });
818
-
819
- const totalStats = normalizedFiles.reduce(
820
- (acc, file) => ({
821
- added: acc.added + (file.stats.added || 0),
822
- removed: acc.removed + (file.stats.removed || 0),
823
- }),
824
- { added: 0, removed: 0 },
825
- );
826
-
827
- const patch = {
828
- patchId,
829
- status: "pending",
830
- origin: payload.origin || "tool",
831
- reason: payload.reason || null,
832
- requestId: payload.requestId || null,
833
- proposedAt: now,
834
- resolvedAt: null,
835
- resolvedBy: null,
836
- files: normalizedFiles,
837
- stats: {
838
- fileCount: normalizedFiles.length,
839
- added: totalStats.added,
840
- removed: totalStats.removed,
841
- },
842
- };
843
-
844
- if (!(session.pendingPatches instanceof Map)) {
845
- session.pendingPatches = new Map();
846
- }
847
- session.pendingPatches.set(patchId, patch);
848
- session.lastActivity = now;
849
- this._persistSessionState(sessionId);
850
- return patch;
851
- }
852
-
853
- /**
854
- * Mark a pending patch as applied. Moves the record to patchHistory so it
855
- * is still visible in the summary view but no longer counts as pending.
856
- */
857
- applyPatch(sessionId, patchId, options = {}) {
858
- const session = this.sessions.get(sessionId);
859
- if (!session || !(session.pendingPatches instanceof Map)) return null;
860
- const patch = session.pendingPatches.get(patchId);
861
- if (!patch) return null;
862
-
863
- patch.status = "applied";
864
- patch.resolvedAt = new Date().toISOString();
865
- patch.resolvedBy = options.resolvedBy || "user";
866
- if (options.note) {
867
- patch.note = String(options.note);
868
- }
869
-
870
- session.pendingPatches.delete(patchId);
871
- if (!Array.isArray(session.patchHistory)) {
872
- session.patchHistory = [];
873
- }
874
- session.patchHistory.push(patch);
875
- session.lastActivity = patch.resolvedAt;
876
- this._persistSessionState(sessionId);
877
- return patch;
878
- }
879
-
880
- /**
881
- * Discard a pending patch. Same bookkeeping as applyPatch but records a
882
- * "rejected" decision instead.
883
- */
884
- rejectPatch(sessionId, patchId, options = {}) {
885
- const session = this.sessions.get(sessionId);
886
- if (!session || !(session.pendingPatches instanceof Map)) return null;
887
- const patch = session.pendingPatches.get(patchId);
888
- if (!patch) return null;
889
-
890
- patch.status = "rejected";
891
- patch.resolvedAt = new Date().toISOString();
892
- patch.resolvedBy = options.resolvedBy || "user";
893
- if (options.reason) {
894
- patch.rejectionReason = String(options.reason);
895
- }
896
-
897
- session.pendingPatches.delete(patchId);
898
- if (!Array.isArray(session.patchHistory)) {
899
- session.patchHistory = [];
900
- }
901
- session.patchHistory.push(patch);
902
- session.lastActivity = patch.resolvedAt;
903
- this._persistSessionState(sessionId);
904
- return patch;
905
- }
906
-
907
- /**
908
- * Return a flattened summary of all pending + resolved patches on the
909
- * session. Shape matches what the renderer strip consumes:
910
- * { pending: [...], history: [...], totals: { added, removed, fileCount } }
911
- */
912
- getPatchSummary(sessionId) {
913
- const session = this.sessions.get(sessionId);
914
- if (!session) return null;
915
-
916
- const pending =
917
- session.pendingPatches instanceof Map
918
- ? Array.from(session.pendingPatches.values())
919
- : [];
920
- const history = Array.isArray(session.patchHistory)
921
- ? session.patchHistory
922
- : [];
923
-
924
- const totals = [...pending, ...history].reduce(
925
- (acc, patch) => ({
926
- fileCount: acc.fileCount + (patch.stats?.fileCount || 0),
927
- added: acc.added + (patch.stats?.added || 0),
928
- removed: acc.removed + (patch.stats?.removed || 0),
929
- }),
930
- { fileCount: 0, added: 0, removed: 0 },
931
- );
932
-
933
- return { pending, history, totals };
934
- }
935
-
936
- hasPendingPatches(sessionId) {
937
- const session = this.sessions.get(sessionId);
938
- if (!session || !(session.pendingPatches instanceof Map)) return false;
939
- return session.pendingPatches.size > 0;
940
- }
941
-
942
- _computePatchStats(file) {
943
- if (file && file.stats && typeof file.stats === "object") {
944
- return {
945
- added: Number(file.stats.added) || 0,
946
- removed: Number(file.stats.removed) || 0,
947
- };
948
- }
949
- const before = file && typeof file.before === "string" ? file.before : "";
950
- const after = file && typeof file.after === "string" ? file.after : "";
951
- const beforeLines = before ? before.split(/\r?\n/).length : 0;
952
- const afterLines = after ? after.split(/\r?\n/).length : 0;
953
- // Rough heuristic when no explicit diff is provided: full replace counts
954
- // the entire file as added/removed.
955
- if (!before && after) return { added: afterLines, removed: 0 };
956
- if (before && !after) return { added: 0, removed: beforeLines };
957
- return {
958
- added: Math.max(0, afterLines - beforeLines),
959
- removed: Math.max(0, beforeLines - afterLines),
960
- };
961
- }
962
-
963
- /**
964
- * Persist current messages for a session.
965
- */
966
- persistMessages(sessionId) {
967
- const session = this.sessions.get(sessionId);
968
- if (!session || !this.db) return;
969
-
970
- try {
971
- dbSaveMessages(
972
- this.db,
973
- sessionId,
974
- session.messages,
975
- this._serializeSessionMetadata(session),
976
- );
977
- } catch (_err) {
978
- // Non-critical
979
- }
980
-
981
- session.lastActivity = new Date().toISOString();
982
- }
983
-
984
- _prepareSessionWorkspace(projectRoot, sessionId, options = {}) {
985
- if (options.worktreeIsolation !== true) {
986
- return {
987
- projectRoot,
988
- worktree: null,
989
- };
990
- }
991
-
992
- if (!isGitRepo(projectRoot)) {
993
- throw new Error(
994
- `Worktree isolation requires a git repository: ${projectRoot}`,
995
- );
996
- }
997
-
998
- const branchName = `coding-agent/${sessionId}`;
999
- const worktree = createWorktree(projectRoot, branchName);
1000
-
1001
- return {
1002
- projectRoot: worktree.path,
1003
- worktree: {
1004
- branch: worktree.branch,
1005
- path: worktree.path,
1006
- baseProjectRoot: projectRoot,
1007
- },
1008
- };
1009
- }
1010
-
1011
- _persistSessionState(sessionId) {
1012
- const session = this.sessions.get(sessionId);
1013
- if (!session || !this.db) return;
1014
-
1015
- try {
1016
- dbSaveMessages(
1017
- this.db,
1018
- sessionId,
1019
- session.messages,
1020
- this._serializeSessionMetadata(session),
1021
- );
1022
- } catch (_err) {
1023
- // Non-critical
1024
- }
1025
-
1026
- session.lastActivity = new Date().toISOString();
1027
- }
1028
-
1029
- _serializeSessionMetadata(session) {
1030
- return {
1031
- version: 1,
1032
- sessionType: session.type || "agent",
1033
- projectRoot: session.projectRoot || null,
1034
- baseProjectRoot: session.baseProjectRoot || session.projectRoot || null,
1035
- baseUrl: session.baseUrl || null,
1036
- hostManagedToolPolicy: session.hostManagedToolPolicy || null,
1037
- enabledToolNames: session.enabledToolNames || [],
1038
- worktreeIsolation: session.worktreeIsolation === true,
1039
- worktree: session.worktree || null,
1040
- planSnapshot: this._serializePlanManager(session.planManager),
1041
- reviewState: session.reviewState || null,
1042
- pendingPatches:
1043
- session.pendingPatches instanceof Map
1044
- ? Array.from(session.pendingPatches.values())
1045
- : [],
1046
- patchHistory: Array.isArray(session.patchHistory)
1047
- ? session.patchHistory
1048
- : [],
1049
- };
1050
- }
1051
-
1052
- _hydratePendingPatches(list) {
1053
- const map = new Map();
1054
- if (Array.isArray(list)) {
1055
- for (const patch of list) {
1056
- if (patch && patch.patchId) {
1057
- map.set(patch.patchId, patch);
1058
- }
1059
- }
1060
- }
1061
- return map;
1062
- }
1063
-
1064
- _serializePlanManager(planManager) {
1065
- if (!planManager) {
1066
- return null;
1067
- }
1068
-
1069
- return {
1070
- state: planManager.state || PlanState.INACTIVE,
1071
- currentPlan: planManager.currentPlan || null,
1072
- history: Array.isArray(planManager.history) ? planManager.history : [],
1073
- blockedToolLog: Array.isArray(planManager.blockedToolLog)
1074
- ? planManager.blockedToolLog
1075
- : [],
1076
- };
1077
- }
1078
-
1079
- _normalizeSessionMetadata(metadata) {
1080
- if (!metadata) {
1081
- return {};
1082
- }
1083
-
1084
- if (typeof metadata === "string") {
1085
- try {
1086
- return JSON.parse(metadata);
1087
- } catch (_err) {
1088
- return {};
1089
- }
1090
- }
1091
-
1092
- return typeof metadata === "object" ? metadata : {};
1093
- }
1094
-
1095
- _hydratePlanManager(snapshot) {
1096
- const planManager = new PlanModeManager();
1097
- if (!snapshot || typeof snapshot !== "object") {
1098
- return planManager;
1099
- }
1100
-
1101
- planManager.state = snapshot.state || PlanState.INACTIVE;
1102
- planManager.currentPlan = snapshot.currentPlan
1103
- ? new ExecutionPlan(snapshot.currentPlan)
1104
- : null;
1105
- planManager.history = Array.isArray(snapshot.history)
1106
- ? snapshot.history.map((plan) => new ExecutionPlan(plan))
1107
- : [];
1108
- planManager.blockedToolLog = Array.isArray(snapshot.blockedToolLog)
1109
- ? [...snapshot.blockedToolLog]
1110
- : [];
1111
- return planManager;
1112
- }
1113
-
1114
- _restoreSessionWorkspace(sessionId, baseProjectRoot, metadata = {}) {
1115
- const requestedWorktreeIsolation = metadata.worktreeIsolation === true;
1116
- const persistedWorktreePath = metadata.worktree?.path || null;
1117
-
1118
- if (!requestedWorktreeIsolation) {
1119
- return {
1120
- projectRoot: metadata.projectRoot || baseProjectRoot,
1121
- worktree: null,
1122
- };
1123
- }
1124
-
1125
- if (persistedWorktreePath && fs.existsSync(persistedWorktreePath)) {
1126
- return {
1127
- projectRoot: persistedWorktreePath,
1128
- worktree: {
1129
- ...(metadata.worktree || {}),
1130
- baseProjectRoot,
1131
- },
1132
- };
1133
- }
1134
-
1135
- try {
1136
- return this._prepareSessionWorkspace(baseProjectRoot, sessionId, {
1137
- worktreeIsolation: true,
1138
- });
1139
- } catch (_err) {
1140
- return {
1141
- projectRoot: baseProjectRoot,
1142
- worktree: null,
1143
- };
1144
- }
1145
- }
1146
-
1147
- _bindPlanManagerPersistence(session) {
1148
- if (
1149
- !session?.id ||
1150
- !session.planManager ||
1151
- typeof session.planManager.on !== "function"
1152
- ) {
1153
- return;
1154
- }
1155
-
1156
- if (typeof session._planPersistenceCleanup === "function") {
1157
- session._planPersistenceCleanup();
1158
- }
1159
-
1160
- const persist = () => this._persistSessionState(session.id);
1161
- const events = [
1162
- "enter",
1163
- "exit",
1164
- "item-added",
1165
- "plan-ready",
1166
- "plan-approved",
1167
- "tool-blocked",
1168
- ];
1169
-
1170
- for (const eventName of events) {
1171
- session.planManager.on(eventName, persist);
1172
- }
1173
-
1174
- session._planPersistenceCleanup = () => {
1175
- if (typeof session.planManager.off === "function") {
1176
- for (const eventName of events) {
1177
- session.planManager.off(eventName, persist);
1178
- }
1179
- }
1180
- };
1181
- }
1182
- }
12
+ export { WSSessionManager } from "../gateways/ws/ws-session-gateway.js";