sentinelayer-cli 0.36.2 → 0.36.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.36.2",
3
+ "version": "0.36.4",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -69,6 +69,8 @@ import {
69
69
  getListenerProcessStatus,
70
70
  removeListenerPidRecord,
71
71
  requestListenerProcessStop,
72
+ summarizeLocalListenerProcesses,
73
+ stopMatchingListenerProcesses,
72
74
  writeListenerPidRecord,
73
75
  } from "../session/listener-process.js";
74
76
  import {
@@ -4465,7 +4467,47 @@ export function registerSessionCommand(program) {
4465
4467
  limit,
4466
4468
  maxPages,
4467
4469
  });
4468
- const listeners = Array.isArray(result.listeners) ? result.listeners : [];
4470
+ let listeners = Array.isArray(result.listeners) ? result.listeners : [];
4471
+ let localProcessScan = {
4472
+ ok: false,
4473
+ reason: listeners.length > 0 ? "not_scanned" : "no_listener_rows",
4474
+ processCount: 0,
4475
+ duplicateProcessCount: 0,
4476
+ agents: [],
4477
+ };
4478
+ if (listeners.length > 0) {
4479
+ try {
4480
+ const summary = await summarizeLocalListenerProcesses(
4481
+ normalizedSessionId,
4482
+ listeners.map((row) => row.agentId),
4483
+ );
4484
+ const byAgent = new Map(summary.agents.map((entry) => [entry.agentId, entry]));
4485
+ listeners = listeners.map((row) => {
4486
+ const local = byAgent.get(row.agentId) || {};
4487
+ const pids = Array.isArray(local.pids) ? local.pids : [];
4488
+ return {
4489
+ ...row,
4490
+ localProcessCount: Number(local.processCount || 0),
4491
+ localDuplicateProcessCount: Number(local.duplicateProcessCount || 0),
4492
+ localProcessPids: pids,
4493
+ };
4494
+ });
4495
+ localProcessScan = {
4496
+ ok: true,
4497
+ processCount: Number(summary.processCount || 0),
4498
+ duplicateProcessCount: Number(summary.duplicateProcessCount || 0),
4499
+ agents: summary.agents,
4500
+ };
4501
+ } catch (error) {
4502
+ localProcessScan = {
4503
+ ok: false,
4504
+ reason: normalizeString(error?.message) || "process_scan_failed",
4505
+ processCount: 0,
4506
+ duplicateProcessCount: 0,
4507
+ agents: [],
4508
+ };
4509
+ }
4510
+ }
4469
4511
  const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
4470
4512
  const payload = {
4471
4513
  command: "session listeners",
@@ -4479,6 +4521,7 @@ export function registerSessionCommand(program) {
4479
4521
  listenerEventCount: Number(result.listenerEventCount || 0),
4480
4522
  partial: Boolean(result.partial),
4481
4523
  beforeSequence: result.beforeSequence || null,
4524
+ localProcessScan,
4482
4525
  listeners,
4483
4526
  };
4484
4527
  if (shouldEmitJson(options, command)) {
@@ -4500,6 +4543,16 @@ export function registerSessionCommand(program) {
4500
4543
  else if (row.status === "idle") console.log(pc.cyan(` ${line}`));
4501
4544
  else console.log(pc.gray(` ${line}`));
4502
4545
  }
4546
+ const duplicateLocalRows = listeners.filter((row) => Number(row.localDuplicateProcessCount || 0) > 0);
4547
+ if (duplicateLocalRows.length > 0) {
4548
+ const summary = duplicateLocalRows
4549
+ .map((row) => `${row.agentId}:${row.localProcessCount}`)
4550
+ .join(", ");
4551
+ console.log(pc.yellow(`Local process warning: multiple matching listener processes detected (${summary}).`));
4552
+ console.log(pc.gray(
4553
+ `Use "sl session listen --session ${normalizedSessionId} --agent <your-agent> --force --from-now" to replace your own duplicate listener; coordinate before touching another agent.`,
4554
+ ));
4555
+ }
4503
4556
  return payload;
4504
4557
  });
4505
4558
 
@@ -4752,10 +4805,26 @@ export function registerSessionCommand(program) {
4752
4805
  );
