sentinelayer-cli 0.46.2 → 0.46.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.46.2",
3
+ "version": "0.46.4",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -78,7 +78,6 @@ import {
78
78
  getListenerProcessStatus,
79
79
  LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS,
80
80
  removeListenerPidRecord,
81
- requestListenerProcessStop,
82
81
  summarizeLocalListenerProcesses,
83
82
  stopMatchingListenerProcesses,
84
83
  writeListenerPidRecord,
@@ -5515,33 +5514,30 @@ export function registerSessionCommand(program) {
5515
5514
  );
5516
5515
  }
5517
5516
  if (existingListener.running && options.force) {
5518
- const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId);
5519
- if (!stopMatchesResult.ok) {
5520
- // The listener being replaced is known (a verified pid record or a scan moments
5521
- // ago), so stopping it and starting this one never adds a listener. Only the
5522
- // cleanup of extra copies is skipped, and that is said rather than assumed.
5523
- console.error(
5524
- pc.yellow(
5525
- `The local process check did not complete (${stopMatchesResult.reason}); replacing listener pid ${existingListener.pid} only. Other copies of this listener, if any, were not checked.`,
5526
- ),
5517
+ // --force never stops a process this command runs under, and never on a guess:
5518
+ // it refuses, and stops and starts nothing, unless the takeover scan confirms
5519
+ // what it stops.
5520
+ const refuseTakeover = ({ pid, reason }) =>
5521
+ new Error(
5522
+ reason === "ancestor_listener"
5523
+ ? `A session listener is already running for session ${normalizedSessionId} as ${agentId} (pid ${pid}), and this command runs under it, so --force will not stop it. No listener was stopped or started. Stop that listener first, or use --allow-duplicate if you intentionally need more than one.`
5524
+ : `Could not confirm that pid ${pid} is a session listener --force can replace for session ${normalizedSessionId} as ${agentId} (${reason}). No listener was stopped or started. Run the command again, or use --allow-duplicate to start without this check (it can then run alongside an existing listener).`,
5527
5525
  );
5526
+ if (existingListener.ancestor) {
5527
+ throw refuseTakeover({ pid: existingListener.pid, reason: "ancestor_listener" });
5528
5528
  }
5529
- const scannedStops = stopMatchesResult.ok ? stopMatchesResult.results : [];
5530
- const stoppedPids = new Set(
5531
- scannedStops
5532
- .filter((result) => result.stopped)
5533
- .map((result) => Number(result.pid)),
5534
- );
5535
- let explicitStopResult = null;
5536
- if (existingListener.pid && !stoppedPids.has(Number(existingListener.pid))) {
5537
- explicitStopResult = await requestListenerProcessStop(existingListener.pid);
5529
+ const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId, {
5530
+ listener: existingListener,
5531
+ });
5532
+ if (!stopMatchesResult.ok) {
5533
+ // What the status check found may no longer hold, and an unread table cannot
5534
+ // show which processes this command runs under, so nothing is stopped.
5535
+ throw refuseTakeover({ pid: existingListener.pid, reason: stopMatchesResult.reason });
5536
+ }
5537
+ if (stopMatchesResult.refused) {
5538
+ throw refuseTakeover(stopMatchesResult.refused);
5538
5539
  }
5539
- const failedStops = [
5540
- ...scannedStops.filter((result) => !result.stopped),
5541
- ...(explicitStopResult && !explicitStopResult.stopped
5542
- ? [{ pid: existingListener.pid, ...explicitStopResult }]
5543
- : []),
5544
- ];
5540
+ const failedStops = stopMatchesResult.results.filter((result) => !result.stopped);
5545
5541
  if (failedStops.length > 0) {
5546
5542
  const failed = failedStops[0];
5547
5543
  throw new Error(
@@ -40,6 +40,31 @@ function escapeRegExp(value) {
40
40
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
41
  }
42
42
 
43
+ // The CLI's entry points as package.json "bin" declares them (a test keeps the two in step):
44
+ // the scripts node runs, and the command names npm links to them.
45
+ const CLI_ENTRY_SCRIPTS = ["create-sentinelayer.js", "sentinelayer-cli.js", "sl.js"];
46
+ const CLI_COMMAND_NAMES = ["create-sentinelayer", "sentinel", "sentinelayer-cli", "sl-cli", "slc", "sl"];
47
+
48
+ /**
49
+ * The one test of "this command line runs this CLI": an argument that is a path ending in an
50
+ * entry point (a path separator, the entry name, then the end of the argument). So
51
+ * `.../bin/create-sentinelayer.js` counts, but `xcreate-sentinelayer.js`,
52
+ * `create-sentinelayer.json` and a directory named after a command do not.
53
+ *
54
+ * On POSIX, npm links each command name to its script and the kernel hands node the link path
55
+ * (`node /usr/local/bin/sl ...`), so the command names count there too. On Windows npm's shims
56
+ * start node on the script itself, and the sh shim Git Bash runs stays alive as node's parent
57
+ * with only the command name on its command line, so there only the scripts count.
58
+ *
59
+ * The pattern is valid in JavaScript and in .NET (the Windows scan runs it in PowerShell), and
60
+ * writes its quotes as \x22 and \x27 so it can sit inside a quoted PowerShell string.
61
+ */
62
+ function cliEntryPattern(platform) {
63
+ const entries =
64
+ platform === "win32" ? CLI_ENTRY_SCRIPTS : [...CLI_ENTRY_SCRIPTS, ...CLI_COMMAND_NAMES];
65
+ return `[\\\\/](?:${entries.map(escapeRegExp).join("|")})(?=$|[\\s\\x22\\x27])`;
66
+ }
67
+
43
68
  function commandLineFlagValueMatches(commandLine, flagName, expectedValue) {
44
69
  const expected = normalizeString(expectedValue).toLowerCase();
45
70
  if (!expected) return { found: false, matches: true };
@@ -58,15 +83,20 @@ function commandLineFlagValueMatches(commandLine, flagName, expectedValue) {
58
83
  return { found, matches: false };
59
84
  }
60
85
 
61
- function listenerCommandLineMatches(commandLine, { sessionId = "", agentId = "" } = {}) {
86
+ /**
87
+ * Whether a command line is `session listen` for this session and agent, run through any of
88
+ * this CLI's entry points. `platform` is the system the command line was read on.
89
+ */
90
+ export function listenerCommandLineMatches(
91
+ commandLine,
92
+ { sessionId = "", agentId = "", platform = process.platform } = {},
93
+ ) {
62
94
  const rawCommandLine = normalizeString(commandLine);
63
95
  const normalized = normalizeCommandLine(rawCommandLine);
64
96
  if (!normalized) return false;
65
97
  const normalizedSessionId = normalizeString(sessionId).toLowerCase();
66
98
  const normalizedAgentId = normalizeString(agentId).toLowerCase();
67
- const hasCliEntry =
68
- normalized.includes("sentinelayer-cli") ||
69
- /[\\/](?:sl|sentinelayer-cli)\.js\b/.test(normalized);
99
+ const hasCliEntry = new RegExp(cliEntryPattern(platform)).test(normalized);
70
100
  const sessionFlagMatch = commandLineFlagValueMatches(rawCommandLine, "session", normalizedSessionId);
71
101
  const agentFlagMatch = commandLineFlagValueMatches(rawCommandLine, "agent", normalizedAgentId);
72
102
  return (
@@ -84,22 +114,44 @@ function listenerCommandLineMatches(commandLine, { sessionId = "", agentId = ""
84
114
  );
85
115
  }
86
116
 
117
+ function optionalPositiveNumber(value) {
118
+ if (value === null || value === undefined || value === "") return null;
119
+ const number = Number(value);
120
+ return Number.isFinite(number) && number > 0 ? number : null;
121
+ }
122
+
123
+ // A row is { pid, ppid, startedAtMs, name, commandLine }, one for every process the scan
124
+ // lists, so a parent chain can be followed through processes that are not this CLI. `ppid`
125
+ // (the parent's pid) and `startedAtMs` (when the process was created) are null, and `name`
126
+ // (the executable's file name) is "", when the scan did not give them. `commandLine` is ""
127
+ // when the scan left it out: the Windows scan keeps only the ones that name a CLI entry.
87
128
  function normalizeProcessRows(rows = []) {
88
129
  if (!Array.isArray(rows)) return [];
89
130
  return rows
90
131
  .map((row) => ({
91
132
  pid: Number(row?.pid ?? row?.ProcessId ?? row?.processId),
133
+ ppid: optionalPositiveNumber(row?.ppid ?? row?.ParentProcessId),
134
+ startedAtMs: optionalPositiveNumber(row?.startedAtMs ?? row?.CreationMs),
135
+ name: normalizeString(row?.name ?? row?.Name),
92
136
  commandLine: normalizeString(row?.commandLine ?? row?.CommandLine ?? row?.command ?? row?.args),
93
137
  }))
94
- .filter((row) => Number.isInteger(row.pid) && row.pid > 0 && row.commandLine);
138
+ .filter((row) => Number.isInteger(row.pid) && row.pid > 0);
95
139
  }
96
140
 
141
+ // Win32_Process.CreationDate as milliseconds since the epoch, for a PowerShell expression.
142
+ const WINDOWS_CREATION_MS = "if ($_.CreationDate) { ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() }";
143
+
144
+ // Every process, so the parent chain is complete, with its command line only when that names
145
+ // a CLI entry point, which keeps the output small: about 600 processes make some 60 KB of
146
+ // JSON and take under a second, as the filtered scan did.
97
147
  async function listWindowsProcesses({ timeoutMs }) {
148
+ const namesCliEntry = `$_.CommandLine -and ($_.CommandLine -match '${cliEntryPattern("win32")}')`;
98
149
  const command = [
99
150
  "$ErrorActionPreference = 'Stop';",
100
151
  "$rows = Get-CimInstance Win32_Process |",
101
- "Where-Object { $_.CommandLine -and ($_.CommandLine -match 'sentinelayer-cli|[\\\\/](sl|sentinelayer-cli)\\.js') } |",
102
- "Select-Object ProcessId,CommandLine;",
152
+ "Select-Object ProcessId,ParentProcessId,Name,",
153
+ `@{n='CreationMs';e={ ${WINDOWS_CREATION_MS} }},`,
154
+ `@{n='CommandLine';e={ if (${namesCliEntry}) { $_.CommandLine } else { $null } }};`,
103
155
  "if ($rows) { $rows | ConvertTo-Json -Compress }",
104
156
  ].join(" ");
105
157
  const { stdout } = await execFileAsync(
@@ -113,20 +165,248 @@ async function listWindowsProcesses({ timeoutMs }) {
113
165
  return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
114
166
  }
115
167
 
168
+ // ps(1) prints elapsed time as [[dd-]hh:]mm:ss on Linux, macOS and busybox alike.
169
+ function parseElapsedSeconds(value) {
170
+ const match = normalizeString(value).match(/^(?:(?:(\d+)-)?(\d+):)?(\d+):(\d+)$/);
171
+ if (!match) return null;
172
+ const [days, hours, minutes, seconds] = match.slice(1).map((part) => Number(part || 0));
173
+ return ((days * 24 + hours) * 60 + minutes) * 60 + seconds;
174
+ }
175
+
176
+ // Start times come from ps's whole seconds of elapsed time, counted from just after
177
+ // `scannedAtMs`, so each is good to about a second (startTimeSlackMs).
116
178
  async function listPosixProcesses({ timeoutMs }) {
117
- const { stdout } = await execFileAsync("ps", ["-eo", "pid=,command="], {
179
+ const scannedAtMs = Date.now();
180
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,etime=,command="], {
118
181
  timeout: timeoutMs,
119
182
  maxBuffer: 10 * 1024 * 1024,
120
183
  });
121
184
  return String(stdout || "")
122
185
  .split(/\r?\n/)
123
186
  .map((line) => {
124
- const match = line.match(/^\s*(\d+)\s+(.*)$/);
125
- return match ? { pid: Number(match[1]), commandLine: normalizeString(match[2]) } : null;
187
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?$/);
188
+ if (!match) return null;
189
+ const elapsedSeconds = parseElapsedSeconds(match[3]);
190
+ return {
191
+ pid: Number(match[1]),
192
+ ppid: Number(match[2]),
193
+ startedAtMs: elapsedSeconds === null ? null : scannedAtMs - elapsedSeconds * 1000,
194
+ commandLine: normalizeString(match[4]),
195
+ };
126
196
  })
127
197
  .filter(Boolean);
128
198
  }
129
199
 
200
+ // How far apart two readings of one start time can be. Windows gives creation times to the
201
+ // millisecond. A POSIX reading can be up to a second late (whole seconds of elapsed time)
202
+ // and slightly early (the time ps takes to start), so 2 s covers it.
203
+ const POSIX_START_TIME_SLACK_MS = 2_000;
204
+
205
+ function startTimeSlackMs(platform = process.platform) {
206
+ return platform === "win32" ? 0 : POSIX_START_TIME_SLACK_MS;
207
+ }
208
+
209
+ // How far up the parent chain the walk goes. Real launch chains are a handful deep.
210
+ const MAX_ANCESTOR_DEPTH = 64;
211
+
212
+ // Whether `parent` (the row at `child`'s recorded parent pid) is still the process that
213
+ // started `child`. A parent is always created before its child, so a "parent" created after
214
+ // it holds a reused pid. On POSIX a recorded parent is always the live parent, because an
215
+ // exiting process's children are re-parented to init or a subreaper; so with no start times
216
+ // the link holds there. Windows never re-parents: the parent pid of a child whose parent
217
+ // exited can name any process started since, so there a link holds only when both creation
218
+ // times are known and in order.
219
+ function parentLinkHolds(parent, child, platform) {
220
+ if (Number.isFinite(parent.startedAtMs) && Number.isFinite(child.startedAtMs)) {
221
+ return parent.startedAtMs <= child.startedAtMs + startTimeSlackMs(platform);
222
+ }
223
+ return platform !== "win32";
224
+ }
225
+
226
+ /**
227
+ * The pids of a process's ancestors in one scan of the process table: its parent, that
228
+ * process's parent, and so on, while each parent is in the table, still the process that
229
+ * started its child (parentLinkHolds), not seen before (a cycle), and within
230
+ * MAX_ANCESTOR_DEPTH. The walk starts at the parent the table records for `pid`, or at
231
+ * `parentPid` when the table records none. Every row counts, with or without a command line,
232
+ * so the chain runs through processes that are not this CLI (cmd.exe, a service host).
233
+ *
234
+ * The walk ends early under Git Bash (MSYS) on Windows: an MSYS program such as bash, nohup or
235
+ * timeout is started from a forked copy of the shell that exits once the program runs, so its
236
+ * parent pid names no live process. Counting does not rest on the walk (a launcher is never
237
+ * counted, wherever it sits: readListenerScan); the walk marks the listeners --force must not
238
+ * stop, and a listener above such a break is not marked.
239
+ */
240
+ export function listenerAncestorPids(
241
+ rows,
242
+ { pid = process.pid, parentPid = null, platform = process.platform } = {},
243
+ ) {
244
+ const table = new Map(normalizeProcessRows(rows).map((row) => [row.pid, row]));
245
+ const selfPid = Number(pid);
246
+ let child = table.get(selfPid) || { pid: selfPid, ppid: null, startedAtMs: null };
247
+ let next = child.ppid ?? optionalPositiveNumber(parentPid);
248
+ const ancestors = new Set();
249
+ while (next !== null && next !== selfPid && !ancestors.has(next) && ancestors.size < MAX_ANCESTOR_DEPTH) {
250
+ const parent = table.get(next);
251
+ if (!parent || !parentLinkHolds(parent, child, platform)) break;
252
+ ancestors.add(parent.pid);
253
+ child = parent;
254
+ next = parent.ppid;
255
+ }
256
+ return ancestors;
257
+ }
258
+
259
+ // The checking process's ancestors in a scan. When the checker is this process and its row
260
+ // records no parent, its parent is process.ppid.
261
+ function checkerAncestors(rows, selfPid) {
262
+ return listenerAncestorPids(rows, {
263
+ pid: selfPid,
264
+ parentPid: selfPid === process.pid ? process.ppid : null,
265
+ });
266
+ }
267
+
268
+ /**
269
+ * An executable's name as the process kinds below compare it: its file name, without the
270
+ * directory, lower-cased, and without a trailing ".exe". So `C:\Program Files\nodejs\node.exe`,
271
+ * `/usr/bin/node` and `NODE` all read as "node".
272
+ */
273
+ export function normalizeImageName(value) {
274
+ const base = normalizeString(value).split(/[\\/]/).pop().toLowerCase();
275
+ return base.endsWith(".exe") ? base.slice(0, -4) : base;
276
+ }
277
+
278
+ // Executables that run this CLI as node. Also the checker's own node, whatever it is named.
279
+ const NODE_IMAGE_NAMES = ["node", "nodejs"];
280
+ // Executables that only start other programs: a command line naming the CLI there is the
281
+ // command they run, not this CLI running.
282
+ const LAUNCHER_IMAGE_NAMES = new Set([
283
+ "bash", "sh", "dash", "zsh", "cmd", "powershell", "pwsh", "nohup", "timeout", "env",
284
+ "screen", "tmux", "script", "setsid", "nice",
285
+ ]);
286
+ // Node options known to take no value, so the next argument is not theirs.
287
+ const NODE_FLAGS = new Set([
288
+ "--inspect", "--inspect-brk", "--inspect-wait", "--no-warnings", "--no-deprecation",
289
+ "--trace-warnings", "--trace-deprecation", "--trace-uncaught", "--throw-deprecation",
290
+ "--pending-deprecation", "--enable-source-maps", "--expose-gc", "--preserve-symlinks",
291
+ "--preserve-symlinks-main", "--abort-on-uncaught-exception", "--experimental-vm-modules",
292
+ ]);
293
+ // Of those, the ones that may also be given a value after "=" (--inspect=127.0.0.1:9229).
294
+ const NODE_FLAGS_WITH_OPTIONAL_VALUE = new Set(["--inspect", "--inspect-brk", "--inspect-wait"]);
295
+ // Node options known to take a value, as the next argument or after "=".
296
+ const NODE_OPTIONS_WITH_VALUE = new Set([
297
+ "--inspect-port", "--title", "-C", "--conditions", "--input-type", "--env-file",
298
+ "--disable-warning", "--redirect-warnings", "--max-old-space-size", "--stack-size",
299
+ ]);
300
+ // Node options that load a module before the script; that module may be this CLI.
301
+ const NODE_PRELOAD_OPTIONS = new Set(["-r", "--require", "--import", "--loader", "--experimental-loader"]);
302
+ // Node options that run code given on the command line instead of a script.
303
+ const NODE_INLINE_CODE_OPTIONS = new Set(["-e", "--eval", "-p", "--print", "-pe", "-ep"]);
304
+
305
+ // A command line's arguments, in order, and whether they were read with certainty.
306
+ // - Windows quotes an argument with spaces, or an empty one, in double quotes. An empty
307
+ // argument ("") stays an argument, so a value option never takes the next one in its
308
+ // place. A quote left open, or one escaped with a backslash, is not read with certainty.
309
+ // - ps(1) prints POSIX arguments joined by single spaces with no quoting. An argument with a
310
+ // space reads as two (listenerProcessKind allows for that), and a run of spaces may hide an
311
+ // empty argument or an argument made of spaces, so it is not read with certainty. A quote
312
+ // character there is part of an argument, not quoting: it is kept as it is, and a command line
313
+ // that has one is not read with certainty, so no file name is recognised by removing it.
314
+ function commandLineArguments(commandLine, platform) {
315
+ const text = normalizeString(commandLine);
316
+ if (platform === "win32") {
317
+ const certain = (text.match(/"/g) || []).length % 2 === 0 && !/\\"/.test(text);
318
+ const parts = text.match(/(?:"[^"]*"?|[^\s"]+)+/g) || [];
319
+ return { args: parts.map((part) => part.replace(/"/g, "")), certain };
320
+ }
321
+ const parts = text.split(/\s+/);
322
+ return { args: parts.filter(Boolean), certain: !/\s{2,}/.test(text) && !/["']/.test(text) };
323
+ }
324
+
325
+ /**
326
+ * What a process whose command line matches a listen command is. Both kinds are allow-listed;
327
+ * anything else is "unknown", which the listener checks treat as possibly a listener, so
328
+ * starting and --force refuse.
329
+ * - "listener": this CLI running it. The executable is node (node, nodejs, or the name of the
330
+ * checker's own node) and node's first argument that is not a node option is a CLI entry
331
+ * point, read with certainty.
332
+ * - "wrapper": a launcher, which carries the command for the process it starts: an executable
333
+ * in LAUNCHER_IMAGE_NAMES (bash, sh, dash, zsh, cmd, powershell, pwsh, nohup, timeout, env,
334
+ * screen, tmux, script, setsid, nice); or node whose first non-option argument is certainly
335
+ * another script, or node running inline code (-e, -p) with no CLI entry among its arguments.
336
+ * - "unknown": anything else, including node whose arguments cannot be read with certainty: a
337
+ * preload option (-r, --require, --import, --loader), inline code next to a CLI entry, an
338
+ * option this does not know (it may take the next argument), a quote left open, or, on
339
+ * POSIX, a first argument that is not an entry (it may be the first piece of an entry path
340
+ * with a space, which ps prints unquoted).
341
+ * The executable is Win32_Process.Name on Windows and the first argument on POSIX, compared
342
+ * as normalizeImageName gives it.
343
+ */
344
+ export function listenerProcessKind(
345
+ row,
346
+ { platform = process.platform, nodeExecutable = process.execPath } = {},
347
+ ) {
348
+ const { args, certain } = commandLineArguments(row?.commandLine, platform);
349
+ const image = normalizeImageName(normalizeString(row?.name) || args[0]);
350
+ if (LAUNCHER_IMAGE_NAMES.has(image)) return "wrapper";
351
+ const nodeImages = new Set([...NODE_IMAGE_NAMES, normalizeImageName(nodeExecutable)].filter(Boolean));
352
+ if (!nodeImages.has(image)) return "unknown";
353
+ if (!certain) return "unknown";
354
+ const entries =
355
+ platform === "win32" ? CLI_ENTRY_SCRIPTS : [...CLI_ENTRY_SCRIPTS, ...CLI_COMMAND_NAMES];
356
+ const entryPath = new RegExp(`[\\\\/](?:${entries.map(escapeRegExp).join("|")})$`);
357
+ const isEntry = (arg) => entryPath.test(normalizeString(arg).toLowerCase());
358
+ let index = 1;
359
+ for (; index < args.length; index += 1) {
360
+ const arg = args[index];
361
+ if (arg === "--") {
362
+ index += 1;
363
+ break;
364
+ }
365
+ if (!arg.startsWith("-") || arg === "-") break;
366
+ // Every spelling is judged by the option's name: --name, --name value, --name=value.
367
+ const equals = arg.indexOf("=");
368
+ const name = equals === -1 ? arg : arg.slice(0, equals);
369
+ const valueAttached = equals !== -1;
370
+ if (NODE_INLINE_CODE_OPTIONS.has(name)) {
371
+ return args.slice(1).some(isEntry) ? "unknown" : "wrapper";
372
+ }
373
+ if (NODE_PRELOAD_OPTIONS.has(name)) return "unknown";
374
+ if (NODE_OPTIONS_WITH_VALUE.has(name)) {
375
+ if (!valueAttached) index += 1;
376
+ continue;
377
+ }
378
+ if (NODE_FLAGS.has(name) && (!valueAttached || NODE_FLAGS_WITH_OPTIONAL_VALUE.has(name))) continue;
379
+ return "unknown";
380
+ }
381
+ const script = args[index];
382
+ if (!script) return "unknown";
383
+ if (isEntry(script)) return "listener";
384
+ return platform === "win32" ? "wrapper" : "unknown";
385
+ }
386
+
387
+ /**
388
+ * One scan read for one session and agent from the checking process's side. Every process
389
+ * whose command line matches, other than the checker, is judged by listenerProcessKind:
390
+ * `listeners` are the listeners, `unconfirmed` the processes that may be one, and launchers
391
+ * are in neither, wherever they sit. Ancestry is the second layer: a listener or possible
392
+ * listener that is an ancestor of the checker is marked `ancestor: true`, and the takeover
393
+ * never stops one.
394
+ */
395
+ function readListenerScan(rows, { sessionId, agentId, selfPid, ancestors }) {
396
+ const listeners = [];
397
+ const unconfirmed = [];
398
+ for (const row of rows) {
399
+ if (row.pid === selfPid || !listenerCommandLineMatches(row.commandLine, { sessionId, agentId })) {
400
+ continue;
401
+ }
402
+ const kind = listenerProcessKind(row);
403
+ if (kind === "wrapper") continue;
404
+ const marked = ancestors.has(row.pid) ? { ...row, ancestor: true } : row;
405
+ (kind === "listener" ? listeners : unconfirmed).push(marked);
406
+ }
407
+ return { listeners, unconfirmed };
408
+ }
409
+
130
410
  // Resolves to the process rows, or rejects when the table cannot be read (a timeout, a
131
411
  // missing tool, unreadable output). It never turns a failed read into an empty table.
132
412
  async function listProcessTable({ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS } = {}) {
@@ -173,7 +453,10 @@ async function scanProcessTable(listProcesses, timeoutMs) {
173
453
  /**
174
454
  * Listener processes for this session and agent: { ok: true, matches } when the process
175
455
  * table was read, { ok: false, reason } when it could not be. A failed scan has no
176
- * `matches`, so it can never be read as "no listener".
456
+ * `matches`, so it can never be read as "no listener". The matches are the listeners and the
457
+ * processes that may be one (readListenerScan); the checking process (`excludePid`) and
458
+ * launchers never are, and a match that is an ancestor of the checker is marked
459
+ * `ancestor: true`.
177
460
  */
178
461
  export async function listMatchingListenerProcesses(
179
462
  sessionId,
@@ -186,14 +469,10 @@ export async function listMatchingListenerProcesses(
186
469
  ) {
187
470
  const scan = await scanProcessTable(_listProcesses, timeoutMs);
188
471
  if (!scan.ok) return scan;
189
- const excluded = Number(excludePid);
190
- return {
191
- ok: true,
192
- matches: scan.rows.filter((row) =>
193
- row.pid !== excluded &&
194
- listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
195
- ),
196
- };
472
+ const selfPid = Number(excludePid);
473
+ const ancestors = checkerAncestors(scan.rows, selfPid);
474
+ const { listeners, unconfirmed } = readListenerScan(scan.rows, { sessionId, agentId, selfPid, ancestors });
475
+ return { ok: true, matches: [...listeners, ...unconfirmed] };
197
476
  }
198
477
 
199
478
  export async function summarizeLocalListenerProcesses(
@@ -213,16 +492,16 @@ export async function summarizeLocalListenerProcesses(
213
492
  if (!scan.ok) {
214
493
  return { ok: false, reason: scan.reason, sessionId: normalizedSessionId };
215
494
  }
216
- const excluded = Number(excludePid);
217
- const candidates = scan.rows.filter((row) => row.pid !== excluded);
495
+ const selfPid = Number(excludePid);
496
+ const ancestors = checkerAncestors(scan.rows, selfPid);
218
497
  const agents = uniqueAgentIds.map((agentId) => {
219
- const matches = candidates
220
- .filter((row) => listenerCommandLineMatches(row.commandLine, {
221
- sessionId: normalizedSessionId,
222
- agentId,
223
- }))
224
- .sort((a, b) => a.pid - b.pid);
225
- const pids = matches.map((match) => match.pid);
498
+ const { listeners, unconfirmed } = readListenerScan(scan.rows, {
499
+ sessionId: normalizedSessionId,
500
+ agentId,
501
+ selfPid,
502
+ ancestors,
503
+ });
504
+ const pids = [...listeners, ...unconfirmed].map((match) => match.pid).sort((a, b) => a - b);
226
505
  return {
227
506
  agentId,
228
507
  processCount: pids.length,
@@ -463,11 +742,50 @@ async function listenerStatusFromRecord(
463
742
  };
464
743
  }
465
744
 
745
+ /**
746
+ * What the process at a pid record's pid is, judged on one scan (`row` is its row there, or
747
+ * null; `commandLine` is the one read for the record, used when the row has none). A
748
+ * listener writes its record after it starts, so the process that wrote it started no later
749
+ * than the record's `startedAt`, within the platform's precision (startTimeSlackMs).
750
+ * - "confirmed": a listener (listenerProcessKind) that started by then: the one that wrote it.
751
+ * - "ancestor": one of the checking process's ancestors (`ancestors`). The scan judges those
752
+ * (readListenerScan), so the record adds nothing.
753
+ * - "wrapper": a launcher, which never wrote a listener's record.
754
+ * - "reused": a listener started after the record was written, so not the one that wrote it.
755
+ * - "gone": no longer in the process table.
756
+ * - "unconfirmed": a process that may be a listener, or a start time that is unknown, so the
757
+ * record proves nothing either way.
758
+ */
759
+ export function pidRecordHolder(
760
+ record,
761
+ row,
762
+ { ancestors = new Set(), commandLine = "", platform = process.platform } = {},
763
+ ) {
764
+ if (!row) return "gone";
765
+ if (ancestors.has(row.pid)) return "ancestor";
766
+ const kind = listenerProcessKind(
767
+ { name: row.name, commandLine: row.commandLine || commandLine },
768
+ { platform },
769
+ );
770
+ if (kind === "wrapper") return "wrapper";
771
+ if (kind === "unknown") return "unconfirmed";
772
+ const recordedAtMs = Date.parse(normalizeString(record?.startedAt));
773
+ if (!Number.isFinite(row.startedAtMs) || !Number.isFinite(recordedAtMs)) return "unconfirmed";
774
+ return row.startedAtMs > recordedAtMs + startTimeSlackMs(platform) ? "reused" : "confirmed";
775
+ }
776
+
466
777
  /**
467
778
  * Whether a listener for this session and agent is running on this machine. `state` is
468
- * "running", "not_running" or "unknown". A live pid record whose command line matches is
469
- * "running" without a scan. Otherwise the process table decides, and when it cannot be read
470
- * the answer is "unknown" (`running: null`, with `reason`), never "not_running".
779
+ * "running", "not_running" or "unknown".
780
+ *
781
+ * A pid record counts when its pid is alive with a matching command line and one scan of the
782
+ * process table confirms it (pidRecordHolder). The same scan finds untracked listeners
783
+ * (readListenerScan). A running answer carries the process's `startedAtMs`, and `ancestor:
784
+ * true` when the listener is an ancestor of this process, which --force must never stop.
785
+ *
786
+ * Anything uncertain makes the answer "unknown" (`running: null`, with `reason`), never
787
+ * "not_running": a scan that could not be read, a record whose process cannot be confirmed
788
+ * ("pid_record_unconfirmed"), or a process that may be a listener ("listener_unconfirmed").
471
789
  */
472
790
  export async function getListenerProcessStatus(
473
791
  sessionId,
@@ -480,6 +798,7 @@ export async function getListenerProcessStatus(
480
798
  _listProcesses = activeListProcessTable,
481
799
  } = {},
482
800
  ) {
801
+ const listenerKey = normalizeListenerProcessKey(agentId);
483
802
  const records = [
484
803
  {
485
804
  record: await readGlobalListenerPidRecord(sessionId, agentId, { homeDir }),
@@ -490,6 +809,7 @@ export async function getListenerProcessStatus(
490
809
  recordScope: "local",
491
810
  },
492
811
  ];
812
+ const tracked = [];
493
813
  const staleStatuses = [];
494
814
  for (const entry of records) {
495
815
  if (!entry.record) continue;
@@ -499,38 +819,74 @@ export async function getListenerProcessStatus(
499
819
  recordScope: entry.recordScope,
500
820
  _readProcessCommandLine,
501
821
  });
502
- if (status.running) {
503
- return status;
822
+ (status.running ? tracked : staleStatuses).push(status);
823
+ }
824
+ const unknown = (reason, record) => ({
825
+ state: "unknown",
826
+ running: null,
827
+ pid: null,
828
+ reason,
829
+ record: record || tracked[0]?.record || staleStatuses[0]?.record || null,
830
+ listenerKey,
831
+ });
832
+ const scan = await scanProcessTable(_listProcesses, scanTimeoutMs);
833
+ if (!scan.ok) return unknown(scan.reason);
834
+
835
+ const ancestors = checkerAncestors(scan.rows, process.pid);
836
+ const table = new Map(scan.rows.map((row) => [row.pid, row]));
837
+ const confirmed = [];
838
+ const unconfirmedRecords = [];
839
+ for (const status of tracked) {
840
+ const row = table.get(Number(status.pid)) || null;
841
+ const holder = pidRecordHolder(status.record, row, { ancestors, commandLine: status.commandLine });
842
+ if (holder === "confirmed") {
843
+ confirmed.push({ ...status, startedAtMs: row.startedAtMs, ancestor: false });
844
+ } else if (holder === "unconfirmed") {
845
+ unconfirmedRecords.push(status);
846
+ } else {
847
+ staleStatuses.push({
848
+ state: "not_running",
849
+ running: false,
850
+ pid: null,
851
+ stale: true,
852
+ reused: holder !== "gone",
853
+ holder,
854
+ record: status.record,
855
+ recordScope: status.recordScope,
856
+ listenerKey,
857
+ commandLine: status.commandLine,
858
+ });
504
859
  }
505
- staleStatuses.push(status);
506
860
  }
507
- const scan = await listMatchingListenerProcesses(sessionId, agentId, {
508
- timeoutMs: scanTimeoutMs,
509
- _listProcesses,
861
+ const { listeners, unconfirmed } = readListenerScan(scan.rows, {
862
+ sessionId,
863
+ agentId,
864
+ selfPid: process.pid,
865
+ ancestors,
510
866
  });
511
- if (!scan.ok) {
512
- return {
513
- state: "unknown",
514
- running: null,
515
- pid: null,
516
- reason: scan.reason,
517
- record: staleStatuses[0]?.record || null,
518
- listenerKey: normalizeListenerProcessKey(agentId),
519
- };
867
+ if (unconfirmedRecords.length > 0) {
868
+ return unknown("pid_record_unconfirmed", unconfirmedRecords[0].record);
520
869
  }
521
- const matchingProcesses = scan.matches;
522
- if (matchingProcesses.length > 0) {
870
+ if (unconfirmed.length > 0) return unknown("listener_unconfirmed");
871
+ // A listener that is an ancestor of this process is the one reported, so that --force
872
+ // refuses rather than stop it.
873
+ const ancestorListener = listeners.find((row) => row.ancestor);
874
+ if (confirmed.length > 0 && !ancestorListener) return confirmed[0];
875
+ const reported = ancestorListener || listeners[0];
876
+ if (reported) {
523
877
  return {
524
878
  state: "running",
525
879
  running: true,
526
- pid: matchingProcesses[0].pid,
880
+ pid: reported.pid,
881
+ startedAtMs: reported.startedAtMs,
882
+ ancestor: Boolean(reported.ancestor),
527
883
  stale: false,
528
884
  record: null,
529
885
  recordScope: "process_scan",
530
886
  untracked: true,
531
- listenerKey: normalizeListenerProcessKey(agentId),
532
- commandLine: matchingProcesses[0].commandLine,
533
- matchingProcesses,
887
+ listenerKey,
888
+ commandLine: reported.commandLine,
889
+ matchingProcesses: listeners,
534
890
  };
535
891
  }
536
892
  if (staleStatuses.length === 0) {
@@ -540,34 +896,78 @@ export async function getListenerProcessStatus(
540
896
  pid: null,
541
897
  stale: false,
542
898
  record: null,
543
- listenerKey: normalizeListenerProcessKey(agentId),
899
+ listenerKey,
544
900
  };
545
901
  }
546
902
  return staleStatuses[0];
547
903
  }
548
904
 
905
+ // Why the takeover's own scan does not bear out `listener`, the status check's answer, or
906
+ // null when it does. `row` is the scan's row for that pid (null when it is gone, which
907
+ // leaves nothing to stop for it), and `isListener` says whether the scan counts that row as
908
+ // a current listener for this session and agent (readListenerScan). The pid must name the
909
+ // same process the status check saw, by start time (exact on Windows, within 2 s on POSIX:
910
+ // startTimeSlackMs): otherwise it changed hands ("reused"), even when the new holder is a
911
+ // listener too. And it must still be a current listener; a start time that cannot be
912
+ // compared, or the same process no longer counted as a listener, is "unconfirmed".
913
+ function takeoverTargetRefusal(listener, row, { isListener, platform = process.platform }) {
914
+ if (!row) return null;
915
+ if (!Number.isFinite(listener?.startedAtMs) || !Number.isFinite(row.startedAtMs)) return "unconfirmed";
916
+ if (Math.abs(row.startedAtMs - listener.startedAtMs) > startTimeSlackMs(platform)) return "reused";
917
+ return isListener ? null : "unconfirmed";
918
+ }
919
+
549
920
  /**
550
- * Stops every listener process the scan finds for this session and agent. When the process
551
- * table cannot be read it stops nothing and returns { ok: false, reason }.
921
+ * The --force takeover. --force stops only current listeners for this session and agent, and
922
+ * refuses with no stops if the listener the status check saw has changed hands or can no
923
+ * longer be confirmed. One scan is taken, and the stop set is exactly that scan's listeners
924
+ * for this session and agent (readListenerScan): never the checker, never a launcher, and
925
+ * never the pid the status check found unless this scan counts it as a listener. Every
926
+ * refusal is decided before the first stop: a listener that is an ancestor of the checker
927
+ * ("ancestor_listener"), a process that may be a listener ("listener_unconfirmed"), or the
928
+ * status check's listener changed hands or unconfirmed (takeoverTargetRefusal). It returns
929
+ * { ok: true, refused: { pid, reason } } then. When the process table cannot be read it stops
930
+ * nothing and returns { ok: false, reason }; the caller then stops nothing either, since what
931
+ * it found earlier may no longer hold.
552
932
  */
553
933
  export async function stopMatchingListenerProcesses(
554
934
  sessionId,
555
935
  agentId,
556
936
  {
557
937
  excludePid = process.pid,
938
+ listener = null,
558
939
  timeoutMs = 3000,
559
940
  pollIntervalMs = 100,
560
941
  scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
561
942
  _listProcesses = activeListProcessTable,
562
943
  } = {},
563
944
  ) {
564
- const scan = await listMatchingListenerProcesses(sessionId, agentId, {
565
- excludePid,
566
- timeoutMs: scanTimeoutMs,
567
- _listProcesses,
568
- });
945
+ const scan = await scanProcessTable(_listProcesses, scanTimeoutMs);
569
946
  if (!scan.ok) return scan;
570
- const matches = scan.matches;
947
+ const selfPid = Number(excludePid);
948
+ const ancestors = checkerAncestors(scan.rows, selfPid);
949
+ const { listeners, unconfirmed } = readListenerScan(scan.rows, { sessionId, agentId, selfPid, ancestors });
950
+ const refuse = (pid, reason) => ({
951
+ ok: true,
952
+ refused: { pid, reason },
953
+ matches: listeners,
954
+ results: [],
955
+ stoppedCount: 0,
956
+ failedCount: 0,
957
+ failed: [],
958
+ });
959
+ const ancestorListener = listeners.find((row) => row.ancestor);
960
+ if (ancestorListener) return refuse(ancestorListener.pid, "ancestor_listener");
961
+ if (unconfirmed.length > 0) return refuse(unconfirmed[0].pid, "listener_unconfirmed");
962
+ // Decide everything, then act: every refusal above and here comes before the first stop.
963
+ const matches = [...listeners];
964
+ const listenerPid = Number(listener?.pid);
965
+ if (Number.isInteger(listenerPid) && listenerPid > 0) {
966
+ const row = scan.rows.find((entry) => entry.pid === listenerPid) || null;
967
+ const isListener = matches.some((match) => match.pid === listenerPid);
968
+ const refusal = takeoverTargetRefusal(listener, row, { isListener });
969
+ if (refusal) return refuse(listenerPid, refusal);
970
+ }
571
971
  const results = [];
572
972
  for (const match of matches) {
573
973
  const result = await requestListenerProcessStop(match.pid, {