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.
Files changed (93) hide show
  1. package/dist/src/cli/mac-mcp-gateway.js +4 -3
  2. package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
  3. package/dist/src/cli/starmux.js +1 -1
  4. package/dist/src/cli/starmux.js.map +1 -1
  5. package/dist/src/runtime-paths.js +3 -3
  6. package/dist/src/runtime-paths.js.map +1 -1
  7. package/dist/vendor/desktop-commander/LICENSE +21 -0
  8. package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
  9. package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
  10. package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
  11. package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
  12. package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
  13. package/dist/vendor/desktop-commander/dist/config.js +19 -0
  14. package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
  15. package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
  16. package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
  17. package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
  18. package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
  19. package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
  20. package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
  21. package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
  22. package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
  23. package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
  24. package/dist/vendor/desktop-commander/dist/index.js +88 -0
  25. package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
  26. package/dist/vendor/desktop-commander/dist/server.js +1414 -0
  27. package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
  28. package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
  29. package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
  30. package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
  31. package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
  32. package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
  33. package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
  34. package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
  35. package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
  36. package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
  37. package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
  38. package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
  39. package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
  40. package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
  41. package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
  42. package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
  43. package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
  44. package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
  45. package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
  46. package/dist/vendor/desktop-commander/dist/types.js +1 -0
  47. package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
  48. package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
  49. package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
  50. package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
  51. package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
  52. package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
  53. package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
  54. package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
  55. package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
  56. package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
  57. package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
  58. package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
  59. package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
  60. package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
  61. package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
  62. package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
  63. package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
  64. package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
  65. package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
  66. package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
  67. package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
  68. package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
  69. package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
  70. package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
  71. package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
  72. package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
  73. package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
  74. package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
  75. package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
  76. package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
  77. package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
  78. package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
  79. package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
  80. package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
  81. package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
  82. package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
  83. package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
  84. package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
  85. package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
  86. package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
  87. package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
  88. package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
  89. package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
  90. package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
  91. package/dist/vendor/desktop-commander/dist/version.js +1 -0
  92. package/dist/vendor/desktop-commander/package.json +15 -0
  93. 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();