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.
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepseek-pp-shell-host",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Native Messaging Shell MCP host installer for DeepSeek++",
5
5
  "type": "module",
6
6
  "private": false,