sentinelayer-cli 0.36.1 → 0.36.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/package.json +1 -1
- package/src/commands/session.js +20 -3
- package/src/session/listener-process.js +156 -3
- package/src/session/recap.js +45 -8
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
getListenerProcessStatus,
|
|
70
70
|
removeListenerPidRecord,
|
|
71
71
|
requestListenerProcessStop,
|
|
72
|
+
stopMatchingListenerProcesses,
|
|
72
73
|
writeListenerPidRecord,
|
|
73
74
|
} from "../session/listener-process.js";
|
|
74
75
|
import {
|
|
@@ -4752,10 +4753,26 @@ export function registerSessionCommand(program) {
|
|
|
4752
4753
|
);
|
|
4753
4754
|
}
|
|
4754
4755
|
if (existingListener.running && options.force) {
|
|
4755
|
-
const
|
|
4756
|
-
|
|
4756
|
+
const stopMatchesResult = await stopMatchingListenerProcesses(normalizedSessionId, agentId);
|
|
4757
|
+
const stoppedPids = new Set(
|
|
4758
|
+
stopMatchesResult.results
|
|
4759
|
+
.filter((result) => result.stopped)
|
|
4760
|
+
.map((result) => Number(result.pid)),
|
|
4761
|
+
);
|
|
4762
|
+
let explicitStopResult = null;
|
|
4763
|
+
if (existingListener.pid && !stoppedPids.has(Number(existingListener.pid))) {
|
|
4764
|
+
explicitStopResult = await requestListenerProcessStop(existingListener.pid);
|
|
4765
|
+
}
|
|
4766
|
+
const failedStops = [
|
|
4767
|
+
...stopMatchesResult.failed,
|
|
4768
|
+
...(explicitStopResult && !explicitStopResult.stopped
|
|
4769
|
+
? [{ pid: existingListener.pid, ...explicitStopResult }]
|
|
4770
|
+
: []),
|
|
4771
|
+
];
|
|
4772
|
+
if (failedStops.length > 0) {
|
|
4773
|
+
const failed = failedStops[0];
|
|
4757
4774
|
throw new Error(
|
|
4758
|
-
`Unable to stop existing listener pid ${existingListener.pid} for session ${normalizedSessionId} as ${agentId} (${
|
|
4775
|
+
`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
4776
|
);
|
|
4760
4777
|
}
|
|
4761
4778
|
}
|
|
@@ -30,20 +30,120 @@ 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
|
|
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 ||
|
|
46
|
-
|
|
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 },
|
|
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 })
|
|
47
147
|
);
|
|
48
148
|
}
|
|
49
149
|
|
|
@@ -276,6 +376,7 @@ export async function getListenerProcessStatus(
|
|
|
276
376
|
targetPath = process.cwd(),
|
|
277
377
|
homeDir = os.homedir(),
|
|
278
378
|
_readProcessCommandLine = readProcessCommandLine,
|
|
379
|
+
_listProcesses = listProcessTable,
|
|
279
380
|
} = {},
|
|
280
381
|
) {
|
|
281
382
|
const records = [
|
|
@@ -302,6 +403,22 @@ export async function getListenerProcessStatus(
|
|
|
302
403
|
}
|
|
303
404
|
staleStatuses.push(status);
|
|
304
405
|
}
|
|
406
|
+
const matchingProcesses = await listMatchingListenerProcesses(sessionId, agentId, {
|
|
407
|
+
_listProcesses,
|
|
408
|
+
});
|
|
409
|
+
if (matchingProcesses.length > 0) {
|
|
410
|
+
return {
|
|
411
|
+
running: true,
|
|
412
|
+
pid: matchingProcesses[0].pid,
|
|
413
|
+
stale: false,
|
|
414
|
+
record: null,
|
|
415
|
+
recordScope: "process_scan",
|
|
416
|
+
untracked: true,
|
|
417
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
418
|
+
commandLine: matchingProcesses[0].commandLine,
|
|
419
|
+
matchingProcesses,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
305
422
|
if (staleStatuses.length === 0) {
|
|
306
423
|
return {
|
|
307
424
|
running: false,
|
|
@@ -314,6 +431,42 @@ export async function getListenerProcessStatus(
|
|
|
314
431
|
return staleStatuses[0];
|
|
315
432
|
}
|
|
316
433
|
|
|
434
|
+
export async function stopMatchingListenerProcesses(
|
|
435
|
+
sessionId,
|
|
436
|
+
agentId,
|
|
437
|
+
{
|
|
438
|
+
excludePid = process.pid,
|
|
439
|
+
timeoutMs = 3000,
|
|
440
|
+
pollIntervalMs = 100,
|
|
441
|
+
_listProcesses = listProcessTable,
|
|
442
|
+
} = {},
|
|
443
|
+
) {
|
|
444
|
+
const matches = await listMatchingListenerProcesses(sessionId, agentId, {
|
|
445
|
+
excludePid,
|
|
446
|
+
_listProcesses,
|
|
447
|
+
});
|
|
448
|
+
const results = [];
|
|
449
|
+
for (const match of matches) {
|
|
450
|
+
const result = await requestListenerProcessStop(match.pid, {
|
|
451
|
+
timeoutMs,
|
|
452
|
+
pollIntervalMs,
|
|
453
|
+
});
|
|
454
|
+
results.push({
|
|
455
|
+
pid: match.pid,
|
|
456
|
+
commandLine: match.commandLine,
|
|
457
|
+
...result,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const failed = results.filter((result) => !result.stopped);
|
|
461
|
+
return {
|
|
462
|
+
matches,
|
|
463
|
+
results,
|
|
464
|
+
stoppedCount: results.filter((result) => result.stopped).length,
|
|
465
|
+
failedCount: failed.length,
|
|
466
|
+
failed,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
317
470
|
export async function requestListenerProcessStop(
|
|
318
471
|
pid,
|
|
319
472
|
{ signal = "SIGTERM", timeoutMs = 3000, pollIntervalMs = 100 } = {},
|
package/src/session/recap.js
CHANGED
|
@@ -484,6 +484,7 @@ function emptyWorkPlanSummary(sourceContext = {}) {
|
|
|
484
484
|
completed: 0,
|
|
485
485
|
currentSection: "",
|
|
486
486
|
recentOpen: [],
|
|
487
|
+
actionableOpen: [],
|
|
487
488
|
recent: [],
|
|
488
489
|
};
|
|
489
490
|
}
|
|
@@ -534,6 +535,41 @@ function shouldSuppressHistoricalWorkPlanDetails(summary = emptyWorkPlanSummary(
|
|
|
534
535
|
return completed / Math.max(1, total) >= HISTORICAL_WORK_PLAN_COMPLETED_RATIO;
|
|
535
536
|
}
|
|
536
537
|
|
|
538
|
+
function isActionableWorkPlanOpenRecord(record = {}) {
|
|
539
|
+
if (!record || typeof record !== "object" || record.status !== "open") {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
const task = normalizeString(record.task);
|
|
543
|
+
if (!task) {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
return !isGenericWorkPlanSection(record.section);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function selectActionableWorkPlanOpenRecords(records = [], { limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT } = {}) {
|
|
550
|
+
const actionable = (Array.isArray(records) ? records : []).filter(isActionableWorkPlanOpenRecord);
|
|
551
|
+
if (actionable.length === 0) {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
const latest = actionable[actionable.length - 1];
|
|
555
|
+
const latestSection = normalizeString(latest.section);
|
|
556
|
+
const selected = latestSection
|
|
557
|
+
? actionable.filter((record) => normalizeString(record.section) === latestSection)
|
|
558
|
+
: actionable;
|
|
559
|
+
return selected.slice(-Math.max(1, normalizePositiveInteger(limit, DEFAULT_WORK_PLAN_SUMMARY_LIMIT)));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function formatWorkPlanOpenItems(items = []) {
|
|
563
|
+
return (Array.isArray(items) ? items : [])
|
|
564
|
+
.map((item) => {
|
|
565
|
+
const section = normalizeString(item.section);
|
|
566
|
+
const task = normalizeString(item.task);
|
|
567
|
+
return section ? `${section} - ${task}` : task;
|
|
568
|
+
})
|
|
569
|
+
.filter(Boolean)
|
|
570
|
+
.join("; ");
|
|
571
|
+
}
|
|
572
|
+
|
|
537
573
|
function summarizeWorkPlanMarkdown(
|
|
538
574
|
raw = "",
|
|
539
575
|
{ limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false, sourceContext = {} } = {}
|
|
@@ -582,6 +618,7 @@ function summarizeWorkPlanMarkdown(
|
|
|
582
618
|
summary.recentOpen = records
|
|
583
619
|
.filter((record) => record.status === "open")
|
|
584
620
|
.slice(-normalizedLimit);
|
|
621
|
+
summary.actionableOpen = selectActionableWorkPlanOpenRecords(records, { limit: normalizedLimit });
|
|
585
622
|
summary.recent = records.slice(-normalizedLimit);
|
|
586
623
|
if (!summary.detailSuppressed && shouldSuppressHistoricalWorkPlanDetails(summary)) {
|
|
587
624
|
summary.detailSuppressed = true;
|
|
@@ -869,10 +906,17 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
|
|
|
869
906
|
const sourceWindowText = workPlan.truncated
|
|
870
907
|
? `recent ${sourceText} window`
|
|
871
908
|
: sourceText;
|
|
909
|
+
const actionableText = formatWorkPlanOpenItems(workPlan.actionableOpen);
|
|
872
910
|
const suppressionText =
|
|
873
911
|
suppressionReason === "historical_generic_plan_section"
|
|
874
912
|
? "Current/next items suppressed because this generic plan is mostly historical completed work."
|
|
875
913
|
: "Current/next items suppressed because the plan file is large.";
|
|
914
|
+
if (actionableText) {
|
|
915
|
+
const limitText = workPlan.truncated
|
|
916
|
+
? "Full current/next scan suppressed because the plan file is large."
|
|
917
|
+
: suppressionText;
|
|
918
|
+
return `Workspace plan: ${open} open / ${completed} done in ${sourceWindowText}${sourceReasonText}. Recent open: ${actionableText}. ${limitText}`;
|
|
919
|
+
}
|
|
876
920
|
return `Workspace plan: ${open} open / ${completed} done in ${sourceWindowText}${sourceReasonText}. ${suppressionText}`;
|
|
877
921
|
}
|
|
878
922
|
const currentSection = normalizeString(workPlan.currentSection);
|
|
@@ -880,14 +924,7 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
|
|
|
880
924
|
const recentOpen = Array.isArray(workPlan.recentOpen) ? workPlan.recentOpen : [];
|
|
881
925
|
const nextText =
|
|
882
926
|
recentOpen.length > 0
|
|
883
|
-
? ` Next: ${recentOpen
|
|
884
|
-
.map((item) => {
|
|
885
|
-
const section = normalizeString(item.section);
|
|
886
|
-
const task = normalizeString(item.task);
|
|
887
|
-
return section ? `${section} - ${task}` : task;
|
|
888
|
-
})
|
|
889
|
-
.filter(Boolean)
|
|
890
|
-
.join("; ")}.`
|
|
927
|
+
? ` Next: ${formatWorkPlanOpenItems(recentOpen)}.`
|
|
891
928
|
: "";
|
|
892
929
|
const truncatedText = workPlan.truncated ? " Recent window only." : "";
|
|
893
930
|
return `Workspace plan: ${open} open / ${completed} done in ${sourceText}${sourceReasonText}.${currentText}${nextText}${truncatedText}`;
|