byterover-cli 1.0.5 → 1.1.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 (124) hide show
  1. package/README.md +12 -10
  2. package/dist/commands/hook-prompt-submit.d.ts +27 -0
  3. package/dist/commands/hook-prompt-submit.js +39 -0
  4. package/dist/commands/status.js +8 -3
  5. package/dist/constants.d.ts +1 -1
  6. package/dist/constants.js +1 -1
  7. package/dist/core/domain/cipher/tools/constants.d.ts +1 -0
  8. package/dist/core/domain/cipher/tools/constants.js +1 -0
  9. package/dist/core/domain/entities/agent.d.ts +16 -0
  10. package/dist/core/domain/entities/agent.js +24 -0
  11. package/dist/core/domain/entities/connector-type.d.ts +9 -0
  12. package/dist/core/domain/entities/connector-type.js +8 -0
  13. package/dist/core/domain/entities/event.d.ts +1 -1
  14. package/dist/core/domain/entities/event.js +2 -0
  15. package/dist/core/domain/errors/task-error.d.ts +4 -0
  16. package/dist/core/domain/errors/task-error.js +7 -0
  17. package/dist/core/domain/transport/schemas.d.ts +40 -0
  18. package/dist/core/domain/transport/schemas.js +28 -0
  19. package/dist/core/interfaces/connectors/connector-types.d.ts +57 -0
  20. package/dist/core/interfaces/connectors/i-connector-manager.d.ts +72 -0
  21. package/dist/core/interfaces/connectors/i-connector-manager.js +1 -0
  22. package/dist/core/interfaces/connectors/i-connector.d.ts +54 -0
  23. package/dist/core/interfaces/connectors/i-connector.js +1 -0
  24. package/dist/core/interfaces/i-file-service.d.ts +7 -0
  25. package/dist/core/interfaces/usecase/i-connectors-use-case.d.ts +3 -0
  26. package/dist/core/interfaces/usecase/i-connectors-use-case.js +1 -0
  27. package/dist/hooks/init/update-notifier.d.ts +1 -0
  28. package/dist/hooks/init/update-notifier.js +10 -1
  29. package/dist/infra/cipher/file-system/binary-utils.d.ts +7 -12
  30. package/dist/infra/cipher/file-system/binary-utils.js +46 -31
  31. package/dist/infra/cipher/llm/context/context-manager.d.ts +2 -2
  32. package/dist/infra/cipher/llm/context/context-manager.js +23 -2
  33. package/dist/infra/cipher/llm/formatters/gemini-formatter.js +48 -9
  34. package/dist/infra/cipher/llm/internal-llm-service.js +2 -2
  35. package/dist/infra/cipher/system-prompt/contributors/context-tree-structure-contributor.d.ts +6 -7
  36. package/dist/infra/cipher/system-prompt/contributors/context-tree-structure-contributor.js +57 -18
  37. package/dist/infra/cipher/tools/implementations/curate-tool.js +20 -2
  38. package/dist/infra/cipher/tools/implementations/read-file-tool.js +38 -17
  39. package/dist/infra/cipher/tools/implementations/search-knowledge-tool.d.ts +7 -0
  40. package/dist/infra/cipher/tools/implementations/search-knowledge-tool.js +303 -0
  41. package/dist/infra/cipher/tools/index.d.ts +1 -0
  42. package/dist/infra/cipher/tools/index.js +1 -0
  43. package/dist/infra/cipher/tools/tool-manager.js +1 -0
  44. package/dist/infra/cipher/tools/tool-registry.js +7 -0
  45. package/dist/infra/connectors/connector-manager.d.ts +32 -0
  46. package/dist/infra/connectors/connector-manager.js +156 -0
  47. package/dist/infra/connectors/hook/hook-connector-config.d.ts +52 -0
  48. package/dist/infra/connectors/hook/hook-connector-config.js +41 -0
  49. package/dist/infra/connectors/hook/hook-connector.d.ts +46 -0
  50. package/dist/infra/connectors/hook/hook-connector.js +231 -0
  51. package/dist/infra/{rule → connectors/rules}/legacy-rule-detector.d.ts +2 -2
  52. package/dist/infra/{rule → connectors/rules}/legacy-rule-detector.js +1 -1
  53. package/dist/infra/connectors/rules/rules-connector-config.d.ts +95 -0
  54. package/dist/infra/{rule/agent-rule-config.js → connectors/rules/rules-connector-config.js} +10 -10
  55. package/dist/infra/connectors/rules/rules-connector.d.ts +41 -0
  56. package/dist/infra/connectors/rules/rules-connector.js +204 -0
  57. package/dist/infra/{rule/rule-template-service.d.ts → connectors/shared/template-service.d.ts} +3 -3
  58. package/dist/infra/{rule/rule-template-service.js → connectors/shared/template-service.js} +1 -1
  59. package/dist/infra/context-tree/file-context-tree-writer-service.d.ts +5 -2
  60. package/dist/infra/context-tree/file-context-tree-writer-service.js +20 -5
  61. package/dist/infra/core/executors/curate-executor.d.ts +2 -2
  62. package/dist/infra/core/executors/curate-executor.js +7 -7
  63. package/dist/infra/core/executors/query-executor.d.ts +12 -0
  64. package/dist/infra/core/executors/query-executor.js +62 -1
  65. package/dist/infra/file/fs-file-service.d.ts +7 -0
  66. package/dist/infra/file/fs-file-service.js +15 -1
  67. package/dist/infra/process/agent-worker.d.ts +2 -2
  68. package/dist/infra/process/agent-worker.js +626 -142
  69. package/dist/infra/process/constants.d.ts +1 -1
  70. package/dist/infra/process/constants.js +1 -1
  71. package/dist/infra/process/ipc-types.d.ts +17 -4
  72. package/dist/infra/process/ipc-types.js +3 -3
  73. package/dist/infra/process/parent-heartbeat.d.ts +47 -0
  74. package/dist/infra/process/parent-heartbeat.js +118 -0
  75. package/dist/infra/process/process-manager.d.ts +79 -0
  76. package/dist/infra/process/process-manager.js +277 -3
  77. package/dist/infra/process/task-queue-manager.d.ts +13 -0
  78. package/dist/infra/process/task-queue-manager.js +19 -0
  79. package/dist/infra/process/transport-handlers.d.ts +3 -0
  80. package/dist/infra/process/transport-handlers.js +51 -5
  81. package/dist/infra/process/transport-worker.js +9 -69
  82. package/dist/infra/repl/commands/connectors-command.d.ts +8 -0
  83. package/dist/infra/repl/commands/{gen-rules-command.js → connectors-command.js} +21 -10
  84. package/dist/infra/repl/commands/index.js +3 -2
  85. package/dist/infra/repl/commands/init-command.js +11 -7
  86. package/dist/infra/repl/commands/query-command.js +22 -2
  87. package/dist/infra/repl/commands/reset-command.js +1 -1
  88. package/dist/infra/transport/socket-io-transport-client.d.ts +68 -0
  89. package/dist/infra/transport/socket-io-transport-client.js +283 -7
  90. package/dist/infra/usecase/connectors-use-case.d.ts +59 -0
  91. package/dist/infra/usecase/connectors-use-case.js +203 -0
  92. package/dist/infra/usecase/init-use-case.d.ts +8 -43
  93. package/dist/infra/usecase/init-use-case.js +27 -251
  94. package/dist/infra/usecase/logout-use-case.js +1 -1
  95. package/dist/infra/usecase/pull-use-case.js +5 -5
  96. package/dist/infra/usecase/push-use-case.js +4 -4
  97. package/dist/infra/usecase/reset-use-case.js +3 -4
  98. package/dist/infra/usecase/space-list-use-case.js +3 -3
  99. package/dist/infra/usecase/space-switch-use-case.js +3 -3
  100. package/dist/resources/prompts/curate.yml +7 -0
  101. package/dist/resources/prompts/explore.yml +34 -0
  102. package/dist/resources/prompts/query-orchestrator.yml +112 -0
  103. package/dist/resources/prompts/system-prompt.yml +12 -2
  104. package/dist/resources/tools/search_knowledge.txt +32 -0
  105. package/dist/templates/sections/brv-instructions.md +98 -0
  106. package/dist/tui/components/onboarding/onboarding-flow.js +14 -11
  107. package/dist/tui/components/onboarding/welcome-box.js +1 -1
  108. package/dist/tui/contexts/onboarding-context.d.ts +4 -0
  109. package/dist/tui/contexts/onboarding-context.js +14 -2
  110. package/dist/tui/views/command-view.js +4 -0
  111. package/dist/utils/file-validator.d.ts +1 -1
  112. package/dist/utils/file-validator.js +25 -28
  113. package/dist/utils/type-guards.d.ts +5 -0
  114. package/dist/utils/type-guards.js +7 -0
  115. package/oclif.manifest.json +30 -4
  116. package/package.json +4 -1
  117. package/dist/core/interfaces/usecase/i-generate-rules-use-case.d.ts +0 -3
  118. package/dist/infra/repl/commands/gen-rules-command.d.ts +0 -7
  119. package/dist/infra/rule/agent-rule-config.d.ts +0 -19
  120. package/dist/infra/usecase/generate-rules-use-case.d.ts +0 -61
  121. package/dist/infra/usecase/generate-rules-use-case.js +0 -285
  122. /package/dist/core/interfaces/{usecase/i-generate-rules-use-case.js → connectors/connector-types.js} +0 -0
  123. /package/dist/infra/{rule → connectors/shared}/constants.d.ts +0 -0
  124. /package/dist/infra/{rule → connectors/shared}/constants.js +0 -0
