byterover-cli 1.0.5 → 1.2.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 (204) hide show
  1. package/README.md +19 -13
  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/mcp.d.ts +13 -0
  5. package/dist/commands/mcp.js +61 -0
  6. package/dist/commands/status.js +8 -3
  7. package/dist/constants.d.ts +1 -1
  8. package/dist/constants.js +1 -1
  9. package/dist/core/domain/cipher/agent-events/types.d.ts +44 -1
  10. package/dist/core/domain/cipher/tools/constants.d.ts +1 -0
  11. package/dist/core/domain/cipher/tools/constants.js +1 -0
  12. package/dist/core/domain/entities/agent.d.ts +16 -0
  13. package/dist/core/domain/entities/agent.js +78 -0
  14. package/dist/core/domain/entities/connector-type.d.ts +10 -0
  15. package/dist/core/domain/entities/connector-type.js +9 -0
  16. package/dist/core/domain/entities/event.d.ts +1 -1
  17. package/dist/core/domain/entities/event.js +2 -0
  18. package/dist/core/domain/errors/task-error.d.ts +4 -0
  19. package/dist/core/domain/errors/task-error.js +7 -0
  20. package/dist/core/domain/transport/schemas.d.ts +40 -0
  21. package/dist/core/domain/transport/schemas.js +28 -0
  22. package/dist/core/interfaces/connectors/connector-types.d.ts +70 -0
  23. package/dist/core/interfaces/connectors/i-connector-manager.d.ts +72 -0
  24. package/dist/core/interfaces/connectors/i-connector-manager.js +1 -0
  25. package/dist/core/interfaces/connectors/i-connector.d.ts +54 -0
  26. package/dist/core/interfaces/connectors/i-connector.js +1 -0
  27. package/dist/core/interfaces/i-file-service.d.ts +7 -0
  28. package/dist/core/interfaces/i-mcp-config-writer.d.ts +40 -0
  29. package/dist/core/interfaces/i-mcp-config-writer.js +1 -0
  30. package/dist/core/interfaces/i-rule-template-service.d.ts +4 -2
  31. package/dist/core/interfaces/transport/i-transport-client.d.ts +7 -0
  32. package/dist/core/interfaces/usecase/i-connectors-use-case.d.ts +3 -0
  33. package/dist/core/interfaces/usecase/i-connectors-use-case.js +1 -0
  34. package/dist/hooks/init/update-notifier.d.ts +1 -0
  35. package/dist/hooks/init/update-notifier.js +10 -1
  36. package/dist/infra/cipher/agent/cipher-agent.d.ts +8 -0
  37. package/dist/infra/cipher/agent/cipher-agent.js +16 -0
  38. package/dist/infra/cipher/file-system/binary-utils.d.ts +7 -12
  39. package/dist/infra/cipher/file-system/binary-utils.js +46 -31
  40. package/dist/infra/cipher/llm/context/context-manager.d.ts +10 -2
  41. package/dist/infra/cipher/llm/context/context-manager.js +39 -2
  42. package/dist/infra/cipher/llm/formatters/gemini-formatter.js +48 -9
  43. package/dist/infra/cipher/llm/internal-llm-service.d.ts +4 -0
  44. package/dist/infra/cipher/llm/internal-llm-service.js +40 -12
  45. package/dist/infra/cipher/session/chat-session.d.ts +3 -0
  46. package/dist/infra/cipher/session/chat-session.js +7 -1
  47. package/dist/infra/cipher/system-prompt/contributors/context-tree-structure-contributor.d.ts +6 -7
  48. package/dist/infra/cipher/system-prompt/contributors/context-tree-structure-contributor.js +57 -18
  49. package/dist/infra/cipher/tools/implementations/curate-tool.d.ts +1 -8
  50. package/dist/infra/cipher/tools/implementations/curate-tool.js +380 -24
  51. package/dist/infra/cipher/tools/implementations/read-file-tool.js +38 -17
  52. package/dist/infra/cipher/tools/implementations/search-knowledge-tool.d.ts +7 -0
  53. package/dist/infra/cipher/tools/implementations/search-knowledge-tool.js +303 -0
  54. package/dist/infra/cipher/tools/index.d.ts +1 -0
  55. package/dist/infra/cipher/tools/index.js +1 -0
  56. package/dist/infra/cipher/tools/tool-manager.js +1 -0
  57. package/dist/infra/cipher/tools/tool-registry.js +7 -0
  58. package/dist/infra/connectors/connector-manager.d.ts +32 -0
  59. package/dist/infra/connectors/connector-manager.js +158 -0
  60. package/dist/infra/connectors/hook/hook-connector-config.d.ts +52 -0
  61. package/dist/infra/connectors/hook/hook-connector-config.js +41 -0
  62. package/dist/infra/connectors/hook/hook-connector.d.ts +46 -0
  63. package/dist/infra/connectors/hook/hook-connector.js +231 -0
  64. package/dist/infra/connectors/mcp/index.d.ts +4 -0
  65. package/dist/infra/connectors/mcp/index.js +4 -0
  66. package/dist/infra/connectors/mcp/json-mcp-config-writer.d.ts +26 -0
  67. package/dist/infra/connectors/mcp/json-mcp-config-writer.js +71 -0
  68. package/dist/infra/connectors/mcp/mcp-connector-config.d.ts +229 -0
  69. package/dist/infra/connectors/mcp/mcp-connector-config.js +173 -0
  70. package/dist/infra/connectors/mcp/mcp-connector.d.ts +80 -0
  71. package/dist/infra/connectors/mcp/mcp-connector.js +324 -0
  72. package/dist/infra/connectors/mcp/toml-mcp-config-writer.d.ts +45 -0
  73. package/dist/infra/connectors/mcp/toml-mcp-config-writer.js +134 -0
  74. package/dist/infra/{rule → connectors/rules}/legacy-rule-detector.d.ts +2 -2
  75. package/dist/infra/{rule → connectors/rules}/legacy-rule-detector.js +1 -1
  76. package/dist/infra/connectors/rules/rules-connector-config.d.ts +95 -0
  77. package/dist/infra/{rule/agent-rule-config.js → connectors/rules/rules-connector-config.js} +10 -10
  78. package/dist/infra/connectors/rules/rules-connector.d.ts +34 -0
  79. package/dist/infra/connectors/rules/rules-connector.js +139 -0
  80. package/dist/infra/connectors/shared/rule-file-manager.d.ts +72 -0
  81. package/dist/infra/connectors/shared/rule-file-manager.js +119 -0
  82. package/dist/infra/connectors/shared/template-service.d.ts +27 -0
  83. package/dist/infra/connectors/shared/template-service.js +125 -0
  84. package/dist/infra/context-tree/file-context-tree-writer-service.d.ts +5 -2
  85. package/dist/infra/context-tree/file-context-tree-writer-service.js +20 -5
  86. package/dist/infra/core/executors/curate-executor.d.ts +2 -2
  87. package/dist/infra/core/executors/curate-executor.js +7 -7
  88. package/dist/infra/core/executors/query-executor.d.ts +12 -0
  89. package/dist/infra/core/executors/query-executor.js +62 -1
  90. package/dist/infra/file/fs-file-service.d.ts +7 -0
  91. package/dist/infra/file/fs-file-service.js +15 -1
  92. package/dist/infra/mcp/index.d.ts +2 -0
  93. package/dist/infra/mcp/index.js +2 -0
  94. package/dist/infra/mcp/mcp-server.d.ts +58 -0
  95. package/dist/infra/mcp/mcp-server.js +178 -0
  96. package/dist/infra/mcp/tools/brv-curate-tool.d.ts +23 -0
  97. package/dist/infra/mcp/tools/brv-curate-tool.js +68 -0
  98. package/dist/infra/mcp/tools/brv-query-tool.d.ts +17 -0
  99. package/dist/infra/mcp/tools/brv-query-tool.js +68 -0
  100. package/dist/infra/mcp/tools/index.d.ts +3 -0
  101. package/dist/infra/mcp/tools/index.js +3 -0
  102. package/dist/infra/mcp/tools/task-result-waiter.d.ts +30 -0
  103. package/dist/infra/mcp/tools/task-result-waiter.js +56 -0
  104. package/dist/infra/process/agent-worker.d.ts +2 -2
  105. package/dist/infra/process/agent-worker.js +663 -142
  106. package/dist/infra/process/constants.d.ts +1 -1
  107. package/dist/infra/process/constants.js +1 -1
  108. package/dist/infra/process/ipc-types.d.ts +17 -4
  109. package/dist/infra/process/ipc-types.js +3 -3
  110. package/dist/infra/process/parent-heartbeat.d.ts +47 -0
  111. package/dist/infra/process/parent-heartbeat.js +118 -0
  112. package/dist/infra/process/process-manager.d.ts +79 -0
  113. package/dist/infra/process/process-manager.js +277 -3
  114. package/dist/infra/process/task-queue-manager.d.ts +13 -0
  115. package/dist/infra/process/task-queue-manager.js +19 -0
  116. package/dist/infra/process/transport-handlers.d.ts +3 -0
  117. package/dist/infra/process/transport-handlers.js +51 -5
  118. package/dist/infra/process/transport-worker.js +9 -69
  119. package/dist/infra/repl/commands/connectors-command.d.ts +8 -0
  120. package/dist/infra/repl/commands/{gen-rules-command.js → connectors-command.js} +21 -10
  121. package/dist/infra/repl/commands/curate-command.js +2 -2
  122. package/dist/infra/repl/commands/index.js +3 -2
  123. package/dist/infra/repl/commands/init-command.js +11 -7
  124. package/dist/infra/repl/commands/query-command.js +22 -2
  125. package/dist/infra/repl/commands/reset-command.js +1 -1
  126. package/dist/infra/transport/socket-io-transport-client.d.ts +75 -0
  127. package/dist/infra/transport/socket-io-transport-client.js +308 -7
  128. package/dist/infra/transport/socket-io-transport-server.js +4 -0
  129. package/dist/infra/usecase/connectors-use-case.d.ts +63 -0
  130. package/dist/infra/usecase/connectors-use-case.js +222 -0
  131. package/dist/infra/usecase/init-use-case.d.ts +8 -43
  132. package/dist/infra/usecase/init-use-case.js +27 -252
  133. package/dist/infra/usecase/logout-use-case.js +1 -1
  134. package/dist/infra/usecase/pull-use-case.js +5 -5
  135. package/dist/infra/usecase/push-use-case.js +4 -4
  136. package/dist/infra/usecase/reset-use-case.js +3 -4
  137. package/dist/infra/usecase/space-list-use-case.js +3 -3
  138. package/dist/infra/usecase/space-switch-use-case.js +3 -3
  139. package/dist/infra/usecase/status-use-case.d.ts +10 -0
  140. package/dist/infra/usecase/status-use-case.js +53 -0
  141. package/dist/resources/prompts/curate.yml +114 -4
  142. package/dist/resources/prompts/explore.yml +34 -0
  143. package/dist/resources/prompts/query-orchestrator.yml +112 -0
  144. package/dist/resources/prompts/system-prompt.yml +12 -2
  145. package/dist/resources/tools/search_knowledge.txt +32 -0
  146. package/dist/templates/mcp-base.md +1 -0
  147. package/dist/templates/sections/brv-instructions.md +98 -0
  148. package/dist/templates/sections/mcp-workflow.md +13 -0
  149. package/dist/tui/app.js +4 -1
  150. package/dist/tui/components/command-details.js +1 -1
  151. package/dist/tui/components/execution/execution-changes.d.ts +2 -0
  152. package/dist/tui/components/execution/execution-changes.js +5 -1
  153. package/dist/tui/components/execution/execution-content.d.ts +2 -0
  154. package/dist/tui/components/execution/execution-content.js +8 -18
  155. package/dist/tui/components/execution/execution-input.d.ts +2 -0
  156. package/dist/tui/components/execution/execution-input.js +6 -4
  157. package/dist/tui/components/execution/execution-progress.d.ts +2 -0
  158. package/dist/tui/components/execution/execution-progress.js +6 -2
  159. package/dist/tui/components/execution/expanded-log-view.d.ts +20 -0
  160. package/dist/tui/components/execution/expanded-log-view.js +75 -0
  161. package/dist/tui/components/execution/expanded-message-view.d.ts +24 -0
  162. package/dist/tui/components/execution/expanded-message-view.js +68 -0
  163. package/dist/tui/components/execution/index.d.ts +2 -0
  164. package/dist/tui/components/execution/index.js +2 -0
  165. package/dist/tui/components/execution/log-item.d.ts +4 -0
  166. package/dist/tui/components/execution/log-item.js +2 -2
  167. package/dist/tui/components/footer.js +1 -1
  168. package/dist/tui/components/index.d.ts +2 -1
  169. package/dist/tui/components/index.js +2 -1
  170. package/dist/tui/components/init.js +2 -9
  171. package/dist/tui/components/logo.js +4 -3
  172. package/dist/tui/components/markdown.d.ts +13 -0
  173. package/dist/tui/components/markdown.js +88 -0
  174. package/dist/tui/components/message-item.js +1 -1
  175. package/dist/tui/components/onboarding/onboarding-flow.js +14 -11
  176. package/dist/tui/components/onboarding/welcome-box.js +1 -1
  177. package/dist/tui/components/suggestions.js +3 -3
  178. package/dist/tui/contexts/mode-context.js +6 -2
  179. package/dist/tui/contexts/onboarding-context.d.ts +4 -0
  180. package/dist/tui/contexts/onboarding-context.js +14 -2
  181. package/dist/tui/hooks/index.d.ts +1 -0
  182. package/dist/tui/hooks/index.js +1 -0
  183. package/dist/tui/hooks/use-is-latest-version.d.ts +6 -0
  184. package/dist/tui/hooks/use-is-latest-version.js +22 -0
  185. package/dist/tui/views/command-view.d.ts +1 -1
  186. package/dist/tui/views/command-view.js +87 -98
  187. package/dist/tui/views/logs-view.d.ts +8 -0
  188. package/dist/tui/views/logs-view.js +55 -27
  189. package/dist/utils/file-validator.d.ts +1 -1
  190. package/dist/utils/file-validator.js +25 -28
  191. package/dist/utils/type-guards.d.ts +5 -0
  192. package/dist/utils/type-guards.js +7 -0
  193. package/oclif.manifest.json +55 -4
  194. package/package.json +12 -1
  195. package/dist/core/interfaces/usecase/i-generate-rules-use-case.d.ts +0 -3
  196. package/dist/infra/repl/commands/gen-rules-command.d.ts +0 -7
  197. package/dist/infra/rule/agent-rule-config.d.ts +0 -19
  198. package/dist/infra/rule/rule-template-service.d.ts +0 -18
  199. package/dist/infra/rule/rule-template-service.js +0 -88
  200. package/dist/infra/usecase/generate-rules-use-case.d.ts +0 -61
  201. package/dist/infra/usecase/generate-rules-use-case.js +0 -285
  202. /package/dist/core/interfaces/{usecase/i-generate-rules-use-case.js → connectors/connector-types.js} +0 -0
  203. /package/dist/infra/{rule → connectors/shared}/constants.d.ts +0 -0
  204. /package/dist/infra/{rule → connectors/shared}/constants.js +0 -0