4753
4806
  }
4754
4807
  if (existingListener.running && options.force) {
4755
- const stopResult = await requestListenerProcessStop(existingListener.pid);
4756
- if (!stopResult.stopped) {
4808
+ const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId);
4809
+ const stoppedPids = new Set(
4810
+ stopMatchesResult.results
4811
+ .filter((result) => result.stopped)
4812
+ .map((result) => Number(result.pid)),
4813
+ );
4814
+ let explicitStopResult = null;
4815
+ if (existingListener.pid && !stoppedPids.has(Number(existingListener.pid))) {
4816
+ explicitStopResult = await requestListenerProcessStop(existingListener.pid);
4817
+ }
4818
+ const failedStops = [
4819
+ ...stopMatchesResult.failed,
4820
+ ...(explicitStopResult && !explicitStopResult.stopped
4821
+ ? [{ pid: existingListener.pid, ...explicitStopResult }]
4822
+ : []),
4823
+ ];
4824
+ if (failedStops.length > 0) {
4825
+ const failed = failedStops[0];
4757
4826
  throw new Error(
4758
- `Unable to stop existing listener pid ${existingListener.pid} for session ${normalizedSessionId} as ${agentId} (${stopResult.reason}). Stop it manually or use --allow-duplicate if parallel listeners are intentional.`,
4827
+ `Unable to stop existing listener pid ${failed.pid || existingListener.pid} for session ${normalizedSessionId} as ${agentId} (${failed.reason}). Stop it manually or use --allow-duplicate if parallel listeners are intentional.`,
4759
4828
  );
4760
4829
  }
4761
4830
  }
@@ -30,21 +30,159 @@ function normalizeCommandLine(value) {
30
30
  return normalizeString(value).toLowerCase();
31
31
  }
32
32
 
