@remcp/runtime 0.2.0 → 0.2.5

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/src/index.mjs CHANGED
@@ -3,7 +3,7 @@ import process from 'node:process';
3
3
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { toolDefinitions } from './catalog.mjs';
6
- import { describeConfig, runtimeConfigDir } from './config.mjs';
6
+ import { describeConfig, configurationError, runtimeConfigDir } from './config.mjs';
7
7
  import { invokeTool } from './invoke.mjs';
8
8
  import { shutdownSessions, startSessionSweeper } from './sessions.mjs';
9
9
  import { flush, setTelemetrySink, shutdownTelemetry, telemetryEnabled } from './telemetry.mjs';
@@ -49,6 +49,15 @@ if (args.includes('--describe')) {
49
49
  process.exit(0);
50
50
  }
51
51
 
52
+ // A configuration file the user cannot read must not be ignored: that is how allowedRoots
53
+ // and an opt-out quietly disappear. Metadata commands above still work, so an operator can
54
+ // inspect the device; the server itself refuses to start.
55
+ const configProblem = configurationError();
56
+ if (configProblem) {
57
+ console.error(`ReMCP runtime refuses to start: ${configProblem}`);
58
+ process.exit(2);
59
+ }
60
+
52
61
  function announceTelemetryOnce() {
53
62
  if (!telemetryEnabled()) return;
54
63
  const marker = path.join(runtimeConfigDir, '.telemetry-notice');
@@ -81,8 +90,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
81
90
  tools: toolDefinitions.map(({ name, title, description, inputSchema, annotations }) => ({ name, title, description, inputSchema, annotations })),
82
91
  }));
83
92
 
84
- server.setRequestHandler(CallToolRequestSchema, async request => {
85
- return invokeTool(request.params.name, request.params.arguments);
93
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
94
+ return invokeTool(request.params.name, request.params.arguments, extra);
86
95
  });
87
96
 
88
97
  // Telemetry leaves this process only as an MCP notification to the agent that started
@@ -91,17 +100,41 @@ setTelemetrySink(async payload => {
91
100
  await server.notification({ method: 'notifications/remcp/telemetry', params: payload });
92
101
  });
93
102
 
103
+ const SHUTDOWN_BUDGET_MS = 1500;
104
+ let shutdownStarted = false;
105
+
94
106
  async function shutdown(code = 0) {
107
+ if (shutdownStarted) return;
108
+ shutdownStarted = true;
109
+ const deadline = Date.now() + SHUTDOWN_BUDGET_MS;
95
110
  shutdownSessions();
96
- try { await flush(); } catch {}
97
- shutdownTelemetry();
111
+ // The SDK gives a closing server about two seconds before it kills the process, so the
112
+ // telemetry flush is bounded and awaited instead of fire-and-forget: an unawaited
113
+ // notification() after close is an unhandled rejection.
114
+ try {
115
+ await Promise.race([flush(), new Promise(resolve => setTimeout(resolve, Math.max(0, deadline - Date.now())))]);
116
+ } catch {}
117
+ await shutdownTelemetry();
98
118
  try { await server.close(); } catch {}
99
119
  process.exit(code);
100
120
  }
101
121
 
122
+ // A dead agent leaves a broken stdout pipe. Without this the process died on an
123
+ // uncaught EPIPE with a stack trace and left its terminal children behind.
124
+ process.stdout.on('error', error => {
125
+ if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') void shutdown(0);
126
+ else console.error(`ReMCP runtime stdout error: ${error instanceof Error ? error.message : String(error)}`);
127
+ });
128
+ process.on('uncaughtException', error => {
129
+ console.error(`ReMCP runtime uncaught exception: ${error instanceof Error ? error.stack || error.message : String(error)}`);
130
+ void shutdown(1);
131
+ });
132
+ process.on('unhandledRejection', reason => {
133
+ console.error(`ReMCP runtime unhandled rejection: ${reason instanceof Error ? reason.stack || reason.message : String(reason)}`);
134
+ });
102
135
  process.on('SIGINT', () => void shutdown(0));
