sentinelayer-cli 0.46.1 → 0.46.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { runCli } from "../src/cli.js";
3
+ import { bootstrapCli } from "../src/cli-bootstrap.js";
4
4
 
5
- await runCli();
5
+ await bootstrapCli();
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { runCli } from "../src/cli.js";
3
+ import { bootstrapCli } from "../src/cli-bootstrap.js";
4
4
 
5
- await runCli();
5
+ await bootstrapCli();
package/bin/sl.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { runCli } from "../src/cli.js";
3
+ import { bootstrapCli } from "../src/cli-bootstrap.js";
4
4
 
5
- await runCli();
5
+ await bootstrapCli();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.46.1",
3
+ "version": "0.46.3",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,20 @@
1
+ import process from "node:process";
2
+
3
+ import { restrictProgramLookupToPath } from "./program-lookup.js";
4
+
5
+ /**
6
+ * Entry for the bin scripts. Checks the runtime before the rest of the CLI is loaded: importing
7
+ * cli.js loads every command module, and some modules do work as they load (for example, creating
8
+ * ~/.sentinelayer), so this module and program-lookup.js are the only ones a bin script imports
9
+ * statically. runCli repeats the check for callers that import cli.js directly.
10
+ */
11
+ export async function bootstrapCli() {
12
+ const programLookup = restrictProgramLookupToPath();
13
+ if (!programLookup.ok) {
14
+ console.error(programLookup.message);
15
+ process.exitCode = 1;
16
+ return;
17
+ }
18
+ const { runCli } = await import("./cli.js");
19
+ await runCli();
20
+ }
package/src/cli.js CHANGED
@@ -244,7 +244,9 @@ export async function buildCliProgram({
244
244
 
245
245
  export async function runCli(rawArgs = process.argv.slice(2)) {
246
246
  // Programs named without a path come from PATH only, never from the working directory, before
247
- // anything is spawned. On a Windows runtime that cannot guarantee that, nothing runs.
247
+ // anything is spawned. On a Windows runtime that cannot guarantee that, nothing runs. The bin
248
+ // scripts check this before loading this module (cli-bootstrap.js); here it covers callers that
249
+ // import runCli directly.
248
250
  const programLookup = restrictProgramLookupToPath();
249
251
  if (!programLookup.ok) {
250
252
  console.error(programLookup.message);
@@ -76,6 +76,7 @@ import {
76
76
  import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
77
77
  import {
78
78
  getListenerProcessStatus,
79
+ LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS,
79
80
  removeListenerPidRecord,
80
81
  requestListenerProcessStop,
81
82
  summarizeLocalListenerProcesses,
@@ -5149,16 +5150,17 @@ export function registerSessionCommand(program) {
5149
5150
  let localProcessScan = {
5150
5151
  ok: false,
5151
5152
  reason: listeners.length > 0 ? "not_scanned" : "no_listener_rows",
5152
- processCount: 0,
5153
- duplicateProcessCount: 0,
5153
+ // Not scanned, so no count: null, never 0, so a reader that skips `ok` can't read "no duplicates".
5154
+ processCount: null,
5155
+ duplicateProcessCount: null,
5154
5156
  agents: [],
5155
5157
  };
5156
5158
  if (listeners.length > 0) {
5157
- try {
5158
- const summary = await summarizeLocalListenerProcesses(
5159
- normalizedSessionId,
5160
- listeners.map((row) => row.agentId),
5161
- );
5159
+ const summary = await summarizeLocalListenerProcesses(
5160
+ normalizedSessionId,
5161
+ listeners.map((row) => row.agentId),
5162
+ );
5163
+ if (summary.ok) {
5162
5164
  const byAgent = new Map(summary.agents.map((entry) => [entry.agentId, entry]));
5163
5165
  listeners = listeners.map((row) => {
5164
5166
  const local = byAgent.get(row.agentId) || {};
@@ -5176,12 +5178,13 @@ export function registerSessionCommand(program) {
5176
5178
  duplicateProcessCount: Number(summary.duplicateProcessCount || 0),
5177
5179
  agents: summary.agents,
5178
5180
  };
5179
- } catch (error) {
5181
+ } else {
5182
+ // The rows stay unannotated: an unread process table is not "no local listeners".
5180
5183
  localProcessScan = {
5181
5184
  ok: false,
5182
- reason: normalizeString(error?.message) || "process_scan_failed",
5183
- processCount: 0,
5184
- duplicateProcessCount: 0,
5185
+ reason: summary.reason,
5186
+ processCount: null,
5187
+ duplicateProcessCount: null,
5185
5188
  agents: [],
5186
5189
  };
5187
5190
  }
@@ -5231,6 +5234,11 @@ export function registerSessionCommand(program) {
5231
5234
  else if (row.status === "idle") console.log(pc.cyan(` ${line}`));
5232
5235
  else console.log(pc.gray(` ${line}`));
5233
5236
  }
5237
+ if (!localProcessScan.ok) {
5238
+ console.log(pc.yellow(
5239
+ `Local listener processes: unknown. The local process check did not complete (${localProcessScan.reason}), so duplicate local listeners were not checked.`,
5240
+ ));
5241
+ }
5234
5242
  const duplicateLocalRows = listeners.filter((row) => Number(row.localDuplicateProcessCount || 0) > 0);
5235
5243
  if (duplicateLocalRows.length > 0) {
5236
5244
  const summary = duplicateLocalRows
@@ -5484,9 +5492,23 @@ export function registerSessionCommand(program) {
5484
5492
  const singletonEnabled = maxPolls === null && !options.allowDuplicate;
5485
5493
  let listenerPidRecordActive = false;
5486
5494
  if (singletonEnabled) {
5487
- const existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
5495
+ let existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
5488
5496
  targetPath,
5489
5497
  });
5498
+ if (existingListener.state === "unknown") {
5499
+ // A busy machine can need longer than the first limit: check once more, slower.
5500
+ existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
5501
+ targetPath,
5502
+ scanTimeoutMs: LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS,
5503
+ });
5504
+ }
5505
+ if (existingListener.state === "unknown") {
5506
+ // Starting without an answer could add a second listener, so refuse, with or
5507
+ // without --force: --force replaces the listener the check finds.
5508
+ throw new Error(
5509
+ `Could not confirm whether a session listener is already running for session ${normalizedSessionId} as ${agentId}: the local process check did not complete (${existingListener.reason}), including one retry with a longer time limit. No listener was started. Run the command again, or use --allow-duplicate to start without this check (it can then run alongside an existing listener). --force does not skip the check: it stops the listener the check finds, then starts this one.`,
5510
+ );
5511
+ }
5490
5512
  if (existingListener.running && !options.force) {
5491
5513
  throw new Error(
5492
5514
  `A session listener is already running for session ${normalizedSessionId} as ${agentId} (pid ${existingListener.pid}). Use --force to take over or --allow-duplicate if you intentionally need more than one.`,
@@ -5494,8 +5516,19 @@ export function registerSessionCommand(program) {
5494
5516
  }
5495
5517
  if (existingListener.running && options.force) {
5496
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
+ ),
5527
+ );
5528
+ }
5529
+ const scannedStops = stopMatchesResult.ok ? stopMatchesResult.results : [];
5497
5530
  const stoppedPids = new Set(
5498
- stopMatchesResult.results
5531
+ scannedStops
5499
5532
  .filter((result) => result.stopped)
5500
5533
  .map((result) => Number(result.pid)),
5501
5534
  );
@@ -5504,7 +5537,7 @@ export function registerSessionCommand(program) {
5504
5537
  explicitStopResult = await requestListenerProcessStop(existingListener.pid);
5505
5538
  }
5506
5539
  const failedStops = [
5507
- ...stopMatchesResult.failed,
5540
+ ...scannedStops.filter((result) => !result.stopped),
5508
5541
  ...(explicitStopResult && !explicitStopResult.stopped
5509
5542
  ? [{ pid: existingListener.pid, ...explicitStopResult }]
5510
5543
  : []),
package/src/legacy-cli.js CHANGED
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env node
2
-
3
1
  import crypto from "node:crypto";
4
2
  import fs from "node:fs";
5
3
  import fsp from "node:fs/promises";
@@ -8,7 +6,6 @@ import process from "node:process";
8
6
  import { spawnSync } from "node:child_process";
9
7
  import { createInterface } from "node:readline/promises";
10
8
  import { stdin as input, stdout as output } from "node:process";
11
- import { pathToFileURL } from "node:url";
12
9
 
13
10
  import open from "open";
14
11
  import pc from "picocolors";
@@ -3764,10 +3761,3 @@ export async function runLegacyCliWithErrorHandling(rawArgs = process.argv.slice
3764
3761
  }
3765
3762
  }
3766
3763
 
3767
- const invokedAsEntrypoint =
3768
- process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
3769
-
3770
- if (invokedAsEntrypoint) {
3771
- runLegacyCliWithErrorHandling();
3772
- }
3773
-
@@ -13,6 +13,12 @@ const LISTENER_DIR_NAME = "listeners";
13
13
  const GLOBAL_LISTENER_DIR_NAME = "session-listeners";
14
14
  const execFileAsync = promisify(execFile);
15
15
 
16
+ // Time allowed for one scan of the process table. A busy machine can need longer than the
17
+ // first limit, so a caller that must not guess (the one-listener check in `session listen`)
18
+ // retries once with the longer one before it reports the check as incomplete.
19
+ export const LISTENER_PROCESS_SCAN_TIMEOUT_MS = 3_000;
20
+ export const LISTENER_PROCESS_SCAN_RETRY_TIMEOUT_MS = 10_000;
21
+
16
22
  function normalizeString(value) {
17
23
  return String(value || "").trim();
18
24
  }
@@ -34,6 +40,31 @@ function escapeRegExp(value) {
34
40
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
41
  }
36
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
+
37
68
  function commandLineFlagValueMatches(commandLine, flagName, expectedValue) {
38
69
  const expected = normalizeString(expectedValue).toLowerCase();
39
70
  if (!expected) return { found: false, matches: true };
@@ -52,15 +83,20 @@ function commandLineFlagValueMatches(commandLine, flagName, expectedValue) {
52
83
  return { found, matches: false };
53
84
  }
54
85
 
55
- 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
+ ) {
56
94
  const rawCommandLine = normalizeString(commandLine);
57
95
  const normalized = normalizeCommandLine(rawCommandLine);
58
96
  if (!normalized) return false;
59
97
  const normalizedSessionId = normalizeString(sessionId).toLowerCase();
60
98
  const normalizedAgentId = normalizeString(agentId).toLowerCase();
61
- const hasCliEntry =
62
- normalized.includes("sentinelayer-cli") ||
63
- /[\\/](?:sl|sentinelayer-cli)\.js\b/.test(normalized);
99
+ const hasCliEntry = new RegExp(cliEntryPattern(platform)).test(normalized);
64
100
  const sessionFlagMatch = commandLineFlagValueMatches(rawCommandLine, "session", normalizedSessionId);
65
101
  const agentFlagMatch = commandLineFlagValueMatches(rawCommandLine, "agent", normalizedAgentId);
66
102
  return (
@@ -88,18 +124,18 @@ function normalizeProcessRows(rows = []) {
88
124
  .filter((row) => Number.isInteger(row.pid) && row.pid > 0 && row.commandLine);
89
125
  }
90
126
 
91
- async function listWindowsProcesses() {
127
+ async function listWindowsProcesses({ timeoutMs }) {
92
128
  const command = [
93
129
  "$ErrorActionPreference = 'Stop';",
94
130
  "$rows = Get-CimInstance Win32_Process |",
95
- "Where-Object { $_.CommandLine -and ($_.CommandLine -match 'sentinelayer-cli|[\\\\/](sl|sentinelayer-cli)\\.js') } |",
131
+ `Where-Object { $_.CommandLine -and ($_.CommandLine -match '${cliEntryPattern("win32")}') } |`,
96
132
  "Select-Object ProcessId,CommandLine;",
97
133
  "if ($rows) { $rows | ConvertTo-Json -Compress }",
98
134
  ].join(" ");
99
135
  const { stdout } = await execFileAsync(
100
136
  "powershell.exe",
101
137
  ["-NoProfile", "-NonInteractive", "-Command", command],
102
- { windowsHide: true, timeout: 3000, maxBuffer: 10 * 1024 * 1024 },
138
+ { windowsHide: true, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
103
139
  );
104
140
  const text = normalizeString(stdout);
105
141
  if (!text) return [];
@@ -107,9 +143,9 @@ async function listWindowsProcesses() {
107
143
  return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
108
144
  }
109
145
 
110
- async function listPosixProcesses() {
146
+ async function listPosixProcesses({ timeoutMs }) {
111
147
  const { stdout } = await execFileAsync("ps", ["-eo", "pid=,command="], {
112
- timeout: 3000,
148
+ timeout: timeoutMs,
113
149
  maxBuffer: 10 * 1024 * 1024,
114
150
  });
115
151
  return String(stdout || "")
@@ -121,30 +157,73 @@ async function listPosixProcesses() {
121
157
  .filter(Boolean);
122
158
  }
123
159
 
124
- async function listProcessTable() {
160
+ // Resolves to the process rows, or rejects when the table cannot be read (a timeout, a
161
+ // missing tool, unreadable output). It never turns a failed read into an empty table.
162
+ async function listProcessTable({ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS } = {}) {
163
+ return process.platform === "win32"
164
+ ? listWindowsProcesses({ timeoutMs })
165
+ : listPosixProcesses({ timeoutMs });
166
+ }
167
+
168
+ // The probes `session listen` uses; replaced only through setListenerProcessProbesForTests.
169
+ let activeListProcessTable = listProcessTable;
170
+ let activeReadProcessCommandLine = readProcessCommandLine;
171
+
172
+ /**
173
+ * Test seam: replace the process-table scan and the per-pid command-line read that the
174
+ * listener commands use. Call with no argument to restore the real probes.
175
+ */
176
+ export function setListenerProcessProbesForTests({ listProcesses = null, readCommandLine = null } = {}) {
177
+ activeListProcessTable = listProcesses || listProcessTable;
178
+ activeReadProcessCommandLine = readCommandLine || readProcessCommandLine;
179
+ }
180
+
181
+ function processScanFailureReason(error) {
182
+ const code = normalizeString(error?.code);
183
+ // execFile kills the scan at its timeout and reports it as killed with no code.
184
+ if (code === "ETIMEDOUT" || (!code && error?.killed)) return "process_scan_timeout";
185
+ if (error instanceof SyntaxError) return "process_scan_unreadable";
186
+ return code ? `process_scan_failed:${code}` : "process_scan_failed";
187
+ }
188
+
189
+ // One scan of the process table: { ok: true, rows } or { ok: false, reason }.
190
+ async function scanProcessTable(listProcesses, timeoutMs) {
191
+ let rows;
125
192
  try {
126
- return process.platform === "win32"
127
- ? await listWindowsProcesses()
128
- : await listPosixProcesses();
129
- } catch {
130
- return [];
193
+ rows = await listProcesses({ timeoutMs });
194
+ } catch (error) {
195
+ return { ok: false, reason: processScanFailureReason(error) };
196
+ }
197
+ if (!Array.isArray(rows)) {
198
+ return { ok: false, reason: "process_scan_unreadable" };
131
199
  }
200
+ return { ok: true, rows: normalizeProcessRows(rows) };
132
201
  }
133
202
 
203
+ /**
204
+ * Listener processes for this session and agent: { ok: true, matches } when the process
205
+ * 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".
207
+ */
134
208
  export async function listMatchingListenerProcesses(
135
209
  sessionId,
136
210
  agentId,
137
211
  {
138
212
  excludePid = process.pid,
139
- _listProcesses = listProcessTable,
213
+ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
214
+ _listProcesses = activeListProcessTable,
140
215
  } = {},
141
216
  ) {
142
- const rows = normalizeProcessRows(await _listProcesses());
217
+ const scan = await scanProcessTable(_listProcesses, timeoutMs);
218
+ if (!scan.ok) return scan;
143
219
  const excluded = Number(excludePid);
144
- return rows.filter((row) =>
145
- row.pid !== excluded &&
146
- listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
147
- );
220
+ return {
221
+ ok: true,
222
+ matches: scan.rows.filter((row) =>
223
+ row.pid !== excluded &&
224
+ listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
225
+ ),
226
+ };
148
227
  }
149
228
 
150
229
  export async function summarizeLocalListenerProcesses(
@@ -152,16 +231,20 @@ export async function summarizeLocalListenerProcesses(
152
231
  agentIds = [],
153
232
  {
154
233
  excludePid = process.pid,
155
- _listProcesses = listProcessTable,
234
+ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
235
+ _listProcesses = activeListProcessTable,
156
236
  } = {},
157
237
  ) {
158
238
  const normalizedSessionId = normalizeString(sessionId);
159
239
  const uniqueAgentIds = [...new Set((Array.isArray(agentIds) ? agentIds : [])
160
240
  .map((agentId) => normalizeString(agentId))
161
241
  .filter(Boolean))];
162
- const rows = normalizeProcessRows(await _listProcesses());
242
+ const scan = await scanProcessTable(_listProcesses, timeoutMs);
243
+ if (!scan.ok) {
244
+ return { ok: false, reason: scan.reason, sessionId: normalizedSessionId };
245
+ }
163
246
  const excluded = Number(excludePid);
164
- const candidates = rows.filter((row) => row.pid !== excluded);
247
+ const candidates = scan.rows.filter((row) => row.pid !== excluded);
165
248
  const agents = uniqueAgentIds.map((agentId) => {
166
249
  const matches = candidates
167
250
  .filter((row) => listenerCommandLineMatches(row.commandLine, {
@@ -178,6 +261,7 @@ export async function summarizeLocalListenerProcesses(
178
261
  };
179
262
  });
180
263
  return {
264
+ ok: true,
181
265
  sessionId: normalizedSessionId,
182
266
  processCount: agents.reduce((sum, agent) => sum + agent.processCount, 0),
183
267
  duplicateProcessCount: agents.reduce((sum, agent) => sum + agent.duplicateProcessCount, 0),
@@ -367,12 +451,13 @@ async function listenerStatusFromRecord(
367
451
  sessionId,
368
452
  agentId,
369
453
  recordScope,
370
- _readProcessCommandLine = readProcessCommandLine,
454
+ _readProcessCommandLine = activeReadProcessCommandLine,
371
455
  } = {},
372
456
  ) {
373
457
  const alive = isProcessAlive(record.pid);
374
458
  if (!alive) {
375
459
  return {
460
+ state: "not_running",
376
461
  running: false,
377
462
  pid: null,
378
463
  stale: true,
@@ -384,6 +469,7 @@ async function listenerStatusFromRecord(
384
469
  const commandLine = await _readProcessCommandLine(record.pid);
385
470
  if (!listenerCommandLineMatches(commandLine, { sessionId, agentId })) {
386
471
  return {
472
+ state: "not_running",
387
473
  running: false,
388
474
  pid: null,
389
475
  stale: true,
@@ -396,6 +482,7 @@ async function listenerStatusFromRecord(
396
482
  };
397
483
  }
398
484
  return {
485
+ state: "running",
399
486
  running: true,
400
487
  pid: Number(record.pid),
401
488
  stale: false,
@@ -406,14 +493,21 @@ async function listenerStatusFromRecord(
406
493
  };
407
494
  }
408
495
 
496
+ /**
497
+ * 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".
501
+ */
409
502
  export async function getListenerProcessStatus(
410
503
  sessionId,
411
504
  agentId,
412
505
  {
413
506
  targetPath = process.cwd(),
414
507
  homeDir = os.homedir(),
415
- _readProcessCommandLine = readProcessCommandLine,
416
- _listProcesses = listProcessTable,
508
+ scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
509
+ _readProcessCommandLine = activeReadProcessCommandLine,
510
+ _listProcesses = activeListProcessTable,
417
511
  } = {},
418
512
  ) {
419
513
  const records = [
@@ -440,11 +534,24 @@ export async function getListenerProcessStatus(
440
534
  }
441
535
  staleStatuses.push(status);
442
536
  }
443
- const matchingProcesses = await listMatchingListenerProcesses(sessionId, agentId, {
537
+ const scan = await listMatchingListenerProcesses(sessionId, agentId, {
538
+ timeoutMs: scanTimeoutMs,
444
539
  _listProcesses,
445
540
  });
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
+ };
550
+ }
551
+ const matchingProcesses = scan.matches;
446
552
  if (matchingProcesses.length > 0) {
447
553
  return {
554
+ state: "running",
448
555
  running: true,
449
556
  pid: matchingProcesses[0].pid,
450
557
  stale: false,
@@ -458,6 +565,7 @@ export async function getListenerProcessStatus(
458
565
  }
459
566
  if (staleStatuses.length === 0) {
460
567
  return {
568
+ state: "not_running",
461
569
  running: false,
462
570
  pid: null,
463
571
  stale: false,
@@ -468,6 +576,10 @@ export async function getListenerProcessStatus(
468
576
  return staleStatuses[0];
469
577
  }
470
578
 
579
+ /**
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 }.
582
+ */
471
583
  export async function stopMatchingListenerProcesses(
472
584
  sessionId,
473
585
  agentId,
@@ -475,13 +587,17 @@ export async function stopMatchingListenerProcesses(
475
587
  excludePid = process.pid,
476
588
  timeoutMs = 3000,
477
589
  pollIntervalMs = 100,
478
- _listProcesses = listProcessTable,
590
+ scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
591
+ _listProcesses = activeListProcessTable,
479
592
  } = {},
480
593
  ) {
481
- const matches = await listMatchingListenerProcesses(sessionId, agentId, {
594
+ const scan = await listMatchingListenerProcesses(sessionId, agentId, {
482
595
  excludePid,
596
+ timeoutMs: scanTimeoutMs,
483
597
  _listProcesses,
484
598
  });
599
+ if (!scan.ok) return scan;
600
+ const matches = scan.matches;
485
601
  const results = [];
486
602
  for (const match of matches) {
487
603
  const result = await requestListenerProcessStop(match.pid, {
@@ -496,6 +612,7 @@ export async function stopMatchingListenerProcesses(
496
612
  }
497
613
  const failed = results.filter((result) => !result.stopped);
498
614
  return {
615
+ ok: true,
499
616
  matches,
500
617
  results,
501
618
  stoppedCount: results.filter((result) => result.stopped).length,