33
+ function escapeRegExp(value) {
34
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ }
36
+
37
+ function commandLineFlagValueMatches(commandLine, flagName, expectedValue) {
38
+ const expected = normalizeString(expectedValue).toLowerCase();
39
+ if (!expected) return { found: false, matches: true };
40
+ const pattern = new RegExp(
41
+ `(?:^|\\s)--${escapeRegExp(flagName)}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|([^\\s]+))`,
42
+ "gi",
43
+ );
44
+ let found = false;
45
+ for (const match of commandLine.matchAll(pattern)) {
46
+ found = true;
47
+ const value = normalizeString(match[1] ?? match[2] ?? match[3]).toLowerCase();
48
+ if (value === expected) {
49
+ return { found: true, matches: true };
50
+ }
51
+ }
52
+ return { found, matches: false };
53
+ }
54
+
33
55
  function listenerCommandLineMatches(commandLine, { sessionId = "", agentId = "" } = {}) {
34
- const normalized = normalizeCommandLine(commandLine);
56
+ const rawCommandLine = normalizeString(commandLine);
57
+ const normalized = normalizeCommandLine(rawCommandLine);
35
58
  if (!normalized) return false;
36
59
  const normalizedSessionId = normalizeString(sessionId).toLowerCase();
37
60
  const normalizedAgentId = normalizeString(agentId).toLowerCase();
38
61
  const hasCliEntry =
39
62
  normalized.includes("sentinelayer-cli") ||
40
63
  /[\\/](?:sl|sentinelayer-cli)\.js\b/.test(normalized);
64
+ const sessionFlagMatch = commandLineFlagValueMatches(rawCommandLine, "session", normalizedSessionId);
65
+ const agentFlagMatch = commandLineFlagValueMatches(rawCommandLine, "agent", normalizedAgentId);
41
66
  return (
42
67
  hasCliEntry &&
43
68
  normalized.includes("session") &&
44
69
  normalized.includes("listen") &&
45
- (!normalizedSessionId || normalized.includes(normalizedSessionId)) &&
46
- (!normalizedAgentId || normalized.includes(normalizedAgentId))
70
+ (!normalizedSessionId ||
71
+ (sessionFlagMatch.found
72
+ ? sessionFlagMatch.matches
73
+ : normalized.includes(normalizedSessionId))) &&
74
+ (!normalizedAgentId ||
75
+ (agentFlagMatch.found
76
+ ? agentFlagMatch.matches
77
+ : normalized.includes(normalizedAgentId)))
78
+ );
79
+ }
80
+
81
+ function normalizeProcessRows(rows = []) {
82
+ if (!Array.isArray(rows)) return [];
83
+ return rows
84
+ .map((row) => ({
85
+ pid: Number(row?.pid ?? row?.ProcessId ?? row?.processId),
86
+ commandLine: normalizeString(row?.commandLine ?? row?.CommandLine ?? row?.command ?? row?.args),
87
+ }))
88
+ .filter((row) => Number.isInteger(row.pid) && row.pid > 0 && row.commandLine);
89
+ }
90
+
91
+ async function listWindowsProcesses() {
92
+ const command = [
93
+ "$ErrorActionPreference = 'Stop';",
94
+ "$rows = Get-CimInstance Win32_Process |",
95
+ "Where-Object { $_.CommandLine -and ($_.CommandLine -match 'sentinelayer-cli|[\\\\/](sl|sentinelayer-cli)\\.js') } |",
96
+ "Select-Object ProcessId,CommandLine;",
97
+ "if ($rows) { $rows | ConvertTo-Json -Compress }",
98
+ ].join(" ");
99
+ const { stdout } = await execFileAsync(
100
+ "powershell.exe",
101
+ ["-NoProfile", "-NonInteractive", "-Command", command],
102
+ { windowsHide: true, timeout: 3000, maxBuffer: 10 * 1024 * 1024 },
47
103
  );
104
+ const text = normalizeString(stdout);
105
+ if (!text) return [];
106
+ const parsed = JSON.parse(text);
107
+ return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
108
+ }
109
+
110
+ async function listPosixProcesses() {
111
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,command="], {
112
+ timeout: 3000,
113
+ maxBuffer: 10 * 1024 * 1024,
114
+ });
115
+ return String(stdout || "")
116
+ .split(/\r?\n/)
117
+ .map((line) => {
118
+ const match = line.match(/^\s*(\d+)\s+(.*)$/);
119
+ return match ? { pid: Number(match[1]), commandLine: normalizeString(match[2]) } : null;
120
+ })
121
+ .filter(Boolean);
122
+ }
123
+
124
+ async function listProcessTable() {
125
+ try {
126
+ return process.platform === "win32"
127
+ ? await listWindowsProcesses()
128
+ : await listPosixProcesses();
129
+ } catch {
130
+ return [];
131
+ }
132
+ }
133
+
134
+ export async function listMatchingListenerProcesses(
135
+ sessionId,
136
+ agentId,
137
+ {
138
+ excludePid = process.pid,
139
+ _listProcesses = listProcessTable,
140
+ } = {},
141
+ ) {
142
+ const rows = normalizeProcessRows(await _listProcesses());
143
+ const excluded = Number(excludePid);
144
+ return rows.filter((row) =>
145
+ row.pid !== excluded &&
146
+ listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
147
+ );
148
+ }
149
+
150
+ export async function summarizeLocalListenerProcesses(
151
+ sessionId,
152
+ agentIds = [],
153
+ {
154
+ excludePid = process.pid,
155
+ _listProcesses = listProcessTable,
156
+ } = {},
157
+ ) {
158
+ const normalizedSessionId = normalizeString(sessionId);
159
+ const uniqueAgentIds = [...new Set((Array.isArray(agentIds) ? agentIds : [])
160
+ .map((agentId) => normalizeString(agentId))
161
+ .filter(Boolean))];
162
+ const rows = normalizeProcessRows(await _listProcesses());
163
+ const excluded = Number(excludePid);
164
+ const candidates = rows.filter((row) => row.pid !== excluded);
165
+ const agents = uniqueAgentIds.map((agentId) => {
166
+ const matches = candidates
167
+ .filter((row) => listenerCommandLineMatches(row.commandLine, {
168
+ sessionId: normalizedSessionId,
169
+ agentId,
170
+ }))
171
+ .sort((a, b) => a.pid - b.pid);
172
+ const pids = matches.map((match) => match.pid);
173
+ return {
174
+ agentId,
175
+ processCount: pids.length,
176
+ duplicateProcessCount: Math.max(0, pids.length - 1),
177
+ pids,
178
+ };
179
+ });
180
+ return {
181
+ sessionId: normalizedSessionId,
182
+ processCount: agents.reduce((sum, agent) => sum + agent.processCount, 0),
183
+ duplicateProcessCount: agents.reduce((sum, agent) => sum + agent.duplicateProcessCount, 0),
184
+ agents,
185
+ };
48
186
  }
