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,4 +1,758 @@
1
- export {
2
- ChainlessChainWSServer,
3
- tokenizeCommand,
4
- } from "../../lib/ws-server.js";
1
+ /**
2
+ * ChainlessChain WebSocket Server
3
+ *
4
+ * Exposes CLI commands over WebSocket for remote access by IDE plugins,
5
+ * web frontends, automation scripts, etc. Commands are executed by spawning
6
+ * child processes — all 60+ CLI commands are available immediately.
7
+ *
8
+ * Canonical location (moved from src/lib/ws-server.js as part of the
9
+ * CLI Runtime Convergence roadmap, Phase 6a). src/lib/ws-server.js is
10
+ * now a thin re-export shim for backwards compatibility.
11
+ */
12
+
13
+ import { EventEmitter } from "node:events";
14
+ import { spawn } from "node:child_process";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, join } from "node:path";
17
+ import { WebSocketServer } from "ws";
18
+ import { createTaskRecord } from "../../runtime/contracts/task-record.js";
19
+ import {
20
+ RUNTIME_EVENTS,
21
+ createRuntimeEvent,
22
+ } from "../../runtime/runtime-events.js";
23
+ import { createWsMessageDispatcher } from "./message-dispatcher.js";
24
+ import { handleTaskDetail, handleTaskHistory } from "./task-protocol.js";
25
+ import {
26
+ handleSessionCreate,
27
+ handleSessionResume,
28
+ handleSessionMessage,
29
+ handleSessionPolicyUpdate,
30
+ handleSessionList,
31
+ handleSessionClose,
32
+ handleSessionInterrupt,
33
+ handleSessionAnswer,
34
+ handleHostToolResult,
35
+ handleSubAgentList,
36
+ handleSubAgentGet,
37
+ handleReviewEnter,
38
+ handleReviewSubmit,
39
+ handleReviewResolve,
40
+ handleReviewStatus,
41
+ handlePatchPropose,
42
+ handlePatchApply,
43
+ handlePatchReject,
44
+ handlePatchSummary,
45
+ handleTaskGraphCreate,
46
+ handleTaskGraphAddNode,
47
+ handleTaskGraphUpdateNode,
48
+ handleTaskGraphAdvance,
49
+ handleTaskGraphState,
50
+ } from "./session-protocol.js";
51
+ import { handleSlashCommand, handleOrchestrate } from "./action-protocol.js";
52
+ import {
53
+ handleWorktreeDiff,
54
+ handleWorktreeMerge,
55
+ handleWorktreeMergePreview,
56
+ handleWorktreeAutomationApply,
57
+ handleWorktreeList,
58
+ handleCompressionStats,
59
+ } from "./worktree-protocol.js";
60
+
61
+ const __filename = fileURLToPath(import.meta.url);
62
+ const __dirname = dirname(__filename);
63
+
64
+ /** Absolute path to the CLI entry point */
65
+ const BIN_PATH = join(__dirname, "..", "..", "..", "bin", "chainlesschain.js");
66
+
67
+ /** Commands that must not be executed via WebSocket */
68
+ const BLOCKED_COMMANDS = new Set(["serve", "chat", "agent", "setup"]);
69
+
70
+ /** Heartbeat interval (ms) */
71
+ const HEARTBEAT_INTERVAL = 30_000;
72
+
73
+ /**
74
+ * Tokenize a command string into an array of arguments.
75
+ * Handles double-quoted and single-quoted strings. Does NOT invoke a shell.
76
+ */
77
+ export function tokenizeCommand(input) {
78
+ const args = [];
79
+ let current = "";
80
+ let inDouble = false;
81
+ let inSingle = false;
82
+ let escape = false;
83
+
84
+ for (const ch of input) {
85
+ if (escape) {
86
+ current += ch;
87
+ escape = false;
88
+ continue;
89
+ }
90
+ if (ch === "\\" && inDouble) {
91
+ escape = true;
92
+ continue;
93
+ }
94
+ if (ch === '"' && !inSingle) {
95
+ inDouble = !inDouble;
96
+ continue;
97
+ }
98
+ if (ch === "'" && !inDouble) {
99
+ inSingle = !inSingle;
100
+ continue;
101
+ }
102
+ if ((ch === " " || ch === "\t") && !inDouble && !inSingle) {
103
+ if (current.length > 0) {
104
+ args.push(current);
105
+ current = "";
106
+ }
107
+ continue;
108
+ }
109
+ current += ch;
110
+ }
111
+ if (current.length > 0) {
112
+ args.push(current);
113
+ }
114
+ return args;
115
+ }
116
+
117
+ export class ChainlessChainWSServer extends EventEmitter {
118
+ /**
119
+ * @param {object} options
120
+ * @param {number} [options.port=18800]
121
+ * @param {string} [options.host="127.0.0.1"]
122
+ * @param {string} [options.token] - If set, clients must authenticate first
123
+ * @param {number} [options.maxConnections=10]
124
+ * @param {number} [options.timeout=30000] - Command execution timeout (ms)
125
+ */
126
+ constructor(options = {}) {
127
+ super();
128
+ this.port = options.port || 18800;
129
+ this.host = options.host || "127.0.0.1";
130
+ this.token = options.token || null;
131
+ this.maxConnections = options.maxConnections || 10;
132
+ this.timeout = options.timeout || 30000;
133
+
134
+ /** @type {WebSocketServer|null} */
135
+ this.wss = null;
136
+
137
+ /** Connected clients: clientId → { ws, authenticated, connectedAt } */
138
+ this.clients = new Map();
139
+
140
+ /** Running child processes: requestId → ChildProcess */
141
+ this.processes = new Map();
142
+
143
+ /** Session manager for stateful agent/chat sessions */
144
+ this.sessionManager = options.sessionManager || null;
145
+
146
+ /** Session handlers: sessionId → WSAgentHandler | WSChatHandler */
147
+ this.sessionHandlers = new Map();
148
+ this._dispatcher = createWsMessageDispatcher(this);
149
+
150
+ this._heartbeatTimer = null;
151
+ this._clientCounter = 0;
152
+ }
153
+
154
+ /** Start the WebSocket server */
155
+ start() {
156
+ return new Promise((resolve, reject) => {
157
+ this.wss = new WebSocketServer({
158
+ port: this.port,
159
+ host: this.host,
160
+ });
161
+
162
+ this.wss.on("listening", () => {
163
+ this._startHeartbeat();
164
+ this.emit("listening", { port: this.port, host: this.host });
165
+ resolve();
166
+ });
167
+
168
+ this.wss.on("error", (err) => {
169
+ this.emit("error", err);
170
+ reject(err);
171
+ });
172
+
173
+ this.wss.on("connection", (ws, req) => this._handleConnection(ws, req));
174
+ });
175
+ }
176
+
177
+ /** Stop the server and clean up */
178
+ async stop() {
179
+ if (this._heartbeatTimer) {
180
+ clearInterval(this._heartbeatTimer);
181
+ this._heartbeatTimer = null;
182
+ }
183
+
184
+ // Close all session handlers
185
+ for (const [sessionId, handler] of this.sessionHandlers) {
186
+ if (handler && handler.destroy) {
187
+ handler.destroy();
188
+ }
189
+ if (this.sessionManager) {
190
+ try {
191
+ this.sessionManager.closeSession(sessionId);
192
+ } catch (_err) {
193
+ // Non-critical
194
+ }
195
+ }
196
+ }
197
+ this.sessionHandlers.clear();
198
+
199
+ // Kill all running child processes
200
+ for (const [id, child] of this.processes) {
201
+ try {
202
+ child.kill("SIGTERM");
203
+ } catch (_err) {
204
+ // Process may have already exited
205
+ }
206
+ this.processes.delete(id);
207
+ }
208
+
209
+ // Close all client connections
210
+ for (const [, client] of this.clients) {
211
+ try {
212
+ client.ws.close(1001, "Server shutting down");
213
+ } catch (_err) {
214
+ // Connection may already be closed
215
+ }
216
+ }
217
+ this.clients.clear();
218
+
219
+ // Close the server
220
+ if (this.wss) {
221
+ await new Promise((resolve) => {
222
+ this.wss.close(() => resolve());
223
+ });
224
+ this.wss = null;
225
+ }
226
+
227
+ this.emit("stopped");
228
+ }
229
+
230
+ /** @private */
231
+ _handleConnection(ws, req) {
232
+ if (this.clients.size >= this.maxConnections) {
233
+ ws.close(1013, "Max connections reached");
234
+ return;
235
+ }
236
+
237
+ const clientId = `client-${++this._clientCounter}`;
238
+ const clientIp =
239
+ req.socket.remoteAddress || req.headers["x-forwarded-for"] || "unknown";
240
+
241
+ this.clients.set(clientId, {
242
+ ws,
243
+ authenticated: !this.token, // If no token required, auto-authenticated
244
+ connectedAt: Date.now(),
245
+ ip: clientIp,
246
+ alive: true,
247
+ });
248
+
249
+ this.emit("connection", { clientId, ip: clientIp });
250
+
251
+ ws.on("message", (data) => {
252
+ try {
253
+ const message = JSON.parse(data.toString("utf8"));
254
+ this._handleMessage(clientId, ws, message);
255
+ } catch (_err) {
256
+ this._send(ws, {
257
+ type: "error",
258
+ code: "INVALID_JSON",
259
+ message: "Failed to parse message as JSON",
260
+ });
261
+ }
262
+ });
263
+
264
+ ws.on("close", () => {
265
+ this.clients.delete(clientId);
266
+ this.emit("disconnection", { clientId });
267
+ });
268
+
269
+ ws.on("pong", () => {
270
+ const client = this.clients.get(clientId);
271
+ if (client) client.alive = true;
272
+ });
273
+ }
274
+
275
+ /** @private */
276
+ async _handleMessage(clientId, ws, message) {
277
+ return this._dispatcher.dispatch(clientId, ws, message);
278
+ }
279
+
280
+ /**
281
+ * Handle an orchestrate message — runs ChainlessChain orchestration with
282
+ * real-time progress pushed back over this WebSocket connection.
283
+ *
284
+ * Message format:
285
+ * { type: "orchestrate", id: "req-1", task: "Fix bug X",
286
+ * cwd: "/path", agents: 3, ci: "npm test", notify: false }
287
+ *
288
+ * Events emitted back:
289
+ * { type: "orchestrate:event", event: "start|agent:output|ci:pass|ci:fail|task:status", ... }
290
+ * { type: "orchestrate:done", id, taskId, status }
291
+ * { type: "error", code: "ORCHESTRATE_FAILED", ... }
292
+ */
293
+ async _handleOrchestrate(id, ws, message) {
294
+ return handleOrchestrate(this, id, ws, message);
295
+ }
296
+
297
+ /** @private – list background tasks */
298
+ async _handleTasksList(id, ws) {
299
+ try {
300
+ await this._ensureTaskManager();
301
+ const tasks = this._taskManager.list();
302
+ this._send(ws, { id, type: "tasks-list", tasks });
303
+ } catch (err) {
304
+ this._send(ws, { id, type: "tasks-list", tasks: [] });
305
+ }
306
+ }
307
+
308
+ /** @private — subscribe to task completion events and broadcast to all clients */
309
+ _subscribeTaskNotifications() {
310
+ if (!this._taskManager || this._taskNotificationsSubscribed) return;
311
+ this._taskNotificationsSubscribed = true;
312
+
313
+ this._taskManager.on("task:complete", (task) => {
314
+ const record = createTaskRecord(task, {
315
+ source: "background-task-manager",
316
+ });
317
+ this.emit(
318
+ RUNTIME_EVENTS.TASK_NOTIFICATION,
319
+ createRuntimeEvent(
320
+ RUNTIME_EVENTS.TASK_NOTIFICATION,
321
+ { task: record },
322
+ { kind: "server" },
323
+ ),
324
+ );
325
+ this._broadcast({
326
+ type: "task:notification",
327
+ task: record,
328
+ });
329
+ });
330
+ }
331
+
332
+ /** @private – stop a background task */
333
+ async _handleTasksStop(id, ws, message) {
334
+ try {
335
+ await this._ensureTaskManager();
336
+
337
+ if (this._taskManager && message.taskId) {
338
+ this._taskManager.stop(message.taskId);
339
+ this._send(ws, { id, type: "tasks-stopped", taskId: message.taskId });
340
+ } else {
341
+ this._send(ws, {
342
+ id,
343
+ type: "error",
344
+ code: "NO_TASK",
345
+ message: "taskId required or no task manager",
346
+ });
347
+ }
348
+ } catch (err) {
349
+ this._send(ws, {
350
+ id,
351
+ type: "error",
352
+ code: "TASKS_STOP_FAILED",
353
+ message: err.message,
354
+ });
355
+ }
356
+ }
357
+
358
+ /** @private */
359
+ async _handleTaskDetail(id, ws, message) {
360
+ return handleTaskDetail(this, id, ws, message);
361
+ }
362
+
363
+ /** @private */
364
+ async _handleTaskHistory(id, ws, message) {
365
+ return handleTaskHistory(this, id, ws, message);
366
+ }
367
+
368
+ /** @private — diff preview for agent worktree branch */
369
+ async _handleWorktreeDiff(id, ws, message) {
370
+ return handleWorktreeDiff(this, id, ws, message);
371
+ }
372
+
373
+ /** @private — one-click merge of agent worktree branch */
374
+ async _handleWorktreeMerge(id, ws, message) {
375
+ return handleWorktreeMerge(this, id, ws, message);
376
+ }
377
+
378
+ /** @private - dry-run merge preview for an agent worktree branch */
379
+ async _handleWorktreeMergePreview(id, ws, message) {
380
+ return handleWorktreeMergePreview(this, id, ws, message);
381
+ }
382
+
383
+ /** @private - apply a safe automation candidate inside an agent worktree */
384
+ async _handleWorktreeAutomationApply(id, ws, message) {
385
+ return handleWorktreeAutomationApply(this, id, ws, message);
386
+ }
387
+
388
+ /** @private - list agent worktrees */
389
+ async _handleWorktreeList(id, ws) {
390
+ return handleWorktreeList(this, id, ws);
391
+ }
392
+
393
+ /** @private */
394
+ async _handleCompressionStats(id, ws, message) {
395
+ return handleCompressionStats(this, id, ws, message);
396
+ }
397
+
398
+ /** @private */
399
+ _handleAuth(clientId, ws, message) {
400
+ const { id, token } = message;
401
+ const success = token === this.token;
402
+ const client = this.clients.get(clientId);
403
+
404
+ if (success && client) {
405
+ client.authenticated = true;
406
+ }
407
+
408
+ this._send(ws, {
409
+ id,
410
+ type: "auth-result",
411
+ success,
412
+ ...(success ? {} : { message: "Invalid token" }),
413
+ });
414
+
415
+ if (!success) {
416
+ // Disconnect after failed auth
417
+ setTimeout(() => ws.close(4001, "Authentication failed"), 100);
418
+ }
419
+ }
420
+
421
+ /** @private */
422
+ _executeCommand(id, ws, command, stream) {
423
+ if (!command || typeof command !== "string") {
424
+ this._send(ws, {
425
+ id,
426
+ type: "error",
427
+ code: "INVALID_COMMAND",
428
+ message: "Command must be a non-empty string",
429
+ });
430
+ return;
431
+ }
432
+
433
+ const args = tokenizeCommand(command.trim());
434
+ if (args.length === 0) {
435
+ this._send(ws, {
436
+ id,
437
+ type: "error",
438
+ code: "INVALID_COMMAND",
439
+ message: "Empty command",
440
+ });
441
+ return;
442
+ }
443
+
444
+ // Block dangerous/interactive commands
445
+ const baseCmd = args[0];
446
+ if (BLOCKED_COMMANDS.has(baseCmd)) {
447
+ this._send(ws, {
448
+ id,
449
+ type: "error",
450
+ code: "COMMAND_BLOCKED",
451
+ message: `Command "${baseCmd}" cannot be executed via WebSocket (interactive or recursive)`,
452
+ });
453
+ return;
454
+ }
455
+
456
+ const child = spawn(process.execPath, [BIN_PATH, ...args], {
457
+ env: {
458
+ ...process.env,
459
+ FORCE_COLOR: "0",
460
+ NO_SPINNER: "1",
461
+ },
462
+ stdio: ["pipe", "pipe", "pipe"],
463
+ windowsHide: true,
464
+ });
465
+
466
+ this.processes.set(id, child);
467
+ this.emit("command:start", { id, command, stream });
468
+
469
+ // Timeout handling
470
+ const timer = setTimeout(() => {
471
+ if (this.processes.has(id)) {
472
+ try {
473
+ child.kill("SIGTERM");
474
+ } catch (_err) {
475
+ // Process may have already exited
476
+ }
477
+ this.processes.delete(id);
478
+ this._send(ws, {
479
+ id,
480
+ type: "error",
481
+ code: "COMMAND_TIMEOUT",
482
+ message: `Command timed out after ${this.timeout}ms`,
483
+ });
484
+ }
485
+ }, this.timeout);
486
+
487
+ if (stream) {
488
+ // Stream mode: send chunks as they arrive
489
+ child.stdout.on("data", (data) => {
490
+ this._send(ws, {
491
+ id,
492
+ type: "stream-data",
493
+ channel: "stdout",
494
+ data: data.toString("utf8"),
495
+ });
496
+ });
497
+
498
+ child.stderr.on("data", (data) => {
499
+ this._send(ws, {
500
+ id,
501
+ type: "stream-data",
502
+ channel: "stderr",
503
+ data: data.toString("utf8"),
504
+ });
505
+ });
506
+
507
+ child.on("close", (exitCode) => {
508
+ clearTimeout(timer);
509
+ this.processes.delete(id);
510
+ this._send(ws, {
511
+ id,
512
+ type: "stream-end",
513
+ exitCode: exitCode ?? 1,
514
+ });
515
+ this.emit("command:end", { id, exitCode });
516
+ });
517
+ } else {
518
+ // Buffered mode: collect all output then send result
519
+ const stdoutChunks = [];
520
+ const stderrChunks = [];
521
+
522
+ child.stdout.on("data", (data) => stdoutChunks.push(data));
523
+ child.stderr.on("data", (data) => stderrChunks.push(data));
524
+
525
+ child.on("close", (exitCode) => {
526
+ clearTimeout(timer);
527
+ this.processes.delete(id);
528
+
529
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
530
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
531
+
532
+ this._send(ws, {
533
+ id,
534
+ type: "result",
535
+ success: exitCode === 0,
536
+ exitCode: exitCode ?? 1,
537
+ stdout,
538
+ stderr,
539
+ });
540
+ this.emit("command:end", { id, exitCode });
541
+ });
542
+ }
543
+
544
+ child.on("error", (err) => {
545
+ clearTimeout(timer);
546
+ this.processes.delete(id);
547
+ this._send(ws, {
548
+ id,
549
+ type: "error",
550
+ code: "SPAWN_ERROR",
551
+ message: err.message,
552
+ });
553
+ });
554
+ }
555
+
556
+ /** @private */
557
+ _cancelRequest(id, ws) {
558
+ const child = this.processes.get(id);
559
+ if (child) {
560
+ try {
561
+ child.kill("SIGTERM");
562
+ } catch (_err) {
563
+ // Process may have already exited
564
+ }
565
+ this.processes.delete(id);
566
+ this._send(ws, {
567
+ id,
568
+ type: "result",
569
+ success: false,
570
+ exitCode: -1,
571
+ stdout: "",
572
+ stderr: "Cancelled by client",
573
+ });
574
+ } else {
575
+ this._send(ws, {
576
+ id,
577
+ type: "error",
578
+ code: "NOT_FOUND",
579
+ message: `No running command with id "${id}"`,
580
+ });
581
+ }
582
+ }
583
+
584
+ // ─── Session handlers ─────────────────────────────────────────────
585
+
586
+ /** @private */
587
+ async _handleSessionCreate(id, ws, message) {
588
+ return handleSessionCreate(this, id, ws, message);
589
+ }
590
+
591
+ /** @private */
592
+ async _handleSessionResume(id, ws, message) {
593
+ return handleSessionResume(this, id, ws, message);
594
+ }
595
+
596
+ /** @private */
597
+ _handleSessionMessage(id, ws, message) {
598
+ return handleSessionMessage(this, id, ws, message);
599
+ }
600
+
601
+ /** @private */
602
+ _handleSessionPolicyUpdate(id, ws, message) {
603
+ return handleSessionPolicyUpdate(this, id, ws, message);
604
+ }
605
+
606
+ /** @private */
607
+ _handleSessionList(id, ws) {
608
+ return handleSessionList(this, id, ws);
609
+ }
610
+
611
+ /** @private */
612
+ _handleSessionClose(id, ws, message) {
613
+ return handleSessionClose(this, id, ws, message);
614
+ }
615
+
616
+ /** @private */
617
+ _handleSessionInterrupt(id, ws, message) {
618
+ return handleSessionInterrupt(this, id, ws, message);
619
+ }
620
+
621
+ /** @private */
622
+ _handleSlashCommand(id, ws, message) {
623
+ return handleSlashCommand(this, id, ws, message);
624
+ }
625
+
626
+ /** @private */
627
+ _handleSessionAnswer(id, ws, message) {
628
+ return handleSessionAnswer(this, id, ws, message);
629
+ }
630
+
631
+ _handleHostToolResult(id, ws, message) {
632
+ return handleHostToolResult(this, id, ws, message);
633
+ }
634
+
635
+ /** @private */
636
+ _handleSubAgentList(id, ws, message) {
637
+ return handleSubAgentList(this, id, ws, message);
638
+ }
639
+
640
+ /** @private */
641
+ _handleSubAgentGet(id, ws, message) {
642
+ return handleSubAgentGet(this, id, ws, message);
643
+ }
644
+
645
+ /** @private */
646
+ _handleReviewEnter(id, ws, message) {
647
+ return handleReviewEnter(this, id, ws, message);
648
+ }
649
+
650
+ /** @private */
651
+ _handleReviewSubmit(id, ws, message) {
652
+ return handleReviewSubmit(this, id, ws, message);
653
+ }
654
+
655
+ /** @private */
656
+ _handleReviewResolve(id, ws, message) {
657
+ return handleReviewResolve(this, id, ws, message);
658
+ }
659
+
660
+ /** @private */
661
+ _handleReviewStatus(id, ws, message) {
662
+ return handleReviewStatus(this, id, ws, message);
663
+ }
664
+
665
+ /** @private */
666
+ _handlePatchPropose(id, ws, message) {
667
+ return handlePatchPropose(this, id, ws, message);
668
+ }
669
+
670
+ /** @private */
671
+ _handlePatchApply(id, ws, message) {
672
+ return handlePatchApply(this, id, ws, message);
673
+ }
674
+
675
+ /** @private */
676
+ _handlePatchReject(id, ws, message) {
677
+ return handlePatchReject(this, id, ws, message);
678
+ }
679
+
680
+ /** @private */
681
+ _handlePatchSummary(id, ws, message) {
682
+ return handlePatchSummary(this, id, ws, message);
683
+ }
684
+
685
+ /** @private */
686
+ _handleTaskGraphCreate(id, ws, message) {
687
+ return handleTaskGraphCreate(this, id, ws, message);
688
+ }
689
+
690
+ /** @private */
691
+ _handleTaskGraphAddNode(id, ws, message) {
692
+ return handleTaskGraphAddNode(this, id, ws, message);
693
+ }
694
+
695
+ /** @private */
696
+ _handleTaskGraphUpdateNode(id, ws, message) {
697
+ return handleTaskGraphUpdateNode(this, id, ws, message);
698
+ }
699
+
700
+ /** @private */
701
+ _handleTaskGraphAdvance(id, ws, message) {
702
+ return handleTaskGraphAdvance(this, id, ws, message);
703
+ }
704
+
705
+ /** @private */
706
+ _handleTaskGraphState(id, ws, message) {
707
+ return handleTaskGraphState(this, id, ws, message);
708
+ }
709
+
710
+ /** @private — ping/pong heartbeat to detect dead connections */
711
+ async _ensureTaskManager() {
712
+ if (this._taskManager) return this._taskManager;
713
+ const { BackgroundTaskManager } =
714
+ await import("../../harness/background-task-manager.js");
715
+ this._taskManager = new BackgroundTaskManager({ recoverOnStart: true });
716
+ this._subscribeTaskNotifications();
717
+ return this._taskManager;
718
+ }
719
+
720
+ _startHeartbeat() {
721
+ this._heartbeatTimer = setInterval(() => {
722
+ for (const [clientId, client] of this.clients) {
723
+ if (!client.alive) {
724
+ client.ws.terminate();
725
+ this.clients.delete(clientId);
726
+ this.emit("disconnection", { clientId, reason: "heartbeat timeout" });
727
+ continue;
728
+ }
729
+ client.alive = false;
730
+ try {
731
+ client.ws.ping();
732
+ } catch (_err) {
733
+ // Connection may be closing
734
+ }
735
+ }
736
+ }, HEARTBEAT_INTERVAL);
737
+ }
738
+
739
+ /** @private — safe JSON send */
740
+ _send(ws, data) {
741
+ if (ws.readyState === ws.OPEN) {
742
+ try {
743
+ ws.send(JSON.stringify(data));
744
+ } catch (_err) {
745
+ // Connection may have just closed
746
+ }
747
+ }
748
+ }
749
+
750
+ /** @private — broadcast a message to all connected, authenticated clients */
751
+ _broadcast(data) {
752
+ for (const [, client] of this.clients) {
753
+ if (client.authenticated || !this.token) {
754
+ this._send(client.ws, data);
755
+ }
756
+ }
757
+ }
758
+ }