@@ -9,8 +9,8 @@
9
9
  * - NO Socket.IO server (Transport is the only server)
10
10
  *
11
11
  * IPC messages:
12
- * - Receives: 'ping', 'shutdown'
13
- * - Sends: 'ready', 'pong', 'stopped', 'error'
12
+ * - Receives: 'ping', 'shutdown', 'health-check'
13
+ * - Sends: 'ready', 'pong', 'stopped', 'error', 'health-check-result'
14
14
  *
15
15
  * Socket.IO events (as client):
16
16
  * - Sends: 'agent:register' (identify as Agent)
@@ -20,7 +20,7 @@
20
20
  import { randomUUID } from 'node:crypto';
21
21
  import { getCurrentConfig } from '../../config/environment.js';
22
22
  import { DEFAULT_LLM_MODEL, PROJECT } from '../../constants.js';
23
- import { NotAuthenticatedError, ProcessorNotInitError, serializeTaskError } from '../../core/domain/errors/task-error.js';
23
+ import { AgentNotInitializedError, NotAuthenticatedError, ProcessorNotInitError, serializeTaskError, } from '../../core/domain/errors/task-error.js';
24
24
  import { agentLog } from '../../utils/process-logger.js';
25
25
  import { CipherAgent } from '../cipher/agent/index.js';
