sentinelayer-cli 0.46.3 → 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.3",
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(
@@ -114,22 +114,44 @@ export function listenerCommandLineMatches(
114
114
  );
115
115
  }
116
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.
117
128
  function normalizeProcessRows(rows = []) {
118
129
  if (!Array.isArray(rows)) return [];
119
130
  return rows
120
131
  .map((row) => ({
121
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),
122
136
  commandLine: normalizeString(row?.commandLine ?? row?.CommandLine ?? row?.command ?? row?.args),
123
137
  }))
124
- .filter((row) => Number.isInteger(row.pid) && row.pid > 0 && row.commandLine);
138
+ .filter((row) => Number.isInteger(row.pid) && row.pid > 0);
125
139
  }
126
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.
127
147
  async function listWindowsProcesses({ timeoutMs }) {
148
+ const namesCliEntry = `$_.CommandLine -and ($_.CommandLine -match '${cliEntryPattern("win32")}')`;
128
149
  const command = [
129
150
  "$ErrorActionPreference = 'Stop';",
130
151
  "$rows = Get-CimInstance Win32_Process |",
131
- `Where-Object { $_.CommandLine -and ($_.CommandLine -match '${cliEntryPattern("win32")}') } |`,
132
- "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 } }};`,
133
155
  "if ($rows) { $rows | ConvertTo-Json -Compress }",
134
156
  ].join(" ");
135
157
  const { stdout } = await execFileAsync(
@@ -143,20 +165,248 @@ async function listWindowsProcesses({ timeoutMs }) {
143
165
  return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
144
166
  }
145
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).
146
178
  async function listPosixProcesses({ timeoutMs }) {
147
- const { stdout } = await execFileAsync("ps", ["-eo", "pid=,command="], {
179
+ const scannedAtMs = Date.now();
180
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,etime=,command="], {
148
181
  timeout: timeoutMs,
149
182
  maxBuffer: 10 * 1024 * 1024,
150
183
  });
151
184
  return String(stdout || "")
152
185
  .split(/\r?\n/)
153
186
  .map((line) => {
154
- const match = line.match(/^\s*(\d+)\s+(.*)$/);
155
- 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
+ };
156
196
  })
157
197
  .filter(Boolean);
158
198
  }
159
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
+
160
410
  // Resolves to the process rows, or rejects when the table cannot be read (a timeout, a
161
411
  // missing tool, unreadable output). It never turns a failed read into an empty table.