103
136
  process.on('SIGTERM', () => void shutdown(0));
104
- process.on('exit', () => { shutdownSessions(); shutdownTelemetry(); });
137
+ process.on('exit', () => { shutdownSessions(); });
105
138
 
106
139
  const transport = new StdioServerTransport();
107
140
  await server.connect(transport);
package/src/invoke.mjs CHANGED
@@ -12,15 +12,18 @@ function errorKind(error) {
12
12
  return typeof code === 'string' && code ? code.slice(0, 32) : 'runtime_error';
13
13
  }
14
14
 
15
- export async function invokeTool(name, args = {}) {
15
+ export async function invokeTool(name, args = {}, extra = {}) {
16
16
  const started = performance.now();
17
17
  const definition = toolHandlers.get(name);
18
18
  if (!definition) {
19
19
  recordEvent('tool_call', { tool: 'unknown_tool', success: false, errorKind: 'unknown_tool', durationMs: 0 });
20
20
  return text(`Unknown tool: ${name}`, true);
21
21
  }
22
+ if (extra?.signal?.aborted) {
23
+ return text(`Tool ${name} was cancelled by the client before it started.`, true);
24
+ }
22
25
  try {
23
- const result = await definition.handler(args || {});
26
+ const result = await definition.handler(args || {}, extra || {});
24
27
  recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: result?.isError !== true });
25
28
  return result;
26
29
  } catch (error) {
package/src/patch.mjs ADDED
@@ -0,0 +1,95 @@
1
+ import { splitLines } from './util.mjs';
2
+
3
+ // Minimal unified-diff applier. Models produce `--- a/file` / `+++ b/file` patches with
4
+ // `@@ -start,count +start,count @@` hunks; applying them directly is far more reliable
5
+ // than asking a model to re-send whole files or exact blocks.
6
+
7
+ const HUNK_HEADER = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/;
8
+
9
+ export function parseUnifiedDiff(patch) {
10
+ const lines = String(patch).replace(/\r\n/g, '\n').split('\n');
11
+ const files = [];
12
+ let current = null;
13
+ let hunk = null;
14
+ for (const line of lines) {
15
+ if (line.startsWith('--- ')) {
16
+ current = { oldPath: line.slice(4).trim(), newPath: null, hunks: [] };
17
+ files.push(current);
18
+ hunk = null;
19
+ continue;
20
+ }
21
+ if (line.startsWith('+++ ')) {
22
+ if (current) current.newPath = line.slice(4).trim();
23
+ continue;
24
+ }
25
+ const header = line.match(HUNK_HEADER);
26
+ if (header) {
27
+ if (!current) { current = { oldPath: null, newPath: null, hunks: [] }; files.push(current); }
28
+ hunk = {
29
+ oldStart: Number(header[1]),
30
+ oldCount: header[2] === undefined ? 1 : Number(header[2]),
31
+ newStart: Number(header[3]),
32
+ newCount: header[4] === undefined ? 1 : Number(header[4]),
33
+ lines: [],
34
+ };
35
+ current.hunks.push(hunk);
36
+ continue;
37
+ }
38
+ if (!hunk) continue;
39
+ if (line.startsWith('\\')) continue; // ""
40
+ if (line === '' && hunk.lines.length === 0) continue;
41
+ const marker = line[0];
42
+ if (marker === ' ' || marker === '+' || marker === '-') hunk.lines.push({ type: marker, text: line.slice(1) });
43
+ }
44
+ return files.filter(file => file.hunks.length);
45
+ }
46
+
47
+ function normalize(line) {
48
+ return String(line).replace(/[ \t]+/g, ' ').trim();
49
+ }
50
+
51
+ // Find the block a hunk expects, allowing for a few lines of drift and for whitespace
52
+ // differences, the same way patch(1) does with fuzz.
53
+ function locate(lines, hunk) {
54
+ const expected = hunk.lines.filter(entry => entry.type !== '+').map(entry => entry.text);
55
+ if (!expected.length) return { index: hunk.oldStart - 1, fuzz: 0 };
56
+ const candidates = [];
57
+ const anchor = Math.max(0, hunk.oldStart - 1);
58
+ for (let offset = 0; offset <= 200; offset += 1) {
59
+ for (const index of offset === 0 ? [anchor] : [anchor - offset, anchor + offset]) {
60
+ if (index < 0 || index + expected.length > lines.length) continue;
61
+ const window = lines.slice(index, index + expected.length);
62
+ if (window.every((line, position) => line === expected[position])) candidates.push({ index, fuzz: offset });
63
+ else if (window.every((line, position) => normalize(line) === normalize(expected[position]))) candidates.push({ index, fuzz: offset + 1000 });
64
+ }
65
+ if (candidates.length) break;
66
+ }
67
+ if (!candidates.length) return null;
68
+ candidates.sort((a, b) => a.fuzz - b.fuzz || a.index - b.index);
69
+ return candidates[0];
70
+ }
71
+
72
+ export function applyHunks(content, hunks) {
73
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
74
+ const endsWithNewline = /\n$/.test(content);
75
+ const lines = splitLines(content);
76
+ const applied = [];
77
+ const failed = [];
78
+ // Apply from the bottom of the file upwards so earlier hunks keep their line numbers.
79
+ const ordered = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
80
+ for (const hunk of ordered) {
81
+ const found = locate(lines, hunk);
82
+ if (!found) { failed.push(hunk); continue; }
83
+ let cursor = found.index;
84
+ const replacement = [];
85
+ for (const entry of hunk.lines) {
86
+ if (entry.type === ' ') { replacement.push(lines[cursor]); cursor += 1; continue; }
87
+ if (entry.type === '-') { cursor += 1; continue; }
88
+ replacement.push(entry.text);
89
+ }
90
+ lines.splice(found.index, cursor - found.index, ...replacement);
91
+ applied.push({ hunk, fuzz: found.fuzz });
92
+ }
93
+ const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
94
+ return { updated, applied, failed };
95
+ }
package/src/policy.mjs CHANGED
@@ -1,38 +1,84 @@
1
1
  import { runtimeConfig } from './config.mjs';
2
+ import { recordEvent } from './telemetry.mjs';
2
3
  import { fail } from './util.mjs';
3
4
 
4
- // Catastrophic host-level commands that a remote model should never run by accident.
5
- // This is a guardrail, not a sandbox: it protects against obvious mistakes and
6
- // prompt-injected one-liners, not against a determined adversary who already has
7
- // shell access through the paired account.
8
- const DANGEROUS_PATTERNS = [
9
- { id: 'filesystem-format', description: 'formats a filesystem', pattern: /\bmkfs(\.[a-z0-9]+)?\b/i },
10
- { id: 'raw-disk-write', description: 'writes raw data to a block device', pattern: /\bdd\b[^\n]*\bof=\s*\/dev\//i },
11
- { id: 'disk-partition', description: 'repartitions a disk', pattern: /\b(fdisk|sfdisk|cfdisk|parted|diskpart)\b/i },
12
- { id: 'redirect-to-device', description: 'redirects output into a block device', pattern: />>?\s*\/dev\/(sd|hd|vd|nvme|mmcblk|disk)/i },
13
- { id: 'host-power', description: 'powers off or reboots the machine', pattern: /\b(shutdown|reboot|halt|poweroff)\b/i },
14
- { id: 'init-runlevel', description: 'changes the init runlevel', pattern: /\binit\s+[06]\b/i },
15
- { id: 'fork-bomb', description: 'starts a fork bomb', pattern: /:\s*\(\s*\)\s*\{[^}]*\}\s*;\s*:/ },
16
- { id: 'recursive-root-delete', description: 'recursively deletes a root or home path', pattern: /\brm\b(?=[^\n]*\s-[a-z]*r)(?=[^\n]*\s-[a-z]*f)[^\n]*\s(\/|\/\*|~|\$HOME|\$\{HOME\})(\s|$)/i },
17
- { id: 'chmod-root', description: 'recursively rewrites permissions on a root path', pattern: /\bchmod\b[^\n]*\s(\/|\/\*)(\s|$)/i },
18
- { id: 'chown-root', description: 'recursively rewrites ownership on a root path', pattern: /\bchown\b[^\n]*\s(\/|\/\*)(\s|$)/i },
19
- { id: 'history-rewrite', description: 'clears shell history to hide activity', pattern: /\b(history\s+-c|shred\b[^\n]*\.bash_history|>\s*~?\/?\.bash_history)/i },
20
- { id: 'windows-destructive', description: 'destroys Windows system state', pattern: /\b(format|diskpart|bcdedit|cipher\s+\/w)\b/i },
5
+ // Optional hardening rules for catastrophic host-level commands. The default is `allow`:
6
+ // ReMCP is a remote-control tool for computers you own, and the agent needs to be able to
7
+ // do anything you could do at a shell. Deployments that want a safety net can set
8
+ // `dangerousCommands` to `warn` (run and report) or `block` (refuse before running).
9
+ //
10
+ // Rules are matched against the *command word* of each shell segment, so a read-only
11
+ // command that merely mentions a dangerous word (`grep -n format README.md`,
12
+ // `cat notes/shutdown.md`) is not refused.
13
+ const WRAPPERS = new Set(['sudo', 'doas', 'env', 'nohup', 'command', 'builtin', 'time', 'nice', 'ionice', 'stdbuf', 'timeout', 'setsid', 'exec']);
14
+
15
+ const DANGEROUS_RULES = [
16
+ { id: 'filesystem-format', description: 'formats a filesystem', commands: /^mkfs(\.[a-z0-9]+)?$/i, args: () => true },
17
+ { id: 'raw-disk-write', description: 'writes raw data to a block device', commands: /^dd$/i, args: segment => /\bof=\s*\/dev\//i.test(segment) || /\bof=\s*\\\\\.\\/i.test(segment) },
18
+ { id: 'disk-partition', description: 'repartitions a disk', commands: /^(fdisk|sfdisk|cfdisk|parted|diskpart|gdisk)$/i, args: () => true },
19
+ { id: 'redirect-to-device', description: 'redirects output into a block device', commands: null, anySegment: />>?\s*\/dev\/(sd|hd|vd|nvme|mmcblk|disk)/i },
20
+ { id: 'host-power', description: 'powers off or reboots the machine', commands: /^(shutdown|reboot|halt|poweroff|restart-computer|stop-computer)$/i, args: () => true },
21
+ { id: 'init-runlevel', description: 'changes the init runlevel', commands: /^init$/i, args: segment => /\binit\s+[06]\b/i.test(segment) },
22
+ { id: 'fork-bomb', description: 'starts a fork bomb', commands: null, anySegment: /:\s*\(\s*\)\s*\{[^}]*\}\s*;\s*:/ },
23
+ {
24
+ id: 'recursive-root-delete',
25
+ description: 'recursively deletes a root or home path',
26
+ commands: /^rm$/i,
27
+ args: segment => /(^|\s)-[a-z]*r[a-z]*(\s|$)/i.test(segment)
28
+ && /(^|\s)-[a-z]*f[a-z]*(\s|$)/i.test(segment)
29
+ && /(\s)(\/|\/\*|~|~\/\*|\$HOME|\$\{HOME\})(\s|$)/i.test(segment),
30
+ },
31
+ { id: 'chmod-root', description: 'recursively rewrites permissions on a root path', commands: /^chmod$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
32
+ { id: 'chown-root', description: 'recursively rewrites ownership on a root path', commands: /^chown$/i, args: segment => /(\s)(\/|\/\*)(\s|$)/.test(segment) },
33
+ { id: 'history-rewrite', description: 'clears shell history to hide activity', commands: /^(history|shred)$/i, args: segment => /history\s+-c/.test(segment) || /\.(bash_)?history/.test(segment) },
34
+ { id: 'windows-destructive', description: 'destroys Windows system state', commands: /^(format|bcdedit|diskpart)$/i, args: () => true },
35
+ { id: 'windows-cipher-wipe', description: 'wipes free space on a Windows volume', commands: /^cipher$/i, args: segment => /\/w\b/i.test(segment) },
21
36
  ];
22
37
 
23
38
  function normalize(command) {
24
39
  return String(command).replace(/\s+/g, ' ').trim();
25
40
  }
26
41
 
42
+ // Split on shell control operators so each simple command is judged on its own command
43
+ // word instead of on every word in the line.
44
+ function segments(command) {
45
+ return normalize(command)
46
+ .split(/&&|\|\||[;|\n]/)
47
+ .map(part => part.trim())
48
+ .filter(Boolean);
49
+ }
50
+
51
+ function commandWord(segment) {
52
+ const tokens = segment.split(' ').filter(Boolean);
53
+ while (tokens.length) {
54
+ const token = tokens[0];
55
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { tokens.shift(); continue; }
56
+ if (WRAPPERS.has(token.toLowerCase())) {
57
+ tokens.shift();
58
+ while (tokens.length && tokens[0].startsWith('-')) tokens.shift();
59
+ if (token.toLowerCase() === 'timeout' && tokens.length && /^\d+[smhd]?$/.test(tokens[0])) tokens.shift();
60
+ continue;
61
+ }
62
+ break;
63
+ }
64
+ const word = tokens[0] || '';
65
+ return word.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat)$/i, '');
66
+ }
67
+
27
68
  export function checkCommand(command) {
28
69
  const normalized = normalize(command);
70
+ const parts = segments(normalized);
29
71
  const findings = [];
30
72
  for (const blocked of runtimeConfig.blockedCommands) {
31
73
  if (normalized.toLowerCase().includes(blocked.toLowerCase())) findings.push({ id: 'policy', description: blocked, source: 'device-policy' });
32
74
  }
33
75
  if (runtimeConfig.dangerousCommands !== 'allow') {
34
- for (const entry of DANGEROUS_PATTERNS) {
35
- if (entry.pattern.test(normalized)) findings.push({ id: entry.id, description: entry.description, source: 'builtin' });
76
+ for (const part of parts) {
77
+ const word = commandWord(part);
78
+ for (const rule of DANGEROUS_RULES) {
79
+ const matches = rule.anySegment ? rule.anySegment.test(part) : Boolean(word && rule.commands?.test(word) && rule.args(part));
80
+ if (matches) findings.push({ id: rule.id, description: rule.description, source: 'builtin' });
81
+ }
36
82
  }
37
83
  }
38
84
  if (!findings.length) return { warned: false, mode: runtimeConfig.dangerousCommands };
@@ -49,7 +95,12 @@ export function assertAllowedCommand(command) {
49
95
  const detail = verdict.findings.map(item => item.description).join(', ');
50
96
  fail(`Command blocked by ReMCP device policy (${detail}). Set REMCP_RUNTIME_BLOCKED_COMMANDS to adjust the device list, or REMCP_RUNTIME_DANGEROUS_COMMANDS=allow to disable the built-in catastrophic-command guardrail.`);
51
97
  }
98
+ if (verdict.warned) {
99
+ const detail = verdict.findings.map(item => item.description).join(', ');
100
+ recordEvent('policy_warning', { reason: verdict.findings[0]?.id || 'builtin', success: true });
101
+ return { ...verdict, note: `Note: this command matches the optional destructive-command guardrail (${detail}). It was executed.` };
102
+ }
52
103
  return verdict;
53
104
  }
54
105
 
55
- export const dangerousPatternIds = DANGEROUS_PATTERNS.map(entry => entry.id);
106
+ export const dangerousPatternIds = DANGEROUS_RULES.map(entry => entry.id);
package/src/sessions.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import process from 'node:process';
1
2
  import { runtimeConfig } from './config.mjs';
2
3
 
3
4
  const processSessions = new Map();
@@ -5,12 +6,28 @@ const searchSessions = new Map();
5
6
  let searchCounter = 0;
6
7
 
7
8
  const EXITED_SESSION_TTL_MS = 30 * 60 * 1000;
9
+ // A stream with no newlines (a minified bundle, `yes`, a binary accidentally catted)
10
+ // used to grow `partial` without bound: 40 MB measured, then a RangeError inside the
11
+ // data handler killed the runtime. Split long partials and cap the retained characters.
12
+ const MAX_PARTIAL_BYTES = 64 * 1024;
13
+ const MAX_BUFFERED_CHARS = 8 * 1024 * 1024;
8
14
 
9
15
  function trimBuffer(session) {
10
16
  const overflow = session.lines.length - runtimeConfig.maxBufferedLines;
11
- if (overflow <= 0) return;
12
- session.lines.splice(0, overflow);
13
- session.droppedLines += overflow;
17
+ if (overflow > 0) {
18
+ const removed = session.lines.splice(0, overflow);
19
+ for (const line of removed) session.bufferedChars -= line.length;
20
+ session.droppedLines += overflow;
21
+ }
22
+ while (session.bufferedChars > MAX_BUFFERED_CHARS && session.lines.length > 0) {
23
+ session.bufferedChars -= session.lines.shift().length;
24
+ session.droppedLines += 1;
25
+ }
26
+ }
27
+
28
+ function pushLine(session, line) {
29
+ session.lines.push(line);
30
+ session.bufferedChars += line.length;
14
31
  }
15
32
 
16
33
  function notify(session) {
@@ -49,6 +66,7 @@ export function createProcessSession({ pid, child, command, shell }) {
49
66
  partial: '',
50
67
  lastPartialRead: null,
51
68
  droppedLines: 0,
69
+ bufferedChars: 0,
52
70
  cursor: 0,
53
71
  exitCode: null,
54
72
  signal: null,
@@ -60,25 +78,35 @@ export function createProcessSession({ pid, child, command, shell }) {
60
78
  return session;
61
79
  }
62
80
 
81
+ function drainPartial(session) { while (session.partial.length > MAX_PARTIAL_BYTES) {
82
+ pushLine(session, session.partial.slice(0, MAX_PARTIAL_BYTES));
83
+ session.partial = session.partial.slice(MAX_PARTIAL_BYTES);
84
+ session.partialSplit = true;
85
+ }
86
+ }
87
+
63
88
  export function appendProcessOutput(session, chunk) {
64
89
  const combined = session.partial + chunk;
65
90
  const parts = combined.split('\n');
66
91
  session.partial = parts.pop() ?? '';
67
- for (const line of parts) session.lines.push(line);
92
+ for (const line of parts) pushLine(session, line);
93
+ drainPartial(session);
68
94
  trimBuffer(session);
69
95
  session.lastActivityAt = Date.now();
70
96
  notify(session);
71
97
  }
72
98
 
73
99
  export function markProcessExited(session, code, signal) {
100
+ if (session.exited) return;
74
101
  if (session.partial) {
75
- session.lines.push(session.partial);
102
+ pushLine(session, session.partial);
76
103
  session.partial = '';
77
104
  }
78
105
  session.exited = true;
79
106
  session.exitCode = code;
80
107
  session.signal = signal;
81
108
  session.finishedAt = Date.now();
109
+ trimBuffer(session);
82
110
  notify(session);
83
111
  }
84
112
 
@@ -100,7 +128,8 @@ export function totalLines(session) {
100
128
  }
101
129
 
102
130
  export function readNewOutput(session) {
103
- const complete = session.lines.slice(Math.max(0, session.cursor - session.droppedLines));
131
+ const from = Math.max(0, session.cursor - session.droppedLines);
132
+ const complete = session.lines.slice(from);
104
133
  session.cursor = session.droppedLines + session.lines.length;
105
134
  const parts = [...complete];
106
135
  if (session.partial && session.partial !== session.lastPartialRead) parts.push(session.partial);
@@ -108,6 +137,13 @@ export function readNewOutput(session) {
108
137
  return parts;
109
138
  }
110
139
 
140
+ export function hasNewOutput(session) {
141
+ if (session.cursor < session.droppedLines + session.lines.length) return true;
142
+ return Boolean(session.partial && session.partial !== session.lastPartialRead);
143
+ }
144
+
145
+ // A ranged read is a peek: it must not consume the new-output cursor, or reading a tail
146
+ // makes the lines before it undeliverable.
111
147
  export function readOutputRange(session, offset, length) {
112
148
  const snapshot = session.lines.concat(session.partial ? [session.partial] : []);
113
149
  const total = session.droppedLines + snapshot.length;
@@ -121,7 +157,6 @@ export function readOutputRange(session, offset, length) {
121
157
  start = Math.max(0, Math.min(requested - session.droppedLines, snapshot.length));
122
158
  end = Math.min(snapshot.length, start + Math.max(1, Math.trunc(length || 200)));
123
159
  }
124
- session.cursor = session.droppedLines + session.lines.length;
125
160
  return { slice: snapshot.slice(start, end), start: session.droppedLines + start + 1, end: session.droppedLines + end, total };
126
161
  }
127
162
 
@@ -204,12 +239,25 @@ export function startSessionSweeper(intervalMs = 5 * 60 * 1000) {
204
239
  sweepTimer.unref?.();
205
240
  }
206
241
 
242
+ // The child is spawned detached on POSIX so it owns a process group; killing the group
243
+ // takes the whole tree with it (`sleep 20 | cat` used to survive `force_terminate`).
244
+ export function killSessionTree(session, signal = 'SIGKILL') {
245
+ const child = session.child;
246
+ if (!child || session.exited) return false;
247
+ try {
248
+ if (process.platform === 'win32') return child.kill(signal);
249
+ if (child.pid) process.kill(-child.pid, signal);
250
+ else return child.kill(signal);
251
+ return true;
252
+ } catch {
253
+ try { return child.kill(signal); } catch { return false; }
254
+ }
255
+ }
256
+
207
257
  export function shutdownSessions() {
208
258
  if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
209
259
  for (const session of processSessions.values()) {
210
- if (!session.exited) {
211
- try { session.child.kill('SIGKILL'); } catch {}
212
- }
260
+ if (!session.exited) killSessionTree(session, 'SIGKILL');
213
261
  }
214
262
  for (const session of searchSessions.values()) {
215
263
  if (session.status === 'running' && session.cancel) {
package/src/telemetry.mjs CHANGED
@@ -135,12 +135,26 @@ export async function flush() {
135
135
  }
136
136
  }
137
137
 
138
- export function shutdownTelemetry() {
138
+ export async function shutdownTelemetry() {
139
139
  if (state.timer) {
140
140
  clearInterval(state.timer);
141
141
  state.timer = null;
142
142
  }
143
- try { state.sink?.({ runtimeVersion: VERSION, platform: process.platform, arch: process.arch, name: runtimeConfig.name, events: state.buffer.splice(0, state.buffer.length) }); } catch {}
143
+ const sink = state.sink;
144
+ state.sink = null;
145
+ const events = state.buffer.splice(0, state.buffer.length);
146
+ if (!sink || !events.length) return;
147
+ try {
148
+ // Bounded: the SDK closes the transport about two seconds after shutdown starts, and a
149
+ // notification sent after that is a rejected promise nobody awaits.
150
+ await Promise.race([
151
+ sink({ runtimeVersion: VERSION, platform: process.platform, arch: process.arch, name: runtimeConfig.name, events }),
152
+ new Promise(resolve => setTimeout(resolve, 500)),
153
+ ]);
154
+ state.sent += events.length;
155
+ } catch {
156
+ state.dropped += events.length;
157
+ }
144
158
  }
145
159
 
146
160
  export function resetTelemetryForTests() {