49
187
 
50
188
  async function readWindowsCommandLine(pid) {
@@ -276,6 +414,7 @@ export async function getListenerProcessStatus(
276
414
  targetPath = process.cwd(),
277
415
  homeDir = os.homedir(),
278
416
  _readProcessCommandLine = readProcessCommandLine,
417
+ _listProcesses = listProcessTable,
279
418
  } = {},
280
419
  ) {
281
420
  const records = [
@@ -302,6 +441,22 @@ export async function getListenerProcessStatus(
302
441
  }
303
442
  staleStatuses.push(status);
304
443
  }
444
+ const matchingProcesses = await listMatchingListenerProcesses(sessionId, agentId, {
445
+ _listProcesses,
446
+ });
447
+ if (matchingProcesses.length > 0) {
448
+ return {
449
+ running: true,
450
+ pid: matchingProcesses[0].pid,
451
+ stale: false,
452
+ record: null,
453
+ recordScope: "process_scan",
454
+ untracked: true,
455
+ listenerKey: normalizeListenerProcessKey(agentId),
456
+ commandLine: matchingProcesses[0].commandLine,
457
+ matchingProcesses,
458
+ };
459
+ }
305
460
  if (staleStatuses.length === 0) {
306
461
  return {
307
462
  running: false,
@@ -314,6 +469,42 @@ export async function getListenerProcessStatus(
314
469
  return staleStatuses[0];
315
470
  }
316
471
 
472
+ export async function stopMatchingListenerProcesses(
473
+ sessionId,
474
+ agentId,
475
+ {
476
+ excludePid = process.pid,
477
+ timeoutMs = 3000,
478
+ pollIntervalMs = 100,
479
+ _listProcesses = listProcessTable,
480
+ } = {},
481
+ ) {
482
+ const matches = await listMatchingListenerProcesses(sessionId, agentId, {
483
+ excludePid,
484
+ _listProcesses,
485
+ });
486
+ const results = [];
487
+ for (const match of matches) {
488
+ const result = await requestListenerProcessStop(match.pid, {
489
+ timeoutMs,
490
+ pollIntervalMs,
491
+ });
492
+ results.push({
493
+ pid: match.pid,
494
+ commandLine: match.commandLine,
495
+ ...result,
496
+ });
497
+ }
498
+ const failed = results.filter((result) => !result.stopped);
499
+ return {
500
+ matches,
501
+ results,
502
+ stoppedCount: results.filter((result) => result.stopped).length,
503
+ failedCount: failed.length,
504
+ failed,
505
+ };
506
+ }
507
+
317
508
  export async function requestListenerProcessStop(
318
509
  pid,
319
510
  { signal = "SIGTERM", timeoutMs = 3000, pollIntervalMs = 100 } = {},
@@ -246,5 +246,13 @@ export function formatListenerLine(row) {
246
246
  : row.status === "stale"
247
247
  ? "◌ stale"
248
248
  : "× stopped";
249
- return `${statusLabel.padEnd(10)} ${row.agentId.padEnd(24)} cadence=${cadence.padEnd(6)} last_seen=${seen}`;
249
+ const localProcessCount = Number(row.localProcessCount || 0);
250
+ const localPids = Array.isArray(row.localProcessPids)
251
+ ? row.localProcessPids.filter((pid) => Number.isInteger(Number(pid)) && Number(pid) > 0)
252
+ : [];
253
+ const local =
254
+ localProcessCount > 1 && localPids.length > 0
255
+ ? ` local_pids=${localPids.join(",")}`
256
+ : "";
257
+ return `${statusLabel.padEnd(10)} ${row.agentId.padEnd(24)} cadence=${cadence.padEnd(6)} last_seen=${seen}${local}`;
250
258
  }