26
26
  import { ProjectConfigStore } from '../config/file-config-store.js';
@@ -30,6 +30,7 @@ import { createTaskProcessor } from '../core/task-processor.js';
30
30
  import { createTokenStore } from '../storage/token-store.js';
31
31
  import { createTransportClient } from '../transport/transport-factory.js';
32
32
  import { CURATE_MAX_CONCURRENT } from './constants.js';
33
+ import { createParentHeartbeat } from './parent-heartbeat.js';
33
34
  import { TaskQueueManager } from './task-queue-manager.js';
34
35
  // IPC types imported from ./ipc-types.ts
35
36
  function sendToParent(message) {
@@ -63,12 +64,40 @@ let initializationError;
63
64
  let isInitializing = false;
64
65
  /** Guard: prevent double cleanup */
65
66
  let isCleaningUp = false;
66
- /** Parent process PID for heartbeat monitoring */
67
- let parentPid;
68
- /** Parent heartbeat running flag (for recursive setTimeout pattern) */
69
- let parentHeartbeatRunning = false;
70
- /** Parent heartbeat check interval in milliseconds */
71
- const PARENT_HEARTBEAT_INTERVAL_MS = 2000;
67
+ /** Parent heartbeat monitor - lazily initialized in runWorker() */
68
+ let parentHeartbeat;
69
+ // ============================================================================
70
+ // Credentials Polling (detects auth/config changes)
71
+ // ============================================================================
72
+ /**
73
+ * Credentials polling interval in milliseconds.
74
+ *
75
+ * 5 seconds balances:
76
+ * - Responsiveness: User expects agent to react within ~5s after login/logout/space switch
77
+ * - Efficiency: Polling every 5s has minimal CPU/IO overhead (reads 2 small files)
78
+ * - UX: Faster than explicit restart, acceptable latency for credential changes
79
+ */
80
+ const CREDENTIALS_POLL_INTERVAL_MS = 5000;
81
+ /** Current cached credentials (set after successful init) */
82
+ let cachedCredentials;
83
+ /** Guard: prevent concurrent polling checks */
84
+ let isPolling = false;
85
+ /** Credentials polling running flag */
86
+ let credentialsPollingRunning = false;
87
+ /** Guard: prevent task enqueueing during reinit (fixes TOCTOU race condition) */
88
+ let isReinitializing = false;
89
+ /**
90
+ * Lazy-initialized stores for credentials polling.
91
+ * Avoids side effects at import time (file system access).
92
+ * Created once on first poll, then reused (avoid creating new instances every 5 seconds).
93
+ */
94
+ let pollingTokenStore;
95
+ let pollingConfigStore;
96
+ function getPollingStores() {
97
+ pollingTokenStore ??= createTokenStore();
98
+ pollingConfigStore ??= new ProjectConfigStore();
99
+ return { pollingConfigStore, pollingTokenStore };
100
+ }
72
101
  let eventForwarders = [];
73
102
  // ============================================================================
74
103
  // Task Queue Manager (replaces inline queue logic)
@@ -114,6 +143,7 @@ function cleanupAgentEventForwarding() {
114
143
  const eventBus = cipherAgent?.agentEventBus;
115
144
  if (eventBus) {
116
145
  for (const { event, handler } of eventForwarders) {
146
+ // Uses fallback signature: off(eventName: string, listener: (data?: unknown) => void)
117
147
  eventBus.off(event, handler);
118
148
  }
119
149
  }
@@ -121,6 +151,36 @@ function cleanupAgentEventForwarding() {
121
151
  eventForwarders = [];
122
152
  agentLog('Event forwarders cleaned up');
123
153
  }
154
+ /**
155
+ * Check if there is pending work that would be disrupted by reinit.
156
+ * Returns true if tasks are active (running) OR queued (waiting).
157
+ */
158
+ function hasPendingWork() {
159
+ return taskQueueManager.hasActiveTasks() || taskQueueManager.getQueuedCount() > 0;
160
+ }
161
+ /**
162
+ * Wait for active tasks to complete with a timeout.
163
+ * Used before reinit to allow in-flight tasks to finish gracefully.
164
+ */
165
+ async function waitForActiveTasksToComplete(timeoutMs) {
166
+ const start = Date.now();
167
+ const checkInterval = 100;
168
+ // Poll until no active tasks or timeout
169
+ const pollUntilDone = async () => {
170
+ while (taskQueueManager.hasActiveTasks()) {
171
+ if (Date.now() - start >= timeoutMs) {
172
+ agentLog('Timeout waiting for active tasks - proceeding with reinit');
173
+ return;
174
+ }
175
+ // eslint-disable-next-line no-await-in-loop
176
+ await new Promise((resolve) => {
177
+ setTimeout(resolve, checkInterval);
178
+ });
179
+ }
180
+ agentLog('Task queue drained successfully');
181
+ };
182
+ await pollUntilDone();
183
+ }
124
184
  /**
125
185
  * Setup event forwarding from CipherAgent to Transport.
126
186
  * agent-worker subscribes directly to agentEventBus (owns the agent).
@@ -137,10 +197,18 @@ function setupAgentEventForwarding(agent) {
137
197
  agentLog('No agentEventBus available for event forwarding');
138
198
  return;
139
199
  }
140
- // Helper to register and track event forwarder
200
+ // Helper to register and track event forwarder.
201
+ // Wraps typed handler to match event bus fallback signature.
141
202
  const registerForwarder = (event, handler) => {
142
- eventBus.on(event, handler);
143
- eventForwarders.push({ event, handler: handler });
203
+ // Wrapper matches fallback: (data?: unknown) => void
204
+ // BOUNDARY CAST: Event bus delivers unknown data; handler expects T.
205
+ // Type guard not possible for generic T at runtime.
206
+ const wrappedHandler = (data) => {
207
+ handler(data);
208
+ };
209
+ // Uses fallback signature: on(eventName: string, listener: (data?: unknown) => void)
210
+ eventBus.on(event, wrappedHandler);
211
+ eventForwarders.push({ event, handler: wrappedHandler });
144
212
  };
145
213
  // Forward llmservice:thinking
146
214
  registerForwarder('llmservice:thinking', (payload) => {
@@ -244,46 +312,159 @@ function setupAgentEventForwarding(agent) {
244
312
  const TASK_EXECUTION_TIMEOUT_MS = 5 * 60 * 1000;
245
313
  /**
246
314
  * Setup the task executor for TaskQueueManager.
247
- * Called after agent is initialized.
315
+ * Fix #2: Now called unconditionally at startup (before tryInitializeAgent).
316
+ * Lazy init inside executor enables processing tasks even if initial init failed.
248
317
  */
249
318
  function setupTaskExecutor() {
250
319
  taskQueueManager.setExecutor(async (task) => {
251
320
  const { taskId, type } = task;
252
321
  const stats = taskQueueManager.getStats(type);
253
322
  agentLog(`Processing task ${taskId} (${type}), ${stats.queued} queued, ${stats.active} active`);
254
- // Create timeout promise that rejects after 5 minutes
255
- let timeoutId;
256
- const timeoutPromise = new Promise((_, reject) => {
257
- timeoutId = setTimeout(() => {
258
- reject(new Error('TASK_TIMEOUT'));
259
- }, TASK_EXECUTION_TIMEOUT_MS);
260
- });
323
+ // Fix #2: Lazy initialization - if agent not ready, try to initialize now
324
+ // This enables processing tasks that arrived while init was failing
325
+ if (!isAgentInitialized) {
326
+ agentLog(`Task ${taskId} - agent not initialized, attempting lazy init...`);
327
+ const initialized = await tryInitializeAgent();
328
+ if (!initialized) {
329
+ agentLog(`Task ${taskId} rejected - lazy initialization failed`);
330
+ const error = serializeTaskError(initializationError ?? new AgentNotInitializedError('Agent initialization failed'));
331
+ transportClient?.request('task:error', { error, taskId }).catch(logTransportError);
332
+ return;
333
+ }
334
+ agentLog(`Task ${taskId} - lazy initialization successful, proceeding`);
335
+ }
336
+ // Pre-execution guard: Verify agent is still ready (catches race conditions)
337
+ // This catches the case where credentials polling stopped the agent
338
+ // between when lazy init succeeded and when execution starts.
339
+ if (!isAgentInitialized || !taskProcessor) {
340
+ agentLog(`Task ${taskId} rejected - agent stopped during queue wait`);
341
+ const error = serializeTaskError(new AgentNotInitializedError('Agent stopped during execution wait'));
342
+ transportClient?.request('task:error', { error, taskId }).catch(logTransportError);
343
+ return;
344
+ }
345
+ // Track timeout state for error handling
346
+ let timedOut = false;
347
+ const timeoutId = setTimeout(() => {
348
+ timedOut = true;
349
+ agentLog(`Task ${taskId} timed out after 5 minutes - cancelling via CipherAgent`);
350
+ // Cancel via CipherAgent's existing cancel() method
351
+ // This aborts activeStreamControllers and session, causing generate() to throw
352
+ if (cipherAgent) {
353
+ cipherAgent.cancel().catch((error) => {
354
+ agentLog(`Error cancelling CipherAgent on timeout: ${error}`);
355
+ });
356
+ }
357
+ }, TASK_EXECUTION_TIMEOUT_MS);
261
358
  try {
262
- // Race between task execution and timeout
263
- await Promise.race([handleTaskExecute(task), timeoutPromise]);
359
+ // Execute task - if timeout fires, cipherAgent.cancel() will cause this to throw
360
+ await handleTaskExecute(task);
264
361
  }
265
362
  catch (error) {
266
- // Handle timeout specifically
267
- if (error instanceof Error && error.message === 'TASK_TIMEOUT') {
268
- agentLog(`Task ${taskId} timed out after 5 minutes`);
363
+ // Handle timeout-triggered cancellation
364
+ if (timedOut) {
365
+ agentLog(`Task ${taskId} cancelled due to timeout`);
269
366
  const errorData = serializeTaskError(new Error('Task exceeded 5 minute timeout'));
270
367
  transportClient?.request('task:error', { error: errorData, taskId }).catch(logTransportError);
271
368
  return;
272
369
  }
273
- // Handle other errors
370
+ // Handle other errors (not timeout)
274
371
  agentLog(`Task execution failed: ${error}`);
275
372
  const errorData = serializeTaskError(error);
276
373
  transportClient?.request('task:error', { error: errorData, taskId }).catch(logTransportError);
277
374
  }
278
375
  finally {
279
376
  // Always clear timeout to prevent memory leak
280
- if (timeoutId) {
281
- clearTimeout(timeoutId);
282
- }
377
+ clearTimeout(timeoutId);
283
378
  }
284
379
  });
285
380
  agentLog('Task executor setup complete');
286
381
  }
382
+ /**
383
+ * Timeout for CipherAgent initialization operations.
384
+ * This prevents isInitializing flag from getting stuck if agent.start() or createSession() hangs.
385
+ * ProcessManager has 30s timeout for startup, but runtime reinit needs its own protection.
386
+ */
387
+ const AGENT_INIT_TIMEOUT_MS = 30_000;
388
+ /**
389
+ * Helper to wrap a promise with a timeout.
390
+ * Throws an error if the operation doesn't complete within the timeout.
391
+ */
392
+ async function withTimeout(promise, timeoutMs, operation) {
393
+ let timeoutId;
394
+ const timeoutPromise = new Promise((_, reject) => {
395
+ timeoutId = setTimeout(() => {
396
+ reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
397
+ }, timeoutMs);
398
+ });
399
+ try {
400
+ return await Promise.race([promise, timeoutPromise]);
401
+ }
402
+ finally {
403
+ if (timeoutId) {
404
+ clearTimeout(timeoutId);
405
+ }
406
+ }
407
+ }
408
+ /**
409
+ * Validate auth token for initialization.
410
+ * Returns the token if valid, undefined if invalid (also sets initializationError).
411
+ */
412
+ function validateAuthToken(authToken) {
413
+ if (!authToken) {
414
+ initializationError = new NotAuthenticatedError();
415
+ agentLog('Cannot initialize - no auth token');
416
+ return undefined;
417
+ }
418
+ if (authToken.isExpired()) {
419
+ initializationError = new NotAuthenticatedError();
420
+ agentLog('Cannot initialize - token expired (please run /login to re-authenticate)');
421
+ return undefined;
422
+ }
423
+ return authToken;
424
+ }
425
+ /**
426
+ * Check if cleanup started during init and abort if so.
427
+ * Returns true if should abort (cleanup started), false otherwise.
428
+ */
429
+ async function shouldAbortInitForCleanup(agent, phase) {
430
+ if (isCleaningUp) {
431
+ agentLog(`Cleanup started during ${phase}, aborting`);
432
+ await agent.stop();
433
+ return true;
434
+ }
435
+ return false;
436
+ }
437
+ /**
438
+ * Stop a pending agent that was created but not yet assigned to cipherAgent.
439
+ * Used in catch block when timeout/error occurs during agent.start() or createSession().
440
+ */
441
+ async function stopPendingAgent(agent) {
442
+ if (!agent)
443
+ return;
444
+ try {
445
+ await agent.stop();
446
+ }
447
+ catch (stopError) {
448
+ agentLog(`Error stopping pending agent: ${stopError}`);
449
+ }
450
+ }
451
+ /**
452
+ * Stop existing agent during force reinit.
453
+ */
454
+ async function stopExistingAgentForReinit() {
455
+ if (!cipherAgent)
456
+ return;
457
+ agentLog('Reinitializing with new config...');
458
+ try {
459
+ await cipherAgent.stop();
460
+ }
461
+ catch (error) {
462
+ agentLog(`Error stopping previous agent: ${error}`);
463
+ }
464
+ cipherAgent = undefined;
465
+ taskProcessor = undefined;
466
+ isAgentInitialized = false;
467
+ }
287
468
  /**
288
469
  * Try to initialize/reinitialize the CipherAgent.
289
470
  * Called on startup and lazily when tasks arrive but agent is not initialized.
@@ -292,9 +473,15 @@ function setupTaskExecutor() {
292
473
  * @param forceReinit - Force reinitialization even if already initialized (for config reload)
293
474
  */
294
475
  async function tryInitializeAgent(forceReinit = false) {
295
- // Guard: prevent concurrent initialization
296
- if (isInitializing) {
297
- agentLog('Initialization already in progress, skipping');
476
+ // Guard: prevent initialization during cleanup or if already in progress
477
+ if (isCleaningUp || isInitializing) {
478
+ agentLog('Initialization blocked (cleanup or already in progress)');
479
+ // Clear isReinitializing if WE set it (forceReinit case)
480
+ // Without this, the flag would be stuck forever since we return before try block
481
+ // and thus finally block never runs. Next poll will re-detect and retry.
482
+ if (forceReinit) {
483
+ isReinitializing = false;
484
+ }
298
485
  return false;
299
486
  }
300
487
  // Already initialized and not forcing reinit
@@ -302,34 +489,30 @@ async function tryInitializeAgent(forceReinit = false) {
302
489
  return true;
303
490
  }
304
491
  isInitializing = true;
492
+ // Set isReinitializing flag for forceReinit to reject tasks during reinit
493
+ // Note: caller (pollCredentialsAndSync) may have already set this - that's OK, we'll clear in finally
494
+ if (forceReinit) {
495
+ isReinitializing = true;
496
+ }
497
+ // Declare outside try block so catch can cleanup on timeout/error
498
+ let pendingAgent;
305
499
  try {
306
- // If forcing reinit, stop existing agent first
307
- if (forceReinit && cipherAgent) {
308
- agentLog('Reinitializing with new config...');
309
- try {
310
- await cipherAgent.stop();
311
- }
312
- catch (error) {
313
- agentLog(`Error stopping previous agent: ${error}`);
314
- }
315
- cipherAgent = undefined;
316
- taskProcessor = undefined;
317
- isAgentInitialized = false;
500
+ // If forcing reinit, drain queue and stop existing agent first
501
+ if (forceReinit) {
502
+ // Drain task queue before reinit to prevent tasks executing with stale processor
503
+ agentLog('Draining task queue before reinit...');
504
+ taskQueueManager.clear(); // Clear queued (not yet started) tasks
505
+ // Wait for active tasks to complete (with timeout)
506
+ await waitForActiveTasksToComplete(10_000);
507
+ await stopExistingAgentForReinit();
318
508
  }
319
509
  const tokenStore = createTokenStore();
320
510
  const configStore = new ProjectConfigStore();
321
- const authToken = await tokenStore.load();
511
+ const rawToken = await tokenStore.load();
322
512
  const brvConfig = await configStore.read();
323
- // Need at least authToken to initialize
513
+ // Validate auth token (sets initializationError if invalid)
514
+ const authToken = validateAuthToken(rawToken);
324
515
  if (!authToken) {
325
- initializationError = new NotAuthenticatedError();
326
- agentLog('Cannot initialize - no auth token');
327
- return false;
328
- }
329
- // Check if token is expired - fail early with clear message instead of 401 later
330
- if (authToken.isExpired()) {
331
- initializationError = new NotAuthenticatedError();
332
- agentLog('Cannot initialize - token expired (please run /login to re-authenticate)');
333
516
  return false;
334
517
  }
335
518
  // Create Executors
@@ -353,44 +536,71 @@ async function tryInitializeAgent(forceReinit = false) {
353
536
  projectId: PROJECT,
354
537
  sessionKey: authToken.sessionKey,
355
538
  };
356
- const agent = new CipherAgent(agentConfig, brvConfig ?? undefined);
357
- await agent.start();
539
+ pendingAgent = new CipherAgent(agentConfig, brvConfig ?? undefined);
540
+ // Wrap agent.start() with timeout to prevent isInitializing from getting stuck
541
+ await withTimeout(pendingAgent.start(), AGENT_INIT_TIMEOUT_MS, 'CipherAgent.start()');
358
542
  agentLog('CipherAgent started');
359
- // Create ChatSession
543
+ // Check if cleanup started during agent.start() (fixes zombie agent race)
544
+ if (await shouldAbortInitForCleanup(pendingAgent, 'agent.start()')) {
545
+ return false;
546
+ }
547
+ // Create ChatSession (also with timeout to prevent hanging)
360
548
  chatSessionId = `agent-session-${randomUUID()}`;
361
- await agent.createSession(chatSessionId);
549
+ await withTimeout(pendingAgent.createSession(chatSessionId), AGENT_INIT_TIMEOUT_MS, 'CipherAgent.createSession()');
362
550
  agentLog(`ChatSession created: ${chatSessionId}`);
551
+ // Check if cleanup started during createSession() (fixes zombie agent race)
552
+ if (await shouldAbortInitForCleanup(pendingAgent, 'createSession()')) {
553
+ return false;
554
+ }
363
555
  // Setup event forwarding
364
- setupAgentEventForwarding(agent);
365
- cipherAgent = agent;
556
+ setupAgentEventForwarding(pendingAgent);
557
+ cipherAgent = pendingAgent;
558
+ pendingAgent = undefined; // Clear local ref - cipherAgent now owns it
366
559
  // Create TaskProcessor
367
560
  taskProcessor = createTaskProcessor({
368
561
  curateExecutor,
369
562
  queryExecutor,
370
563
  });
371
564
  taskProcessor.setAgent(cipherAgent);
372
- // Setup task executor for queue manager (enables processing)
373
- setupTaskExecutor();
565
+ // NOTE: setupTaskExecutor() is called once in startAgent() before tryInitializeAgent()
566
+ // No need to call again here - executor is already set and handles lazy init
374
567
  // Mark as initialized
375
568
  isAgentInitialized = true;
376
569
  initializationError = undefined;
570
+ // Cache credentials for change detection polling
571
+ updateCachedCredentials(authToken.accessToken, authToken.sessionKey, brvConfig ? { spaceId: brvConfig.spaceId, teamId: brvConfig.teamId } : undefined);
377
572
  if (brvConfig) {
378
573
  agentLog(`Fully initialized with auth and config (team=${brvConfig.teamId}, space=${brvConfig.spaceId})`);
379
574
  }
380
575
  else {
381
576
  agentLog('Initialized with auth only (no project config yet - will reinit when config available)');
382
577
  }
578
+ // Broadcast status change to Transport (init success)
579
+ broadcastStatusChange();
383
580
  return true;
384
581
  }
385
582
  catch (error) {
583
+ // Stop pendingAgent if it was created but not yet assigned to cipherAgent
584
+ // This handles timeout/error during agent.start() or createSession()
585
+ await stopPendingAgent(pendingAgent);
586
+ pendingAgent = undefined;
587
+ // Cleanup partial state before recording error
588
+ // This prevents stale refs from accumulating on repeated failures
589
+ await cleanupPartialInit();
386
590
  // Catch errors and return false instead of throwing
387
591
  // This allows lazy init to retry when tasks arrive
388
592
  initializationError = error instanceof Error ? error : new Error(String(error));
389
593
  agentLog(`Agent initialization failed: ${error}`);
594
+ // Broadcast status change to Transport (init failed)
595
+ broadcastStatusChange();
390
596
  return false;
391
597
  }
392
598
  finally {
393
599
  isInitializing = false;
600
+ // Clear isReinitializing flag (matches the set above for forceReinit)
601
+ if (forceReinit) {
602
+ isReinitializing = false;
603
+ }
394
604
  }
395
605
  }
396
606
  /**
@@ -399,20 +609,8 @@ async function tryInitializeAgent(forceReinit = false) {
399
609
  async function handleTaskExecute(data) {
400
610
  const { clientCwd, content, files, taskId, type } = data;
401
611
  agentLog(`Processing task: ${taskId} (type=${type})`);
402
- // If not initialized, try to initialize now (lazy init for post-onboarding)
403
- if (!isAgentInitialized) {
404
- agentLog('Not initialized, attempting lazy initialization...');
405
- const initialized = await tryInitializeAgent();
406
- if (!initialized) {
407
- agentLog('Lazy initialization failed');
408
- const error = serializeTaskError(initializationError ?? new ProcessorNotInitError());
409
- transportClient?.request('task:error', { error, taskId }).catch(logTransportError);
410
- return;
411
- }
412
- agentLog('Lazy initialization successful!');
413
- }
414
- // NOTE: Config change detection removed - use explicit agent:restart event instead
415
- // (triggered by /init command via TransportHandlers)
612
+ // NOTE: Lazy initialization is handled in setupTaskExecutor() executor callback.
613
+ // By the time we reach here, agent is already initialized (executor does lazy init first).
416
614
  if (!taskProcessor) {
417
615
  agentLog('TaskProcessor not initialized');
418
616
  const error = serializeTaskError(new ProcessorNotInitError());
@@ -483,13 +681,50 @@ async function startAgent() {
483
681
  // Register as Agent
484
682
  await transportClient.request('agent:register', {});
485
683
  agentLog('Registered with Transport');
486
- // Try to initialize agent (may fail if no auth yet - that's OK, will lazy init later)
684
+ // Fix #1: Re-register on any reconnect (Socket.IO auto-reconnect OR force reconnect)
685
+ // When connection is restored, agent needs to re-register with Transport.
686
+ // Using onStateChange instead of 'connect' event to handle all reconnect types.
687
+ // Fix #4: Include status in register payload to prevent race condition window
688
+ let wasDisconnected = false;
689
+ transportClient.onStateChange(async (state) => {
690
+ if (state === 'disconnected' || state === 'reconnecting') {
691
+ wasDisconnected = true;
692
+ }
693
+ else if (state === 'connected' && wasDisconnected) {
694
+ agentLog('Transport reconnected - re-registering with Transport');
695
+ try {
696
+ // Include status in register payload (Transport caches it atomically)
697
+ await transportClient?.request('agent:register', { status: getAgentStatus() });
698
+ // Only clear flag after successful registration - if failed, next reconnect will retry
699
+ wasDisconnected = false;
700
+ agentLog('Re-registered with Transport after reconnect');
701
+ }
702
+ catch (error) {
703
+ // Keep wasDisconnected = true so next reconnect retries registration
704
+ agentLog(`Failed to re-register after reconnect: ${error}`);
705
+ }
706
+ }
707
+ });
708
+ // Fix #2: Setup task executor BEFORE init - enables lazy init when tasks arrive
709
+ // This ensures tasks don't get stuck in queue if initial init fails
710
+ setupTaskExecutor();
711
+ // Try to initialize agent (may fail if no auth yet - that's OK)
712
+ // tryInitializeAgent() broadcasts status on both success and failure,
713
+ // so Transport will have cached status before any task can arrive.
714
+ // If init fails, lazy init will retry when tasks arrive (handled by executor).
487
715
  const initialized = await tryInitializeAgent();
488
716
  if (!initialized) {
489
717
  agentLog('Initial setup incomplete - will retry when tasks arrive (lazy init)');
490
718
  }
491
719
  // Setup event handlers - TaskQueueManager handles queueing and deduplication
492
720
  transportClient.on('task:execute', (data) => {
721
+ // Reject tasks during reinit to prevent TOCTOU race condition
722
+ if (isReinitializing) {
723
+ agentLog(`Task ${data.taskId} rejected - agent reinitializing`);
724
+ const error = serializeTaskError(new AgentNotInitializedError('Agent is reinitializing'));
725
+ transportClient?.request('task:error', { error, taskId: data.taskId }).catch(logTransportError);
726
+ return;
727
+ }
493
728
  const result = taskQueueManager.enqueue(data);
494
729
  if (result.success) {
495
730
  const stats = taskQueueManager.getStats(data.type);
@@ -506,15 +741,40 @@ async function startAgent() {
506
741
  // Handle shutdown from Transport
507
742
  transportClient.on('shutdown', () => {
508
743
  agentLog('Received shutdown from Transport');
509
- stopAgent().then(() => {
744
+ stopAgent()
745
+ .then(() => {
510
746
  sendToParent({ type: 'stopped' });
511
747
  // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
512
748
  process.exit(0);
749
+ })
750
+ .catch((error) => {
751
+ agentLog(`Error during shutdown: ${error}`);
752
+ sendToParent({ type: 'stopped' });
753
+ // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
754
+ process.exit(1);
513
755
  });
514
756
  });
515
757
  // Handle agent:restart from Transport (triggered by client, e.g., after /init)
516
758
  transportClient.on('agent:restart', async (data) => {
517
759
  agentLog(`Agent restart requested: ${data.reason ?? 'no reason'}`);
760
+ // Guard: reject if initialization already in progress (prevents concurrent reinit race condition)
761
+ if (isInitializing || isReinitializing) {
762
+ agentLog('Agent restart rejected - initialization already in progress');
763
+ await transportClient?.request('agent:restarted', {
764
+ error: 'Initialization already in progress',
765
+ success: false,
766
+ });
767
+ return;
768
+ }
769
+ // Reject restart if tasks are in progress or queued (prevents killing active tasks)
770
+ if (hasPendingWork()) {
771
+ agentLog('Agent restart rejected - tasks in progress or queued');
772
+ await transportClient?.request('agent:restarted', {
773
+ error: 'Tasks in progress. Please wait for tasks to complete.',
774
+ success: false,
775
+ });
776
+ return;
777
+ }
518
778
  try {
519
779
  // Reinitialize agent with fresh config
520
780
  const success = await tryInitializeAgent(true); // forceReinit = true
@@ -523,10 +783,21 @@ async function startAgent() {
523
783
  // Notify Transport that restart completed
524
784
  await transportClient?.request('agent:restarted', { success: true });
525
785
  }
786
+ else if (isCleaningUp) {
787
+ // Cleanup in progress - can't restart during shutdown
788
+ agentLog('Agent reinitialization rejected - cleanup in progress');
789
+ await transportClient?.request('agent:restarted', {
790
+ error: 'Agent is shutting down',
791
+ success: false,
792
+ });
793
+ }
526
794
  else {
795
+ // Actual failure - missing auth or config
796
+ // Note: isInitializing is guaranteed to be false here because tryInitializeAgent()
797
+ // always clears it in its finally block before returning
527
798
  agentLog('Agent reinitialization failed - config incomplete');
528
799
  await transportClient?.request('agent:restarted', {
529
- error: 'Config incomplete (no auth token or config)',
800
+ error: initializationError?.message ?? 'Config incomplete (no auth token or config)',
530
801
  success: false,
531
802
  });
532
803
  }
@@ -537,72 +808,281 @@ async function startAgent() {
537
808
  await transportClient?.request('agent:restarted', { error: message, success: false });
538
809
  }
539
810
  });
811
+ // Handle agent:newSession from Transport (triggered by /new command)
812
+ transportClient.on('agent:newSession', async (data) => {
813
+ agentLog(`New session requested: ${data.reason ?? 'no reason'}`);
814
+ try {
815
+ if (!cipherAgent) {
816
+ agentLog('Cannot create new session - agent not initialized');
817
+ await transportClient?.request('agent:newSessionCreated', {
818
+ error: 'Agent not initialized',
819
+ success: false,
820
+ });
821
+ return;
822
+ }
823
+ // Generate new session ID
824
+ const newSessionId = `agent-session-${randomUUID()}`;
825
+ // Create new session
826
+ await cipherAgent.createSession(newSessionId);
827
+ // Switch the agent's default session to the new one
828
+ // This ensures execute()/generate()/stream() use the new session
829
+ cipherAgent.switchDefaultSession(newSessionId);
830
+ // Update the local session ID reference
831
+ chatSessionId = newSessionId;
832
+ agentLog(`New session created: ${newSessionId}`);
833
+ // Notify Transport that new session was created
834
+ await transportClient?.request('agent:newSessionCreated', {
835
+ sessionId: newSessionId,
836
+ success: true,
837
+ });
838
+ }
839
+ catch (error) {
840
+ const message = error instanceof Error ? error.message : String(error);
841
+ agentLog(`New session creation error: ${message}`);
842
+ await transportClient?.request('agent:newSessionCreated', {
843
+ error: message,
844
+ success: false,
845
+ });
846
+ }
847
+ });
540
848
  agentLog('Ready to process tasks');
541
849
  }
542
850
  // ============================================================================
543
- // Parent Heartbeat Monitoring
851
+ // Credentials Polling Functions
544
852
  // ============================================================================
545
853
  /**
546
- * Setup parent process heartbeat monitoring.
547
- *
548
- * Why this is needed:
549
- * - When main process receives SIGKILL, it dies immediately
550
- * - SIGKILL cannot be caught, so no cleanup happens
551
- * - IPC 'disconnect' event may not fire
552
- * - Child processes become orphans (PPID = 1)
854
+ * Stop CipherAgent only (does NOT exit process or disconnect transport).
855
+ * Used when credentials are missing/invalid but we want to keep polling.
856
+ */
857
+ async function stopCipherAgent() {
858
+ // Cleanup event forwarders
859
+ cleanupAgentEventForwarding();
860
+ // Clear task queue (can't process without agent)
861
+ taskQueueManager.clear();
862
+ // Stop CipherAgent
863
+ if (cipherAgent) {
864
+ try {
865
+ await cipherAgent.stop();
866
+ }
867
+ catch (error) {
868
+ agentLog(`Error stopping CipherAgent: ${error}`);
869
+ }
870
+ cipherAgent = undefined;
871
+ }
872
+ taskProcessor = undefined;
873
+ chatSessionId = undefined;
874
+ isAgentInitialized = false;
875
+ cachedCredentials = undefined;
876
+ agentLog('CipherAgent stopped (credentials missing or invalid)');
877
+ // Broadcast status change to Transport (cipher stopped)
878
+ broadcastStatusChange();
879
+ }
880
+ /**
881
+ * Cleanup partial state after failed initialization.
882
+ * Called when tryInitializeAgent() fails partway through.
883
+ * Does NOT broadcast status (caller handles that).
884
+ */
885
+ async function cleanupPartialInit() {
886
+ agentLog('Cleaning up partial initialization state...');
887
+ // Cleanup event forwarders (may have been partially set up)
888
+ cleanupAgentEventForwarding();
889
+ // Stop CipherAgent if it was partially started
890
+ if (cipherAgent) {
891
+ try {
892
+ await cipherAgent.stop();
893
+ }
894
+ catch (error) {
895
+ agentLog(`Error stopping partial CipherAgent: ${error}`);
896
+ }
897
+ cipherAgent = undefined;
898
+ }
899
+ // Clear partial state
900
+ taskProcessor = undefined;
901
+ chatSessionId = undefined;
902
+ // Note: DON'T clear cachedCredentials here - keep old credentials for comparison
903
+ // Note: DON'T set isAgentInitialized - it should already be false
904
+ agentLog('Partial initialization state cleaned up');
905
+ }
906
+ /**
907
+ * Update cached credentials after successful initialization.
908
+ */
909
+ function updateCachedCredentials(accessToken, sessionKey, config) {
910
+ cachedCredentials = {
911
+ accessToken,
912
+ sessionKey,
913
+ spaceId: config?.spaceId,
914
+ teamId: config?.teamId,
915
+ };
916
+ }
917
+ /**
918
+ * Check if credentials have changed compared to cache.
919
+ */
920
+ function credentialsChanged(currentToken, currentConfig) {
921
+ // No cached credentials = first run or was stopped
922
+ if (!cachedCredentials) {
923
+ return currentToken ? 'changed' : 'missing';
924
+ }
925
+ // Token missing = credentials gone
926
+ if (!currentToken) {
927
+ return 'missing';
928
+ }
929
+ // Compare token
930
+ if (currentToken.accessToken !== cachedCredentials.accessToken ||
931
+ currentToken.sessionKey !== cachedCredentials.sessionKey) {
932
+ return 'changed';
933
+ }
934
+ // Compare config (spaceId/teamId)
935
+ const currentSpaceId = currentConfig?.spaceId;
936
+ const currentTeamId = currentConfig?.teamId;
937
+ if (currentSpaceId !== cachedCredentials.spaceId || currentTeamId !== cachedCredentials.teamId) {
938
+ return 'changed';
939
+ }
940
+ return 'unchanged';
941
+ }
942
+ /**
943
+ * Poll credentials and sync CipherAgent state.
553
944
  *
554
- * This function periodically checks if parent is still alive.
555
- * If parent dies, child self-terminates to prevent zombie processes.
945
+ * Called periodically to detect auth/config changes:
946
+ * - If credentials MISSING stop CipherAgent
947
+ * - If credentials CHANGED → reinit CipherAgent
948
+ * - If UNCHANGED → do nothing
556
949
  */
557
- function setupParentHeartbeat() {
558
- // Already running - don't start another
559
- if (parentHeartbeatRunning)
950
+ async function pollCredentialsAndSync() {
951
+ // Guard: prevent concurrent polling
952
+ if (isPolling) {
560
953
  return;
561
- parentHeartbeatRunning = true;
562
- parentPid = process.ppid;
563
- /**
564
- * Recursive setTimeout pattern - safer than setInterval:
565
- * - No callback overlap possible
566
- * - Clean cancellation (just set flag = false)
567
- * - No orphan timers
568
- */
569
- const checkParent = () => {
570
- // Stopped - don't schedule next check
571
- if (!parentHeartbeatRunning || !parentPid)
572
- return;
573
- // Check if parent is still alive using signal 0
574
- // Signal 0 doesn't send any signal, just checks if process exists
575
- try {
576
- process.kill(parentPid, 0);
577
- }
578
- catch {
579
- // Parent is dead - self-terminate
580
- agentLog(`Parent process (${parentPid}) died - shutting down to prevent zombie`);
581
- parentHeartbeatRunning = false;
582
- // Stop agent and exit
583
- stopAgent()
584
- .catch(() => { })
585
- .finally(() => {
586
- // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
587
- process.exit(0);
588
- });
589
- return;
954
+ }
955
+ // Guard: don't poll during cleanup or initialization
956
+ if (isCleaningUp || isInitializing) {
957
+ return;
958
+ }
959
+ isPolling = true;
960
+ try {
961
+ // Use lazy-initialized cached stores (avoid creating new instances every poll)
962
+ const stores = getPollingStores();
963
+ const authToken = await stores.pollingTokenStore.load();
964
+ const brvConfig = await stores.pollingConfigStore.read();
965
+ // Detect change
966
+ const tokenInfo = authToken ? { accessToken: authToken.accessToken, sessionKey: authToken.sessionKey } : undefined;
967
+ const configInfo = brvConfig ? { spaceId: brvConfig.spaceId, teamId: brvConfig.teamId } : undefined;
968
+ const changeStatus = credentialsChanged(tokenInfo, configInfo);
969
+ switch (changeStatus) {
970
+ case 'changed': {
971
+ // Check RIGHT BEFORE reinit - after all awaits, to catch tasks added during awaits
972
+ // Must check BOTH active (running) AND queued tasks to prevent race condition
973
+ // where task is enqueued after this check but before stopCipherAgent() clears taskProcessor
974
+ if (hasPendingWork() || isReinitializing) {
975
+ agentLog('Credentials changed but tasks in progress, queued, or reinit in progress - deferring');
976
+ return;
977
+ }
978
+ // Set flag IMMEDIATELY after check to close TOCTOU window
979
+ // tryInitializeAgent will manage the flag from here (set on entry, clear in finally)
980
+ isReinitializing = true;
981
+ // Credentials changed - reinit CipherAgent
982
+ agentLog('Credentials changed - reinitializing CipherAgent');
983
+ const success = await tryInitializeAgent(true); // forceReinit
984
+ if (success) {
985
+ agentLog('CipherAgent reinitialized with new credentials');
986
+ }
987
+ else {
988
+ agentLog('CipherAgent reinitialization failed');
989
+ }
990
+ break;
991
+ }
992
+ case 'missing': {
993
+ // Credentials gone - stop CipherAgent if running
994
+ if (isAgentInitialized) {
995
+ if (hasPendingWork()) {
996
+ agentLog('Credentials missing but tasks in progress - deferring stop');
997
+ return;
998
+ }
999
+ agentLog('Credentials missing - stopping CipherAgent');
1000
+ await stopCipherAgent();
1001
+ }
1002
+ break;
1003
+ }
1004
+ case 'unchanged': {
1005
+ // No change - check if token expired (edge case)
1006
+ if (authToken?.isExpired() && isAgentInitialized) {
1007
+ if (hasPendingWork()) {
1008
+ agentLog('Token expired but tasks in progress - deferring stop');
1009
+ return;
1010
+ }
1011
+ agentLog('Token expired - stopping CipherAgent');
1012
+ await stopCipherAgent();
1013
+ }
1014
+ break;
1015
+ }
590
1016
  }
591
- // Schedule next check (only if still running)
592
- if (parentHeartbeatRunning) {
593
- setTimeout(checkParent, PARENT_HEARTBEAT_INTERVAL_MS);
1017
+ }
1018
+ catch (error) {
1019
+ // Don't crash on poll errors - just log and continue
1020
+ agentLog(`Credentials poll error: ${error}`);
1021
+ }
1022
+ finally {
1023
+ isPolling = false;
1024
+ }
1025
+ }
1026
+ /**
1027
+ * Start credentials polling.
1028
+ * Uses recursive setTimeout pattern (same as parent heartbeat).
1029
+ */
1030
+ function startCredentialsPolling() {
1031
+ if (credentialsPollingRunning) {
1032
+ return;
1033
+ }
1034
+ credentialsPollingRunning = true;
1035
+ const poll = () => {
1036
+ if (!credentialsPollingRunning) {
1037
+ return;
594
1038
  }
1039
+ pollCredentialsAndSync()
1040
+ .catch((error) => {
1041
+ agentLog(`Credentials poll failed: ${error}`);
1042
+ })
1043
+ .finally(() => {
1044
+ // Schedule next poll (only if still running)
1045
+ if (credentialsPollingRunning) {
1046
+ setTimeout(poll, CREDENTIALS_POLL_INTERVAL_MS);
1047
+ }
1048
+ });
1049
+ };
1050
+ // Start first poll after delay
1051
+ setTimeout(poll, CREDENTIALS_POLL_INTERVAL_MS);
1052
+ agentLog('Credentials polling started');
1053
+ }
1054
+ /**
1055
+ * Stop credentials polling.
1056
+ */
1057
+ function stopCredentialsPolling() {
1058
+ credentialsPollingRunning = false;
1059
+ }
1060
+ // ============================================================================
1061
+ // Agent Status Reporting
1062
+ // ============================================================================
1063
+ /**
1064
+ * Get current agent status for health check.
1065
+ * Used by Transport to check if agent is ready before forwarding tasks.
1066
+ */
1067
+ function getAgentStatus() {
1068
+ return {
1069
+ activeTasks: taskQueueManager.getActiveCount(),
1070
+ hasAuth: cachedCredentials !== undefined,
1071
+ // Check both spaceId and teamId for safety (both must be set after /init)
1072
+ hasConfig: cachedCredentials?.spaceId !== undefined && cachedCredentials?.teamId !== undefined,
1073
+ isInitialized: isAgentInitialized,
1074
+ lastError: initializationError?.message,
1075
+ queuedTasks: taskQueueManager.getQueuedCount(),
595
1076
  };
596
- // Start first check after delay
597
- setTimeout(checkParent, PARENT_HEARTBEAT_INTERVAL_MS);
598
- agentLog(`Parent heartbeat monitoring started (PPID: ${parentPid})`);
599
1077
  }
600
1078
  /**
601
- * Stop the parent heartbeat monitoring.
602
- * With recursive setTimeout, just set flag to false - next check won't schedule.
1079
+ * Broadcast status change to Transport.
1080
+ * Called when cipher state changes (init success/fail, stop, credentials change).
1081
+ * Transport will forward to all connected clients.
603
1082
  */
604
- function stopParentHeartbeat() {
605
- parentHeartbeatRunning = false;
1083
+ function broadcastStatusChange() {
1084
+ const status = getAgentStatus();
1085
+ transportClient?.request('agent:status:changed', status).catch(logTransportError);
606
1086
  }
607
1087
  /**
608
1088
  * Stop Agent Process.
@@ -615,8 +1095,9 @@ async function stopAgent() {
615
1095
  }
616
1096
  isCleaningUp = true;
617
1097
  try {
618
- // Stop parent heartbeat first
619
- stopParentHeartbeat();
1098
+ // Stop polling and heartbeat first
1099
+ stopCredentialsPolling();
1100
+ parentHeartbeat?.stop();
620
1101
  // Clear task queue
621
1102
  taskQueueManager.clear();
622
1103
  // Cleanup event forwarders before stopping agent
@@ -648,7 +1129,14 @@ async function runWorker() {
648
1129
  sendToParent({ type: 'ready' });
649
1130
  // Start parent heartbeat monitoring after ready
650
1131
  // This ensures we self-terminate if parent dies (SIGKILL scenario)
651
- setupParentHeartbeat();
1132
+ parentHeartbeat = createParentHeartbeat({
1133
+ cleanup: stopAgent,
1134
+ log: agentLog,
1135
+ });
1136
+ parentHeartbeat.start();
1137
+ // Start credentials polling to detect auth/config changes
1138
+ // This ensures CipherAgent stays in sync with user's login state
1139
+ startCredentialsPolling();
652
1140
  }
653
1141
  catch (error) {
654
1142
  const message = error instanceof Error ? error.message : String(error);
@@ -661,14 +1149,47 @@ async function runWorker() {
661
1149
  }
662
1150
  // IPC message handler
663
1151
  process.on('message', async (msg) => {
664
- if (msg.type === 'ping') {
665
- sendToParent({ type: 'pong' });
666
- }
667
- else if (msg.type === 'shutdown') {
668
- await stopAgent();
669
- sendToParent({ type: 'stopped' });
670
- // eslint-disable-next-line n/no-process-exit
671
- process.exit(0);
1152
+ switch (msg.type) {
1153
+ case 'health-check': {
1154
+ // Fix #3: Health-check after sleep/wake - verify and repair connection
1155
+ agentLog('Received health-check from parent - verifying connection');
1156
+ // Guard: skip health check during initialization (would interfere with startup sequence)
1157
+ if (isInitializing) {
1158
+ agentLog('Health-check skipped - initialization in progress');
1159
+ sendToParent({ success: true, type: 'health-check-result' });
1160
+ break;
1161
+ }
1162
+ // Guard: transportClient must exist for health check
1163
+ if (!transportClient) {
1164
+ agentLog('Health-check failed - transportClient not initialized');
1165
+ sendToParent({ success: false, type: 'health-check-result' });
1166
+ break;
1167
+ }
1168
+ try {
1169
+ // Re-register with Transport to ensure connection is alive
1170
+ // Include status in register payload (Transport caches it atomically)
1171
+ await transportClient.request('agent:register', { status: getAgentStatus() });
1172
+ agentLog('Health-check passed - connection verified');
1173
+ sendToParent({ success: true, type: 'health-check-result' });
1174
+ }
1175
+ catch (error) {
1176
+ agentLog(`Health-check failed - connection may be stale: ${error}`);
1177
+ sendToParent({ success: false, type: 'health-check-result' });
1178
+ // Socket.IO will attempt reconnection automatically
1179
+ }
1180
+ break;
1181
+ }
1182
+ case 'ping': {
1183
+ sendToParent({ type: 'pong' });
1184
+ break;
1185
+ }
1186
+ case 'shutdown': {
1187
+ await stopAgent();
1188
+ sendToParent({ type: 'stopped' });
1189
+ // eslint-disable-next-line n/no-process-exit
1190
+ process.exit(0);
1191
+ // Note: break unreachable due to process.exit() above
1192
+ }
672
1193
  }
673
1194
  });
674
1195
  // Signal handlers