sentinelayer-cli 0.46.1 → 0.46.2

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.2",
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
  }
@@ -88,7 +94,7 @@ function normalizeProcessRows(rows = []) {
88
94
  .filter((row) => Number.isInteger(row.pid) && row.pid > 0 && row.commandLine);
89
95
  }
90
96
 
91
- async function listWindowsProcesses() {
97
+ async function listWindowsProcesses({ timeoutMs }) {
92
98
  const command = [
93
99
  "$ErrorActionPreference = 'Stop';",
94
100
  "$rows = Get-CimInstance Win32_Process |",
@@ -99,7 +105,7 @@ async function listWindowsProcesses() {
99
105
  const { stdout } = await execFileAsync(
100
106
  "powershell.exe",
101
107
  ["-NoProfile", "-NonInteractive", "-Command", command],
102
- { windowsHide: true, timeout: 3000, maxBuffer: 10 * 1024 * 1024 },
108
+ { windowsHide: true, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
103
109
  );
104
110
  const text = normalizeString(stdout);
105
111
  if (!text) return [];
@@ -107,9 +113,9 @@ async function listWindowsProcesses() {
107
113
  return normalizeProcessRows(Array.isArray(parsed) ? parsed : [parsed]);
108
114
  }
109
115
 
110
- async function listPosixProcesses() {
116
+ async function listPosixProcesses({ timeoutMs }) {
111
117
  const { stdout } = await execFileAsync("ps", ["-eo", "pid=,command="], {
112
- timeout: 3000,
118
+ timeout: timeoutMs,
113
119
  maxBuffer: 10 * 1024 * 1024,
114
120
  });
115
121
  return String(stdout || "")
@@ -121,30 +127,73 @@ async function listPosixProcesses() {
121
127
  .filter(Boolean);
122
128
  }
123
129
 
124
- async function listProcessTable() {
130
+ // Resolves to the process rows, or rejects when the table cannot be read (a timeout, a
131
+ // missing tool, unreadable output). It never turns a failed read into an empty table.
132
+ async function listProcessTable({ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS } = {}) {
133
+ return process.platform === "win32"
134
+ ? listWindowsProcesses({ timeoutMs })
135
+ : listPosixProcesses({ timeoutMs });
136
+ }
137
+
138
+ // The probes `session listen` uses; replaced only through setListenerProcessProbesForTests.
139
+ let activeListProcessTable = listProcessTable;
140
+ let activeReadProcessCommandLine = readProcessCommandLine;
141
+
142
+ /**
143
+ * Test seam: replace the process-table scan and the per-pid command-line read that the
144
+ * listener commands use. Call with no argument to restore the real probes.
145
+ */
146
+ export function setListenerProcessProbesForTests({ listProcesses = null, readCommandLine = null } = {}) {
147
+ activeListProcessTable = listProcesses || listProcessTable;
148
+ activeReadProcessCommandLine = readCommandLine || readProcessCommandLine;
149
+ }
150
+
151
+ function processScanFailureReason(error) {
152
+ const code = normalizeString(error?.code);
153
+ // execFile kills the scan at its timeout and reports it as killed with no code.
154
+ if (code === "ETIMEDOUT" || (!code && error?.killed)) return "process_scan_timeout";
155
+ if (error instanceof SyntaxError) return "process_scan_unreadable";
156
+ return code ? `process_scan_failed:${code}` : "process_scan_failed";
157
+ }
158
+
159
+ // One scan of the process table: { ok: true, rows } or { ok: false, reason }.
160
+ async function scanProcessTable(listProcesses, timeoutMs) {
161
+ let rows;
125
162
  try {
126
- return process.platform === "win32"
127
- ? await listWindowsProcesses()
128
- : await listPosixProcesses();
129
- } catch {
130
- return [];
163
+ rows = await listProcesses({ timeoutMs });
164
+ } catch (error) {
165
+ return { ok: false, reason: processScanFailureReason(error) };
131
166
  }
167
+ if (!Array.isArray(rows)) {
168
+ return { ok: false, reason: "process_scan_unreadable" };
169
+ }
170
+ return { ok: true, rows: normalizeProcessRows(rows) };
132
171
  }
133
172
 
173
+ /**
174
+ * Listener processes for this session and agent: { ok: true, matches } when the process
175
+ * table was read, { ok: false, reason } when it could not be. A failed scan has no
176
+ * `matches`, so it can never be read as "no listener".
177
+ */
134
178
  export async function listMatchingListenerProcesses(
135
179
  sessionId,
136
180
  agentId,
137
181
  {
138
182
  excludePid = process.pid,
139
- _listProcesses = listProcessTable,
183
+ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
184
+ _listProcesses = activeListProcessTable,
140
185
  } = {},
141
186
  ) {
142
- const rows = normalizeProcessRows(await _listProcesses());
187
+ const scan = await scanProcessTable(_listProcesses, timeoutMs);
188
+ if (!scan.ok) return scan;
143
189
  const excluded = Number(excludePid);
144
- return rows.filter((row) =>
145
- row.pid !== excluded &&
146
- listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
147
- );
190
+ return {
191
+ ok: true,
192
+ matches: scan.rows.filter((row) =>
193
+ row.pid !== excluded &&
194
+ listenerCommandLineMatches(row.commandLine, { sessionId, agentId })
195
+ ),
196
+ };
148
197
  }
149
198
 
150
199
  export async function summarizeLocalListenerProcesses(
@@ -152,16 +201,20 @@ export async function summarizeLocalListenerProcesses(
152
201
  agentIds = [],
153
202
  {
154
203
  excludePid = process.pid,
155
- _listProcesses = listProcessTable,
204
+ timeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
205
+ _listProcesses = activeListProcessTable,
156
206
  } = {},
157
207
  ) {
158
208
  const normalizedSessionId = normalizeString(sessionId);
159
209
  const uniqueAgentIds = [...new Set((Array.isArray(agentIds) ? agentIds : [])
160
210
  .map((agentId) => normalizeString(agentId))
161
211
  .filter(Boolean))];
162
- const rows = normalizeProcessRows(await _listProcesses());
212
+ const scan = await scanProcessTable(_listProcesses, timeoutMs);
213
+ if (!scan.ok) {
214
+ return { ok: false, reason: scan.reason, sessionId: normalizedSessionId };
215
+ }
163
216
  const excluded = Number(excludePid);
164
- const candidates = rows.filter((row) => row.pid !== excluded);
217
+ const candidates = scan.rows.filter((row) => row.pid !== excluded);
165
218
  const agents = uniqueAgentIds.map((agentId) => {
166
219
  const matches = candidates
167
220
  .filter((row) => listenerCommandLineMatches(row.commandLine, {
@@ -178,6 +231,7 @@ export async function summarizeLocalListenerProcesses(
178
231
  };
179
232
  });
180
233
  return {
234
+ ok: true,
181
235
  sessionId: normalizedSessionId,
182
236
  processCount: agents.reduce((sum, agent) => sum + agent.processCount, 0),
183
237
  duplicateProcessCount: agents.reduce((sum, agent) => sum + agent.duplicateProcessCount, 0),
@@ -367,12 +421,13 @@ async function listenerStatusFromRecord(
367
421
  sessionId,
368
422
  agentId,
369
423
  recordScope,
370
- _readProcessCommandLine = readProcessCommandLine,
424
+ _readProcessCommandLine = activeReadProcessCommandLine,
371
425
  } = {},
372
426
  ) {
373
427
  const alive = isProcessAlive(record.pid);
374
428
  if (!alive) {
375
429
  return {
430
+ state: "not_running",
376
431
  running: false,
377
432
  pid: null,
378
433
  stale: true,
@@ -384,6 +439,7 @@ async function listenerStatusFromRecord(
384
439
  const commandLine = await _readProcessCommandLine(record.pid);
385
440
  if (!listenerCommandLineMatches(commandLine, { sessionId, agentId })) {
386
441
  return {
442
+ state: "not_running",
387
443
  running: false,
388
444
  pid: null,
389
445
  stale: true,
@@ -396,6 +452,7 @@ async function listenerStatusFromRecord(
396
452
  };
397
453
  }
398
454
  return {
455
+ state: "running",
399
456
  running: true,
400
457
  pid: Number(record.pid),
401
458
  stale: false,
@@ -406,14 +463,21 @@ async function listenerStatusFromRecord(
406
463
  };
407
464
  }
408
465
 
466
+ /**
467
+ * Whether a listener for this session and agent is running on this machine. `state` is
468
+ * "running", "not_running" or "unknown". A live pid record whose command line matches is
469
+ * "running" without a scan. Otherwise the process table decides, and when it cannot be read
470
+ * the answer is "unknown" (`running: null`, with `reason`), never "not_running".
471
+ */
409
472
  export async function getListenerProcessStatus(
410
473
  sessionId,
411
474
  agentId,
412
475
  {
413
476
  targetPath = process.cwd(),
414
477
  homeDir = os.homedir(),
415
- _readProcessCommandLine = readProcessCommandLine,
416
- _listProcesses = listProcessTable,
478
+ scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
479
+ _readProcessCommandLine = activeReadProcessCommandLine,
480
+ _listProcesses = activeListProcessTable,
417
481
  } = {},
418
482
  ) {
419
483
  const records = [
@@ -440,11 +504,24 @@ export async function getListenerProcessStatus(
440
504
  }
441
505
  staleStatuses.push(status);
442
506
  }
443
- const matchingProcesses = await listMatchingListenerProcesses(sessionId, agentId, {
507
+ const scan = await listMatchingListenerProcesses(sessionId, agentId, {
508
+ timeoutMs: scanTimeoutMs,
444
509
  _listProcesses,
445
510
  });
511
+ if (!scan.ok) {
512
+ return {
513
+ state: "unknown",
514
+ running: null,
515
+ pid: null,
516
+ reason: scan.reason,
517
+ record: staleStatuses[0]?.record || null,
518
+ listenerKey: normalizeListenerProcessKey(agentId),
519
+ };
520
+ }
521
+ const matchingProcesses = scan.matches;
446
522
  if (matchingProcesses.length > 0) {
447
523
  return {
524
+ state: "running",
448
525
  running: true,
449
526
  pid: matchingProcesses[0].pid,
450
527
  stale: false,
@@ -458,6 +535,7 @@ export async function getListenerProcessStatus(
458
535
  }
459
536
  if (staleStatuses.length === 0) {
460
537
  return {
538
+ state: "not_running",
461
539
  running: false,
462
540
  pid: null,
463
541
  stale: false,
@@ -468,6 +546,10 @@ export async function getListenerProcessStatus(
468
546
  return staleStatuses[0];
469
547
  }
470
548
 
549
+ /**
550
+ * Stops every listener process the scan finds for this session and agent. When the process
551
+ * table cannot be read it stops nothing and returns { ok: false, reason }.
552
+ */
471
553
  export async function stopMatchingListenerProcesses(
472
554
  sessionId,
473
555
  agentId,
@@ -475,13 +557,17 @@ export async function stopMatchingListenerProcesses(
475
557
  excludePid = process.pid,
476
558
  timeoutMs = 3000,
477
559
  pollIntervalMs = 100,
478
- _listProcesses = listProcessTable,
560
+ scanTimeoutMs = LISTENER_PROCESS_SCAN_TIMEOUT_MS,
561
+ _listProcesses = activeListProcessTable,
479
562
  } = {},
480
563
  ) {
481
- const matches = await listMatchingListenerProcesses(sessionId, agentId, {
564
+ const scan = await listMatchingListenerProcesses(sessionId, agentId, {
482
565
  excludePid,
566
+ timeoutMs: scanTimeoutMs,
483
567
  _listProcesses,
484
568
  });
569
+ if (!scan.ok) return scan;
570
+ const matches = scan.matches;
485
571
  const results = [];
486
572
  for (const match of matches) {
487
573
  const result = await requestListenerProcessStop(match.pid, {
@@ -496,6 +582,7 @@ export async function stopMatchingListenerProcesses(
496
582
  }
497
583
  const failed = results.filter((result) => !result.stopped);
498
584
  return {
585
+ ok: true,
499
586
  matches,
500
587
  results,
501
588
  stoppedCount: results.filter((result) => result.stopped).length,