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.
- package/LICENSE +21 -0
- package/README.md +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- 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
|