deepseek-pp-shell-host 1.0.1 → 1.0.2
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/native/shell-mcp-host.mjs +391 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync, spawn } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import {
|
|
@@ -79,6 +80,22 @@ const WINDOWS_POWERSHELL_UTF8_PREAMBLE = [
|
|
|
79
80
|
'try { chcp.com 65001 > $null } catch {}',
|
|
80
81
|
].join('; ');
|
|
81
82
|
|
|
83
|
+
// --- Persistent shell session ---
|
|
84
|
+
//
|
|
85
|
+
// Each session keeps one long-lived shell child open and pipes commands to its
|
|
86
|
+
// stdin. A randomized end-marker is appended after each command so the host can
|
|
87
|
+
// detect where a single command's output ends on stdout and read back the exit
|
|
88
|
+
// code. This makes resident-mode tools (e.g. OfficeCLI) survive across separate
|
|
89
|
+
// tool calls instead of dying with a one-shot `shell_exec` shell (issue #230).
|
|
90
|
+
//
|
|
91
|
+
// Why delimiter-based instead of a PTY: the host ships as a single .mjs copied
|
|
92
|
+
// into app-data with no node_modules, so native deps (node-pty/conPTY) would
|
|
93
|
+
// force per-platform prebuilt binaries and double the install footprint.
|
|
94
|
+
// Pure child_process + sentinel is the established pattern for this constraint.
|
|
95
|
+
const SESSION_IDLE_TIMEOUT_MS = 300_000; // 5min; aligns with resident-tool idle windows
|
|
96
|
+
const SESSION_MAX_OUTPUT_BYTES = MAX_OUTPUT_BYTES;
|
|
97
|
+
const SESSION_MARKER_PREFIX = '__DPP_SESSION_END__';
|
|
98
|
+
|
|
82
99
|
const TOOL_DEFINITIONS = [
|
|
83
100
|
{
|
|
84
101
|
name: 'shell_exec',
|
|
@@ -159,6 +176,51 @@ const TOOL_DEFINITIONS = [
|
|
|
159
176
|
},
|
|
160
177
|
annotations: { operation: 'read', risk: 'low' },
|
|
161
178
|
},
|
|
179
|
+
{
|
|
180
|
+
name: 'shell_session_begin',
|
|
181
|
+
title: 'Open Persistent Shell Session',
|
|
182
|
+
description: 'Start a long-lived shell session whose working directory, environment, and resident child processes (e.g. OfficeCLI resident mode) survive across later shell_session_exec calls. Use it for multi-step workflows where separate shell_exec calls would lose state. Returns a session_id to pass to subsequent calls.',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: {
|
|
186
|
+
cwd: { type: 'string', description: 'Initial working directory. Defaults to user home.' },
|
|
187
|
+
env: { type: 'object', additionalProperties: { type: 'string' }, description: 'Additional environment variables to set on the session shell.' },
|
|
188
|
+
shell: { type: 'string', description: 'Shell binary to use. Defaults to the shell reported by shell_status.' },
|
|
189
|
+
},
|
|
190
|
+
additionalProperties: false,
|
|
191
|
+
},
|
|
192
|
+
annotations: { operation: 'write', risk: 'high' },
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: 'shell_session_exec',
|
|
196
|
+
title: 'Run Command in Persistent Shell Session',
|
|
197
|
+
description: 'Run a command inside a previously opened shell session (shell_session_begin). State (cwd, exports, resident processes) carries over between calls. Returns stdout, stderr, and exit code like shell_exec. Sessions auto-close after an idle timeout.',
|
|
198
|
+
inputSchema: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {
|
|
201
|
+
session_id: { type: 'string', description: 'Session id returned by shell_session_begin.' },
|
|
202
|
+
command: { type: 'string', description: 'The shell command to execute in the session.' },
|
|
203
|
+
timeout_ms: { type: 'integer', minimum: 1000, maximum: 600000, description: 'Timeout in milliseconds. Default 120000.' },
|
|
204
|
+
},
|
|
205
|
+
required: ['session_id', 'command'],
|
|
206
|
+
additionalProperties: false,
|
|
207
|
+
},
|
|
208
|
+
annotations: { operation: 'write', risk: 'high' },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'shell_session_end',
|
|
212
|
+
title: 'Close Persistent Shell Session',
|
|
213
|
+
description: 'Close a persistent shell session opened by shell_session_begin and release its child process. After this, the session_id is no longer valid.',
|
|
214
|
+
inputSchema: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
session_id: { type: 'string', description: 'Session id returned by shell_session_begin.' },
|
|
218
|
+
},
|
|
219
|
+
required: ['session_id'],
|
|
220
|
+
additionalProperties: false,
|
|
221
|
+
},
|
|
222
|
+
annotations: { operation: 'write', risk: 'medium' },
|
|
223
|
+
},
|
|
162
224
|
];
|
|
163
225
|
|
|
164
226
|
// --- Native messaging framing (4-byte LE length prefix) ---
|
|
@@ -337,6 +399,18 @@ async function handleCallTool(id, params) {
|
|
|
337
399
|
return jsonRpcResult(id, createLocalFolderPickResult(args));
|
|
338
400
|
}
|
|
339
401
|
|
|
402
|
+
if (name === 'shell_session_begin') {
|
|
403
|
+
return jsonRpcResult(id, await beginShellSession(args));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (name === 'shell_session_exec') {
|
|
407
|
+
return jsonRpcResult(id, await execInShellSession(args));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (name === 'shell_session_end') {
|
|
411
|
+
return jsonRpcResult(id, await endShellSession(args));
|
|
412
|
+
}
|
|
413
|
+
|
|
340
414
|
return jsonRpcError(id, -32602, `Unknown tool: ${name}`);
|
|
341
415
|
}
|
|
342
416
|
|
|
@@ -804,6 +878,314 @@ function execCommand(command, { cwd, env, timeoutMs }) {
|
|
|
804
878
|
});
|
|
805
879
|
}
|
|
806
880
|
|
|
881
|
+
// --- Persistent shell session ---
|
|
882
|
+
|
|
883
|
+
const shellSessions = new Map();
|
|
884
|
+
|
|
885
|
+
function createPersistentShellArgs(shell) {
|
|
886
|
+
// Keep the shell reading commands from stdin so subsequent commands reuse the
|
|
887
|
+
// same process. `-NonInteractive` on Windows keeps PowerShell from printing
|
|
888
|
+
// prompts; `-Command -` makes it read a script from stdin. POSIX shells with
|
|
889
|
+
// no script argument and `-s` read commands from stdin — crucially the arg
|
|
890
|
+
// array must be empty so argv[0] (the binary path, supplied by spawn) is the
|
|
891
|
+
// only positional and the shell doesn't try to execute a stray arg as a script.
|
|
892
|
+
if (platform() === 'win32') {
|
|
893
|
+
return ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'];
|
|
894
|
+
}
|
|
895
|
+
return ['-s'];
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function buildSessionEndMarkerLine(token) {
|
|
899
|
+
// Print the marker + exit code. POSIX uses $?; PowerShell uses $LASTEXITCODE
|
|
900
|
+
// (falls back to 0 when no native command ran, which matches shell semantics
|
|
901
|
+
// for pure-shell commands). The random token makes accidental marker collisions
|
|
902
|
+
// in command output effectively impossible.
|
|
903
|
+
if (platform() === 'win32') {
|
|
904
|
+
return `Write-Output '${SESSION_MARKER_PREFIX}${token}__:'$LASTEXITCODE`;
|
|
905
|
+
}
|
|
906
|
+
return `printf '__DPP_SESSION_END__%s__:%s\\n' "${token}" "$?"`;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function beginShellSession(args) {
|
|
910
|
+
const requestedShell = typeof args?.shell === 'string' && args.shell.trim() ? args.shell.trim() : null;
|
|
911
|
+
const cwd = typeof args?.cwd === 'string' && args.cwd.trim() ? args.cwd.trim() : homedir();
|
|
912
|
+
const env = createChildEnv(args?.env);
|
|
913
|
+
const shellBin = requestedShell || DEFAULT_SHELL;
|
|
914
|
+
const shellArgs = createPersistentShellArgs(requestedShell);
|
|
915
|
+
|
|
916
|
+
let child;
|
|
917
|
+
try {
|
|
918
|
+
// Run the session shell as its own process group leader so we can tear down
|
|
919
|
+
// the whole tree — shell + resident grandchildren (e.g. an OfficeCLI
|
|
920
|
+
// resident process) — with a single negative-PID kill. Without this, a
|
|
921
|
+
// SIGKILL to the shell alone leaves the resident as an orphan holding the
|
|
922
|
+
// document file lock, which is exactly the failure mode in issue #230.
|
|
923
|
+
child = spawn(shellBin, shellArgs, {
|
|
924
|
+
cwd,
|
|
925
|
+
env,
|
|
926
|
+
shell: false,
|
|
927
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
928
|
+
windowsHide: true,
|
|
929
|
+
detached: true,
|
|
930
|
+
});
|
|
931
|
+
} catch (err) {
|
|
932
|
+
return {
|
|
933
|
+
isError: true,
|
|
934
|
+
content: [{ type: 'text', text: `Failed to start persistent shell: ${err.message}` }],
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const sessionId = randomUUID();
|
|
939
|
+
const session = {
|
|
940
|
+
id: sessionId,
|
|
941
|
+
child,
|
|
942
|
+
shell: shellBin,
|
|
943
|
+
cwd,
|
|
944
|
+
env,
|
|
945
|
+
createdAt: Date.now(),
|
|
946
|
+
lastActivityAt: Date.now(),
|
|
947
|
+
idleTimer: null,
|
|
948
|
+
closed: false,
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
// Between commands the shell blocks reading stdin and emits nothing, so we do
|
|
952
|
+
// not attach a background drain listener — runInSession takes exclusive
|
|
953
|
+
// ownership of stdout/stderr for the duration of each command. A second 'data'
|
|
954
|
+
// listener here would race with it and swallow the marker bytes.
|
|
955
|
+
child.on('exit', () => {
|
|
956
|
+
session.closed = true;
|
|
957
|
+
if (shellSessions.has(sessionId)) closeShellSession(sessionId, 'process_exited');
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
shellSessions.set(sessionId, session);
|
|
961
|
+
armSessionIdleTimer(session);
|
|
962
|
+
|
|
963
|
+
return {
|
|
964
|
+
content: [{ type: 'text', text: `Persistent shell session ${sessionId} started (${shellBin}).` }],
|
|
965
|
+
structuredContent: {
|
|
966
|
+
ok: true,
|
|
967
|
+
data: {
|
|
968
|
+
session_id: sessionId,
|
|
969
|
+
shell: shellBin,
|
|
970
|
+
cwd,
|
|
971
|
+
pid: typeof child.pid === 'number' ? child.pid : null,
|
|
972
|
+
idleTimeoutMs: SESSION_IDLE_TIMEOUT_MS,
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function armSessionIdleTimer(session) {
|
|
979
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
980
|
+
session.idleTimer = setTimeout(() => {
|
|
981
|
+
closeShellSession(session.id, 'idle_timeout');
|
|
982
|
+
}, SESSION_IDLE_TIMEOUT_MS);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function killSessionProcessGroup(child) {
|
|
986
|
+
if (!child || child.exitCode !== null || typeof child.pid !== 'number') return;
|
|
987
|
+
// POSIX: the session shell is a process-group leader (detached:true), so a
|
|
988
|
+
// negative PID signal reaches the whole tree — including resident
|
|
989
|
+
// grandchildren (OfficeCLI resident, watch servers) that would otherwise
|
|
990
|
+
// outlive the shell and keep the document locked.
|
|
991
|
+
if (platform() !== 'win32') {
|
|
992
|
+
try { process.kill(-child.pid, 'SIGKILL'); return; } catch {}
|
|
993
|
+
}
|
|
994
|
+
// Windows has no process groups; fall back to killing the shell. Resident
|
|
995
|
+
// grandchildren there typically reattach when the next command opens the file,
|
|
996
|
+
// and Windows Job Objects would be needed for true tree kill (out of scope).
|
|
997
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function closeShellSession(sessionId, reason) {
|
|
1001
|
+
const session = shellSessions.get(sessionId);
|
|
1002
|
+
if (!session) return;
|
|
1003
|
+
shellSessions.delete(sessionId);
|
|
1004
|
+
session.closed = true;
|
|
1005
|
+
if (session.idleTimer) {
|
|
1006
|
+
clearTimeout(session.idleTimer);
|
|
1007
|
+
session.idleTimer = null;
|
|
1008
|
+
}
|
|
1009
|
+
killSessionProcessGroup(session.child);
|
|
1010
|
+
process.stderr.write(`[shell-mcp-host] Session ${sessionId} closed (${reason}).\n`);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function execInShellSession(args) {
|
|
1014
|
+
const sessionId = args?.session_id;
|
|
1015
|
+
const command = args?.command;
|
|
1016
|
+
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
1017
|
+
return { isError: true, content: [{ type: 'text', text: 'session_id is required.' }] };
|
|
1018
|
+
}
|
|
1019
|
+
if (typeof command !== 'string' || command.trim().length === 0) {
|
|
1020
|
+
return { isError: true, content: [{ type: 'text', text: 'command is required and must be a non-empty string.' }] };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const session = shellSessions.get(sessionId);
|
|
1024
|
+
if (!session) {
|
|
1025
|
+
return {
|
|
1026
|
+
isError: true,
|
|
1027
|
+
content: [{ type: 'text', text: `Session not found: ${sessionId}. It may have been closed, expired (idle timeout), or its shell exited. Open a new session with shell_session_begin.` }],
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
if (session.closed) {
|
|
1031
|
+
shellSessions.delete(sessionId);
|
|
1032
|
+
return {
|
|
1033
|
+
isError: true,
|
|
1034
|
+
content: [{ type: 'text', text: `Session shell has exited: ${sessionId}. Open a new session with shell_session_begin.` }],
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const timeoutMs = typeof args.timeout_ms === 'number' && args.timeout_ms >= 1000
|
|
1039
|
+
? Math.min(args.timeout_ms, 600_000)
|
|
1040
|
+
: DEFAULT_TIMEOUT_MS;
|
|
1041
|
+
|
|
1042
|
+
// Refresh idle window on activity.
|
|
1043
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
1044
|
+
|
|
1045
|
+
try {
|
|
1046
|
+
const result = await runInSession(session, command, { timeoutMs });
|
|
1047
|
+
session.lastActivityAt = Date.now();
|
|
1048
|
+
armSessionIdleTimer(session);
|
|
1049
|
+
return {
|
|
1050
|
+
content: [{ type: 'text', text: formatExecSummary(result) }],
|
|
1051
|
+
structuredContent: { ok: result.exitCode === 0, data: result },
|
|
1052
|
+
isError: result.exitCode !== 0,
|
|
1053
|
+
};
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
// A timeout or shell crash means the session is unrecoverable.
|
|
1056
|
+
closeShellSession(sessionId, 'exec_failed');
|
|
1057
|
+
return { isError: true, content: [{ type: 'text', text: err.message }] };
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function runInSession(session, command, { timeoutMs }) {
|
|
1062
|
+
return new Promise((resolve, reject) => {
|
|
1063
|
+
const { child } = session;
|
|
1064
|
+
const token = randomUUID();
|
|
1065
|
+
const markerLine = buildSessionEndMarkerLine(token);
|
|
1066
|
+
const markerText = `${SESSION_MARKER_PREFIX}${token}__:`;
|
|
1067
|
+
|
|
1068
|
+
// One write: the user's command, then the exit-code marker. POSIX shells
|
|
1069
|
+
// execute line by line; PowerShell in `-Command -` mode reads the whole
|
|
1070
|
+
// stdin script but still runs statements in order.
|
|
1071
|
+
const script = platform() === 'win32'
|
|
1072
|
+
? `${command}\n${markerLine}\n`
|
|
1073
|
+
: `${command}\n${markerLine}\n`;
|
|
1074
|
+
try {
|
|
1075
|
+
child.stdin.write(script);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
reject(new Error(`Failed to write to session shell: ${err.message}`));
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const stdoutChunks = [];
|
|
1082
|
+
let stdoutBytes = 0;
|
|
1083
|
+
let stderrText = '';
|
|
1084
|
+
let stderrBytes = 0;
|
|
1085
|
+
let resolved = false;
|
|
1086
|
+
let timedOut = false;
|
|
1087
|
+
|
|
1088
|
+
const timer = setTimeout(() => {
|
|
1089
|
+
timedOut = true;
|
|
1090
|
+
detach();
|
|
1091
|
+
reject(new Error(`Command timed out after ${timeoutMs} ms; session shell killed.`));
|
|
1092
|
+
}, timeoutMs);
|
|
1093
|
+
|
|
1094
|
+
function detach() {
|
|
1095
|
+
child.stdout.off('data', onStdout);
|
|
1096
|
+
child.stderr.off('data', onStderr);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function onStderr(chunk) {
|
|
1100
|
+
if (stderrBytes < SESSION_MAX_OUTPUT_BYTES) {
|
|
1101
|
+
const remaining = SESSION_MAX_OUTPUT_BYTES - stderrBytes;
|
|
1102
|
+
stderrText += chunk.toString('utf8').slice(0, remaining);
|
|
1103
|
+
}
|
|
1104
|
+
stderrBytes += chunk.length;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function onStdout(chunk) {
|
|
1108
|
+
const text = chunk.toString('utf8');
|
|
1109
|
+
// Scan for the marker line; accumulate everything before it as stdout.
|
|
1110
|
+
const combined = stdoutChunks.concat([text]).join('');
|
|
1111
|
+
const markerIdx = combined.indexOf(markerText);
|
|
1112
|
+
if (markerIdx === -1) {
|
|
1113
|
+
// Not yet; keep what we have under the byte budget.
|
|
1114
|
+
stdoutChunks.length = 0;
|
|
1115
|
+
stdoutChunks.push(combined);
|
|
1116
|
+
stdoutBytes = Buffer.byteLength(combined, 'utf8');
|
|
1117
|
+
if (stdoutBytes > SESSION_MAX_OUTPUT_BYTES) {
|
|
1118
|
+
stdoutChunks[0] = stdoutChunks[0].slice(0, SESSION_MAX_OUTPUT_BYTES);
|
|
1119
|
+
}
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Marker found. Parse exit code from the rest of the marker line.
|
|
1124
|
+
resolved = true;
|
|
1125
|
+
clearTimeout(timer);
|
|
1126
|
+
detach();
|
|
1127
|
+
|
|
1128
|
+
const before = combined.slice(0, markerIdx);
|
|
1129
|
+
const afterMarker = combined.slice(markerIdx + markerText.length);
|
|
1130
|
+
const newlineIdx = afterMarker.indexOf('\n');
|
|
1131
|
+
const exitToken = newlineIdx === -1 ? afterMarker.trim() : afterMarker.slice(0, newlineIdx).trim();
|
|
1132
|
+
// Exit token may carry a leading ':' already consumed; strip any non-digit trailing chars.
|
|
1133
|
+
const exitMatch = exitToken.match(/^(-?\d+)/);
|
|
1134
|
+
const exitCode = exitMatch ? Number.parseInt(exitMatch[1], 10) : 0;
|
|
1135
|
+
|
|
1136
|
+
const stdout = before.replace(/\r?\n$/, '');
|
|
1137
|
+
resolve({
|
|
1138
|
+
command,
|
|
1139
|
+
shell: session.shell,
|
|
1140
|
+
session_id: session.id,
|
|
1141
|
+
exitCode: timedOut ? -1 : exitCode,
|
|
1142
|
+
stdout,
|
|
1143
|
+
stderr: stderrText,
|
|
1144
|
+
truncated: stdoutBytes > SESSION_MAX_OUTPUT_BYTES || stderrBytes > SESSION_MAX_OUTPUT_BYTES,
|
|
1145
|
+
timedOut,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
child.stdout.on('data', onStdout);
|
|
1150
|
+
child.stderr.on('data', onStderr);
|
|
1151
|
+
|
|
1152
|
+
// If the command kills the shell itself (e.g. `exit N`), treat the shell's
|
|
1153
|
+
// exit code as the command's result rather than a generic failure. The
|
|
1154
|
+
// session is dead either way — caller will get "session not found" on reuse.
|
|
1155
|
+
const onExit = (exitCode) => {
|
|
1156
|
+
if (resolved || timedOut) return;
|
|
1157
|
+
clearTimeout(timer);
|
|
1158
|
+
detach();
|
|
1159
|
+
child.off('exit', onExit);
|
|
1160
|
+
resolve({
|
|
1161
|
+
command,
|
|
1162
|
+
shell: session.shell,
|
|
1163
|
+
session_id: session.id,
|
|
1164
|
+
exitCode: typeof exitCode === 'number' ? exitCode : 1,
|
|
1165
|
+
stdout: stdoutChunks.join('').replace(/\r?\n$/, ''),
|
|
1166
|
+
stderr: stderrText,
|
|
1167
|
+
truncated: stdoutBytes > SESSION_MAX_OUTPUT_BYTES || stderrBytes > SESSION_MAX_OUTPUT_BYTES,
|
|
1168
|
+
timedOut: false,
|
|
1169
|
+
shellExited: true,
|
|
1170
|
+
});
|
|
1171
|
+
};
|
|
1172
|
+
child.once('exit', onExit);
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function endShellSession(args) {
|
|
1177
|
+
const sessionId = args?.session_id;
|
|
1178
|
+
if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
|
|
1179
|
+
return { isError: true, content: [{ type: 'text', text: 'session_id is required.' }] };
|
|
1180
|
+
}
|
|
1181
|
+
const existed = shellSessions.has(sessionId);
|
|
1182
|
+
closeShellSession(sessionId, 'ended');
|
|
1183
|
+
return {
|
|
1184
|
+
content: [{ type: 'text', text: existed ? `Session ${sessionId} closed.` : `Session ${sessionId} was already gone (ignored).` }],
|
|
1185
|
+
structuredContent: { ok: true, data: { session_id: sessionId, closed: existed } },
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
807
1189
|
async function createPythonStatusResult() {
|
|
808
1190
|
const status = await detectPythonStatus();
|
|
809
1191
|
const text = status.available
|
|
@@ -1418,9 +1800,18 @@ async function main() {
|
|
|
1418
1800
|
await writeNativeMessage(jsonRpcError(null, -32603, err.message || 'Internal error'));
|
|
1419
1801
|
}
|
|
1420
1802
|
}
|
|
1803
|
+
|
|
1804
|
+
// stdin closed (extension gone): reap every persistent shell so we don't leak
|
|
1805
|
+
// orphaned children bound to a dead host.
|
|
1806
|
+
for (const sessionId of [...shellSessions.keys()]) {
|
|
1807
|
+
closeShellSession(sessionId, 'host_shutdown');
|
|
1808
|
+
}
|
|
1421
1809
|
}
|
|
1422
1810
|
|
|
1423
1811
|
main().catch((err) => {
|
|
1424
1812
|
process.stderr.write(`[shell-mcp-host] Fatal: ${err.message || err}\n`);
|
|
1813
|
+
for (const sessionId of [...shellSessions.keys()]) {
|
|
1814
|
+
closeShellSession(sessionId, 'host_shutdown');
|
|
1815
|
+
}
|
|
1425
1816
|
process.exit(1);
|
|
1426
1817
|
});
|