starmux 0.12.0 → 0.13.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/dist/src/cli/mac-mcp-gateway.js +4 -3
- package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
- package/dist/src/cli/starmux.js +1 -1
- package/dist/src/cli/starmux.js.map +1 -1
- package/dist/src/runtime-paths.js +3 -3
- package/dist/src/runtime-paths.js.map +1 -1
- package/dist/vendor/desktop-commander/LICENSE +21 -0
- package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
- package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
- package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
- package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
- package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
- package/dist/vendor/desktop-commander/dist/config.js +19 -0
- package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
- package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
- package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
- package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
- package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
- package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
- package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
- package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
- package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
- package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
- package/dist/vendor/desktop-commander/dist/index.js +88 -0
- package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
- package/dist/vendor/desktop-commander/dist/server.js +1414 -0
- package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
- package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
- package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
- package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
- package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
- package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
- package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
- package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
- package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
- package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
- package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
- package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
- package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
- package/dist/vendor/desktop-commander/dist/types.js +1 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
- package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
- package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
- package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
- package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
- package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
- package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
- package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
- package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
- package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
- package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
- package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
- package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
- package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
- package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
- package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
- package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
- package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
- package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
- package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
- package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
- package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
- package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
- package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
- package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
- package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
- package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
- package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
- package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
- package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
- package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
- package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
- package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
- package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
- package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
- package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
- package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
- package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
- package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
- package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
- package/dist/vendor/desktop-commander/dist/version.js +1 -0
- package/dist/vendor/desktop-commander/package.json +15 -0
- package/package.json +14 -2
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { DEFAULT_COMMAND_TIMEOUT, MAX_PROCESS_WAIT_MS } from './config.js';
|
|
3
|
+
import { configManager } from './config-manager.js';
|
|
4
|
+
import { capture } from "./utils/capture.js";
|
|
5
|
+
import { analyzeProcessState, STATE_DETECTION_TAIL_CHARS } from './utils/process-detection.js';
|
|
6
|
+
import { getDefaultShell, getShellSpawnArgs } from './utils/shell.js';
|
|
7
|
+
import { terminateProcessTree } from './utils/process-tree.js';
|
|
8
|
+
/**
|
|
9
|
+
* Standard Windows PATHEXT value, used to repair a corrupted PATHEXT before
|
|
10
|
+
* spawning child shells.
|
|
11
|
+
*
|
|
12
|
+
* On some Windows Claude Desktop / DXT launches the server process inherits a
|
|
13
|
+
* broken PATHEXT (observed as ".CPL" only). Because we build the child env from
|
|
14
|
+
* { ...process.env }, that broken value would propagate into every spawned
|
|
15
|
+
* shell, stripping ".EXE" and breaking resolution of git / node / python / rg /
|
|
16
|
+
* etc. (and even full-path .exe invocations under PowerShell). See issue #481.
|
|
17
|
+
*/
|
|
18
|
+
const STANDARD_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC';
|
|
19
|
+
/**
|
|
20
|
+
* Return a healthy PATHEXT for spawned Windows shells.
|
|
21
|
+
* - Unset -> use the standard list.
|
|
22
|
+
* - Missing ".EXE" -> corrupted; merge the standard list with whatever was
|
|
23
|
+
* present (preserves any extra extensions, order-stable).
|
|
24
|
+
* - Otherwise -> leave the inherited value untouched.
|
|
25
|
+
*/
|
|
26
|
+
function getRepairedPathExt() {
|
|
27
|
+
const current = process.env.PATHEXT;
|
|
28
|
+
if (!current)
|
|
29
|
+
return STANDARD_PATHEXT;
|
|
30
|
+
const exts = current.split(';').map(e => e.trim().toUpperCase()).filter(Boolean);
|
|
31
|
+
if (!exts.includes('.EXE')) {
|
|
32
|
+
return [...new Set([...STANDARD_PATHEXT.split(';'), ...exts])].join(';');
|
|
33
|
+
}
|
|
34
|
+
return current;
|
|
35
|
+
}
|
|
36
|
+
const OUTPUT_STREAMS = ['stdout', 'stderr'];
|
|
37
|
+
function newStreamTails() {
|
|
38
|
+
return { stdout: { text: '', chars: 0, turn: 0 }, stderr: { text: '', chars: 0, turn: 0 } };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The one place that turns a caller's timeout_ms into the time a process wait
|
|
42
|
+
* may actually block. When `capped`, the call returns at the ceiling with the
|
|
43
|
+
* process still running and the caller continues with read_process_output.
|
|
44
|
+
*/
|
|
45
|
+
export function getProcessWaitLimit(timeoutMs, capMs = MAX_PROCESS_WAIT_MS) {
|
|
46
|
+
return { waitMs: Math.min(timeoutMs, capMs), capMs, capped: timeoutMs > capMs };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Output buffering caps. Without a cap, a process emitting enough output makes
|
|
50
|
+
* string concatenation throw "RangeError: Invalid string length" at V8's max
|
|
51
|
+
* string size (~536M chars) inside a stdout 'data' handler — an uncaught
|
|
52
|
+
* exception that kills the whole server (index.ts exits on uncaughtException).
|
|
53
|
+
* The cap also bounds the cost of the reads that are O(total output), such as
|
|
54
|
+
* since-start snapshot reads.
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_BUFFERED_OUTPUT_CHARS = 50 * 1024 * 1024; // per session; oldest lines evicted first
|
|
57
|
+
const MAX_LINE_CHARS = 1024 * 1024; // force-split longer lines so eviction can work
|
|
58
|
+
const MAX_WAIT_OUTPUT_CHARS = 2 * 1024 * 1024; // start_process wait buffer (the initial output it returns)
|
|
59
|
+
export class TerminalManager {
|
|
60
|
+
constructor() {
|
|
61
|
+
this.sessions = new Map();
|
|
62
|
+
this.completedSessions = new Map();
|
|
63
|
+
this.deliveryTurn = 0;
|
|
64
|
+
this.deliveryTurnOpen = false;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Send input to a running process
|
|
68
|
+
* @param pid Process ID
|
|
69
|
+
* @param input Text to send to the process
|
|
70
|
+
* @returns Whether input was successfully sent
|
|
71
|
+
*/
|
|
72
|
+
sendInputToProcess(pid, input) {
|
|
73
|
+
const session = this.sessions.get(pid);
|
|
74
|
+
if (!session) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
if (session.process.stdin && !session.process.stdin.destroyed) {
|
|
79
|
+
// Ensure input ends with a newline for most REPLs
|
|
80
|
+
const inputWithNewline = input.endsWith('\n') ? input : input + '\n';
|
|
81
|
+
session.process.stdin.write(inputWithNewline);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
console.error(`Error sending input to process ${pid}:`, error);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async executeCommand(command, timeoutMs = DEFAULT_COMMAND_TIMEOUT, shell, collectTiming = false, maxWaitMs = MAX_PROCESS_WAIT_MS) {
|
|
92
|
+
let config = {};
|
|
93
|
+
try {
|
|
94
|
+
config = await configManager.getConfig();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// If there's an error getting the config, fall back to the default shell
|
|
98
|
+
}
|
|
99
|
+
// Get the shell from config if not specified
|
|
100
|
+
const shellToUse = shell || config.defaultShell || getDefaultShell();
|
|
101
|
+
const waitLimit = getProcessWaitLimit(timeoutMs, maxWaitMs);
|
|
102
|
+
// For REPL interactions, we need to ensure stdin, stdout, and stderr are properly configured
|
|
103
|
+
// Note: No special stdio options needed here, Node.js handles pipes by default
|
|
104
|
+
// Enhance SSH commands automatically
|
|
105
|
+
let enhancedCommand = command;
|
|
106
|
+
if (command.trim().startsWith('ssh ') && !command.includes(' -t')) {
|
|
107
|
+
enhancedCommand = command.replace(/^ssh /, 'ssh -t ');
|
|
108
|
+
console.log(`Enhanced SSH command: ${enhancedCommand}`);
|
|
109
|
+
}
|
|
110
|
+
// Get the appropriate spawn configuration for the shell, with login flags where appropriate
|
|
111
|
+
const spawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand);
|
|
112
|
+
const spawnOptions = {
|
|
113
|
+
env: {
|
|
114
|
+
...process.env,
|
|
115
|
+
TERM: 'xterm-256color' // Better terminal compatibility
|
|
116
|
+
},
|
|
117
|
+
windowsHide: true // Prevent visible console windows on Windows
|
|
118
|
+
};
|
|
119
|
+
// Add shell option if needed (for unknown shells)
|
|
120
|
+
if (spawnConfig.useShellOption) {
|
|
121
|
+
spawnOptions.shell = spawnConfig.useShellOption;
|
|
122
|
+
}
|
|
123
|
+
// Repair PATHEXT on Windows before spawning. On some Windows DXT launches
|
|
124
|
+
// the server process inherits a corrupted PATHEXT (e.g. ".CPL"), which we
|
|
125
|
+
// would otherwise propagate via { ...process.env } and break command
|
|
126
|
+
// resolution (git, node, python, rg, ...) in the spawned shell. See #481.
|
|
127
|
+
if (process.platform === 'win32' && spawnOptions.env) {
|
|
128
|
+
spawnOptions.env.PATHEXT = getRepairedPathExt();
|
|
129
|
+
}
|
|
130
|
+
// On Windows, when we invoke cmd.exe directly and pass the user's command as a
|
|
131
|
+
// single argument, Node/libuv applies MSVCRT-style quoting that escapes embedded
|
|
132
|
+
// double quotes as \" . cmd.exe does not understand that escaping, so any command
|
|
133
|
+
// containing quotes (e.g. a quoted path with spaces like "C:\Program Files\app.exe")
|
|
134
|
+
// is corrupted before the shell ever parses it. Passing arguments verbatim lets
|
|
135
|
+
// cmd handle its own quoting. Scoped to shells that set windowsVerbatim (cmd only)
|
|
136
|
+
// because PowerShell/pwsh have different quote rules and must NOT use verbatim.
|
|
137
|
+
if (process.platform === 'win32' && spawnConfig.windowsVerbatim) {
|
|
138
|
+
spawnOptions.windowsVerbatimArguments = true;
|
|
139
|
+
}
|
|
140
|
+
// Spawn the process with appropriate arguments
|
|
141
|
+
const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions);
|
|
142
|
+
let output = '';
|
|
143
|
+
// spawn() reports failure asynchronously via an 'error' event; it does not
|
|
144
|
+
// throw. Node rethrows an 'error' that has no listener as an uncaught
|
|
145
|
+
// exception, and our process-level handler (src/index.ts) turns that into
|
|
146
|
+
// process.exit(1). So an unresolvable executable — e.g. shell: "/usr/bin/bash"
|
|
147
|
+
// on Windows — used to kill the entire MCP server one tick AFTER this
|
|
148
|
+
// function had already returned "Failed to get process ID", making the crash
|
|
149
|
+
// look unrelated to the command that caused it. Under `remote` the parent
|
|
150
|
+
// then kept accepting tool calls over a dead stdio pipe ("Not connected").
|
|
151
|
+
//
|
|
152
|
+
// Attach before any return path below, and keep it for the session lifetime
|
|
153
|
+
// so a later runtime error (EPIPE on a closed stdin, ...) can't crash us either.
|
|
154
|
+
let forwardProcessError = null;
|
|
155
|
+
let pendingProcessError = null;
|
|
156
|
+
childProcess.on('error', (err) => {
|
|
157
|
+
if (forwardProcessError) {
|
|
158
|
+
forwardProcessError(err);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
pendingProcessError = err;
|
|
162
|
+
console.error(`Process error for "${command}": ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// Ensure childProcess.pid is defined before proceeding
|
|
166
|
+
if (!childProcess.pid) {
|
|
167
|
+
// Return a consistent error object instead of throwing
|
|
168
|
+
const output = 'Error: Failed to get process ID. The command could not be executed.';
|
|
169
|
+
return {
|
|
170
|
+
pid: -1, // Use -1 to indicate an error state
|
|
171
|
+
output,
|
|
172
|
+
isBlocked: false,
|
|
173
|
+
processState: analyzeProcessState(output)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const session = {
|
|
177
|
+
pid: childProcess.pid,
|
|
178
|
+
process: childProcess,
|
|
179
|
+
outputLines: [], // Line-based buffer
|
|
180
|
+
lastReadIndex: 0, // Track where "new" output starts
|
|
181
|
+
lastReadOpenLine: '',
|
|
182
|
+
startTime: new Date(),
|
|
183
|
+
bufferedChars: 0,
|
|
184
|
+
evictedLines: 0,
|
|
185
|
+
evictedChars: 0,
|
|
186
|
+
streams: newStreamTails()
|
|
187
|
+
};
|
|
188
|
+
this.sessions.set(childProcess.pid, session);
|
|
189
|
+
// Timing telemetry
|
|
190
|
+
const startTime = Date.now();
|
|
191
|
+
let firstOutputTime;
|
|
192
|
+
let lastOutputTime;
|
|
193
|
+
const outputEvents = [];
|
|
194
|
+
let exitReason = 'timeout';
|
|
195
|
+
return new Promise((resolve) => {
|
|
196
|
+
let resolved = false;
|
|
197
|
+
let periodicCheck = null;
|
|
198
|
+
let waitTimer = null;
|
|
199
|
+
// Whether the process waits for input, judged as the periodic check does:
|
|
200
|
+
// from the end of each output stream, not from the chunk that just came
|
|
201
|
+
// (a chunk can end in the middle of a line, e.g. right after "<div>").
|
|
202
|
+
const waitingForInput = () => this.getProcessState(childProcess.pid)?.isWaitingForInput === true;
|
|
203
|
+
const resolveOnce = (waitResult) => {
|
|
204
|
+
if (resolved)
|
|
205
|
+
return;
|
|
206
|
+
resolved = true;
|
|
207
|
+
if (periodicCheck)
|
|
208
|
+
clearInterval(periodicCheck);
|
|
209
|
+
if (waitTimer)
|
|
210
|
+
clearTimeout(waitTimer);
|
|
211
|
+
// The state the wait ended in, from the session: finished if the
|
|
212
|
+
// process exited, else judged from its output. A process error after
|
|
213
|
+
// the exit leaves no session behind, so the text it returns is judged.
|
|
214
|
+
const result = {
|
|
215
|
+
...waitResult,
|
|
216
|
+
processState: this.getProcessState(childProcess.pid) ?? analyzeProcessState(waitResult.output)
|
|
217
|
+
};
|
|
218
|
+
// Add timing info if requested
|
|
219
|
+
if (collectTiming) {
|
|
220
|
+
const endTime = Date.now();
|
|
221
|
+
result.timingInfo = {
|
|
222
|
+
startTime,
|
|
223
|
+
endTime,
|
|
224
|
+
totalDurationMs: endTime - startTime,
|
|
225
|
+
exitReason,
|
|
226
|
+
firstOutputTime,
|
|
227
|
+
lastOutputTime,
|
|
228
|
+
timeToFirstOutputMs: firstOutputTime ? firstOutputTime - startTime : undefined,
|
|
229
|
+
outputEvents: outputEvents.length > 0 ? outputEvents : undefined
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
resolve(result);
|
|
233
|
+
};
|
|
234
|
+
// Now that resolveOnce exists, route process errors into it, so the caller
|
|
235
|
+
// doesn't sit waiting for output. The session goes only if the process
|
|
236
|
+
// has exited: Node also emits 'error' when a kill fails or a message can't
|
|
237
|
+
// be sent, and the process runs on. It then stays listed, readable and
|
|
238
|
+
// terminable, and its 'exit' records the completion as usual.
|
|
239
|
+
forwardProcessError = (err) => {
|
|
240
|
+
if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
|
|
241
|
+
this.sessions.delete(childProcess.pid);
|
|
242
|
+
}
|
|
243
|
+
exitReason = 'process_exit';
|
|
244
|
+
resolveOnce({
|
|
245
|
+
pid: childProcess.pid,
|
|
246
|
+
output: output + `\nProcess error: ${err.message}`,
|
|
247
|
+
isBlocked: false
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
// An error emitted between spawn and here (the common case — spawn errors
|
|
251
|
+
// land on the next tick) is replayed rather than dropped.
|
|
252
|
+
if (pendingProcessError) {
|
|
253
|
+
forwardProcessError(pendingProcessError);
|
|
254
|
+
}
|
|
255
|
+
childProcess.stdout.on('data', (data) => {
|
|
256
|
+
const text = data.toString();
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
if (!firstOutputTime)
|
|
259
|
+
firstOutputTime = now;
|
|
260
|
+
lastOutputTime = now;
|
|
261
|
+
// `output` only feeds the wait-phase result, so stop growing it once
|
|
262
|
+
// resolved and keep only a bounded tail.
|
|
263
|
+
if (!resolved) {
|
|
264
|
+
output += text;
|
|
265
|
+
if (output.length > MAX_WAIT_OUTPUT_CHARS) {
|
|
266
|
+
output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
this.recordOutput(session, 'stdout', text);
|
|
270
|
+
// Record output event if collecting timing
|
|
271
|
+
if (collectTiming) {
|
|
272
|
+
outputEvents.push({
|
|
273
|
+
timestamp: now,
|
|
274
|
+
deltaMs: now - startTime,
|
|
275
|
+
source: 'stdout',
|
|
276
|
+
length: text.length,
|
|
277
|
+
snippet: text.slice(0, 50).replace(/\n/g, '\\n')
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
// Immediate check for a prompt
|
|
281
|
+
if (waitingForInput()) {
|
|
282
|
+
exitReason = 'early_exit_quick_pattern';
|
|
283
|
+
if (collectTiming && outputEvents.length > 0) {
|
|
284
|
+
outputEvents[outputEvents.length - 1].matchedPattern = 'quick_pattern';
|
|
285
|
+
}
|
|
286
|
+
resolveOnce({
|
|
287
|
+
pid: childProcess.pid,
|
|
288
|
+
output,
|
|
289
|
+
isBlocked: true
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
childProcess.stderr.on('data', (data) => {
|
|
294
|
+
const text = data.toString();
|
|
295
|
+
const now = Date.now();
|
|
296
|
+
if (!firstOutputTime)
|
|
297
|
+
firstOutputTime = now;
|
|
298
|
+
lastOutputTime = now;
|
|
299
|
+
if (!resolved) {
|
|
300
|
+
output += text;
|
|
301
|
+
if (output.length > MAX_WAIT_OUTPUT_CHARS) {
|
|
302
|
+
output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
this.recordOutput(session, 'stderr', text);
|
|
306
|
+
// Record output event if collecting timing
|
|
307
|
+
if (collectTiming) {
|
|
308
|
+
outputEvents.push({
|
|
309
|
+
timestamp: now,
|
|
310
|
+
deltaMs: now - startTime,
|
|
311
|
+
source: 'stderr',
|
|
312
|
+
length: text.length,
|
|
313
|
+
snippet: text.slice(0, 50).replace(/\n/g, '\\n')
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
// Periodic comprehensive check every 100ms
|
|
318
|
+
periodicCheck = setInterval(() => {
|
|
319
|
+
if (waitingForInput()) {
|
|
320
|
+
exitReason = 'early_exit_periodic_check';
|
|
321
|
+
resolveOnce({
|
|
322
|
+
pid: childProcess.pid,
|
|
323
|
+
output,
|
|
324
|
+
isBlocked: true
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}, 100);
|
|
328
|
+
// Timeout fallback, bounded by the wait ceiling so the call returns
|
|
329
|
+
// before the MCP client gives up on it; the process keeps running.
|
|
330
|
+
waitTimer = setTimeout(() => {
|
|
331
|
+
exitReason = 'timeout';
|
|
332
|
+
resolveOnce({
|
|
333
|
+
pid: childProcess.pid,
|
|
334
|
+
output,
|
|
335
|
+
isBlocked: true,
|
|
336
|
+
...(waitLimit.capped && { waitCappedAtMs: waitLimit.capMs })
|
|
337
|
+
});
|
|
338
|
+
}, waitLimit.waitMs);
|
|
339
|
+
childProcess.on('exit', (code, signal) => {
|
|
340
|
+
if (childProcess.pid) {
|
|
341
|
+
// Store completed session before removing active session
|
|
342
|
+
this.completedSessions.set(childProcess.pid, {
|
|
343
|
+
session,
|
|
344
|
+
exitCode: code,
|
|
345
|
+
signal,
|
|
346
|
+
endTime: new Date()
|
|
347
|
+
});
|
|
348
|
+
// Keep only last 100 completed sessions
|
|
349
|
+
if (this.completedSessions.size > 100) {
|
|
350
|
+
const oldestKey = Array.from(this.completedSessions.keys())[0];
|
|
351
|
+
this.completedSessions.delete(oldestKey);
|
|
352
|
+
}
|
|
353
|
+
this.sessions.delete(childProcess.pid);
|
|
354
|
+
}
|
|
355
|
+
exitReason = 'process_exit';
|
|
356
|
+
resolveOnce({
|
|
357
|
+
pid: childProcess.pid,
|
|
358
|
+
output,
|
|
359
|
+
isBlocked: false
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Add a chunk of one stream's output to the session: to the merged line
|
|
366
|
+
* buffer, and to that stream's tail for state detection.
|
|
367
|
+
*/
|
|
368
|
+
recordOutput(session, stream, text) {
|
|
369
|
+
this.appendToLineBuffer(session, text);
|
|
370
|
+
const tail = session.streams[stream];
|
|
371
|
+
tail.text = (text.length >= STATE_DETECTION_TAIL_CHARS ? text : tail.text + text).slice(-STATE_DETECTION_TAIL_CHARS);
|
|
372
|
+
tail.chars += text.length;
|
|
373
|
+
tail.turn = this.currentDeliveryTurn();
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Number of the event-loop turn now delivering output; every chunk
|
|
377
|
+
* dispatched before the next check phase (setImmediate) shares it.
|
|
378
|
+
*
|
|
379
|
+
* stdout and stderr are separate pipes. Each turn delivers what the OS had
|
|
380
|
+
* ready when the loop polled, so a chunk delivered in a later turn was
|
|
381
|
+
* written after the chunks of earlier turns. Within one turn the pipes come
|
|
382
|
+
* in either order: when python -i's final stdout newline and its stderr
|
|
383
|
+
* ">>> " prompt are both waiting, the prompt is often delivered first.
|
|
384
|
+
*/
|
|
385
|
+
currentDeliveryTurn() {
|
|
386
|
+
if (!this.deliveryTurnOpen) {
|
|
387
|
+
this.deliveryTurnOpen = true;
|
|
388
|
+
this.deliveryTurn++;
|
|
389
|
+
setImmediate(() => { this.deliveryTurnOpen = false; });
|
|
390
|
+
}
|
|
391
|
+
return this.deliveryTurn;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Append text to a session's line buffer
|
|
395
|
+
* Handles partial lines and newline splitting
|
|
396
|
+
*/
|
|
397
|
+
appendToLineBuffer(session, text) {
|
|
398
|
+
if (!text)
|
|
399
|
+
return;
|
|
400
|
+
// Split text into lines, keeping track of whether text ends with newline
|
|
401
|
+
const lines = text.split('\n');
|
|
402
|
+
for (let i = 0; i < lines.length; i++) {
|
|
403
|
+
const line = lines[i];
|
|
404
|
+
const isLastFragment = i === lines.length - 1;
|
|
405
|
+
const endsWithNewline = text.endsWith('\n');
|
|
406
|
+
if (session.outputLines.length === 0) {
|
|
407
|
+
// First line ever
|
|
408
|
+
session.outputLines.push(line);
|
|
409
|
+
}
|
|
410
|
+
else if (i === 0) {
|
|
411
|
+
// First fragment - append to last line (might be partial)
|
|
412
|
+
session.outputLines[session.outputLines.length - 1] += line;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
// Subsequent lines - add as new lines
|
|
416
|
+
session.outputLines.push(line);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Appended text contributes exactly its length to the joined buffer
|
|
420
|
+
// (its newlines become the join separators).
|
|
421
|
+
session.bufferedChars += text.length;
|
|
422
|
+
// A process printing without newlines grows a single line forever, which
|
|
423
|
+
// eviction can't bound — force-split so no line exceeds MAX_LINE_CHARS.
|
|
424
|
+
// Each inserted break adds one separator to the joined length.
|
|
425
|
+
let lastIndex = session.outputLines.length - 1;
|
|
426
|
+
while (session.outputLines[lastIndex].length > MAX_LINE_CHARS) {
|
|
427
|
+
const overlong = session.outputLines[lastIndex];
|
|
428
|
+
session.outputLines[lastIndex] = overlong.slice(0, MAX_LINE_CHARS);
|
|
429
|
+
session.outputLines.push(overlong.slice(MAX_LINE_CHARS));
|
|
430
|
+
session.bufferedChars += 1;
|
|
431
|
+
lastIndex++;
|
|
432
|
+
}
|
|
433
|
+
// Enforce the per-session cap by evicting the oldest lines. Keeps the
|
|
434
|
+
// buffer far below V8's max string length so concatenation and join()
|
|
435
|
+
// can never throw "Invalid string length" and kill the server.
|
|
436
|
+
// Count them first and drop them with one splice: on an array this large
|
|
437
|
+
// every shift() copies the whole array, and a line-by-line shift() stalled
|
|
438
|
+
// the event loop for seconds per chunk once a session reached the cap.
|
|
439
|
+
let evicted = 0;
|
|
440
|
+
while (session.bufferedChars > MAX_BUFFERED_OUTPUT_CHARS && evicted < session.outputLines.length - 1) {
|
|
441
|
+
const droppedJoinedChars = session.outputLines[evicted].length + 1; // +1 for its join separator
|
|
442
|
+
session.bufferedChars -= droppedJoinedChars;
|
|
443
|
+
session.evictedChars += droppedJoinedChars;
|
|
444
|
+
evicted++;
|
|
445
|
+
}
|
|
446
|
+
if (evicted > 0) {
|
|
447
|
+
session.outputLines.splice(0, evicted);
|
|
448
|
+
session.evictedLines += evicted;
|
|
449
|
+
session.lastReadIndex = Math.max(0, session.lastReadIndex - evicted);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Read process output with pagination (like file reading)
|
|
454
|
+
* @param pid Process ID
|
|
455
|
+
* @param offset Line offset: 0=from lastReadIndex, positive=absolute, negative=tail
|
|
456
|
+
* @param length Max lines to return
|
|
457
|
+
* @param updateReadIndex Whether to update lastReadIndex (default: true for offset=0)
|
|
458
|
+
*/
|
|
459
|
+
readOutputPaginated(pid, offset = 0, length = 1000) {
|
|
460
|
+
// First check active sessions
|
|
461
|
+
const session = this.sessions.get(pid);
|
|
462
|
+
if (session) {
|
|
463
|
+
const result = this.readFromLineBuffer(session, offset, length, false, undefined);
|
|
464
|
+
result.evictedLines = session.evictedLines;
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
// Then check completed sessions: they keep their read position, so
|
|
468
|
+
// reading on after the exit continues where the last read stopped
|
|
469
|
+
const completedSession = this.completedSessions.get(pid);
|
|
470
|
+
if (completedSession) {
|
|
471
|
+
const runtimeMs = completedSession.endTime.getTime() - completedSession.session.startTime.getTime();
|
|
472
|
+
const result = this.readFromLineBuffer(completedSession.session, offset, length, true, completedSession.exitCode, runtimeMs);
|
|
473
|
+
result.evictedLines = completedSession.session.evictedLines;
|
|
474
|
+
result.signal = completedSession.signal;
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Whether a default read (offset 0) has anything to return: complete lines
|
|
481
|
+
* past the read position, or an unfinished last line with text a read
|
|
482
|
+
* hasn't returned (see readFromLineBuffer). Line counts alone can't tell
|
|
483
|
+
* the second: text appended to that line adds no line.
|
|
484
|
+
*/
|
|
485
|
+
hasUnreadOutput(pid) {
|
|
486
|
+
const session = this.sessions.get(pid) ?? this.completedSessions.get(pid)?.session;
|
|
487
|
+
if (!session) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
return session.outputLines.length - 1 > session.lastReadIndex || TerminalManager.openLineHasNewText(session);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Whether the unfinished last line has text that a default read hasn't
|
|
494
|
+
* returned. Empty, it has nothing to return. Its text is compared with what
|
|
495
|
+
* a read last returned only while it is the line that read stopped at: a
|
|
496
|
+
* later line is new even when its text is the same (a REPL's next ">>> ").
|
|
497
|
+
*/
|
|
498
|
+
static openLineHasNewText(session) {
|
|
499
|
+
const lines = session.outputLines;
|
|
500
|
+
const openLine = lines[lines.length - 1] ?? '';
|
|
501
|
+
return openLine !== '' && (lines.length - 1 !== session.lastReadIndex || openLine !== session.lastReadOpenLine);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Internal helper to read from a session's line buffer with offset/length
|
|
505
|
+
*/
|
|
506
|
+
readFromLineBuffer(session, offset, length, isComplete, exitCode, runtimeMs) {
|
|
507
|
+
const lines = session.outputLines;
|
|
508
|
+
// The empty last line after a trailing newline holds nothing: it isn't
|
|
509
|
+
// counted (total, remaining, tail offsets) or returned. A last line with
|
|
510
|
+
// text, unfinished or not, is.
|
|
511
|
+
const totalLines = lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
|
|
512
|
+
let startIndex;
|
|
513
|
+
let linesToRead;
|
|
514
|
+
let readableEnd = totalLines;
|
|
515
|
+
if (offset < 0) {
|
|
516
|
+
// Negative offset = start position from end, then read 'length' lines forward
|
|
517
|
+
// e.g., offset=-50, length=10 means: start 50 lines from end, read 10 lines
|
|
518
|
+
const fromEnd = Math.abs(offset);
|
|
519
|
+
startIndex = Math.max(0, totalLines - fromEnd);
|
|
520
|
+
linesToRead = lines.slice(startIndex, Math.min(startIndex + length, totalLines));
|
|
521
|
+
// Don't update lastReadIndex for tail reads
|
|
522
|
+
}
|
|
523
|
+
else if (offset === 0) {
|
|
524
|
+
// offset=0 means "from where I last read" (like getNewOutput).
|
|
525
|
+
// The last line is unfinished: output is appended to it until a newline
|
|
526
|
+
// arrives, even after the exit (a process the command started can still
|
|
527
|
+
// write). So it is returned only when it has text a read hasn't
|
|
528
|
+
// returned, and the read position never moves past it: counting it as
|
|
529
|
+
// read would lose whatever is appended to it next.
|
|
530
|
+
const openLineIndex = Math.max(lines.length - 1, 0);
|
|
531
|
+
readableEnd = TerminalManager.openLineHasNewText(session) ? lines.length : openLineIndex;
|
|
532
|
+
startIndex = session.lastReadIndex;
|
|
533
|
+
linesToRead = lines.slice(startIndex, Math.min(startIndex + length, readableEnd));
|
|
534
|
+
const readTo = startIndex + linesToRead.length;
|
|
535
|
+
if (readTo === lines.length) {
|
|
536
|
+
session.lastReadOpenLine = lines[lines.length - 1];
|
|
537
|
+
}
|
|
538
|
+
session.lastReadIndex = Math.min(readTo, openLineIndex);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
// Positive offset = absolute position
|
|
542
|
+
startIndex = offset;
|
|
543
|
+
linesToRead = lines.slice(startIndex, Math.min(startIndex + length, totalLines));
|
|
544
|
+
// Don't update lastReadIndex for absolute position reads
|
|
545
|
+
}
|
|
546
|
+
const readCount = linesToRead.length;
|
|
547
|
+
const endIndex = startIndex + readCount;
|
|
548
|
+
const remaining = Math.max(0, readableEnd - endIndex);
|
|
549
|
+
return {
|
|
550
|
+
lines: linesToRead,
|
|
551
|
+
totalLines,
|
|
552
|
+
readFrom: startIndex,
|
|
553
|
+
readCount,
|
|
554
|
+
remaining,
|
|
555
|
+
isComplete,
|
|
556
|
+
exitCode,
|
|
557
|
+
runtimeMs
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Get total line count for a process
|
|
562
|
+
*/
|
|
563
|
+
getOutputLineCount(pid) {
|
|
564
|
+
const session = this.sessions.get(pid);
|
|
565
|
+
if (session) {
|
|
566
|
+
return session.outputLines.length;
|
|
567
|
+
}
|
|
568
|
+
const completedSession = this.completedSessions.get(pid);
|
|
569
|
+
if (completedSession) {
|
|
570
|
+
return completedSession.session.outputLines.length;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Legacy method for backward compatibility
|
|
576
|
+
* Returns all new output since last read
|
|
577
|
+
* @param maxLines Maximum lines to return (default: 1000 for context protection)
|
|
578
|
+
* @deprecated Use readOutputPaginated instead
|
|
579
|
+
*/
|
|
580
|
+
getNewOutput(pid, maxLines = 1000) {
|
|
581
|
+
const result = this.readOutputPaginated(pid, 0, maxLines);
|
|
582
|
+
if (!result)
|
|
583
|
+
return null;
|
|
584
|
+
const output = result.lines.join('\n').trim();
|
|
585
|
+
// For completed sessions, append completion info with runtime
|
|
586
|
+
if (result.isComplete) {
|
|
587
|
+
const runtimeStr = result.runtimeMs !== undefined
|
|
588
|
+
? `\nRuntime: ${(result.runtimeMs / 1000).toFixed(2)}s`
|
|
589
|
+
: '';
|
|
590
|
+
if (output) {
|
|
591
|
+
return `${output}\n\nProcess completed with exit code ${result.exitCode}${runtimeStr}`;
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
return `Process completed with exit code ${result.exitCode}${runtimeStr}\n(No output produced)`;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Add truncation warning if there's more output
|
|
598
|
+
if (result.remaining > 0) {
|
|
599
|
+
return `${output}\n\n[Output truncated: ${result.remaining} more lines available. Use read_process_output with offset/length for full output.]`;
|
|
600
|
+
}
|
|
601
|
+
return output || null;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Capture a snapshot of current output state for interaction tracking.
|
|
605
|
+
* Used by interactWithProcess to know what output existed before sending input.
|
|
606
|
+
*/
|
|
607
|
+
captureOutputSnapshot(pid) {
|
|
608
|
+
const session = this.sessions.get(pid);
|
|
609
|
+
return session ? TerminalManager.endOfOutput(session) : null;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Get output that appeared since a snapshot was taken.
|
|
613
|
+
* This handles the case where output is appended to the last line (REPL prompts).
|
|
614
|
+
* Also checks completed sessions in case process finished between snapshot and poll.
|
|
615
|
+
*/
|
|
616
|
+
getOutputSinceSnapshot(pid, snapshot) {
|
|
617
|
+
return this.readOutputSince(pid, snapshot)?.output ?? null;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Output that appeared since `snapshot`, plus the snapshot at the current end
|
|
621
|
+
* of output. A poller passes `next` back in to read only the output that
|
|
622
|
+
* arrived since its previous poll. Costs O(output since snapshot), never
|
|
623
|
+
* O(whole buffer), so polling stays cheap however much history the session
|
|
624
|
+
* has retained. Also checks completed sessions in case the process finished
|
|
625
|
+
* between snapshot and poll.
|
|
626
|
+
*/
|
|
627
|
+
readOutputSince(pid, snapshot) {
|
|
628
|
+
const buffer = this.sessions.get(pid) ?? this.completedSessions.get(pid)?.session;
|
|
629
|
+
if (!buffer) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
output: TerminalManager.outputSinceSnapshot(buffer, snapshot.totalChars),
|
|
634
|
+
next: TerminalManager.endOfOutput(buffer)
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Snapshot at the current end of a buffer, in O(1): bufferedChars is the
|
|
639
|
+
* joined length of the retained lines, so nothing is joined here.
|
|
640
|
+
*/
|
|
641
|
+
static endOfOutput(buffer) {
|
|
642
|
+
return {
|
|
643
|
+
// Absolute since process start (includes evicted output), so the
|
|
644
|
+
// offset stays valid even if the cap evicts lines between
|
|
645
|
+
// snapshot and read.
|
|
646
|
+
totalChars: buffer.evictedChars + buffer.bufferedChars,
|
|
647
|
+
lineCount: buffer.evictedLines + buffer.outputLines.length,
|
|
648
|
+
streamChars: { stdout: buffer.streams.stdout.chars, stderr: buffer.streams.stderr.chars }
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The process's state: finished once it has exited, whatever its output
|
|
653
|
+
* says; otherwise waiting for input or running, judged from the end of the
|
|
654
|
+
* output it wrote since `since` (default: since it started). Reads only the
|
|
655
|
+
* per-stream tails, so it costs the same however much output the process
|
|
656
|
+
* has produced. The one place start_process, interact_with_process,
|
|
657
|
+
* read_process_output and list_sessions get the state from.
|
|
658
|
+
*/
|
|
659
|
+
getProcessState(pid, since) {
|
|
660
|
+
const session = this.sessions.get(pid);
|
|
661
|
+
if (session) {
|
|
662
|
+
return analyzeProcessState(TerminalManager.lastWrittenOutput(session.streams, since), pid);
|
|
663
|
+
}
|
|
664
|
+
const completedSession = this.completedSessions.get(pid);
|
|
665
|
+
if (!completedSession) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
const lastOutput = TerminalManager.lastWrittenOutput(completedSession.session.streams, since).join('\n');
|
|
669
|
+
return { isWaitingForInput: false, isFinished: true, isRunning: false, lastOutput };
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The output since `since` of each stream that may have written last: the
|
|
673
|
+
* streams delivered in the latest turn that delivered anything since then.
|
|
674
|
+
* Their relative order within that turn is unknown (see
|
|
675
|
+
* currentDeliveryTurn), so each is judged on its own; streams last
|
|
676
|
+
* delivered in an earlier turn wrote before them and can't end the output.
|
|
677
|
+
*/
|
|
678
|
+
static lastWrittenOutput(streams, since) {
|
|
679
|
+
const recent = OUTPUT_STREAMS
|
|
680
|
+
.map(name => {
|
|
681
|
+
const { text, chars, turn } = streams[name];
|
|
682
|
+
const newChars = chars - (since?.streamChars[name] ?? 0);
|
|
683
|
+
return { text: newChars > 0 ? text.slice(-newChars) : '', turn };
|
|
684
|
+
})
|
|
685
|
+
.filter(stream => stream.text.length > 0);
|
|
686
|
+
const lastTurn = Math.max(...recent.map(stream => stream.turn));
|
|
687
|
+
return recent.filter(stream => stream.turn === lastTurn).map(stream => stream.text);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* New output since a snapshot, in absolute (since process start) offsets:
|
|
691
|
+
* the last (end - snapshot) chars of the joined buffer. Walks back from the
|
|
692
|
+
* newest line only until the snapshot position is covered and joins just
|
|
693
|
+
* those lines. If eviction dropped part of the unseen output, returns what
|
|
694
|
+
* the buffer still holds — the oldest unseen chars are lost to the cap.
|
|
695
|
+
*/
|
|
696
|
+
static outputSinceSnapshot(buffer, snapshotTotalChars) {
|
|
697
|
+
const { outputLines } = buffer;
|
|
698
|
+
const newChars = buffer.evictedChars + buffer.bufferedChars - snapshotTotalChars;
|
|
699
|
+
if (newChars <= 0 || outputLines.length === 0) {
|
|
700
|
+
return ''; // No new output
|
|
701
|
+
}
|
|
702
|
+
let firstLine = outputLines.length - 1;
|
|
703
|
+
let tailChars = outputLines[firstLine].length; // Joined length of outputLines[firstLine..]
|
|
704
|
+
while (tailChars < newChars && firstLine > 0) {
|
|
705
|
+
firstLine--;
|
|
706
|
+
tailChars += outputLines[firstLine].length + 1; // +1 for its join separator
|
|
707
|
+
}
|
|
708
|
+
const tail = outputLines.slice(firstLine).join('\n');
|
|
709
|
+
return tail.substring(Math.max(0, tail.length - newChars));
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Get a session by PID
|
|
713
|
+
* @param pid Process ID
|
|
714
|
+
* @returns The session or undefined if not found
|
|
715
|
+
*/
|
|
716
|
+
getSession(pid) {
|
|
717
|
+
return this.sessions.get(pid);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* End a session: its shell and every process the command started (see
|
|
721
|
+
* terminateProcessTree). Killing only the shell left the program running.
|
|
722
|
+
* Resolves once they are gone: 'terminated', or 'failed' if some could not
|
|
723
|
+
* be ended; 'not_found' when no session runs as `pid`. Never rejects.
|
|
724
|
+
*/
|
|
725
|
+
async forceTerminate(pid) {
|
|
726
|
+
const session = this.sessions.get(pid);
|
|
727
|
+
if (!session) {
|
|
728
|
+
return 'not_found';
|
|
729
|
+
}
|
|
730
|
+
if (!session.termination) {
|
|
731
|
+
// Calls made while it runs share it; a failed one is dropped, so the next call tries again
|
|
732
|
+
session.termination = terminateProcessTree(session.process).then((ended) => {
|
|
733
|
+
if (!ended)
|
|
734
|
+
session.termination = undefined;
|
|
735
|
+
return ended;
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
if (await session.termination) {
|
|
739
|
+
return 'terminated';
|
|
740
|
+
}
|
|
741
|
+
capture('server_request_error', { error: 'Process tree termination failed', message: `Failed to terminate process ${pid}:` });
|
|
742
|
+
return 'failed';
|
|
743
|
+
}
|
|
744
|
+
listActiveSessions() {
|
|
745
|
+
const now = new Date();
|
|
746
|
+
return Array.from(this.sessions.values()).map(session => ({
|
|
747
|
+
pid: session.pid,
|
|
748
|
+
// Waiting for input now, judged from its output like every other state answer
|
|
749
|
+
isBlocked: this.getProcessState(session.pid)?.isWaitingForInput ?? false,
|
|
750
|
+
runtime: now.getTime() - session.startTime.getTime()
|
|
751
|
+
}));
|
|
752
|
+
}
|
|
753
|
+
listCompletedSessions() {
|
|
754
|
+
return Array.from(this.completedSessions.values());
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
export const terminalManager = new TerminalManager();
|