sentinelayer-cli 0.30.8 → 0.30.10

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.30.8",
3
+ "version": "0.30.10",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -138,6 +138,49 @@ function normalizeString(value) {
138
138
  return String(value || "").trim();
139
139
  }
140
140
 
141
+ function messagePartsHaveContent(messageParts) {
142
+ const parts = Array.isArray(messageParts) ? messageParts : [messageParts];
143
+ return parts.some((part) => normalizeString(part));
144
+ }
145
+
146
+ function joinMessageParts(messageParts) {
147
+ if (Array.isArray(messageParts)) {
148
+ return messageParts.length === 1 ? String(messageParts[0] || "") : messageParts.join(" ");
149
+ }
150
+ return String(messageParts || "");
151
+ }
152
+
153
+ async function readUtf8FromStdin(stdin = process.stdin) {
154
+ if (stdin.isTTY) {
155
+ throw new Error("--stdin requires piped input.");
156
+ }
157
+ stdin.setEncoding("utf8");
158
+ let text = "";
159
+ for await (const chunk of stdin) {
160
+ text += String(chunk);
161
+ }
162
+ return text;
163
+ }
164
+
165
+ async function resolveSessionSayMessageInput(messageParts, options = {}) {
166
+ const hasPositionalMessage = messagePartsHaveContent(messageParts);
167
+ const hasMessageFile = Boolean(normalizeString(options.messageFile));
168
+ const hasStdin = Boolean(options.stdin);
169
+ const sources = [hasPositionalMessage, hasMessageFile, hasStdin].filter(Boolean).length;
170
+ if (sources > 1) {
171
+ throw new Error("Use only one message source: positional message, --message-file, or --stdin.");
172
+ }
173
+ if (hasMessageFile) {
174
+ return normalizeString(
175
+ await fsp.readFile(path.resolve(process.cwd(), String(options.messageFile)), "utf-8"),
176
+ );
177
+ }
178
+ if (hasStdin) {
179
+ return normalizeString(await readUtf8FromStdin());
180
+ }
181
+ return normalizeString(joinMessageParts(messageParts));
182
+ }
183
+
141
184
  function optionWasSetByCli(command, optionName) {
142
185
  if (!command || typeof command.getOptionValueSource !== "function") {
143
186
  return false;
@@ -3343,7 +3386,7 @@ export function registerSessionCommand(program) {
3343
3386
  });
3344
3387
 
3345
3388
  session
3346
- .command("say <sessionId> <message>")
3389
+ .command("say <sessionId> [message...]")
3347
3390
  .description("Send a message to the session")
3348
3391
  .option(
3349
3392
  "--agent <id>",
@@ -3367,16 +3410,18 @@ export function registerSessionCommand(program) {
3367
3410
  .option("--to <agent>", "Direct the message to a specific agent id")
3368
3411
  .option("--reply-to <sequence>", "Mark this message as a reply to a target sequence id")
3369
3412
  .option("--reply-cursor <cursor>", "Mark this message as a reply to a target event cursor")
3413
+ .option("--message-file <path>", "Read the message body from a UTF-8 file")
3414
+ .option("--stdin", "Read the message body from stdin")
3370
3415
  .option("--force-cli-user", "Allow fallback sends as cli-user when no agent identity can be resolved")
3371
3416
  .option("--local-only", "Append only to the local session cache without remote send confirmation")
3372
3417
  .option("--path <path>", "Workspace path for the session", ".")
3373
3418
  .option("--json", "Emit machine-readable output")
3374
- .action(async (sessionId, message, options, command) => {
3419
+ .action(async (sessionId, messageParts, options, command) => {
3375
3420
  const normalizedSessionId = normalizeString(sessionId);
3376
3421
  if (!normalizedSessionId) {
3377
3422
  throw new Error("session id is required.");
3378
3423
  }
3379
- const normalizedMessage = normalizeString(message);
3424
+ const normalizedMessage = await resolveSessionSayMessageInput(messageParts, options);
3380
3425
  if (!normalizedMessage) {
3381
3426
  throw new Error("message is required.");
3382
3427
  }
@@ -4324,8 +4369,9 @@ export function registerSessionCommand(program) {
4324
4369
  const durablePresenceEnabled = options.presence !== false;
4325
4370
  const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
4326
4371
  const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
4327
- const presenceKeepaliveMs =
4328
- Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds, 1) * 1000;
4372
+ const effectivePresenceKeepaliveSeconds =
4373
+ Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds, intervalSeconds, 1);
4374
+ const presenceKeepaliveMs = effectivePresenceKeepaliveSeconds * 1000;
4329
4375
  let lastPresenceHeartbeatMs = 0;
4330
4376
  let lastPresenceHeartbeatFingerprint = "";
4331
4377
 
@@ -4512,7 +4558,7 @@ export function registerSessionCommand(program) {
4512
4558
  const publishLifecycle = {
4513
4559
  ...lifecycle,
4514
4560
  presenceIntervalSeconds,
4515
- presenceKeepaliveSeconds: Math.max(presenceIntervalSeconds, presenceKeepaliveSeconds),
4561
+ presenceKeepaliveSeconds: effectivePresenceKeepaliveSeconds,
4516
4562
  };
4517
4563
  if (lifecycleType === "heartbeat") {
4518
4564
  const nowMs = Date.now();
@@ -9,6 +9,7 @@ const LISTENER_EVENT_TYPES = new Set([
9
9
  // A heartbeat older than this (and not explicitly stopped) means the
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
+ const MAX_STALE_GRACE_MS = 60_000;
12
13
 
13
14
  function normalizeString(value) {
14
15
  return String(value || "").trim();
@@ -29,6 +30,38 @@ function positiveInt(value) {
29
30
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
30
31
  }
31
32
 
33
+ function listenerStaleAfterMs({
34
+ staleAfterMs,
35
+ presenceKeepaliveSeconds,
36
+ presenceIntervalSeconds,
37
+ cadenceSeconds,
38
+ idleIntervalSeconds,
39
+ activeIntervalSeconds,
40
+ } = {}) {
41
+ const fallbackMs = Math.max(1, Number(staleAfterMs) || DEFAULT_STALE_AFTER_MS);
42
+ const keepaliveMs = presenceKeepaliveSeconds ? presenceKeepaliveSeconds * 1000 : null;
43
+ const expectedIntervalSeconds =
44
+ cadenceSeconds ||
45
+ presenceIntervalSeconds ||
46
+ idleIntervalSeconds ||
47
+ activeIntervalSeconds ||
48
+ null;
49
+ const expectedIntervalMs = expectedIntervalSeconds ? expectedIntervalSeconds * 1000 : 0;
50
+
51
+ if (keepaliveMs) {
52
+ // The listener can only publish on a poll tick. Allow one bounded poll
53
+ // interval after the advertised keepalive, not the old 2.5x keepalive
54
+ // window that made dead listeners look live for several extra minutes.
55
+ return keepaliveMs + Math.min(expectedIntervalMs || 0, MAX_STALE_GRACE_MS);
56
+ }
57
+
58
+ if (expectedIntervalMs) {
59
+ return Math.max(fallbackMs, Math.round(expectedIntervalMs * 2.5));
60
+ }
61
+
62
+ return fallbackMs;
63
+ }
64
+
32
65
  /**
33
66
  * Reduce a stream of session_listener_* events into one row per agent: who is
34
67
  * listening, at what cadence, and whether they're currently active or idle.
@@ -66,17 +99,14 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
66
99
  const cadenceSeconds = active
67
100
  ? activeIntervalSeconds || nextPollSeconds
68
101
  : idleIntervalSeconds || nextPollSeconds;
69
- const expectedPresenceSeconds =
70
- presenceKeepaliveSeconds ||
71
- presenceIntervalSeconds ||
72
- cadenceSeconds ||
73
- idleIntervalSeconds ||
74
- activeIntervalSeconds ||
75
- null;
76
- const staleAfterForRowMs = Math.max(
102
+ const staleAfterForRowMs = listenerStaleAfterMs({
77
103
  staleAfterMs,
78
- expectedPresenceSeconds ? expectedPresenceSeconds * 2_500 : 0,
79
- );
104
+ presenceKeepaliveSeconds,
105
+ presenceIntervalSeconds,
106
+ cadenceSeconds,
107
+ idleIntervalSeconds,
108
+ activeIntervalSeconds,
109
+ });
80
110
  let status;
81
111
  if (stopped) status = "stopped";
82
112
  else if (ageMs !== null && ageMs > staleAfterForRowMs) status = "stale";
@@ -94,6 +124,7 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
94
124
  presenceIntervalSeconds,
95
125
  presenceKeepaliveSeconds,
96
126
  nextPollSeconds,
127
+ staleAfterSeconds: Math.round(staleAfterForRowMs / 1000),
97
128
  lastSeenAt: epoch ? new Date(epoch).toISOString() : null,
98
129
  lastSeenAgoSeconds: ageMs !== null ? Math.round(ageMs / 1000) : null,
99
130
  lastHumanActivityAt: normalizeString(payload.lastHumanActivityAt) || null,