neuro-cli 4.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 (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +510 -0
  3. package/dist/advisor/advisor.d.ts +50 -0
  4. package/dist/advisor/advisor.js +178 -0
  5. package/dist/agents/base.d.ts +62 -0
  6. package/dist/agents/base.js +215 -0
  7. package/dist/agents/orchestrator.d.ts +46 -0
  8. package/dist/agents/orchestrator.js +192 -0
  9. package/dist/agents/team.d.ts +51 -0
  10. package/dist/agents/team.js +210 -0
  11. package/dist/api/models.d.ts +23 -0
  12. package/dist/api/models.js +514 -0
  13. package/dist/api/ollama.d.ts +153 -0
  14. package/dist/api/ollama.js +751 -0
  15. package/dist/api/openrouter.d.ts +55 -0
  16. package/dist/api/openrouter.js +223 -0
  17. package/dist/commands/commands.d.ts +43 -0
  18. package/dist/commands/commands.js +308 -0
  19. package/dist/config/config.d.ts +8 -0
  20. package/dist/config/config.js +311 -0
  21. package/dist/context/compaction.d.ts +54 -0
  22. package/dist/context/compaction.js +251 -0
  23. package/dist/context/custom-agents.d.ts +107 -0
  24. package/dist/context/custom-agents.js +397 -0
  25. package/dist/context/custom-tools.d.ts +120 -0
  26. package/dist/context/custom-tools.js +564 -0
  27. package/dist/context/git-checkpoint.d.ts +52 -0
  28. package/dist/context/git-checkpoint.js +240 -0
  29. package/dist/context/neuro-md.d.ts +48 -0
  30. package/dist/context/neuro-md.js +202 -0
  31. package/dist/context/neuroignore.d.ts +102 -0
  32. package/dist/context/neuroignore.js +441 -0
  33. package/dist/context/repo-map.d.ts +38 -0
  34. package/dist/context/repo-map.js +220 -0
  35. package/dist/context/skill-standard.d.ts +262 -0
  36. package/dist/context/skill-standard.js +1156 -0
  37. package/dist/context/skill-system.d.ts +75 -0
  38. package/dist/context/skill-system.js +578 -0
  39. package/dist/context/tree-sitter.d.ts +99 -0
  40. package/dist/context/tree-sitter.js +1956 -0
  41. package/dist/core/acp.d.ts +325 -0
  42. package/dist/core/acp.js +1498 -0
  43. package/dist/core/api-server.d.ts +143 -0
  44. package/dist/core/api-server.js +550 -0
  45. package/dist/core/approval.d.ts +81 -0
  46. package/dist/core/approval.js +432 -0
  47. package/dist/core/auto-compact.d.ts +127 -0
  48. package/dist/core/auto-compact.js +436 -0
  49. package/dist/core/auto-mode.d.ts +232 -0
  50. package/dist/core/auto-mode.js +831 -0
  51. package/dist/core/background-session.d.ts +166 -0
  52. package/dist/core/background-session.js +696 -0
  53. package/dist/core/cicd.d.ts +257 -0
  54. package/dist/core/cicd.js +1443 -0
  55. package/dist/core/cloud-sync.d.ts +156 -0
  56. package/dist/core/cloud-sync.js +582 -0
  57. package/dist/core/code-review.d.ts +132 -0
  58. package/dist/core/code-review.js +1191 -0
  59. package/dist/core/completion.d.ts +49 -0
  60. package/dist/core/completion.js +384 -0
  61. package/dist/core/context.d.ts +38 -0
  62. package/dist/core/context.js +144 -0
  63. package/dist/core/diff-preview.d.ts +35 -0
  64. package/dist/core/diff-preview.js +173 -0
  65. package/dist/core/doom-loop.d.ts +51 -0
  66. package/dist/core/doom-loop.js +179 -0
  67. package/dist/core/engine.d.ts +183 -0
  68. package/dist/core/engine.js +942 -0
  69. package/dist/core/extended-thinking.d.ts +103 -0
  70. package/dist/core/extended-thinking.js +269 -0
  71. package/dist/core/fallback.d.ts +54 -0
  72. package/dist/core/fallback.js +104 -0
  73. package/dist/core/git-worktree.d.ts +139 -0
  74. package/dist/core/git-worktree.js +614 -0
  75. package/dist/core/headless.d.ts +30 -0
  76. package/dist/core/headless.js +101 -0
  77. package/dist/core/i18n.d.ts +83 -0
  78. package/dist/core/i18n.js +739 -0
  79. package/dist/core/linting.d.ts +129 -0
  80. package/dist/core/linting.js +699 -0
  81. package/dist/core/model-router.d.ts +109 -0
  82. package/dist/core/model-router.js +581 -0
  83. package/dist/core/multi-model.d.ts +243 -0
  84. package/dist/core/multi-model.js +1099 -0
  85. package/dist/core/multi-session.d.ts +144 -0
  86. package/dist/core/multi-session.js +442 -0
  87. package/dist/core/multimodal.d.ts +125 -0
  88. package/dist/core/multimodal.js +286 -0
  89. package/dist/core/observability.d.ts +93 -0
  90. package/dist/core/observability.js +737 -0
  91. package/dist/core/os-sandbox.d.ts +122 -0
  92. package/dist/core/os-sandbox.js +1193 -0
  93. package/dist/core/outcome-grading.d.ts +228 -0
  94. package/dist/core/outcome-grading.js +1123 -0
  95. package/dist/core/output-styles.d.ts +57 -0
  96. package/dist/core/output-styles.js +382 -0
  97. package/dist/core/parallel-agents.d.ts +183 -0
  98. package/dist/core/parallel-agents.js +563 -0
  99. package/dist/core/plugin-bundle.d.ts +236 -0
  100. package/dist/core/plugin-bundle.js +887 -0
  101. package/dist/core/plugin-sdk.d.ts +139 -0
  102. package/dist/core/plugin-sdk.js +273 -0
  103. package/dist/core/prompt-cache.d.ts +163 -0
  104. package/dist/core/prompt-cache.js +599 -0
  105. package/dist/core/sandbox.d.ts +127 -0
  106. package/dist/core/sandbox.js +369 -0
  107. package/dist/core/scheduled-tasks.d.ts +151 -0
  108. package/dist/core/scheduled-tasks.js +502 -0
  109. package/dist/core/security-scanner.d.ts +160 -0
  110. package/dist/core/security-scanner.js +1494 -0
  111. package/dist/core/session.d.ts +83 -0
  112. package/dist/core/session.js +269 -0
  113. package/dist/core/shell-completion.d.ts +51 -0
  114. package/dist/core/shell-completion.js +674 -0
  115. package/dist/core/smart-monitor.d.ts +146 -0
  116. package/dist/core/smart-monitor.js +1199 -0
  117. package/dist/core/spec-driven.d.ts +233 -0
  118. package/dist/core/spec-driven.js +1485 -0
  119. package/dist/core/spending-warnings.d.ts +123 -0
  120. package/dist/core/spending-warnings.js +456 -0
  121. package/dist/core/sub-agent.d.ts +298 -0
  122. package/dist/core/sub-agent.js +1023 -0
  123. package/dist/core/telemetry.d.ts +157 -0
  124. package/dist/core/telemetry.js +412 -0
  125. package/dist/core/terminal-ux.d.ts +134 -0
  126. package/dist/core/terminal-ux.js +649 -0
  127. package/dist/core/testing.d.ts +146 -0
  128. package/dist/core/testing.js +1200 -0
  129. package/dist/core/types.d.ts +439 -0
  130. package/dist/core/types.js +6 -0
  131. package/dist/core/undo-redo.d.ts +112 -0
  132. package/dist/core/undo-redo.js +290 -0
  133. package/dist/core/updater.d.ts +159 -0
  134. package/dist/core/updater.js +608 -0
  135. package/dist/core/vim-mode.d.ts +151 -0
  136. package/dist/core/vim-mode.js +771 -0
  137. package/dist/core/voice.d.ts +137 -0
  138. package/dist/core/voice.js +538 -0
  139. package/dist/core/web-dashboard.d.ts +109 -0
  140. package/dist/core/web-dashboard.js +484 -0
  141. package/dist/hooks/hooks.d.ts +74 -0
  142. package/dist/hooks/hooks.js +160 -0
  143. package/dist/hooks/llm-evaluator.d.ts +165 -0
  144. package/dist/hooks/llm-evaluator.js +560 -0
  145. package/dist/index.d.ts +3 -0
  146. package/dist/index.js +1186 -0
  147. package/dist/lsp/lsp-manager.d.ts +63 -0
  148. package/dist/lsp/lsp-manager.js +351 -0
  149. package/dist/mcp/client.d.ts +133 -0
  150. package/dist/mcp/client.js +684 -0
  151. package/dist/mcp/mcp-apps.d.ts +70 -0
  152. package/dist/mcp/mcp-apps.js +1007 -0
  153. package/dist/tools/bash.d.ts +5 -0
  154. package/dist/tools/bash.js +195 -0
  155. package/dist/tools/browser.d.ts +92 -0
  156. package/dist/tools/browser.js +1570 -0
  157. package/dist/tools/extended.d.ts +6 -0
  158. package/dist/tools/extended.js +191 -0
  159. package/dist/tools/file.d.ts +10 -0
  160. package/dist/tools/file.js +382 -0
  161. package/dist/tools/github.d.ts +389 -0
  162. package/dist/tools/github.js +759 -0
  163. package/dist/tools/index.d.ts +9 -0
  164. package/dist/tools/index.js +40 -0
  165. package/dist/tools/memory.d.ts +6 -0
  166. package/dist/tools/memory.js +197 -0
  167. package/dist/tools/registry.d.ts +29 -0
  168. package/dist/tools/registry.js +64 -0
  169. package/dist/tools/web.d.ts +6 -0
  170. package/dist/tools/web.js +150 -0
  171. package/dist/ui/renderer.d.ts +97 -0
  172. package/dist/ui/renderer.js +279 -0
  173. package/dist/ui/theme.d.ts +27 -0
  174. package/dist/ui/theme.js +106 -0
  175. package/package.json +83 -0
@@ -0,0 +1,696 @@
1
+ // ============================================================
2
+ // NeuroCLI - Background Session Manager
3
+ // Manages long-running sessions that execute independently in
4
+ // the background — inspired by Claude Code background sessions
5
+ // and Agent View. Supports persistence, output capture, desktop
6
+ // notifications, pause/resume, and attach/detach workflows.
7
+ // Uses only Node.js built-in modules.
8
+ // ============================================================
9
+ import { EventEmitter } from 'events';
10
+ import { join, } from 'path';
11
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, readdirSync, appendFileSync, } from 'fs';
12
+ import { homedir } from 'os';
13
+ import { spawn } from 'child_process';
14
+ // ---- Constants ----
15
+ const SESSION_DIR = join(homedir(), '.neuro', 'background-sessions');
16
+ const OUTPUT_DIR = join(homedir(), '.neuro', 'background-output');
17
+ const HISTORY_DIR = join(homedir(), '.neuro', 'background-history');
18
+ const DEFAULT_MAX_ITERATIONS = 100;
19
+ const DEFAULT_MAX_COST = 5.0; // USD
20
+ const OUTPUT_BUFFER_MAX = 1000;
21
+ const CLEANUP_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
22
+ // ============================================================
23
+ // BackgroundSessionManager
24
+ // ============================================================
25
+ export class BackgroundSessionManager extends EventEmitter {
26
+ sessions = new Map();
27
+ currentlyAttached = null;
28
+ constructor() {
29
+ super();
30
+ this.ensureDir(SESSION_DIR);
31
+ this.ensureDir(OUTPUT_DIR);
32
+ this.ensureDir(HISTORY_DIR);
33
+ // Restore persisted sessions on startup
34
+ this.restoreSessions();
35
+ }
36
+ // ---- Public API ----
37
+ /**
38
+ * Create a new background session. The session starts in "created" status.
39
+ * Call `startSession(sessionId)` to begin execution.
40
+ */
41
+ createSession(config) {
42
+ if (this.sessions.has(config.id)) {
43
+ throw new Error(`Session with id "${config.id}" already exists`);
44
+ }
45
+ const outputFilePath = config.saveOutput
46
+ ? join(OUTPUT_DIR, `${config.id}.log`)
47
+ : undefined;
48
+ const state = {
49
+ id: config.id,
50
+ name: config.name,
51
+ status: 'created',
52
+ createdAt: Date.now(),
53
+ iterations: 0,
54
+ tokensUsed: 0,
55
+ cost: 0,
56
+ outputFilePath,
57
+ tags: config.tags ?? [],
58
+ };
59
+ let completionResolve = null;
60
+ let completionReject = null;
61
+ const completionPromise = new Promise((resolve, reject) => {
62
+ completionResolve = resolve;
63
+ completionReject = reject;
64
+ });
65
+ const record = {
66
+ config,
67
+ state,
68
+ history: [],
69
+ abortController: new AbortController(),
70
+ outputBuffer: [],
71
+ attached: false,
72
+ completionPromise,
73
+ completionResolve,
74
+ completionReject,
75
+ pausePromise: null,
76
+ pauseResolve: null,
77
+ };
78
+ this.sessions.set(config.id, record);
79
+ this.addHistoryEntry(config.id, 'info', `Session "${config.name}" created`);
80
+ if (outputFilePath) {
81
+ this.initializeOutputFile(outputFilePath, config);
82
+ }
83
+ this.persistSession(config.id);
84
+ this.emit('session:created', config.id, state);
85
+ return config.id;
86
+ }
87
+ /**
88
+ * Start running a session in the background.
89
+ */
90
+ startSession(sessionId) {
91
+ const record = this.sessions.get(sessionId);
92
+ if (!record)
93
+ return false;
94
+ if (record.state.status !== 'created' && record.state.status !== 'paused') {
95
+ return false; // Can only start from 'created' or 'paused'
96
+ }
97
+ if (record.state.status === 'paused' && record.pauseResolve) {
98
+ // Unpause by resolving the pause promise
99
+ record.pauseResolve();
100
+ record.pausePromise = null;
101
+ record.pauseResolve = null;
102
+ record.state.status = 'running';
103
+ this.addHistoryEntry(sessionId, 'resume', `Session "${record.config.name}" resumed`);
104
+ this.persistSession(sessionId);
105
+ this.emit('session:resumed', sessionId, record.state);
106
+ return true;
107
+ }
108
+ // Fresh start
109
+ record.abortController = new AbortController();
110
+ record.state.status = 'running';
111
+ record.state.startedAt = Date.now();
112
+ this.addHistoryEntry(sessionId, 'info', `Session "${record.config.name}" started`);
113
+ this.persistSession(sessionId);
114
+ this.emit('session:started', sessionId, record.state);
115
+ this.runSessionLoop(record)
116
+ .then(() => {
117
+ record.state.status = 'completed';
118
+ record.state.completedAt = Date.now();
119
+ record.completionResolve?.();
120
+ this.addHistoryEntry(sessionId, 'complete', `Session completed after ${record.state.iterations} iterations`);
121
+ this.persistSession(sessionId);
122
+ if (record.config.notifyOnComplete) {
123
+ this.sendDesktopNotification('NeuroCLI Session Complete', `"${record.config.name}" finished successfully (${record.state.iterations} iterations, $${record.state.cost.toFixed(4)}).`);
124
+ }
125
+ this.emit('session:completed', sessionId, record.state);
126
+ })
127
+ .catch((err) => {
128
+ if (record.state.status === 'stopped' || record.state.status === 'paused') {
129
+ return; // Handled by stop/pause
130
+ }
131
+ const errorMsg = err instanceof Error ? err.message : String(err);
132
+ record.state.status = 'failed';
133
+ record.state.completedAt = Date.now();
134
+ record.state.lastOutput = errorMsg;
135
+ record.completionReject?.(err instanceof Error ? err : new Error(errorMsg));
136
+ this.addHistoryEntry(sessionId, 'error', `Session failed: ${errorMsg}`);
137
+ this.persistSession(sessionId);
138
+ if (record.config.notifyOnComplete) {
139
+ this.sendDesktopNotification('NeuroCLI Session Failed', `"${record.config.name}" encountered an error: ${errorMsg}`);
140
+ }
141
+ this.emit('session:failed', sessionId, errorMsg, record.state);
142
+ });
143
+ return true;
144
+ }
145
+ /**
146
+ * Stop a running session. Cannot be resumed.
147
+ */
148
+ stopSession(sessionId) {
149
+ const record = this.sessions.get(sessionId);
150
+ if (!record)
151
+ return false;
152
+ if (record.state.status !== 'running')
153
+ return false;
154
+ record.abortController.abort();
155
+ record.state.status = 'stopped';
156
+ record.state.completedAt = Date.now();
157
+ // If paused, also resolve the pause so the loop can exit
158
+ if (record.pauseResolve) {
159
+ record.pauseResolve();
160
+ record.pausePromise = null;
161
+ record.pauseResolve = null;
162
+ }
163
+ this.addHistoryEntry(sessionId, 'stop', `Session "${record.config.name}" stopped by user`);
164
+ this.persistSession(sessionId);
165
+ this.emit('session:stopped', sessionId, record.state);
166
+ return true;
167
+ }
168
+ /**
169
+ * Pause a running session. It can be resumed later.
170
+ */
171
+ pauseSession(sessionId) {
172
+ const record = this.sessions.get(sessionId);
173
+ if (!record)
174
+ return false;
175
+ if (record.state.status !== 'running')
176
+ return false;
177
+ // Set up a pause barrier that the loop will wait on
178
+ let pauseResolve = null;
179
+ const pausePromise = new Promise((resolve) => {
180
+ pauseResolve = resolve;
181
+ });
182
+ record.pausePromise = pausePromise;
183
+ record.pauseResolve = pauseResolve;
184
+ record.state.status = 'paused';
185
+ this.addHistoryEntry(sessionId, 'pause', `Session "${record.config.name}" paused by user`);
186
+ this.persistSession(sessionId);
187
+ this.emit('session:paused', sessionId, record.state);
188
+ return true;
189
+ }
190
+ /**
191
+ * Resume a paused session.
192
+ */
193
+ resumeSession(sessionId) {
194
+ const record = this.sessions.get(sessionId);
195
+ if (!record)
196
+ return false;
197
+ if (record.state.status !== 'paused')
198
+ return false;
199
+ return this.startSession(sessionId); // startSession handles unpause
200
+ }
201
+ /**
202
+ * Get detailed status of a session.
203
+ */
204
+ getSessionStatus(sessionId) {
205
+ const record = this.sessions.get(sessionId);
206
+ if (!record)
207
+ return null;
208
+ return { ...record.state };
209
+ }
210
+ /**
211
+ * List all background sessions.
212
+ */
213
+ listSessions() {
214
+ const result = [];
215
+ const records = Array.from(this.sessions.values());
216
+ for (const record of records) {
217
+ result.push({ ...record.state });
218
+ }
219
+ return result.sort((a, b) => b.createdAt - a.createdAt);
220
+ }
221
+ /**
222
+ * Get recent output from a session.
223
+ * @param lines Number of recent lines to return (default 50).
224
+ */
225
+ getSessionOutput(sessionId, lines = 50) {
226
+ const record = this.sessions.get(sessionId);
227
+ if (!record)
228
+ return null;
229
+ // If we have an output file, read from it
230
+ if (record.state.outputFilePath && existsSync(record.state.outputFilePath)) {
231
+ try {
232
+ const content = readFileSync(record.state.outputFilePath, 'utf-8');
233
+ const allLines = content.split('\n').filter(Boolean);
234
+ const fetchedLines = allLines.slice(-lines);
235
+ return {
236
+ sessionId,
237
+ lines: fetchedLines,
238
+ totalLines: allLines.length,
239
+ fetchedAt: Date.now(),
240
+ };
241
+ }
242
+ catch {
243
+ // Fall through to buffer
244
+ }
245
+ }
246
+ // Fall back to in-memory buffer
247
+ const fetchedLines = record.outputBuffer.slice(-lines);
248
+ return {
249
+ sessionId,
250
+ lines: fetchedLines,
251
+ totalLines: record.outputBuffer.length,
252
+ fetchedAt: Date.now(),
253
+ };
254
+ }
255
+ /**
256
+ * Attach to a background session for interactive monitoring.
257
+ * Only one session can be attached at a time.
258
+ */
259
+ attachSession(sessionId) {
260
+ const record = this.sessions.get(sessionId);
261
+ if (!record)
262
+ return false;
263
+ // Detach from any currently attached session
264
+ if (this.currentlyAttached && this.currentlyAttached !== sessionId) {
265
+ this.detachSession();
266
+ }
267
+ record.attached = true;
268
+ this.currentlyAttached = sessionId;
269
+ this.addHistoryEntry(sessionId, 'info', `User attached to session`);
270
+ this.emit('session:attached', sessionId);
271
+ return true;
272
+ }
273
+ /**
274
+ * Detach from the currently attached session.
275
+ */
276
+ detachSession() {
277
+ if (!this.currentlyAttached)
278
+ return false;
279
+ const record = this.sessions.get(this.currentlyAttached);
280
+ if (record) {
281
+ record.attached = false;
282
+ this.addHistoryEntry(this.currentlyAttached, 'info', `User detached from session`);
283
+ }
284
+ this.emit('session:detached', this.currentlyAttached);
285
+ this.currentlyAttached = null;
286
+ return true;
287
+ }
288
+ /**
289
+ * Wait for a session to complete.
290
+ */
291
+ async waitForSession(sessionId) {
292
+ const record = this.sessions.get(sessionId);
293
+ if (!record) {
294
+ throw new Error(`Session "${sessionId}" not found`);
295
+ }
296
+ if (record.state.status === 'completed')
297
+ return;
298
+ if (record.state.status === 'failed') {
299
+ throw new Error(record.state.lastOutput ?? `Session "${sessionId}" failed`);
300
+ }
301
+ if (record.state.status === 'stopped') {
302
+ throw new Error(`Session "${sessionId}" was stopped`);
303
+ }
304
+ return record.completionPromise;
305
+ }
306
+ /**
307
+ * Get the full execution history of a session.
308
+ */
309
+ getSessionHistory(sessionId) {
310
+ const record = this.sessions.get(sessionId);
311
+ if (!record)
312
+ return null;
313
+ return [...record.history];
314
+ }
315
+ /**
316
+ * Clean up old completed/failed/stopped sessions older than
317
+ * the specified age (default 7 days).
318
+ */
319
+ cleanup(maxAgeMs = CLEANUP_AGE_MS) {
320
+ const cutoff = Date.now() - maxAgeMs;
321
+ let removed = 0;
322
+ const toRemove = [];
323
+ const entries = Array.from(this.sessions.entries());
324
+ for (const [id, record] of entries) {
325
+ const isTerminal = record.state.status === 'completed' ||
326
+ record.state.status === 'failed' ||
327
+ record.state.status === 'stopped';
328
+ if (!isTerminal)
329
+ continue;
330
+ const endTime = record.state.completedAt ?? record.state.createdAt;
331
+ if (endTime < cutoff) {
332
+ toRemove.push(id);
333
+ }
334
+ }
335
+ for (const id of toRemove) {
336
+ this.removeSession(id);
337
+ removed++;
338
+ }
339
+ return removed;
340
+ }
341
+ /**
342
+ * Get the currently attached session ID (if any).
343
+ */
344
+ getAttachedSessionId() {
345
+ return this.currentlyAttached;
346
+ }
347
+ /**
348
+ * Find sessions by tag.
349
+ */
350
+ findByTag(tag) {
351
+ const results = [];
352
+ const records = Array.from(this.sessions.values());
353
+ for (const record of records) {
354
+ if (record.state.tags.includes(tag)) {
355
+ results.push({ ...record.state });
356
+ }
357
+ }
358
+ return results;
359
+ }
360
+ // ---- Private: Session Execution Loop ----
361
+ /**
362
+ * The main execution loop for a background session.
363
+ *
364
+ * In production, this would call an LLM, execute tools, handle
365
+ * approvals, etc. Here we simulate with iteration tracking,
366
+ * cost accounting, pause/resume, abort, and output capture.
367
+ */
368
+ async runSessionLoop(record) {
369
+ const maxIterations = record.config.maxIterations || DEFAULT_MAX_ITERATIONS;
370
+ const maxCost = record.config.maxCost || DEFAULT_MAX_COST;
371
+ const abortSignal = record.abortController.signal;
372
+ for (let i = 0; i < maxIterations; i++) {
373
+ // ---- Abort check ----
374
+ if (abortSignal.aborted) {
375
+ throw new Error('Session aborted');
376
+ }
377
+ // ---- Pause check ----
378
+ if (record.pausePromise) {
379
+ this.addHistoryEntry(record.config.id, 'pause', `Paused at iteration ${i}`);
380
+ await record.pausePromise;
381
+ // After resuming, check abort again
382
+ if (abortSignal.aborted) {
383
+ throw new Error('Session aborted after resume');
384
+ }
385
+ }
386
+ // ---- Simulate iteration ----
387
+ const iterationTokens = 50 + Math.floor(Math.random() * 200);
388
+ const iterationCost = (iterationTokens / 1_000_000) * 3.0; // simulated $3/1M tokens
389
+ record.state.iterations = i + 1;
390
+ record.state.tokensUsed += iterationTokens;
391
+ record.state.cost += iterationCost;
392
+ const outputLine = `[iter ${i + 1}] Processing... (tokens: ${record.state.tokensUsed}, cost: $${record.state.cost.toFixed(4)})`;
393
+ record.state.lastOutput = outputLine;
394
+ // Capture output
395
+ this.captureOutput(record, outputLine);
396
+ // Add history entry every 10 iterations to avoid bloat
397
+ if ((i + 1) % 10 === 0 || i === 0) {
398
+ this.addHistoryEntry(record.config.id, 'iteration', `Iteration ${i + 1}/${maxIterations}`, { tokensUsed: record.state.tokensUsed, cost: record.state.cost });
399
+ }
400
+ // ---- Cost limit check ----
401
+ if (record.state.cost >= maxCost) {
402
+ const msg = `Cost limit ($${maxCost}) reached at iteration ${i + 1}`;
403
+ this.captureOutput(record, msg);
404
+ this.addHistoryEntry(record.config.id, 'info', msg);
405
+ break;
406
+ }
407
+ // Persist state periodically (every 5 iterations)
408
+ if ((i + 1) % 5 === 0) {
409
+ this.persistSession(record.config.id);
410
+ }
411
+ // Yield control for cooperative multitasking
412
+ await this.yieldControl();
413
+ }
414
+ // Final persist
415
+ this.persistSession(record.config.id);
416
+ }
417
+ /**
418
+ * Yield control to the event loop.
419
+ */
420
+ yieldControl() {
421
+ return new Promise((resolve) => setImmediate(resolve));
422
+ }
423
+ // ---- Private: Output Management ----
424
+ /**
425
+ * Capture an output line to both the in-memory buffer and
426
+ * the output file (if configured).
427
+ */
428
+ captureOutput(record, line) {
429
+ // In-memory buffer (bounded)
430
+ record.outputBuffer.push(line);
431
+ if (record.outputBuffer.length > OUTPUT_BUFFER_MAX) {
432
+ record.outputBuffer.shift();
433
+ }
434
+ // File output
435
+ if (record.state.outputFilePath) {
436
+ try {
437
+ appendFileSync(record.state.outputFilePath, line + '\n', 'utf-8');
438
+ }
439
+ catch {
440
+ // Non-critical — best effort file I/O
441
+ }
442
+ }
443
+ }
444
+ /**
445
+ * Initialize an output file with a header.
446
+ */
447
+ initializeOutputFile(filePath, config) {
448
+ try {
449
+ const header = [
450
+ `=== NeuroCLI Background Session Output ===`,
451
+ `Session: ${config.name} (${config.id})`,
452
+ `Model: ${config.model ?? 'default'}`,
453
+ `Working Directory: ${config.workingDirectory}`,
454
+ `Max Iterations: ${config.maxIterations}`,
455
+ `Max Cost: $${config.maxCost}`,
456
+ `Started: ${new Date().toISOString()}`,
457
+ `==========================================\n`,
458
+ ].join('\n');
459
+ writeFileSync(filePath, header, 'utf-8');
460
+ }
461
+ catch {
462
+ // Non-critical
463
+ }
464
+ }
465
+ // ---- Private: History ----
466
+ /**
467
+ * Add a history entry to a session.
468
+ */
469
+ addHistoryEntry(sessionId, type, message, data) {
470
+ const record = this.sessions.get(sessionId);
471
+ if (!record)
472
+ return;
473
+ record.history.push({
474
+ timestamp: Date.now(),
475
+ type,
476
+ message,
477
+ data,
478
+ });
479
+ // Persist history to disk (append)
480
+ this.persistHistory(sessionId, {
481
+ timestamp: Date.now(),
482
+ type,
483
+ message,
484
+ data,
485
+ });
486
+ }
487
+ // ---- Private: Persistence ----
488
+ /**
489
+ * Persist session state to disk so it survives app restarts.
490
+ */
491
+ persistSession(sessionId) {
492
+ const record = this.sessions.get(sessionId);
493
+ if (!record)
494
+ return;
495
+ const filePath = join(SESSION_DIR, `${sessionId}.json`);
496
+ try {
497
+ const data = {
498
+ config: record.config,
499
+ state: record.state,
500
+ // Don't persist runtime objects like AbortController or promises
501
+ persistedAt: Date.now(),
502
+ };
503
+ writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
504
+ }
505
+ catch {
506
+ // Non-critical — persistence is best-effort
507
+ }
508
+ }
509
+ /**
510
+ * Persist a single history entry to the session's history file.
511
+ */
512
+ persistHistory(sessionId, entry) {
513
+ const filePath = join(HISTORY_DIR, `${sessionId}.jsonl`);
514
+ try {
515
+ appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf-8');
516
+ }
517
+ catch {
518
+ // Non-critical
519
+ }
520
+ }
521
+ /**
522
+ * Restore sessions from disk on startup.
523
+ * Only restores terminal-state sessions; running sessions
524
+ * from a previous process are marked as 'stopped'.
525
+ */
526
+ restoreSessions() {
527
+ if (!existsSync(SESSION_DIR))
528
+ return;
529
+ try {
530
+ for (const file of readdirSync(SESSION_DIR)) {
531
+ if (!file.endsWith('.json'))
532
+ continue;
533
+ const filePath = join(SESSION_DIR, file);
534
+ try {
535
+ const content = readFileSync(filePath, 'utf-8');
536
+ const data = JSON.parse(content);
537
+ // If the session was running when the app exited, mark it stopped
538
+ if (data.state.status === 'running' || data.state.status === 'paused') {
539
+ data.state.status = 'stopped';
540
+ data.state.completedAt = data.state.completedAt ?? Date.now();
541
+ }
542
+ // Re-create a minimal record (no running loop)
543
+ let completionResolve = null;
544
+ let completionReject = null;
545
+ const completionPromise = new Promise((resolve, reject) => {
546
+ completionResolve = resolve;
547
+ completionReject = reject;
548
+ });
549
+ // Resolve immediately for terminal-state sessions
550
+ if (data.state.status === 'completed' ||
551
+ data.state.status === 'failed' ||
552
+ data.state.status === 'stopped') {
553
+ // Pre-resolved
554
+ completionPromise.catch(() => { }); // swallow
555
+ }
556
+ // Restore history from disk if available
557
+ const history = this.loadHistoryFromDisk(data.config.id);
558
+ const record = {
559
+ config: data.config,
560
+ state: data.state,
561
+ history,
562
+ abortController: new AbortController(),
563
+ outputBuffer: [],
564
+ attached: false,
565
+ completionPromise,
566
+ completionResolve,
567
+ completionReject,
568
+ pausePromise: null,
569
+ pauseResolve: null,
570
+ };
571
+ this.sessions.set(data.config.id, record);
572
+ }
573
+ catch {
574
+ // Corrupted file — skip
575
+ }
576
+ }
577
+ }
578
+ catch {
579
+ // Session directory read failure — skip
580
+ }
581
+ }
582
+ /**
583
+ * Load history entries from the JSONL file on disk.
584
+ */
585
+ loadHistoryFromDisk(sessionId) {
586
+ const filePath = join(HISTORY_DIR, `${sessionId}.jsonl`);
587
+ if (!existsSync(filePath))
588
+ return [];
589
+ const entries = [];
590
+ try {
591
+ const content = readFileSync(filePath, 'utf-8');
592
+ for (const line of content.split('\n')) {
593
+ if (!line.trim())
594
+ continue;
595
+ try {
596
+ entries.push(JSON.parse(line));
597
+ }
598
+ catch {
599
+ // Skip malformed lines
600
+ }
601
+ }
602
+ }
603
+ catch {
604
+ // Read failure — return what we have
605
+ }
606
+ return entries;
607
+ }
608
+ /**
609
+ * Remove a session and its associated files.
610
+ */
611
+ removeSession(sessionId) {
612
+ const record = this.sessions.get(sessionId);
613
+ if (!record)
614
+ return;
615
+ // Remove session file
616
+ const sessionFile = join(SESSION_DIR, `${sessionId}.json`);
617
+ try {
618
+ if (existsSync(sessionFile))
619
+ unlinkSync(sessionFile);
620
+ }
621
+ catch { /* ignore */ }
622
+ // Remove history file
623
+ const historyFile = join(HISTORY_DIR, `${sessionId}.jsonl`);
624
+ try {
625
+ if (existsSync(historyFile))
626
+ unlinkSync(historyFile);
627
+ }
628
+ catch { /* ignore */ }
629
+ // Remove output file
630
+ if (record.state.outputFilePath) {
631
+ try {
632
+ if (existsSync(record.state.outputFilePath))
633
+ unlinkSync(record.state.outputFilePath);
634
+ }
635
+ catch { /* ignore */ }
636
+ }
637
+ // Detach if currently attached
638
+ if (this.currentlyAttached === sessionId) {
639
+ this.currentlyAttached = null;
640
+ }
641
+ this.sessions.delete(sessionId);
642
+ this.emit('session:removed', sessionId);
643
+ }
644
+ // ---- Private: Desktop Notifications ----
645
+ /**
646
+ * Send a desktop notification using the operating system's
647
+ * native notification mechanism.
648
+ */
649
+ sendDesktopNotification(title, body) {
650
+ try {
651
+ const platform = process.platform;
652
+ if (platform === 'darwin') {
653
+ // macOS: use osascript to display a notification
654
+ const escapedTitle = title.replace(/"/g, '\\"');
655
+ const escapedBody = body.replace(/"/g, '\\"');
656
+ spawn('osascript', [
657
+ '-e',
658
+ `display notification "${escapedBody}" with title "${escapedTitle}"`,
659
+ ]).unref();
660
+ }
661
+ else if (platform === 'linux') {
662
+ // Linux: try notify-send
663
+ spawn('notify-send', [title, body]).unref();
664
+ }
665
+ else if (platform === 'win32') {
666
+ // Windows: use PowerShell toast
667
+ const escapedTitle = title.replace(/'/g, "''");
668
+ const escapedBody = body.replace(/'/g, "''");
669
+ const psCmd = `
670
+ Add-Type -AssemblyName System.Windows.Forms
671
+ $notify = New-Object System.Windows.Forms.NotifyIcon
672
+ $notify.Icon = [System.Drawing.SystemIcons]::Information
673
+ $notify.Visible = $true
674
+ $notify.ShowBalloonTip(5000, '${escapedTitle}', '${escapedBody}', [System.Windows.Forms.ToolTipIcon]::Info)
675
+ `.trim();
676
+ spawn('powershell', ['-NoProfile', '-Command', psCmd], {
677
+ windowsHide: true,
678
+ }).unref();
679
+ }
680
+ // If platform is unsupported, silently skip notification
681
+ }
682
+ catch {
683
+ // Notification failure must never crash the manager
684
+ }
685
+ }
686
+ // ---- Private: Utilities ----
687
+ /**
688
+ * Ensure a directory exists.
689
+ */
690
+ ensureDir(dir) {
691
+ if (!existsSync(dir)) {
692
+ mkdirSync(dir, { recursive: true });
693
+ }
694
+ }
695
+ }
696
+ //# sourceMappingURL=background-session.js.map