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.
- package/bin/create-sentinelayer.js +2 -2
- package/bin/sentinelayer-cli.js +2 -2
- package/bin/sl.js +2 -2
- package/package.json +1 -1
- package/src/cli-bootstrap.js +20 -0
- package/src/cli.js +3 -1
- package/src/commands/session.js +47 -14
- package/src/legacy-cli.js +0 -10
- package/src/session/listener-process.js +147 -30
package/bin/sentinelayer-cli.js
CHANGED
package/bin/sl.js
CHANGED
package/package.json
CHANGED
|
@@ -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);
|
package/src/commands/session.js
CHANGED
|
@@ -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
|
-
|
|
5153
|
-
|
|
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
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
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
|
-
}
|
|
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:
|
|
5183
|
-
processCount:
|
|
5184
|
-
duplicateProcessCount:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
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
|
-
|
|
213
|
+
timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
|
|
214
|
+
_listProcesses = activeListProcessTable,
|
|
140
215
|
} = {},
|
|
141
216
|
) {
|
|
142
|
-
const
|
|
217
|
+
const scan = await scanProcessTable(_listProcesses, timeoutMs);
|
|
218
|
+
if (!scan.ok) return scan;
|
|
143
219
|
const excluded = Number(excludePid);
|
|
144
|
-
return
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
590
|
+
scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
|
|
591
|
+
_listProcesses = activeListProcessTable,
|
|
479
592
|
} = {},
|
|
480
593
|
) {
|
|
481
|
-
const
|
|
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,
|