162
412
  async function listProcessTable({ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS } = {}) {
@@ -203,7 +453,10 @@ async function scanProcessTable(listProcesses, timeoutMs) {
203
453
  /**
204
454
  * Listener processes for this session and agent: { ok: true, matches } when the process
205
455
  * table was read, { ok: false, reason } when it could not be. A failed scan has no
206
- * `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`.
207
460
  */
208
461
  export async function listMatchingListenerProcesses(
209
462
  sessionId,
@@ -216,14 +469,10 @@ export async function listMatchingListenerProcesses(
216
469
  ) {
217
470
  const scan = await scanProcessTable(_listProcesses, timeoutMs);
218
471
  if (!scan.ok) return scan;
219
- const excluded = Number(excludePid);
220
- return {
221
- ok: true,
222
- matches: scan.rows.filter((row) =>
223
- row.pid !== excluded &&
224
- listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
225
- ),
226
- };
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] };
227
476
  }
228
477
 
229
478
  export async function summarizeLocalListenerProcesses(
@@ -243,16 +492,16 @@ export async function summarizeLocalListenerProcesses(
243
492
  if (!scan.ok) {
244
493
  return { ok: false, reason: scan.reason, sessionId: normalizedSessionId };
245
494
  }
246
- const excluded = Number(excludePid);
247
- const candidates = scan.rows.filter((row) => row.pid !== excluded);
495
+ const selfPid = Number(excludePid);
496
+ const ancestors = checkerAncestors(scan.rows, selfPid);
248
497
  const agents = uniqueAgentIds.map((agentId) => {
249
- const matches = candidates
250
- .filter((row) => listenerCommandLineMatches(row.commandLine, {
251
- sessionId: normalizedSessionId,
252
- agentId,
253
- }))
254
- .sort((a, b) => a.pid - b.pid);
255
- 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);
256
505
  return {
257
506
  agentId,
258
507
  processCount: pids.length,
@@ -493,11 +742,50 @@ async function listenerStatusFromRecord(
493
742
  };
494
743
  }
495
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
+
496
777
  /**
497
778
  * Whether a listener for this session and agent is running on this machine. `state` is
498
- * "running", "not_running" or "unknown". A live pid record whose command line matches is
499
- * "running" without a scan. Otherwise the process table decides, and when it cannot be read
500
- * 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").
501
789
  */
502
790
  export async function getListenerProcessStatus(
503
791
  sessionId,
@@ -510,6 +798,7 @@ export async function getListenerProcessStatus(
510
798
  _listProcesses = activeListProcessTable,
511
799
  } = {},
512
800
  ) {
801
+ const listenerKey = normalizeListenerProcessKey(agentId);
513
802
  const records = [
514
803
  {
515
804
  record: await readGlobalListenerPidRecord(sessionId, agentId, { homeDir }),
@@ -520,6 +809,7 @@ export async function getListenerProcessStatus(
520
809
  recordScope: "local",
521
810
  },
522
811
  ];
812
+ const tracked = [];
523
813
  const staleStatuses = [];
524
814
  for (const entry of records) {
525
815
  if (!entry.record) continue;
@@ -529,38 +819,74 @@ export async function getListenerProcessStatus(
529
819
  recordScope: entry.recordScope,
530
820
  _readProcessCommandLine,
531
821
  });
532
- if (status.running) {
533
- 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
+ });
534
859
  }
535
- staleStatuses.push(status);
536
860
  }
537
- const scan = await listMatchingListenerProcesses(sessionId, agentId, {
538
- timeoutMs: scanTimeoutMs,
539
- _listProcesses,
861
+ const { listeners, unconfirmed } = readListenerScan(scan.rows, {
862
+ sessionId,
863
+ agentId,
864
+ selfPid: process.pid,
865
+ ancestors,
540
866
  });
541
- if (!scan.ok) {
542
- return {
543
- state: "unknown",
544
- running: null,
545
- pid: null,
546
- reason: scan.reason,
547
- record: staleStatuses[0]?.record || null,
548
- listenerKey: normalizeListenerProcessKey(agentId),
549
- };
867
+ if (unconfirmedRecords.length > 0) {
868
+ return unknown("pid_record_unconfirmed", unconfirmedRecords[0].record);
550
869
  }
551
- const matchingProcesses = scan.matches;
552
- 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) {
553
877
  return {
554
878
  state: "running",
555
879
  running: true,
556
- pid: matchingProcesses[0].pid,
880
+ pid: reported.pid,
881
+ startedAtMs: reported.startedAtMs,
882
+ ancestor: Boolean(reported.ancestor),
557
883
  stale: false,
558
884
  record: null,
559
885
  recordScope: "process_scan",
560
886
  untracked: true,
561
- listenerKey: normalizeListenerProcessKey(agentId),
562
- commandLine: matchingProcesses[0].commandLine,
563
- matchingProcesses,
887
+ listenerKey,
888
+ commandLine: reported.commandLine,
889
+ matchingProcesses: listeners,
564
890
  };
565
891
  }
566
892
  if (staleStatuses.length === 0) {
@@ -570,34 +896,78 @@ export async function getListenerProcessStatus(
570
896
  pid: null,
571
897
  stale: false,
572
898
  record: null,
573
- listenerKey: normalizeListenerProcessKey(agentId),
899
+ listenerKey,
574
900
  };
575
901
  }
576
902
  return staleStatuses[0];
577
903
  }
578
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
+
579
920
  /**
580
- * Stops every listener process the scan finds for this session and agent. When the process
581
- * 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.
582
932
  */
583
933
  export async function stopMatchingListenerProcesses(
584
934
  sessionId,
585
935
  agentId,
586
936
  {
587
937
  excludePid = process.pid,
938
+ listener = null,
588
939
  timeoutMs = 3000,
589
940
  pollIntervalMs = 100,
590
941
  scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
591
942
  _listProcesses = activeListProcessTable,
592
943
  } = {},
593
944
  ) {
594
- const scan = await listMatchingListenerProcesses(sessionId, agentId, {
595
- excludePid,
596
- timeoutMs: scanTimeoutMs,
597
- _listProcesses,
598
- });
945
+ const scan = await scanProcessTable(_listProcesses, scanTimeoutMs);
599
946
  if (!scan.ok) return scan;
600
- 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
+ }
601
971
  const results = [];
602
972
  for (const match of matches) {
603
973
  const result = await requestListenerProcessStop(match.pid, {