sentinelayer-cli 0.35.0 → 0.35.1

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.35.0",
3
+ "version": "0.35.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -4401,6 +4401,7 @@ export function registerSessionCommand(program) {
4401
4401
  )
4402
4402
  .option("--path <path>", "Workspace path for the session", ".")
4403
4403
  .option("--limit <n>", "Recent events to scan for heartbeats (default 200)", "200")
4404
+ .option("--max-pages <n>", "Maximum older event pages to scan for heartbeats (default 5)", "5")
4404
4405
  .option("--json", "Emit machine-readable output")
4405
4406
  .action(async (sessionId, options, command) => {
4406
4407
  const normalizedSessionId = normalizeString(sessionId);
@@ -4410,7 +4411,12 @@ export function registerSessionCommand(program) {
4410
4411
  const targetPath = path.resolve(process.cwd(), String(options.path || "."));
4411
4412
  await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
4412
4413
  const limit = parsePositiveInteger(options.limit, "limit", 200);
4413
- const result = await fetchSessionListeners(normalizedSessionId, { targetPath, limit });
4414
+ const maxPages = parsePositiveInteger(options.maxPages, "max-pages", 5);
4415
+ const result = await fetchSessionListeners(normalizedSessionId, {
4416
+ targetPath,
4417
+ limit,
4418
+ maxPages,
4419
+ });
4414
4420
  const listeners = Array.isArray(result.listeners) ? result.listeners : [];
4415
4421
  const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
4416
4422
  const payload = {
@@ -4420,6 +4426,11 @@ export function registerSessionCommand(program) {
4420
4426
  reason: result.ok ? undefined : result.reason,
4421
4427
  count: listeners.length,
4422
4428
  liveCount: live,
4429
+ pageCount: Number(result.pageCount || 0),
4430
+ scannedEventCount: Number(result.scannedEventCount || 0),
4431
+ listenerEventCount: Number(result.listenerEventCount || 0),
4432
+ partial: Boolean(result.partial),
4433
+ beforeSequence: result.beforeSequence || null,
4423
4434
  listeners,
4424
4435
  };
4425
4436
  if (shouldEmitJson(options, command)) {
@@ -4689,7 +4700,7 @@ export function registerSessionCommand(program) {
4689
4700
  });
4690
4701
  if (existingListener.running && !options.force) {
4691
4702
  throw new Error(
4692
- `A local 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.`,
4703
+ `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.`,
4693
4704
  );
4694
4705
  }
4695
4706
  if (existingListener.running && options.force) {
@@ -1,4 +1,5 @@
1
1
  import fsp from "node:fs/promises";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import process from "node:process";
4
5
  import { execFile } from "node:child_process";
@@ -9,6 +10,7 @@ import { isProcessAlive } from "./daemon-spawn.js";
9
10
  import { resolveSessionPaths } from "./paths.js";
10
11
 
11
12
  const LISTENER_DIR_NAME = "listeners";
13
+ const GLOBAL_LISTENER_DIR_NAME = "session-listeners";
12
14
  const execFileAsync = promisify(execFile);
13
15
 
14
16
  function normalizeString(value) {
@@ -99,12 +101,21 @@ export function resolveListenerPidPath(
99
101
  );
100
102
  }
101
103
 
102
- export async function readListenerPidRecord(
104
+ export function resolveGlobalListenerPidPath(
103
105
  sessionId,
104
106
  agentId,
105
- { targetPath = process.cwd() } = {},
107
+ { homeDir = os.homedir() } = {},
106
108
  ) {
107
- const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
109
+ return path.join(
110
+ path.resolve(String(homeDir || os.homedir())),
111
+ ".sentinelayer",
112
+ GLOBAL_LISTENER_DIR_NAME,
113
+ normalizeListenerProcessKey(sessionId),
114
+ `${normalizeListenerProcessKey(agentId)}.json`,
115
+ );
116
+ }
117
+
118
+ async function readPidRecordAtPath(pidPath) {
108
119
  try {
109
120
  const raw = await fsp.readFile(pidPath, "utf-8");
110
121
  const parsed = JSON.parse(raw);
@@ -117,6 +128,44 @@ export async function readListenerPidRecord(
117
128
  }
118
129
  }
119
130
 
131
+ async function writePidRecordAtPath(pidPath, record) {
132
+ await fsp.mkdir(path.dirname(pidPath), { recursive: true });
133
+ await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
134
+ }
135
+
136
+ async function removePidRecordAtPath(pidPath, { onlyForPid = null } = {}) {
137
+ if (onlyForPid != null) {
138
+ const existing = await readPidRecordAtPath(pidPath);
139
+ if (existing && Number(existing.pid) !== Number(onlyForPid)) {
140
+ return false;
141
+ }
142
+ }
143
+ try {
144
+ await fsp.unlink(pidPath);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ export async function readListenerPidRecord(
152
+ sessionId,
153
+ agentId,
154
+ { targetPath = process.cwd() } = {},
155
+ ) {
156
+ const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
157
+ return readPidRecordAtPath(pidPath);
158
+ }
159
+
160
+ export async function readGlobalListenerPidRecord(
161
+ sessionId,
162
+ agentId,
163
+ { homeDir = os.homedir() } = {},
164
+ ) {
165
+ const pidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
166
+ return readPidRecordAtPath(pidPath);
167
+ }
168
+
120
169
  export async function writeListenerPidRecord(
121
170
  sessionId,
122
171
  agentId,
@@ -129,9 +178,11 @@ export async function writeListenerPidRecord(
129
178
  activeIntervalSeconds = null,
130
179
  presenceIntervalSeconds = null,
131
180
  logFile = "",
181
+ homeDir = os.homedir(),
132
182
  } = {},
133
183
  ) {
134
184
  const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
185
+ const globalPidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
135
186
  const record = {
136
187
  pid: Number(pid),
137
188
  sessionId: normalizeString(sessionId),
@@ -146,46 +197,42 @@ export async function writeListenerPidRecord(
146
197
  logFile: normalizeString(logFile) || undefined,
147
198
  startedAt: new Date().toISOString(),
148
199
  };
149
- await fsp.mkdir(path.dirname(pidPath), { recursive: true });
150
- await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
200
+ const globalRecord = {
201
+ ...record,
202
+ recordScope: "global",
203
+ localPidPath: pidPath,
204
+ };
205
+ await writePidRecordAtPath(globalPidPath, globalRecord);
206
+ try {
207
+ await writePidRecordAtPath(pidPath, record);
208
+ } catch (error) {
209
+ await removePidRecordAtPath(globalPidPath, { onlyForPid: pid }).catch(() => {});
210
+ throw error;
211
+ }
151
212
  return record;
152
213
  }
153
214
 
154
215
  export async function removeListenerPidRecord(
155
216
  sessionId,
156
217
  agentId,
157
- { targetPath = process.cwd(), onlyForPid = null } = {},
218
+ { targetPath = process.cwd(), onlyForPid = null, homeDir = os.homedir() } = {},
158
219
  ) {
159
220
  const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
160
- if (onlyForPid != null) {
161
- const existing = await readListenerPidRecord(sessionId, agentId, { targetPath });
162
- if (existing && Number(existing.pid) !== Number(onlyForPid)) {
163
- return false;
164
- }
165
- }
166
- try {
167
- await fsp.unlink(pidPath);
168
- return true;
169
- } catch {
170
- return false;
171
- }
221
+ const globalPidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
222
+ const localRemoved = await removePidRecordAtPath(pidPath, { onlyForPid });
223
+ const globalRemoved = await removePidRecordAtPath(globalPidPath, { onlyForPid });
224
+ return Boolean(localRemoved || globalRemoved);
172
225
  }
173
226
 
174
- export async function getListenerProcessStatus(
175
- sessionId,
176
- agentId,
177
- { targetPath = process.cwd(), _readProcessCommandLine = readProcessCommandLine } = {},
227
+ async function listenerStatusFromRecord(
228
+ record,
229
+ {
230
+ sessionId,
231
+ agentId,
232
+ recordScope,
233
+ _readProcessCommandLine = readProcessCommandLine,
234
+ } = {},
178
235
  ) {
179
- const record = await readListenerPidRecord(sessionId, agentId, { targetPath });
180
- if (!record) {
181
- return {
182
- running: false,
183
- pid: null,
184
- stale: false,
185
- record: null,
186
- listenerKey: normalizeListenerProcessKey(agentId),
187
- };
188
- }
189
236
  const alive = isProcessAlive(record.pid);
190
237
  if (!alive) {
191
238
  return {
@@ -193,6 +240,7 @@ export async function getListenerProcessStatus(
193
240
  pid: null,
194
241
  stale: true,
195
242
  record,
243
+ recordScope,
196
244
  listenerKey: normalizeListenerProcessKey(agentId),
197
245
  };
198
246
  }
@@ -205,6 +253,7 @@ export async function getListenerProcessStatus(
205
253
  reused: Boolean(commandLine),
206
254
  unverified: !commandLine,
207
255
  record,
256
+ recordScope,
208
257
  listenerKey: normalizeListenerProcessKey(agentId),
209
258
  commandLine,
210
259
  };
@@ -214,11 +263,57 @@ export async function getListenerProcessStatus(
214
263
  pid: Number(record.pid),
215
264
  stale: false,
216
265
  record,
266
+ recordScope,
217
267
  listenerKey: normalizeListenerProcessKey(agentId),
218
268
  commandLine,
219
269
  };
220
270
  }
221
271
 
272
+ export async function getListenerProcessStatus(
273
+ sessionId,
274
+ agentId,
275
+ {
276
+ targetPath = process.cwd(),
277
+ homeDir = os.homedir(),
278
+ _readProcessCommandLine = readProcessCommandLine,
279
+ } = {},
280
+ ) {
281
+ const records = [
282
+ {
283
+ record: await readGlobalListenerPidRecord(sessionId, agentId, { homeDir }),
284
+ recordScope: "global",
285
+ },
286
+ {
287
+ record: await readListenerPidRecord(sessionId, agentId, { targetPath }),
288
+ recordScope: "local",
289
+ },
290
+ ];
291
+ const staleStatuses = [];
292
+ for (const entry of records) {
293
+ if (!entry.record) continue;
294
+ const status = await listenerStatusFromRecord(entry.record, {
295
+ sessionId,
296
+ agentId,
297
+ recordScope: entry.recordScope,
298
+ _readProcessCommandLine,
299
+ });
300
+ if (status.running) {
301
+ return status;
302
+ }
303
+ staleStatuses.push(status);
304
+ }
305
+ if (staleStatuses.length === 0) {
306
+ return {
307
+ running: false,
308
+ pid: null,
309
+ stale: false,
310
+ record: null,
311
+ listenerKey: normalizeListenerProcessKey(agentId),
312
+ };
313
+ }
314
+ return staleStatuses[0];
315
+ }
316
+
222
317
  export async function requestListenerProcessStop(
223
318
  pid,
224
319
  { signal = "SIGTERM", timeoutMs = 3000, pollIntervalMs = 100 } = {},
@@ -10,6 +10,9 @@ const LISTENER_EVENT_TYPES = new Set([
10
10
  // listener likely died without a clean stop — show it as stale, not live.
11
11
  const DEFAULT_STALE_AFTER_MS = 180_000;
12
12
  const MAX_STALE_GRACE_MS = 60_000;
13
+ const DEFAULT_LISTENER_FETCH_LIMIT = 200;
14
+ const DEFAULT_LISTENER_FETCH_MAX_PAGES = 5;
15
+ const MAX_LISTENER_FETCH_MAX_PAGES = 25;
13
16
 
14
17
  function normalizeString(value) {
15
18
  return String(value || "").trim();
@@ -143,24 +146,93 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
143
146
 
144
147
  /**
145
148
  * Fetch recent session events from the API and summarize the listeners.
146
- * `limit` controls how far back we look for heartbeats.
149
+ * `limit` controls the raw API page size. Busy rooms can push durable
150
+ * keepalive heartbeats out of a single tail page, so we walk a small bounded
151
+ * number of older pages while the tail contains no listener lifecycle events
152
+ * and the sequence cursor advances.
147
153
  */
148
154
  export async function fetchSessionListeners(
149
155
  sessionId,
150
156
  {
151
157
  targetPath = process.cwd(),
152
- limit = 200,
158
+ limit = DEFAULT_LISTENER_FETCH_LIMIT,
159
+ maxPages = DEFAULT_LISTENER_FETCH_MAX_PAGES,
153
160
  nowMs = Date.now,
154
161
  forceCircuitProbe = true,
155
162
  poll = pollSessionEventsBefore,
156
163
  } = {}
157
164
  ) {
158
- const result = await poll(sessionId, { targetPath, limit, forceCircuitProbe });
159
- if (!result?.ok) {
160
- return { ok: false, reason: normalizeString(result?.reason) || "fetch_failed", listeners: [] };
165
+ const pageLimit = Math.max(
166
+ 1,
167
+ Math.min(DEFAULT_LISTENER_FETCH_LIMIT, positiveInt(limit) || DEFAULT_LISTENER_FETCH_LIMIT),
168
+ );
169
+ const maxPageCount = Math.max(
170
+ 1,
171
+ Math.min(MAX_LISTENER_FETCH_MAX_PAGES, positiveInt(maxPages) || DEFAULT_LISTENER_FETCH_MAX_PAGES),
172
+ );
173
+ let beforeSequence = null;
174
+ let latestResult = null;
175
+ let allEvents = [];
176
+ let pageCount = 0;
177
+ let listenerEventCount = 0;
178
+ let partial = false;
179
+ let partialReason = "";
180
+
181
+ for (let page = 0; page < maxPageCount; page += 1) {
182
+ const result = await poll(sessionId, {
183
+ targetPath,
184
+ beforeSequence,
185
+ limit: pageLimit,
186
+ forceCircuitProbe,
187
+ });
188
+ pageCount += 1;
189
+
190
+ if (!result?.ok) {
191
+ if (!latestResult) {
192
+ return {
193
+ ok: false,
194
+ reason: normalizeString(result?.reason) || "fetch_failed",
195
+ listeners: [],
196
+ pageCount,
197
+ scannedEventCount: allEvents.length,
198
+ listenerEventCount: 0,
199
+ partial: false,
200
+ };
201
+ }
202
+ partial = true;
203
+ partialReason = normalizeString(result?.reason) || "partial_fetch";
204
+ break;
205
+ }
206
+
207
+ latestResult = result;
208
+ const pageEvents = Array.isArray(result.events) ? result.events : [];
209
+ allEvents = [...pageEvents, ...allEvents];
210
+ listenerEventCount += pageEvents.filter((event) =>
211
+ LISTENER_EVENT_TYPES.has(normalizeString(event?.event))
212
+ ).length;
213
+
214
+ if (pageEvents.length < pageLimit) break;
215
+ if (listenerEventCount > 0) break;
216
+
217
+ const candidateBeforeSequence = Number(result.beforeSequence || 0);
218
+ if (!Number.isFinite(candidateBeforeSequence) || candidateBeforeSequence <= 0) break;
219
+ const currentBeforeSequence = Number(beforeSequence || 0);
220
+ if (currentBeforeSequence > 0 && candidateBeforeSequence >= currentBeforeSequence) break;
221
+ beforeSequence = Math.floor(candidateBeforeSequence);
161
222
  }
162
- const listeners = summarizeListeners(result.events || [], { nowMs: nowMs() });
163
- return { ok: true, sessionId: normalizeString(sessionId), listeners };
223
+
224
+ const listeners = summarizeListeners(allEvents, { nowMs: nowMs() });
225
+ return {
226
+ ok: true,
227
+ sessionId: normalizeString(sessionId),
228
+ listeners,
229
+ pageCount,
230
+ scannedEventCount: allEvents.length,
231
+ listenerEventCount,
232
+ beforeSequence: latestResult?.beforeSequence || beforeSequence || null,
233
+ partial,
234
+ reason: partial ? partialReason : "",
235
+ };
164
236
  }
165
237
 
166
238
  export function formatListenerLine(row) {