@@ -26,8 +26,11 @@ import { fileURLToPath } from 'node:url';
26
26
  import { crashLog, getSessionLogPath, processManagerLog } from '../../utils/process-logger.js';
27
27
  const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000;
28
28
  const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
29
- const HEALTH_CHECK_INTERVAL_MS = 5000; // Check every 5 seconds
29
+ const HEALTH_CHECK_INTERVAL_MS = 5000; // Check every 5 seconds for sleep detection
30
30
  const SLEEP_DETECTION_THRESHOLD_MS = 30_000; // If 30s passed when expecting 5s, likely slept
31
+ const TRANSPORT_PING_TIMEOUT_MS = 5000; // Timeout for Transport ping response
32
+ const AGENT_HEALTH_CHECK_TIMEOUT_MS = 5000; // Timeout for Agent health-check response
33
+ const PERIODIC_HEALTH_CHECK_INTERVAL_MS = 30_000; // Periodic health check every 30s
31
34
  /**
32
35
  * Creates a system error with crash log.
33
36
  * Logs error details to session log and returns user-friendly message.
@@ -46,9 +49,21 @@ function createSystemError(error, context) {
46
49
  * - Crash recovery: respawn on exit
47
50
  */
48
51
  export class ProcessManager {
52
+ /** Whether an Agent health-check is pending response */
53
+ agentHealthCheckPending = false;
54
+ /** Timeout for Agent health-check response */
55
+ agentHealthCheckTimeout;
49
56
  currentSessionId;
50
57
  healthCheckInterval;
58
+ /** Guard to prevent concurrent Agent restarts */
59
+ isRestartingAgent = false;
60
+ /** Guard to prevent concurrent Transport restarts */
61
+ isRestartingTransport = false;
51
62
  lastHealthCheckTime = Date.now();
63
+ /** Periodic health check interval (30s) */
64
+ periodicHealthCheckInterval;
65
+ /** Stored handler ref for idempotent listener setup (prevents accumulation on respawn) */
66
+ runtimeMessageHandler;
52
67
  shutdownTimeoutMs;
53
68
  startupTimeoutMs;
54
69
  state = {
@@ -56,6 +71,12 @@ export class ProcessManager {
56
71
  running: false,
57
72
  transportReady: false,
58
73
  };
74
+ /** Stored handler ref for Transport runtime messages (prevents accumulation on respawn) */
75
+ transportMessageHandler;
76
+ /** Whether a Transport ping is pending response */
77
+ transportPingPending = false;
78
+ /** Timeout for Transport ping response */
79
+ transportPingTimeout;
59
80
  constructor(config) {
60
81
  this.startupTimeoutMs = config?.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
61
82
  this.shutdownTimeoutMs = config?.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
@@ -106,6 +127,8 @@ export class ProcessManager {
106
127
  this.state.running = true;
107
128
  // Step 3: Start health check for sleep/wake detection
108
129
  this.startHealthCheck();
130
+ // Step 4: Start periodic health check (30s) for zombie detection mid-session
131
+ this.startPeriodicHealthCheck();
109
132
  }
110
133
  /**
111
134
  * Stop all processes gracefully.
@@ -119,8 +142,9 @@ export class ProcessManager {
119
142
  return;
120
143
  }
121
144
  this.state.running = false;
122
- // Stop health check first
145
+ // Stop health checks first
123
146
  this.stopHealthCheck();
147
+ this.stopPeriodicHealthCheck();
124
148
  // Step 1: Stop Agent Process
125
149
  await this.stopAgentProcess();
126
150
  this.state.agentReady = false;
@@ -141,9 +165,34 @@ export class ProcessManager {
141
165
  }
142
166
  return currentDir;
143
167
  }
168
+ /**
169
+ * Handle runtime IPC messages from Agent process.
170
+ * Called by the message event handler set up in setupAgentRuntimeHandlers.
171
+ */
172
+ handleAgentRuntimeMessage(message) {
173
+ if (message.type === 'health-check-result') {
174
+ // Clear pending flag and timeout (for periodic health check)
175
+ if (this.agentHealthCheckTimeout) {
176
+ clearTimeout(this.agentHealthCheckTimeout);
177
+ this.agentHealthCheckTimeout = undefined;
178
+ }
179
+ this.agentHealthCheckPending = false;
180
+ if (message.success) {
181
+ processManagerLog('Agent health-check passed');
182
+ }
183
+ else {
184
+ processManagerLog('Agent health-check FAILED - Socket.IO connection stale, restarting agent');
185
+ // Restart agent to force reconnection
186
+ this.restartAgent().catch((error) => {
187
+ processManagerLog(`Failed to restart agent after health-check failure: ${error}`);
188
+ });
189
+ }
190
+ }
191
+ }
144
192
  /**
145
193
  * Handle system wake from sleep.
146
194
  * Verify processes are still alive and restart if needed.
195
+ * Triggers immediate health checks on both Transport and Agent.
147
196
  */
148
197
  handleSystemWake() {
149
198
  const { agentProcess, transportProcess } = this.state;
@@ -165,7 +214,139 @@ export class ProcessManager {
165
214
  }
166
215
  }
167
216
  else {
168
- processManagerLog('Processes healthy after wake');
217
+ processManagerLog('Processes healthy after wake - triggering immediate health check');
218
+ // Trigger immediate health checks (reuse the timeout-based methods)
219
+ // Check Transport (if not already pinging)
220
+ if (this.state.transportReady && !this.transportPingPending) {
221
+ this.pingTransportWithTimeout();
222
+ }
223
+ // Check Agent (if not already checking)
224
+ if (this.state.agentReady && !this.agentHealthCheckPending) {
225
+ this.healthCheckAgentWithTimeout();
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * Handle runtime IPC messages from Transport process.
231
+ * Called by the message event handler set up in setupTransportRuntimeHandlers.
232
+ */
233
+ handleTransportRuntimeMessage(message) {
234
+ if (message.type === 'pong' && this.transportPingPending) {
235
+ // Clear timeout and flag
236
+ if (this.transportPingTimeout) {
237
+ clearTimeout(this.transportPingTimeout);
238
+ this.transportPingTimeout = undefined;
239
+ }
240
+ this.transportPingPending = false;
241
+ processManagerLog('Transport ping successful - process healthy');
242
+ }
243
+ }
244
+ /**
245
+ * Send health-check to Agent with timeout.
246
+ * If no response within timeout, Agent is considered stuck and restarted.
247
+ * Used by periodic health check and after system wake.
248
+ */
249
+ healthCheckAgentWithTimeout() {
250
+ const { agentProcess } = this.state;
251
+ if (!agentProcess || this.agentHealthCheckPending)
252
+ return;
253
+ this.agentHealthCheckPending = true;
254
+ processManagerLog('Sending health-check to agent');
255
+ this.sendToChild(agentProcess, { type: 'health-check' });
256
+ this.agentHealthCheckTimeout = setTimeout(() => {
257
+ if (this.agentHealthCheckPending) {
258
+ processManagerLog('Agent health-check timeout - process may be stuck, restarting');
259
+ this.agentHealthCheckPending = false;
260
+ this.agentHealthCheckTimeout = undefined;
261
+ this.restartAgent().catch((error) => {
262
+ processManagerLog(`Failed to restart agent after health-check timeout: ${error}`);
263
+ });
264
+ }
265
+ }, AGENT_HEALTH_CHECK_TIMEOUT_MS);
266
+ // Don't block process exit
267
+ this.agentHealthCheckTimeout.unref();
268
+ }
269
+ /**
270
+ * Send ping to Transport with timeout.
271
+ * If no pong received within timeout, Transport is considered zombie and restarted.
272
+ */
273
+ pingTransportWithTimeout() {
274
+ const { transportProcess } = this.state;
275
+ if (!transportProcess || this.transportPingPending)
276
+ return;
277
+ this.transportPingPending = true;
278
+ processManagerLog('Sending ping to transport after wake');
279
+ this.sendToChild(transportProcess, { type: 'ping' });
280
+ this.transportPingTimeout = setTimeout(() => {
281
+ if (this.transportPingPending) {
282
+ processManagerLog('Transport ping timeout - process may be zombie, restarting');
283
+ this.transportPingPending = false;
284
+ this.transportPingTimeout = undefined;
285
+ this.restartTransport().catch((error) => {
286
+ processManagerLog(`Failed to restart transport after ping timeout: ${error}`);
287
+ });
288
+ }
289
+ }, TRANSPORT_PING_TIMEOUT_MS);
290
+ // Don't block process exit
291
+ this.transportPingTimeout.unref();
292
+ }
293
+ /**
294
+ * Restart Agent process gracefully.
295
+ * Used when health-check fails after sleep/wake or periodic check.
296
+ * Guarded to prevent concurrent restart race conditions.
297
+ */
298
+ async restartAgent() {
299
+ // Guard: prevent concurrent restarts
300
+ if (!this.state.running || !this.state.port || this.isRestartingAgent)
301
+ return;
302
+ this.isRestartingAgent = true;
303
+ try {
304
+ processManagerLog('Restarting agent process...');
305
+ // Stop existing agent
306
+ await this.stopAgentProcess();
307
+ this.state.agentReady = false;
308
+ // Start new agent
309
+ await this.startAgentProcess(this.state.port);
310
+ this.state.agentReady = true;
311
+ processManagerLog('Agent process restarted successfully');
312
+ }
313
+ finally {
314
+ this.isRestartingAgent = false;
315
+ }
316
+ }
317
+ /**
318
+ * Restart Transport process gracefully.
319
+ * Used when ping timeout detects zombie process after sleep/wake or periodic check.
320
+ * Note: Agent must also be restarted since Transport port changes.
321
+ * Guarded to prevent concurrent restart race conditions.
322
+ */
323
+ async restartTransport() {
324
+ // Guard: prevent concurrent restarts
325
+ if (!this.state.running || this.isRestartingTransport)
326
+ return;
327
+ this.isRestartingTransport = true;
328
+ try {
329
+ processManagerLog('Restarting transport process...');
330
+ // Stop existing transport
331
+ await this.stopTransportProcess();
332
+ this.state.transportReady = false;
333
+ // Start new transport (gets new port)
334
+ const newPort = await this.startTransportProcess();
335
+ this.state.port = newPort;
336
+ this.state.transportReady = true;
337
+ processManagerLog(`Transport process restarted on port ${newPort}`);
338
+ // Agent needs to reconnect to new port - restart Agent too
339
+ if (this.state.agentProcess) {
340
+ await this.stopAgentProcess();
341
+ this.state.agentReady = false;
342
+ await this.startAgentProcess(newPort);
343
+ this.state.agentReady = true;
344
+ processManagerLog('Agent reconnected to new Transport');
345
+ }
346
+ processManagerLog('Transport process restarted successfully');
347
+ }
348
+ finally {
349
+ this.isRestartingTransport = false;
169
350
  }
170
351
  }
171
352
  /**
@@ -199,6 +380,29 @@ export class ProcessManager {
199
380
  });
200
381
  }
201
382
  });
383
+ // Setup runtime message handler for health-check results
384
+ this.setupAgentRuntimeHandlers();
385
+ }
386
+ /**
387
+ * Setup runtime message handlers for Agent process.
388
+ * Handles IPC messages that arrive during normal operation (not just startup).
389
+ *
390
+ * IMPORTANT: Uses stored handler reference to prevent listener accumulation.
391
+ * Each respawn calls this method, so we must remove the old listener first.
392
+ */
393
+ setupAgentRuntimeHandlers() {
394
+ const { agentProcess } = this.state;
395
+ if (!agentProcess)
396
+ return;
397
+ // Remove old listener FIRST (prevents accumulation on respawn)
398
+ if (this.runtimeMessageHandler) {
399
+ agentProcess.off('message', this.runtimeMessageHandler);
400
+ }
401
+ // Create and store new handler
402
+ this.runtimeMessageHandler = (message) => {
403
+ this.handleAgentRuntimeMessage(message);
404
+ };
405
+ agentProcess.on('message', this.runtimeMessageHandler);
202
406
  }
203
407
  /**
204
408
  * Setup Transport crash recovery.
@@ -232,6 +436,29 @@ export class ProcessManager {
232
436
  processManagerLog(`Failed to respawn Transport: ${error}`);
233
437
  });
234
438
  });
439
+ // Setup runtime message handler for ping/pong health checks
440
+ this.setupTransportRuntimeHandlers();
441
+ }
442
+ /**
443
+ * Setup runtime message handlers for Transport process.
444
+ * Handles IPC messages that arrive during normal operation (pong for health check).
445
+ *
446
+ * IMPORTANT: Uses stored handler reference to prevent listener accumulation.
447
+ * Each respawn calls this method, so we must remove the old listener first.
448
+ */
449
+ setupTransportRuntimeHandlers() {
450
+ const { transportProcess } = this.state;
451
+ if (!transportProcess)
452
+ return;
453
+ // Remove old listener FIRST (prevents accumulation on respawn)
454
+ if (this.transportMessageHandler) {
455
+ transportProcess.off('message', this.transportMessageHandler);
456
+ }
457
+ // Create and store new handler
458
+ this.transportMessageHandler = (message) => {
459
+ this.handleTransportRuntimeMessage(message);
460
+ };
461
+ transportProcess.on('message', this.transportMessageHandler);
235
462
  }
236
463
  /**
237
464
  * Start Agent Process.
@@ -312,6 +539,27 @@ export class ProcessManager {
312
539
  // Don't prevent process exit
313
540
  this.healthCheckInterval.unref();
314
541
  }
542
+ /**
543
+ * Start periodic health check for Transport and Agent.
544
+ * Runs every 30s to detect zombie processes mid-session (not just after wake).
545
+ */
546
+ startPeriodicHealthCheck() {
547
+ this.periodicHealthCheckInterval = setInterval(() => {
548
+ if (!this.state.running)
549
+ return;
550
+ // Check Transport (if ready and not already pinging)
551
+ if (this.state.transportReady && !this.transportPingPending) {
552
+ this.pingTransportWithTimeout();
553
+ }
554
+ // Check Agent (if ready and not already checking)
555
+ if (this.state.agentReady && !this.agentHealthCheckPending) {
556
+ this.healthCheckAgentWithTimeout();
557
+ }
558
+ }, PERIODIC_HEALTH_CHECK_INTERVAL_MS);
559
+ // Don't prevent process exit
560
+ this.periodicHealthCheckInterval.unref();
561
+ processManagerLog('Periodic health check started (30s interval)');
562
+ }
315
563
  /**
316
564
  * Start Transport Process.
317
565
  * @returns The port Transport is listening on
@@ -385,6 +633,8 @@ export class ProcessManager {
385
633
  clearTimeout(timeout);
386
634
  agentProcess.off('message', onMessage);
387
635
  agentProcess.off('exit', onExit);
636
+ // Clear stored handler reference (prevents stale refs on respawn)
637
+ this.runtimeMessageHandler = undefined;
388
638
  };
389
639
  const timeout = setTimeout(() => {
390
640
  cleanup();
@@ -419,6 +669,22 @@ export class ProcessManager {
419
669
  this.healthCheckInterval = undefined;
420
670
  }
421
671
  }
672
+ /**
673
+ * Stop periodic health check interval.
674
+ * Also clears any pending agent health check timeout.
675
+ */
676
+ stopPeriodicHealthCheck() {
677
+ if (this.periodicHealthCheckInterval) {
678
+ clearInterval(this.periodicHealthCheckInterval);
679
+ this.periodicHealthCheckInterval = undefined;
680
+ }
681
+ // Clear any pending agent health check
682
+ if (this.agentHealthCheckTimeout) {
683
+ clearTimeout(this.agentHealthCheckTimeout);
684
+ this.agentHealthCheckTimeout = undefined;
685
+ }
686
+ this.agentHealthCheckPending = false;
687
+ }
422
688
  /**
423
689
  * Stop Transport Process.
424
690
  */
@@ -431,6 +697,14 @@ export class ProcessManager {
431
697
  clearTimeout(timeout);
432
698
  transportProcess.off('message', onMessage);
433
699
  transportProcess.off('exit', onExit);
700
+ // Clear stored handler reference (prevents stale refs on respawn)
701
+ this.transportMessageHandler = undefined;
702
+ // Clear ping state
703
+ if (this.transportPingTimeout) {
704
+ clearTimeout(this.transportPingTimeout);
705
+ this.transportPingTimeout = undefined;
706
+ }
707
+ this.transportPingPending = false;
434
708
  };
435
709
  const timeout = setTimeout(() => {
436
710
  cleanup();
@@ -81,14 +81,27 @@ export declare class TaskQueueManager {
81
81
  * Returns success with queue position, or failure reason.
82
82
  */
83
83
  enqueue(task: TaskExecute): EnqueueResult;
84
+ /**
85
+ * Get total active task count across all queues.
86
+ */
87
+ getActiveCount(): number;
84
88
  /**
85
89
  * Get all queue statistics.
86
90
  */
87
91
  getAllStats(): Record<TaskType, TaskQueueStats>;
92
+ /**
93
+ * Get total queued task count across all queues.
94
+ */
95
+ getQueuedCount(): number;
88
96
  /**
89
97
  * Get statistics for a specific queue.
90
98
  */
91
99
  getStats(type: TaskType): TaskQueueStats;
100
+ /**
101
+ * Check if there are any active tasks (currently being processed).
102
+ * Used to prevent reinit during task execution.
103
+ */
104
+ hasActiveTasks(): boolean;
92
105
  /**
93
106
  * Check if a taskId is known (queued or processing).
94
107
  */
@@ -91,6 +91,12 @@ export class TaskQueueManager {
91
91
  this.tryProcessNext('query');
92
92
  return { position: this.queryQueue.length, success: true };
93
93
  }
94
+ /**
95
+ * Get total active task count across all queues.
96
+ */
97
+ getActiveCount() {
98
+ return this.activeCurateTasks + this.activeQueryTasks;
99
+ }
94
100
  /**
95
101
  * Get all queue statistics.
96
102
  */
@@ -100,6 +106,12 @@ export class TaskQueueManager {
100
106
  query: this.getStats('query'),
101
107
  };
102
108
  }
109
+ /**
110
+ * Get total queued task count across all queues.
111
+ */
112
+ getQueuedCount() {
113
+ return this.curateQueue.length + this.queryQueue.length;
114
+ }
103
115
  /**
104
116
  * Get statistics for a specific queue.
105
117
  */
@@ -117,6 +129,13 @@ export class TaskQueueManager {
117
129
  queued: this.queryQueue.length,
118
130
  };
119
131
  }
132
+ /**
133
+ * Check if there are any active tasks (currently being processed).
134
+ * Used to prevent reinit during task execution.
135
+ */
136
+ hasActiveTasks() {
137
+ return this.activeCurateTasks > 0 || this.activeQueryTasks > 0;
138
+ }
120
139
  /**
121
140
  * Check if a taskId is known (queued or processing).
122
141
  */
@@ -40,6 +40,8 @@ import type { ITransportServer } from '../../core/interfaces/transport/i-transpo
40
40
  export declare class TransportHandlers {
41
41
  /** The Agent's client ID (set when Agent registers) */
42
42
  private agentClientId;
43
+ /** Cached agent status from last status:changed broadcast */
44
+ private cachedAgentStatus;
43
45
  /** Track active tasks */
44
46
  private tasks;
45
47
  /** Transport server reference */
@@ -56,6 +58,7 @@ export declare class TransportHandlers {
56
58
  /**
57
59
  * Handle Agent registration.
58
60
  * Agent connects as Socket.IO client and sends 'agent:register'.
61
+ * Fix #4: Accepts optional status in payload to cache atomically with registration.
59
62
  */
60
63
  private handleAgentRegister;
61
64
  /**
@@ -30,9 +30,16 @@
30
30
  * - agent:connected / agent:disconnected: Broadcast to all clients
31
31
  * - broadcast-room: TUI joins this room to monitor all events
32
32
  */
33
- import { AgentDisconnectedError, AgentNotAvailableError, serializeTaskError, } from '../../core/domain/errors/task-error.js';
34
- import { LlmEventNames, TransportAgentEventNames, TransportLlmEventList, TransportTaskEventNames, } from '../../core/domain/transport/schemas.js';
33
+ import { AgentDisconnectedError, AgentNotAvailableError, AgentNotInitializedError, serializeTaskError, } from '../../core/domain/errors/task-error.js';
34
+ import { AgentStatusEventNames, LlmEventNames, TransportAgentEventNames, TransportLlmEventList, TransportTaskEventNames, } from '../../core/domain/transport/schemas.js';
35
35
  import { eventLog, transportLog } from '../../utils/process-logger.js';
36
+ /**
37
+ * Type guard for valid task types.
38
+ * Replaces unsafe `as` assertion per CLAUDE.md standards.
39
+ */
40
+ function isValidTaskType(type) {
41
+ return type === 'curate' || type === 'query';
42
+ }
36
43
  // All message types are imported from core/domain/transport/schemas.ts
37
44
  // - TaskExecute: Transport → Agent (command)
38
45
  // - TaskStartedEvent, TaskCompletedEvent, TaskErrorEvent: Agent → Transport (task lifecycle events)
@@ -49,6 +56,8 @@ import { eventLog, transportLog } from '../../utils/process-logger.js';
49
56
  export class TransportHandlers {
50
57
  /** The Agent's client ID (set when Agent registers) */
51
58
  agentClientId;
59
+ /** Cached agent status from last status:changed broadcast */
60
+ cachedAgentStatus;
52
61
  /** Track active tasks */
53
62
  tasks = new Map();
54
63
  /** Transport server reference */
@@ -62,6 +71,7 @@ export class TransportHandlers {
62
71
  cleanup() {
63
72
  this.tasks.clear();
64
73
  this.agentClientId = undefined;
74
+ this.cachedAgentStatus = undefined;
65
75
  }
66
76
  /**
67
77
  * Setup all message handlers.
@@ -75,10 +85,15 @@ export class TransportHandlers {
75
85
  /**
76
86
  * Handle Agent registration.
77
87
  * Agent connects as Socket.IO client and sends 'agent:register'.
88
+ * Fix #4: Accepts optional status in payload to cache atomically with registration.
78
89
  */
79
- handleAgentRegister(clientId) {
90
+ handleAgentRegister(clientId, data) {
80
91
  transportLog(`Agent registered: ${clientId}`);
81
92
  this.agentClientId = clientId;
93
+ // Cache status if provided (prevents race window between register and status broadcast)
94
+ if (data?.status) {
95
+ this.cachedAgentStatus = data.status;
96
+ }
82
97
  // Broadcast to all clients that Agent is online
83
98
  this.transport.broadcast(TransportAgentEventNames.CONNECTED, {});
84
99
  }
@@ -169,6 +184,26 @@ export class TransportHandlers {
169
184
  });
170
185
  // Forward to Agent
171
186
  if (this.agentClientId) {
187
+ // Pre-task check: verify cipher is initialized before forwarding
188
+ // Reject if: (1) no status cached yet, OR (2) status shows not initialized
189
+ // This prevents race condition where task arrives before agent broadcasts initial status
190
+ if (!this.cachedAgentStatus || !this.cachedAgentStatus.isInitialized) {
191
+ transportLog(`Agent not initialized, cannot process task ${taskId}`);
192
+ const error = serializeTaskError(new AgentNotInitializedError(this.cachedAgentStatus?.lastError ?? 'Agent status unknown'));
193
+ this.transport.sendTo(clientId, TransportTaskEventNames.ERROR, { error, taskId });
194
+ this.transport.broadcastTo('broadcast-room', TransportTaskEventNames.ERROR, { error, taskId });
195
+ this.tasks.delete(taskId);
196
+ return { taskId };
197
+ }
198
+ // Validate task type before forwarding (type guard replaces unsafe `as` assertion)
199
+ if (!isValidTaskType(data.type)) {
200
+ transportLog(`Invalid task type: ${data.type}`);
201
+ const error = serializeTaskError(new Error(`Invalid task type: ${data.type}`));
202
+ this.transport.sendTo(clientId, TransportTaskEventNames.ERROR, { error, taskId });
203
+ this.transport.broadcastTo('broadcast-room', TransportTaskEventNames.ERROR, { error, taskId });
204
+ this.tasks.delete(taskId);
205
+ return { taskId };
206
+ }
172
207
  const executeMsg = {
173
208
  clientId,
174
209
  content: data.content,
@@ -323,8 +358,9 @@ export class TransportHandlers {
323
358
  */
324
359
  setupAgentHandlers() {
325
360
  // Agent registration
326
- this.transport.onRequest(TransportAgentEventNames.REGISTER, (_data, clientId) => {
327
- this.handleAgentRegister(clientId);
361
+ // Fix #4: Accept optional status in payload for atomic caching
362
+ this.transport.onRequest(TransportAgentEventNames.REGISTER, (data, clientId) => {
363
+ this.handleAgentRegister(clientId, data);
328
364
  return { success: true };
329
365
  });
330
366
  // Task lifecycle events (Transport-generated names)
@@ -344,6 +380,15 @@ export class TransportHandlers {
344
380
  for (const eventName of TransportLlmEventList) {
345
381
  this.registerLlmEvent(eventName);
346
382
  }
383
+ // Agent status events
384
+ // agent:status:changed - Agent broadcasts status changes
385
+ this.transport.onRequest(AgentStatusEventNames.STATUS_CHANGED, (data) => {
386
+ transportLog(`Agent status changed: initialized=${data.isInitialized}, auth=${data.hasAuth}, config=${data.hasConfig}`);
387
+ // Cache status for pre-task check
388
+ this.cachedAgentStatus = data;
389
+ // Broadcast status change to all clients
390
+ this.transport.broadcast(AgentStatusEventNames.STATUS_CHANGED, data);
391
+ });
347
392
  }
348
393
  /**
349
394
  * Setup client-related handlers.
@@ -368,6 +413,7 @@ export class TransportHandlers {
368
413
  if (clientId === this.agentClientId) {
369
414
  transportLog('Agent disconnected!');
370
415
  this.agentClientId = undefined;
416
+ this.cachedAgentStatus = undefined;
371
417
  // Broadcast to all clients
372
418
  this.transport.broadcast(TransportAgentEventNames.DISCONNECTED, {});
373
419
  // Fail all pending tasks - send to client AND broadcast-room for TUI monitoring
@@ -24,6 +24,7 @@ import { transportLog } from '../../utils/process-logger.js';
24
24
  import { FileInstanceManager } from '../instance/file-instance-manager.js';
25
25
  import { findAvailablePort } from '../transport/port-utils.js';
26
26
  import { createTransportServer } from '../transport/transport-factory.js';
27
+ import { createParentHeartbeat } from './parent-heartbeat.js';
27
28
  import { TransportHandlers } from './transport-handlers.js';
28
29
  // IPC types imported from ./ipc-types.ts
29
30
  function sendToParent(message) {
@@ -35,13 +36,10 @@ function sendToParent(message) {
35
36
  let transportServer;
36
37
  let transportHandlers;
37
38
  let instancePollingInterval;
38
- let parentHeartbeatRunning = false;
39
- let parentPid;
39
+ let parentHeartbeat;
40
40
  const instanceManager = new FileInstanceManager();
41
41
  /** Polling interval in milliseconds */
42
42
  const INSTANCE_POLLING_INTERVAL_MS = 2000;
43
- /** Parent heartbeat check interval in milliseconds */
44
- const PARENT_HEARTBEAT_INTERVAL_MS = 2000;
45
43
  /**
46
44
  * Setup polling to detect instance.json deletion and recreate it.
47
45
  *
@@ -83,69 +81,6 @@ function stopInstancePolling() {
83
81
  instancePollingInterval = undefined;
84
82
  }
85
83
  }
86
- /**
87
- * Setup parent process heartbeat monitoring.
88
- *
89
- * Why this is needed:
90
- * - When main process receives SIGKILL, it dies immediately
91
- * - SIGKILL cannot be caught, so no cleanup happens
92
- * - IPC 'disconnect' event may not fire
93
- * - Child processes become orphans (PPID = 1)
94
- *
95
- * This function periodically checks if parent is still alive.
96
- * If parent dies, child self-terminates to prevent zombie processes.
97
- */
98
- function setupParentHeartbeat() {
99
- // Already running - don't start another
100
- if (parentHeartbeatRunning)
101
- return;
102
- parentHeartbeatRunning = true;
103
- parentPid = process.ppid;
104
- /**
105
- * Recursive setTimeout pattern - safer than setInterval:
106
- * - No callback overlap possible
107
- * - Clean cancellation (just set flag = false)
108
- * - No orphan timers
109
- */
110
- const checkParent = () => {
111
- // Stopped - don't schedule next check
112
- if (!parentHeartbeatRunning || !parentPid)
113
- return;
114
- // Check if parent is still alive using signal 0
115
- // Signal 0 doesn't send any signal, just checks if process exists
116
- try {
117
- process.kill(parentPid, 0);
118
- }
119
- catch {
120
- // Parent is dead - self-terminate
121
- transportLog(`Parent process (${parentPid}) died - shutting down to prevent zombie`);
122
- parentHeartbeatRunning = false;
123
- stopInstancePolling();
124
- // Release instance lock and exit
125
- stopTransport()
126
- .catch(() => { })
127
- .finally(() => {
128
- // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
129
- process.exit(0);
130
- });
131
- return;
132
- }
133
- // Schedule next check (only if still running)
134
- if (parentHeartbeatRunning) {
135
- setTimeout(checkParent, PARENT_HEARTBEAT_INTERVAL_MS);
136
- }
137
- };
138
- // Start first check after delay
139
- setTimeout(checkParent, PARENT_HEARTBEAT_INTERVAL_MS);
140
- transportLog(`Parent heartbeat monitoring started (PPID: ${parentPid})`);
141
- }
142
- /**
143
- * Stop the parent heartbeat monitoring.
144
- * With recursive setTimeout, just set flag to false - next check won't schedule.
145
- */
146
- function stopParentHeartbeat() {
147
- parentHeartbeatRunning = false;
148
- }
149
84
  async function startTransport() {
150
85
  // Create Socket.IO server
151
86
  transportServer = createTransportServer();
@@ -171,7 +106,7 @@ async function startTransport() {
171
106
  }
172
107
  async function stopTransport() {
173
108
  // Stop heartbeat and polling first
174
- stopParentHeartbeat();
109
+ parentHeartbeat?.stop();
175
110
  stopInstancePolling();
176
111
  // Release instance.json
177
112
  const projectRoot = process.cwd();
@@ -195,7 +130,12 @@ async function runWorker() {
195
130
  sendToParent({ port, type: 'ready' });
196
131
  // Start parent heartbeat monitoring after ready
197
132
  // This ensures we self-terminate if parent dies (SIGKILL scenario)
198
- setupParentHeartbeat();
133
+ parentHeartbeat = createParentHeartbeat({
134
+ cleanup: stopTransport,
135
+ log: transportLog,
136
+ preCleanup: stopInstancePolling,
137
+ });
138
+ parentHeartbeat.start();
199
139
  }
200
140
  catch (error) {
201
141
  const message = error instanceof Error ? error.message : String(error);