@virtengine/openfleet 0.25.0

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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,914 @@
1
+ /**
2
+ * vk-log-stream.mjs — Real-time agent log capture from Vibe-Kanban WebSocket.
3
+ *
4
+ * Connects to VK's execution-process raw-logs WebSocket endpoints to capture
5
+ * agent stdout/stderr that would otherwise be invisible to the monitor.
6
+ *
7
+ * VK Architecture (reverse-engineered from BloopAI/vibe-kanban):
8
+ * - Each agent session creates an "execution process" inside VK
9
+ * - Agent stdout/stderr → MsgStore (in-memory broadcast channel)
10
+ * - MsgStore → raw-logs WebSocket endpoint for live streaming
11
+ * - MsgStore → SQLite (JSONL) for persistence
12
+ *
13
+ * VK API endpoints used:
14
+ * GET /api/execution-processes/{id} — get single process (REST)
15
+ * GET /api/execution-processes/{id}/raw-logs/ws — raw log stream (WebSocket)
16
+ * GET /api/execution-processes/stream/session/ws?session_id=X — process discovery per session (WebSocket, JSON Patch)
17
+ *
18
+ * Note: There is NO list-all endpoint (GET /api/execution-processes). Discovery
19
+ * must go through the session-based WebSocket stream or direct connectToProcess() calls.
20
+ *
21
+ * WebSocket message format (LogMsg enum variants):
22
+ * {"Stdout": "line of text"}
23
+ * {"Stderr": "line of text"}
24
+ * {"JsonPatch": [{"op": "add", "path": "...", "value": {...}}]}
25
+ * {"Finished": ""}
26
+ *
27
+ * Usage:
28
+ * import { VkLogStream } from "./vk-log-stream.mjs";
29
+ * const stream = new VkLogStream(vkEndpointUrl, { logDir, onLine });
30
+ * stream.start();
31
+ * stream.connectToSession(sessionId); // discover processes via session stream
32
+ * stream.connectToProcess(processId); // direct connection to known process
33
+ * // ... later
34
+ * stream.stop();
35
+ */
36
+
37
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
38
+ import { resolve } from "node:path";
39
+
40
+ // ── Configuration defaults ──────────────────────────────────────────────────
41
+ const RECONNECT_DELAY_MS = 3000; // Delay before reconnecting a dropped WebSocket
42
+ const MAX_RECONNECT_ATTEMPTS = 10; // Max consecutive reconnect failures per process
43
+ const SESSION_RECONNECT_DELAY_MS = 5000; // Delay before reconnecting a dropped session stream
44
+ const MAX_SESSION_RECONNECT_ATTEMPTS = 15; // Max consecutive reconnect failures per session
45
+
46
+ /**
47
+ * VkLogStream - Captures real-time agent logs from VK execution processes.
48
+ *
49
+ * Discovery model:
50
+ * - No REST list endpoint exists; discovery uses the session-based WebSocket
51
+ * (stream/session/ws) which pushes JSON Patch updates as processes start/stop.
52
+ * - Monitor calls connectToSession(sessionId) when sessions are created.
53
+ * - Monitor calls connectToProcess(processId) when a specific process ID is known.
54
+ */
55
+ export class VkLogStream {
56
+ /** @type {string} VK API base URL (e.g. http://192.168.0.161:54089) */
57
+ #baseUrl;
58
+
59
+ /** @type {string} WebSocket base URL (ws:// or wss://) */
60
+ #wsBaseUrl;
61
+
62
+ /** @type {string} Directory to write per-process log files */
63
+ #logDir;
64
+
65
+ /** @type {string|null} Directory for structured VK session logs (like codex-exec) */
66
+ #sessionLogDir;
67
+
68
+ /** @type {boolean} Whether to echo log lines to console */
69
+ #echo;
70
+
71
+ /** @type {((line: string, meta: {processId: string, stream: string}) => boolean)|null} */
72
+ #filterLine;
73
+
74
+ /** @type {((line: string, meta: {processId: string, stream: string}) => void)|null} */
75
+ #onLine;
76
+
77
+ /** @type {Map<string, WebSocket>} Active raw-log WebSocket connections by process ID */
78
+ #connections = new Map();
79
+
80
+ /** @type {Map<string, number>} Reconnect attempt counts by process ID */
81
+ #reconnectCounts = new Map();
82
+
83
+ /** @type {Set<string>} Process IDs that have sent Finished */
84
+ #finished = new Set();
85
+
86
+ /** @type {boolean} Whether the stream is running */
87
+ #running = false;
88
+
89
+ /** @type {Set<string>} Known process IDs (to avoid re-connecting) */
90
+ #knownProcessIds = new Set();
91
+
92
+ /** @type {Map<string, WebSocket>} Session stream WebSocket connections by session ID */
93
+ #sessionStreams = new Map();
94
+
95
+ /** @type {Map<string, number>} Session stream reconnect attempt counts */
96
+ #sessionReconnectCounts = new Map();
97
+
98
+ /** @type {Set<string>} Session IDs we're tracking */
99
+ #trackedSessions = new Set();
100
+
101
+ /** @type {Map<string, object>} Per-process task metadata (attemptId, taskTitle, branch, etc.) */
102
+ #processMeta = new Map();
103
+
104
+ /** @type {Set<string>} Process IDs that already had their session-log header written */
105
+ #sessionLogHeaderWritten = new Set();
106
+
107
+ /**
108
+ * Callback invoked when a new execution process is discovered and connected.
109
+ * The monitor uses this to look up task metadata and call setProcessMeta().
110
+ * @type {((processId: string, meta: {sessionId?: string, runReason?: string}) => void)|null}
111
+ */
112
+ #onProcessConnected;
113
+
114
+ /**
115
+ * @param {string} vkEndpointUrl - VK API base URL (e.g. http://127.0.0.1:54089)
116
+ * @param {object} [opts]
117
+ * @param {string} [opts.logDir] - Directory for per-process log files
118
+ * @param {string} [opts.sessionLogDir] - Directory for structured session logs (codex-exec style)
119
+ * @param {boolean} [opts.echo=false] - Echo log lines to console
120
+ * @param {(line: string, meta: {processId: string, stream: string}) => boolean} [opts.filterLine] - Return false to drop noisy lines
121
+ * @param {(line: string, meta: {processId: string, stream: string}) => void} [opts.onLine] - Callback per log line
122
+ * @param {(processId: string, meta: {sessionId?: string}) => void} [opts.onProcessConnected] - Callback when new process discovered
123
+ */
124
+ constructor(vkEndpointUrl, opts = {}) {
125
+ this.#baseUrl = vkEndpointUrl.replace(/\/+$/, "");
126
+ this.#wsBaseUrl = this.#baseUrl
127
+ .replace(/^http:/, "ws:")
128
+ .replace(/^https:/, "wss:");
129
+ this.#logDir = opts.logDir || null;
130
+ this.#sessionLogDir = opts.sessionLogDir || null;
131
+ this.#echo = opts.echo || false;
132
+ this.#filterLine = typeof opts.filterLine === "function" ? opts.filterLine : null;
133
+ this.#onLine = opts.onLine || null;
134
+ this.#onProcessConnected = opts.onProcessConnected || null;
135
+
136
+ if (this.#logDir) {
137
+ try {
138
+ mkdirSync(this.#logDir, { recursive: true });
139
+ } catch {
140
+ /* best effort */
141
+ }
142
+ }
143
+ if (this.#sessionLogDir) {
144
+ try {
145
+ mkdirSync(this.#sessionLogDir, { recursive: true });
146
+ } catch {
147
+ /* best effort */
148
+ }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Set task metadata for a process, used to enrich structured session logs.
154
+ * Call this before or after connectToProcess/connectToSession — metadata
155
+ * will be applied whenever the first log line arrives.
156
+ *
157
+ * @param {string} processId - The execution process UUID
158
+ * @param {object} meta - Task context metadata
159
+ * @param {string} [meta.attemptId] - VK attempt UUID
160
+ * @param {string} [meta.taskId] - VK task UUID
161
+ * @param {string} [meta.taskTitle] - Human-readable task title
162
+ * @param {string} [meta.branch] - Git branch name
163
+ * @param {string} [meta.sessionId] - VK session UUID
164
+ * @param {string} [meta.executor] - Executor type (e.g. "codex", "copilot")
165
+ * @param {string} [meta.executorVariant] - Executor variant
166
+ */
167
+ setProcessMeta(processId, meta) {
168
+ if (!processId) return;
169
+ const existing = this.#processMeta.get(processId) || {};
170
+ this.#processMeta.set(processId, { ...existing, ...meta });
171
+ }
172
+
173
+ /**
174
+ * Get the structured session log path for a process.
175
+ * Lazily assigns a stable timestamp on first call per process.
176
+ * @param {string} processId
177
+ * @returns {string|null}
178
+ */
179
+ getSessionLogPath(processId) {
180
+ if (!this.#sessionLogDir || !processId) return null;
181
+ const shortId = processId.slice(0, 8);
182
+ let meta = this.#processMeta.get(processId);
183
+ if (!meta) {
184
+ meta = {};
185
+ this.#processMeta.set(processId, meta);
186
+ }
187
+ if (!meta._logStamp) {
188
+ meta._logStamp = new Date().toISOString().replace(/[:.]/g, "-");
189
+ }
190
+ return resolve(
191
+ this.#sessionLogDir,
192
+ `vk-session-${meta._logStamp}-${shortId}.log`,
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Start the log stream manager (enables connections, no automatic polling).
198
+ * Call connectToSession() or connectToProcess() to actually capture logs.
199
+ */
200
+ start() {
201
+ if (this.#running) return;
202
+ this.#running = true;
203
+ console.log(
204
+ `[vk-log-stream] started — ready for session/process connections (${this.#baseUrl})`,
205
+ );
206
+ }
207
+
208
+ /**
209
+ * Stop all connections (raw-log streams + session streams).
210
+ */
211
+ stop() {
212
+ if (!this.#running) return;
213
+ this.#running = false;
214
+
215
+ // Close session stream WebSockets
216
+ for (const [id, ws] of this.#sessionStreams) {
217
+ try {
218
+ ws.close(1000, "monitor shutdown");
219
+ } catch {
220
+ /* best effort */
221
+ }
222
+ }
223
+ this.#sessionStreams.clear();
224
+ this.#sessionReconnectCounts.clear();
225
+ this.#trackedSessions.clear();
226
+
227
+ // Close raw-log WebSockets
228
+ for (const [id, ws] of this.#connections) {
229
+ try {
230
+ ws.close(1000, "monitor shutdown");
231
+ } catch {
232
+ /* best effort */
233
+ }
234
+ }
235
+ this.#connections.clear();
236
+ this.#reconnectCounts.clear();
237
+ console.log("[vk-log-stream] stopped");
238
+ }
239
+
240
+ /**
241
+ * Connect to a session's execution-process stream to auto-discover processes.
242
+ * VK endpoint: GET /api/execution-processes/stream/session/ws?session_id=X
243
+ *
244
+ * The server pushes JSON Patch updates as processes are created, updated, or
245
+ * completed. This method parses those patches to automatically connect to
246
+ * each running process's raw-logs WebSocket.
247
+ *
248
+ * @param {string} sessionId - The VK session UUID
249
+ */
250
+ connectToSession(sessionId) {
251
+ if (!sessionId || !this.#running) return;
252
+ if (this.#sessionStreams.has(sessionId)) return; // already connected
253
+ this.#trackedSessions.add(sessionId);
254
+ this.#openSessionStream(sessionId);
255
+ }
256
+
257
+ /**
258
+ * Connect to a specific execution process's raw-logs WebSocket.
259
+ * @param {string} processId - The execution process UUID
260
+ * @param {object} [meta] - Optional metadata (task_id, branch, etc.)
261
+ */
262
+ connectToProcess(processId, meta = {}) {
263
+ if (!processId || !this.#running) return;
264
+ if (this.#connections.has(processId) || this.#finished.has(processId)) {
265
+ return;
266
+ }
267
+ this.#knownProcessIds.add(processId);
268
+ this.#connectWebSocket(processId, meta);
269
+ }
270
+
271
+ /**
272
+ * Get the set of currently connected process IDs.
273
+ * @returns {Set<string>}
274
+ */
275
+ getActiveConnections() {
276
+ return new Set(this.#connections.keys());
277
+ }
278
+
279
+ /**
280
+ * Get stream statistics.
281
+ * @returns {{ active: number, finished: number, known: number, sessions: number }}
282
+ */
283
+ getStats() {
284
+ return {
285
+ active: this.#connections.size,
286
+ finished: this.#finished.size,
287
+ known: this.#knownProcessIds.size,
288
+ sessions: this.#sessionStreams.size,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Close the WebSocket for a specific process, effectively killing the log stream.
294
+ * @param {string} processId
295
+ * @param {string} [reason="killed by anomaly detector"]
296
+ * @returns {boolean} True if a connection was found and closed.
297
+ */
298
+ killProcess(processId, reason = "killed by anomaly detector") {
299
+ const ws = this.#connections.get(processId);
300
+ if (!ws) return false;
301
+ const shortId = processId.slice(0, 8);
302
+ console.warn(`[vk-log-stream:${shortId}] killing process: ${reason}`);
303
+ // Mark as finished BEFORE closing so the close handler won't reconnect
304
+ this.#finished.add(processId);
305
+ try {
306
+ ws.close(1000, reason);
307
+ } catch {
308
+ /* best effort */
309
+ }
310
+ this.#connections.delete(processId);
311
+ return true;
312
+ }
313
+
314
+
315
+ /**
316
+ * Close a session stream WebSocket and stop tracking it.
317
+ * @param {string} sessionId
318
+ * @param {string} [reason="session no longer tracked"]
319
+ * @returns {boolean} True if the session was tracked.
320
+ */
321
+ closeSession(sessionId, reason = "session no longer tracked") {
322
+ if (!sessionId) return false;
323
+ const ws = this.#sessionStreams.get(sessionId);
324
+ if (ws) {
325
+ try {
326
+ ws.close(1000, reason);
327
+ } catch {
328
+ /* best effort */
329
+ }
330
+ }
331
+ const wasTracked =
332
+ this.#sessionStreams.delete(sessionId) ||
333
+ this.#trackedSessions.delete(sessionId);
334
+ this.#trackedSessions.delete(sessionId);
335
+ this.#sessionReconnectCounts.delete(sessionId);
336
+ return wasTracked;
337
+ }
338
+
339
+ /**
340
+ * Close any session streams not in the allowed set.
341
+ * @param {Iterable<string>} allowedSessionIds
342
+ * @param {string} [reason="session no longer active"]
343
+ * @returns {number} Number of sessions pruned.
344
+ */
345
+ pruneSessions(allowedSessionIds, reason = "session no longer active") {
346
+ const allowed = new Set(allowedSessionIds || []);
347
+ let pruned = 0;
348
+ for (const sessionId of Array.from(this.#trackedSessions)) {
349
+ if (!allowed.has(sessionId)) {
350
+ if (this.closeSession(sessionId, reason)) {
351
+ pruned += 1;
352
+ }
353
+ }
354
+ }
355
+ return pruned;
356
+ }
357
+
358
+ // ── Private methods ────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Open the session-based execution-process stream WebSocket.
362
+ * Receives JSON Patch updates for all processes in the session.
363
+ *
364
+ * Initial snapshot: {"op":"replace","path":"/execution_processes","value":{...}}
365
+ * Live updates: {"op":"add|replace|remove","path":"/execution_processes/<id>","value":{...}}
366
+ *
367
+ * @param {string} sessionId
368
+ */
369
+ #openSessionStream(sessionId) {
370
+ const shortSid = sessionId.slice(0, 8);
371
+ const params = new URLSearchParams({ session_id: sessionId });
372
+ const wsUrl = `${this.#wsBaseUrl}/api/execution-processes/stream/session/ws?${params}`;
373
+
374
+ let ws;
375
+ try {
376
+ ws = new WebSocket(wsUrl);
377
+ } catch (err) {
378
+ console.warn(
379
+ `[vk-log-stream] failed to create session stream WS for ${shortSid}: ${err.message}`,
380
+ );
381
+ return;
382
+ }
383
+
384
+ this.#sessionStreams.set(sessionId, ws);
385
+ this.#sessionReconnectCounts.set(sessionId, 0);
386
+
387
+ ws.addEventListener("open", () => {
388
+ console.log(`[vk-log-stream] session stream connected (${shortSid})`);
389
+ this.#sessionReconnectCounts.set(sessionId, 0);
390
+ });
391
+
392
+ ws.addEventListener("message", (event) => {
393
+ this.#handleSessionStreamMessage(sessionId, event.data);
394
+ });
395
+
396
+ ws.addEventListener("close", () => {
397
+ this.#sessionStreams.delete(sessionId);
398
+ if (!this.#running || !this.#trackedSessions.has(sessionId)) return;
399
+
400
+ const attempts = (this.#sessionReconnectCounts.get(sessionId) || 0) + 1;
401
+ this.#sessionReconnectCounts.set(sessionId, attempts);
402
+
403
+ if (attempts > MAX_SESSION_RECONNECT_ATTEMPTS) {
404
+ console.warn(
405
+ `[vk-log-stream] session stream ${shortSid} max reconnects (${MAX_SESSION_RECONNECT_ATTEMPTS}) reached`,
406
+ );
407
+ this.#trackedSessions.delete(sessionId);
408
+ return;
409
+ }
410
+
411
+ const delay = Math.min(
412
+ SESSION_RECONNECT_DELAY_MS * Math.pow(1.5, attempts - 1),
413
+ 60000,
414
+ );
415
+ setTimeout(() => {
416
+ if (this.#running && this.#trackedSessions.has(sessionId)) {
417
+ this.#openSessionStream(sessionId);
418
+ }
419
+ }, delay);
420
+ });
421
+
422
+ ws.addEventListener("error", (event) => {
423
+ const msg = event?.message || event?.error?.message || "";
424
+ if (msg && !msg.includes("ECONNREFUSED")) {
425
+ console.warn(
426
+ `[vk-log-stream] session stream ${shortSid} error: ${msg}`,
427
+ );
428
+ }
429
+ });
430
+ }
431
+
432
+ /**
433
+ * Handle a message from the session execution-process stream.
434
+ *
435
+ * VK sends JSON Patch arrays. The initial snapshot replaces /execution_processes
436
+ * with an object keyed by process ID. Live updates add/replace/remove at
437
+ * /execution_processes/<processId>.
438
+ *
439
+ * @param {string} sessionId
440
+ * @param {string|Buffer} rawData
441
+ */
442
+ #handleSessionStreamMessage(sessionId, rawData) {
443
+ const text = typeof rawData === "string" ? rawData : rawData.toString();
444
+ if (!text) return;
445
+
446
+ let msg;
447
+ try {
448
+ msg = JSON.parse(text);
449
+ } catch {
450
+ return; // ignore non-JSON
451
+ }
452
+
453
+ // ── LogMsg::JsonPatch — the primary format for session streams ──
454
+ if (Array.isArray(msg.JsonPatch)) {
455
+ for (const patch of msg.JsonPatch) {
456
+ this.#processSessionPatch(sessionId, patch);
457
+ }
458
+ return;
459
+ }
460
+
461
+ // ── Raw JSON Patch array (some VK versions send this directly) ──
462
+ if (Array.isArray(msg)) {
463
+ for (const patch of msg) {
464
+ this.#processSessionPatch(sessionId, patch);
465
+ }
466
+ return;
467
+ }
468
+
469
+ // ── LogMsg::Finished — session is done ──
470
+ if ("Finished" in msg || "finished" in msg) {
471
+ const shortSid = sessionId.slice(0, 8);
472
+ console.log(
473
+ `[vk-log-stream] session stream ${shortSid} received Finished`,
474
+ );
475
+ this.#trackedSessions.delete(sessionId);
476
+ const ws = this.#sessionStreams.get(sessionId);
477
+ if (ws) {
478
+ try {
479
+ ws.close(1000, "session finished");
480
+ } catch {
481
+ /* best effort */
482
+ }
483
+ this.#sessionStreams.delete(sessionId);
484
+ }
485
+ return;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Process a single JSON Patch operation from the session stream.
491
+ * Extracts execution process IDs and connects to their raw-logs streams.
492
+ *
493
+ * @param {string} sessionId
494
+ * @param {object} patch - { op, path, value }
495
+ */
496
+ #processSessionPatch(sessionId, patch) {
497
+ if (!patch || !patch.path) return;
498
+
499
+ const { op, path, value } = patch;
500
+
501
+ // Initial snapshot: replace /execution_processes → object keyed by ID
502
+ if (
503
+ path === "/execution_processes" &&
504
+ op === "replace" &&
505
+ value &&
506
+ typeof value === "object"
507
+ ) {
508
+ for (const [processId, proc] of Object.entries(value)) {
509
+ this.#maybeConnectProcess(processId, proc, sessionId);
510
+ }
511
+ return;
512
+ }
513
+
514
+ // Live update: add/replace /execution_processes/<processId>
515
+ const match = path.match(/^\/execution_processes\/([^/]+)$/);
516
+ if (match) {
517
+ const processId = match[1];
518
+ if (op === "remove") {
519
+ // Process removed — mark finished
520
+ this.#finished.add(processId);
521
+ return;
522
+ }
523
+ if (value && typeof value === "object") {
524
+ this.#maybeConnectProcess(processId, value, sessionId);
525
+ }
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Connect to a process's raw-logs stream if it's running and not already tracked.
531
+ * @param {string} processId
532
+ * @param {object} proc - process data from VK (status, run_reason, etc.)
533
+ * @param {string} sessionId
534
+ */
535
+ #maybeConnectProcess(processId, proc, sessionId) {
536
+ if (!processId) return;
537
+
538
+ const status = (proc.status || "").toLowerCase();
539
+ if (status === "completed" || status === "killed" || status === "failed") {
540
+ this.#finished.add(processId);
541
+ return;
542
+ }
543
+
544
+ if (!this.#connections.has(processId) && !this.#finished.has(processId)) {
545
+ this.#knownProcessIds.add(processId);
546
+ const connMeta = {
547
+ sessionId,
548
+ runReason: proc.run_reason,
549
+ status,
550
+ };
551
+ this.#connectWebSocket(processId, connMeta);
552
+
553
+ // Notify the monitor so it can look up task metadata and call setProcessMeta()
554
+ if (this.#onProcessConnected) {
555
+ try {
556
+ this.#onProcessConnected(processId, connMeta);
557
+ } catch {
558
+ /* callback error — ignore */
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Connect WebSocket to a specific execution process's raw-logs endpoint.
566
+ * @param {string} processId
567
+ * @param {object} meta
568
+ */
569
+ #connectWebSocket(processId, meta = {}) {
570
+ const shortId = processId.slice(0, 8);
571
+ const wsUrl = `${this.#wsBaseUrl}/api/execution-processes/${processId}/raw-logs/ws`;
572
+
573
+ let ws;
574
+ try {
575
+ ws = new WebSocket(wsUrl);
576
+ } catch (err) {
577
+ console.warn(
578
+ `[vk-log-stream] failed to create WebSocket for ${shortId}: ${err.message}`,
579
+ );
580
+ return;
581
+ }
582
+
583
+ this.#connections.set(processId, ws);
584
+ this.#reconnectCounts.set(processId, 0);
585
+
586
+ const logPrefix = `[vk-log-stream:${shortId}]`;
587
+
588
+ ws.addEventListener("open", () => {
589
+ console.log(
590
+ `${logPrefix} connected to raw-logs WebSocket` +
591
+ (meta.taskId ? ` (task: ${meta.taskId.slice(0, 8)})` : ""),
592
+ );
593
+ this.#reconnectCounts.set(processId, 0);
594
+ });
595
+
596
+ ws.addEventListener("message", (event) => {
597
+ this.#handleMessage(processId, event.data, meta);
598
+ });
599
+
600
+ ws.addEventListener("close", (event) => {
601
+ this.#connections.delete(processId);
602
+ if (this.#finished.has(processId)) {
603
+ console.log(`${logPrefix} WebSocket closed (process finished)`);
604
+ return;
605
+ }
606
+ if (!this.#running) return;
607
+
608
+ const attempts = (this.#reconnectCounts.get(processId) || 0) + 1;
609
+ this.#reconnectCounts.set(processId, attempts);
610
+
611
+ if (attempts > MAX_RECONNECT_ATTEMPTS) {
612
+ console.warn(
613
+ `${logPrefix} max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, giving up`,
614
+ );
615
+ return;
616
+ }
617
+
618
+ const delay = Math.min(
619
+ RECONNECT_DELAY_MS * Math.pow(1.5, attempts - 1),
620
+ 30000,
621
+ );
622
+ console.log(
623
+ `${logPrefix} reconnecting in ${Math.round(delay)}ms (attempt ${attempts})`,
624
+ );
625
+ setTimeout(() => {
626
+ if (this.#running && !this.#finished.has(processId)) {
627
+ this.#connectWebSocket(processId, meta);
628
+ }
629
+ }, delay);
630
+ });
631
+
632
+ ws.addEventListener("error", (event) => {
633
+ // Errors are followed by close events, so just log
634
+ const msg = event?.message || event?.error?.message || "unknown";
635
+ if (!msg.includes("ECONNREFUSED")) {
636
+ console.warn(`${logPrefix} WebSocket error: ${msg}`);
637
+ }
638
+ });
639
+ }
640
+
641
+ /**
642
+ * Handle a WebSocket message from the raw-logs stream.
643
+ *
644
+ * VK sends LogMsg variants serialized as JSON:
645
+ * {"Stdout": "line"}
646
+ * {"Stderr": "line"}
647
+ * {"JsonPatch": [...]}
648
+ * {"Finished": ""}
649
+ *
650
+ * @param {string} processId
651
+ * @param {string|Buffer} rawData
652
+ * @param {object} meta
653
+ */
654
+ #handleMessage(processId, rawData, meta) {
655
+ const text = typeof rawData === "string" ? rawData : rawData.toString();
656
+ if (!text) return;
657
+
658
+ let msg;
659
+ try {
660
+ msg = JSON.parse(text);
661
+ } catch {
662
+ // Not JSON — treat as raw text line
663
+ this.#emitLine(processId, text, "stdout", meta);
664
+ return;
665
+ }
666
+
667
+ // ── LogMsg::Stdout ──
668
+ if (typeof msg.Stdout === "string") {
669
+ this.#emitLine(processId, msg.Stdout, "stdout", meta);
670
+ return;
671
+ }
672
+
673
+ // ── LogMsg::Stderr ──
674
+ if (typeof msg.Stderr === "string") {
675
+ this.#emitLine(processId, msg.Stderr, "stderr", meta);
676
+ return;
677
+ }
678
+
679
+ // ── LogMsg::Finished ──
680
+ if ("Finished" in msg) {
681
+ this.#finished.add(processId);
682
+ const shortId = processId.slice(0, 8);
683
+ console.log(`[vk-log-stream:${shortId}] execution process finished`);
684
+ const ws = this.#connections.get(processId);
685
+ if (ws) {
686
+ try {
687
+ ws.close(1000, "finished");
688
+ } catch {
689
+ /* best effort */
690
+ }
691
+ this.#connections.delete(processId);
692
+ }
693
+ // Write final marker to log files (raw + structured session log)
694
+ this.#writeToFile(
695
+ processId,
696
+ `\n--- [vk-log-stream] Process ${shortId} finished at ${new Date().toISOString()} ---\n`,
697
+ );
698
+ this.#writeSessionLogFooter(processId);
699
+ return;
700
+ }
701
+
702
+ // ── LogMsg::JsonPatch — extract content from patch operations ──
703
+ if (Array.isArray(msg.JsonPatch)) {
704
+ for (const patch of msg.JsonPatch) {
705
+ if (patch?.value) {
706
+ const type = (patch.value.type || "").toUpperCase();
707
+ const content = patch.value.content || patch.value.text || "";
708
+ if (content) {
709
+ const stream =
710
+ type === "STDERR"
711
+ ? "stderr"
712
+ : type === "STDOUT"
713
+ ? "stdout"
714
+ : "stdout";
715
+ this.#emitLine(processId, content, stream, meta);
716
+ }
717
+ }
718
+ }
719
+ return;
720
+ }
721
+
722
+ // ── LogMsg::SessionId, LogMsg::MessageId, LogMsg::Ready — informational ──
723
+ if (msg.SessionId || msg.MessageId || msg.Ready !== undefined) {
724
+ return; // Ignore informational messages
725
+ }
726
+
727
+ // ── LogMsg::finished (lowercase variant) — some VK versions send this ──
728
+ if (msg.finished === true || "finished" in msg) {
729
+ this.#finished.add(processId);
730
+ const shortId = processId.slice(0, 8);
731
+ console.log(`[vk-log-stream:${shortId}] execution process finished (lowercase variant)`);
732
+ const ws = this.#connections.get(processId);
733
+ if (ws) {
734
+ try {
735
+ ws.close(1000, "finished");
736
+ } catch {
737
+ /* best effort */
738
+ }
739
+ this.#connections.delete(processId);
740
+ }
741
+ this.#writeToFile(
742
+ processId,
743
+ `\n--- [vk-log-stream] Process ${shortId} finished at ${new Date().toISOString()} ---\n`,
744
+ );
745
+ this.#writeSessionLogFooter(processId);
746
+ return;
747
+ }
748
+
749
+ // Unknown format — log raw for debugging
750
+ const shortId = processId.slice(0, 8);
751
+ console.warn(
752
+ `[vk-log-stream:${shortId}] unknown message format: ${text.slice(0, 200)}`,
753
+ );
754
+ }
755
+
756
+ /**
757
+ * Emit a parsed log line to all outputs (file, console, session log, callback).
758
+ * @param {string} processId
759
+ * @param {string} content
760
+ * @param {"stdout"|"stderr"} stream
761
+ * @param {object} meta
762
+ */
763
+ #emitLine(processId, content, stream, meta) {
764
+ // Strip trailing newlines since we add our own
765
+ const line = content.replace(/\r?\n$/, "");
766
+ if (!line) return;
767
+
768
+ const shortId = processId.slice(0, 8);
769
+
770
+ // ALWAYS invoke onLine callback BEFORE filtering so the anomaly detector
771
+ // sees every line — including errors wrapped in codex/event/ JSON envelopes
772
+ // that filterLine would otherwise drop.
773
+ if (this.#onLine) {
774
+ try {
775
+ this.#onLine(line, { processId, stream, ...meta });
776
+ } catch {
777
+ /* callback error — ignore */
778
+ }
779
+ }
780
+
781
+ // Apply noise filter (drop if false) — only affects file/console output
782
+ if (this.#filterLine && !this.#filterLine(line, { processId, stream })) {
783
+ return;
784
+ }
785
+
786
+ // Write to per-process log file (raw)
787
+ this.#writeToFile(processId, `[${stream}] ${line}\n`);
788
+
789
+ // Write to structured session log (codex-exec style)
790
+ this.#writeSessionLog(processId, line, stream);
791
+
792
+ // Echo to console
793
+ if (this.#echo) {
794
+ const prefix = stream === "stderr" ? "ERR" : "OUT";
795
+ try {
796
+ process.stdout.write(`[vk:${shortId}:${prefix}] ${line}\n`);
797
+ } catch {
798
+ /* EPIPE */
799
+ }
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Write text to the per-process log file.
805
+ * @param {string} processId
806
+ * @param {string} text
807
+ */
808
+ #writeToFile(processId, text) {
809
+ if (!this.#logDir) return;
810
+ const shortId = processId.slice(0, 8);
811
+ const logPath = resolve(this.#logDir, `vk-exec-${shortId}.log`);
812
+ try {
813
+ appendFileSync(logPath, text);
814
+ } catch {
815
+ /* best effort */
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Write a log line to the structured session log file (codex-exec style).
821
+ * On first call per process, writes a metadata header block.
822
+ *
823
+ * @param {string} processId
824
+ * @param {string} line - The log content (no prefix)
825
+ * @param {"stdout"|"stderr"} stream
826
+ */
827
+ #writeSessionLog(processId, line, stream) {
828
+ if (!this.#sessionLogDir) return;
829
+
830
+ // Write header on first line
831
+ if (!this.#sessionLogHeaderWritten.has(processId)) {
832
+ this.#writeSessionLogHeader(processId);
833
+ this.#sessionLogHeaderWritten.add(processId);
834
+ }
835
+
836
+ const logPath = this.getSessionLogPath(processId);
837
+ if (!logPath) return;
838
+ const prefix = stream === "stderr" ? "[stderr] " : "";
839
+ try {
840
+ appendFileSync(logPath, `${prefix}${line}\n`);
841
+ } catch {
842
+ /* best effort */
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Write the structured header to a session log file.
848
+ * Mirrors the codex-exec log format with VK-specific metadata.
849
+ *
850
+ * @param {string} processId
851
+ */
852
+ #writeSessionLogHeader(processId) {
853
+ // getSessionLogPath lazily assigns _logStamp on first call
854
+ const logPath = this.getSessionLogPath(processId);
855
+ if (!logPath) return;
856
+
857
+ const meta = this.#processMeta.get(processId) || {};
858
+ const shortId = processId.slice(0, 8);
859
+ const now = new Date().toISOString();
860
+
861
+ const header = [
862
+ `# VK Session Execution Log`,
863
+ `# Timestamp: ${now}`,
864
+ `# Process ID: ${processId}`,
865
+ `# Process (short): ${shortId}`,
866
+ meta.attemptId
867
+ ? `# Attempt ID: ${meta.attemptId}`
868
+ : `# Attempt ID: (unknown)`,
869
+ meta.taskId ? `# Task ID: ${meta.taskId}` : `# Task ID: (unknown)`,
870
+ meta.taskTitle
871
+ ? `# Task Title: ${meta.taskTitle}`
872
+ : `# Task Title: (unknown)`,
873
+ meta.branch ? `# Branch: ${meta.branch}` : `# Branch: (unknown)`,
874
+ meta.sessionId
875
+ ? `# Session ID: ${meta.sessionId}`
876
+ : `# Session ID: (unknown)`,
877
+ meta.executor
878
+ ? `# Executor: ${meta.executor}${meta.executorVariant ? ` (${meta.executorVariant})` : ""}`
879
+ : `# Executor: (unknown)`,
880
+ `# VK Endpoint: ${this.#baseUrl}`,
881
+ ``,
882
+ `## VK Agent Output Stream:`,
883
+ ``,
884
+ ].join("\n");
885
+
886
+ try {
887
+ writeFileSync(logPath, header);
888
+ } catch {
889
+ /* best effort */
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Write a final footer to the structured session log when a process finishes.
895
+ * @param {string} processId
896
+ */
897
+ #writeSessionLogFooter(processId) {
898
+ if (!this.#sessionLogDir) return;
899
+ if (!this.#sessionLogHeaderWritten.has(processId)) return; // no log started
900
+
901
+ const logPath = this.getSessionLogPath(processId);
902
+ if (!logPath) return;
903
+ const now = new Date().toISOString();
904
+ const shortId = processId.slice(0, 8);
905
+
906
+ try {
907
+ appendFileSync(logPath, `\n\n## Process ${shortId} finished at ${now}\n`);
908
+ } catch {
909
+ /* best effort */
910
+ }
911
+ }
912
+ }
913
+
914
+ export default VkLogStream;