sentinelayer-cli 0.34.7 → 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 +1 -1
- package/src/commands/session.js +82 -10
- package/src/mcp/registry.js +56 -1
- package/src/mcp/session-stdio-server.js +205 -1
- package/src/scan/generator.js +6 -7
- package/src/session/coordination-guidance.js +1 -1
- package/src/session/first-message.js +1 -1
- package/src/session/listener-process.js +354 -0
- package/src/session/listener.js +3 -3
- package/src/session/listeners.js +85 -7
- package/src/session/sync.js +28 -2
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -65,6 +65,12 @@ import {
|
|
|
65
65
|
updateSessionTitle,
|
|
66
66
|
} from "../session/store.js";
|
|
67
67
|
import { fetchSessionListeners, formatListenerLine } from "../session/listeners.js";
|
|
68
|
+
import {
|
|
69
|
+
getListenerProcessStatus,
|
|
70
|
+
removeListenerPidRecord,
|
|
71
|
+
requestListenerProcessStop,
|
|
72
|
+
writeListenerPidRecord,
|
|
73
|
+
} from "../session/listener-process.js";
|
|
68
74
|
import {
|
|
69
75
|
filterSessionMaterialEvents,
|
|
70
76
|
isSessionControlEvent,
|
|
@@ -4395,6 +4401,7 @@ export function registerSessionCommand(program) {
|
|
|
4395
4401
|
)
|
|
4396
4402
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
4397
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")
|
|
4398
4405
|
.option("--json", "Emit machine-readable output")
|
|
4399
4406
|
.action(async (sessionId, options, command) => {
|
|
4400
4407
|
const normalizedSessionId = normalizeString(sessionId);
|
|
@@ -4404,7 +4411,12 @@ export function registerSessionCommand(program) {
|
|
|
4404
4411
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
4405
4412
|
await ensureLocalSessionForRemoteCommand(normalizedSessionId, { targetPath });
|
|
4406
4413
|
const limit = parsePositiveInteger(options.limit, "limit", 200);
|
|
4407
|
-
const
|
|
4414
|
+
const maxPages = parsePositiveInteger(options.maxPages, "max-pages", 5);
|
|
4415
|
+
const result = await fetchSessionListeners(normalizedSessionId, {
|
|
4416
|
+
targetPath,
|
|
4417
|
+
limit,
|
|
4418
|
+
maxPages,
|
|
4419
|
+
});
|
|
4408
4420
|
const listeners = Array.isArray(result.listeners) ? result.listeners : [];
|
|
4409
4421
|
const live = listeners.filter((row) => row.status === "active" || row.status === "idle").length;
|
|
4410
4422
|
const payload = {
|
|
@@ -4414,6 +4426,11 @@ export function registerSessionCommand(program) {
|
|
|
4414
4426
|
reason: result.ok ? undefined : result.reason,
|
|
4415
4427
|
count: listeners.length,
|
|
4416
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,
|
|
4417
4434
|
listeners,
|
|
4418
4435
|
};
|
|
4419
4436
|
if (shouldEmitJson(options, command)) {
|
|
@@ -4538,6 +4555,14 @@ export function registerSessionCommand(program) {
|
|
|
4538
4555
|
.option("--from-now", "Advance the listen cursor to the latest durable event before polling")
|
|
4539
4556
|
.option("--replay", "Emit matching historical events on the first poll")
|
|
4540
4557
|
.option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
|
|
4558
|
+
.option(
|
|
4559
|
+
"--force",
|
|
4560
|
+
"Stop and replace an existing local listener process for this session/agent",
|
|
4561
|
+
)
|
|
4562
|
+
.option(
|
|
4563
|
+
"--allow-duplicate",
|
|
4564
|
+
"Allow multiple local listeners for the same session/agent (advanced; can duplicate wake hooks)",
|
|
4565
|
+
)
|
|
4541
4566
|
.option(
|
|
4542
4567
|
"--wake <command>",
|
|
4543
4568
|
"Wake hook: run this shell command on each matched event (notify->resume bridge). Metadata JSON is piped to stdin; sanitized SL_WAKE_* env vars are set.",
|
|
@@ -4666,18 +4691,59 @@ export function registerSessionCommand(program) {
|
|
|
4666
4691
|
if (options.fromNow && options.since !== undefined) {
|
|
4667
4692
|
throw new Error("Use either --from-now or --since, not both.");
|
|
4668
4693
|
}
|
|
4669
|
-
const
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4694
|
+
const listenerId = `listener-${agentId}-${randomUUID()}`;
|
|
4695
|
+
const singletonEnabled = maxPolls === null && !options.allowDuplicate;
|
|
4696
|
+
let listenerPidRecordActive = false;
|
|
4697
|
+
if (singletonEnabled) {
|
|
4698
|
+
const existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
|
|
4699
|
+
targetPath,
|
|
4700
|
+
});
|
|
4701
|
+
if (existingListener.running && !options.force) {
|
|
4702
|
+
throw new Error(
|
|
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.`,
|
|
4704
|
+
);
|
|
4705
|
+
}
|
|
4706
|
+
if (existingListener.running && options.force) {
|
|
4707
|
+
const stopResult = await requestListenerProcessStop(existingListener.pid);
|
|
4708
|
+
if (!stopResult.stopped) {
|
|
4709
|
+
throw new Error(
|
|
4710
|
+
`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.`,
|
|
4711
|
+
);
|
|
4712
|
+
}
|
|
4713
|
+
}
|
|
4714
|
+
await writeListenerPidRecord(normalizedSessionId, agentId, {
|
|
4715
|
+
targetPath,
|
|
4716
|
+
listenerId,
|
|
4717
|
+
transport: listenTransport,
|
|
4718
|
+
intervalSeconds,
|
|
4719
|
+
activeIntervalSeconds,
|
|
4720
|
+
presenceIntervalSeconds,
|
|
4721
|
+
logFile: listenLogPath,
|
|
4722
|
+
});
|
|
4723
|
+
listenerPidRecordActive = true;
|
|
4724
|
+
}
|
|
4725
|
+
let restoreConsoleLog = null;
|
|
4726
|
+
try {
|
|
4727
|
+
restoreConsoleLog = listenLogPath
|
|
4728
|
+
? installRotatingConsoleLog({
|
|
4729
|
+
logPath: listenLogPath,
|
|
4730
|
+
maxBytes: parsePositiveInteger(options.logMaxBytes, "log-max-bytes", DEFAULT_ROTATING_LOG_MAX_BYTES),
|
|
4731
|
+
maxFiles: parsePositiveInteger(options.logMaxFiles, "log-max-files", DEFAULT_ROTATING_LOG_MAX_FILES),
|
|
4732
|
+
tee: true,
|
|
4733
|
+
})
|
|
4734
|
+
: null;
|
|
4735
|
+
} catch (error) {
|
|
4736
|
+
if (listenerPidRecordActive) {
|
|
4737
|
+
await removeListenerPidRecord(normalizedSessionId, agentId, {
|
|
4738
|
+
targetPath,
|
|
4739
|
+
onlyForPid: process.pid,
|
|
4740
|
+
}).catch(() => {});
|
|
4741
|
+
}
|
|
4742
|
+
throw error;
|
|
4743
|
+
}
|
|
4677
4744
|
const ac = new AbortController();
|
|
4678
4745
|
const onSigint = () => ac.abort();
|
|
4679
4746
|
process.on("SIGINT", onSigint);
|
|
4680
|
-
const listenerId = `listener-${agentId}-${randomUUID()}`;
|
|
4681
4747
|
const durablePresenceEnabled = options.presence !== false;
|
|
4682
4748
|
const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
|
|
4683
4749
|
const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
|
|
@@ -4909,6 +4975,12 @@ export function registerSessionCommand(program) {
|
|
|
4909
4975
|
restoreConsoleLog();
|
|
4910
4976
|
}
|
|
4911
4977
|
process.removeListener("SIGINT", onSigint);
|
|
4978
|
+
if (listenerPidRecordActive) {
|
|
4979
|
+
await removeListenerPidRecord(normalizedSessionId, agentId, {
|
|
4980
|
+
targetPath,
|
|
4981
|
+
onlyForPid: process.pid,
|
|
4982
|
+
}).catch(() => {});
|
|
4983
|
+
}
|
|
4912
4984
|
}
|
|
4913
4985
|
});
|
|
4914
4986
|
|
package/src/mcp/registry.js
CHANGED
|
@@ -563,6 +563,47 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
|
|
|
563
563
|
server: "sentinelayer-session-mcp",
|
|
564
564
|
},
|
|
565
565
|
},
|
|
566
|
+
{
|
|
567
|
+
name: "read_history",
|
|
568
|
+
title: "Read Senti History",
|
|
569
|
+
description:
|
|
570
|
+
"Hydrate a bounded recent, older, or after-cursor SentinelLayer session transcript window without recipient filtering.",
|
|
571
|
+
input_schema: {
|
|
572
|
+
type: "object",
|
|
573
|
+
additionalProperties: false,
|
|
574
|
+
required: ["sessionId"],
|
|
575
|
+
properties: {
|
|
576
|
+
sessionId: { type: "string" },
|
|
577
|
+
cursor: { type: "string" },
|
|
578
|
+
beforeSequence: { type: "integer", minimum: 1 },
|
|
579
|
+
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
580
|
+
actionLimit: { type: "integer", minimum: 1, maximum: 200 },
|
|
581
|
+
includeActions: { type: "boolean" },
|
|
582
|
+
includeControlEvents: { type: "boolean" },
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
transport: {
|
|
586
|
+
type: "internal",
|
|
587
|
+
method: "POST",
|
|
588
|
+
url: "sentinelayer://session/read_history",
|
|
589
|
+
timeout_ms: 15000,
|
|
590
|
+
auth: { mode: "none" },
|
|
591
|
+
},
|
|
592
|
+
budgets: {
|
|
593
|
+
max_calls_per_run: 60,
|
|
594
|
+
max_runtime_ms: 15000,
|
|
595
|
+
},
|
|
596
|
+
security: {
|
|
597
|
+
requires_human_approval: false,
|
|
598
|
+
kill_switch: "enabled",
|
|
599
|
+
scopes: ["session:read"],
|
|
600
|
+
},
|
|
601
|
+
metadata: {
|
|
602
|
+
provider: "sentinelayer",
|
|
603
|
+
adapter: "sentinelayer-cli",
|
|
604
|
+
server: "sentinelayer-session-mcp",
|
|
605
|
+
},
|
|
606
|
+
},
|
|
566
607
|
{
|
|
567
608
|
name: "send_message",
|
|
568
609
|
title: "Send Senti Message",
|
|
@@ -985,6 +1026,13 @@ export function buildHostedSentiSessionConnectorContract({
|
|
|
985
1026
|
long_lived_cli_token_passthrough_allowed: false,
|
|
986
1027
|
},
|
|
987
1028
|
tools: [
|
|
1029
|
+
hostedSessionTool({
|
|
1030
|
+
toolName: "join_and_hydrate",
|
|
1031
|
+
operation: "join_and_hydrate",
|
|
1032
|
+
allowedUserArgs: ["limit", "actionLimit", "includeActions"],
|
|
1033
|
+
requiredScopes: ["session:read"],
|
|
1034
|
+
wakePayload: { policy: "bounded_redacted_window", may_include_message_content: true, max_events: 50 },
|
|
1035
|
+
}),
|
|
988
1036
|
hostedSessionTool({
|
|
989
1037
|
toolName: "poll_inbox",
|
|
990
1038
|
operation: "read_history",
|
|
@@ -992,6 +1040,13 @@ export function buildHostedSentiSessionConnectorContract({
|
|
|
992
1040
|
requiredScopes: ["session:read"],
|
|
993
1041
|
wakePayload: { policy: "bounded_redacted_window", may_include_message_content: true, max_events: 50 },
|
|
994
1042
|
}),
|
|
1043
|
+
hostedSessionTool({
|
|
1044
|
+
toolName: "read_history",
|
|
1045
|
+
operation: "read_history",
|
|
1046
|
+
allowedUserArgs: ["cursor", "beforeSequence", "limit", "actionLimit", "includeActions"],
|
|
1047
|
+
requiredScopes: ["session:read"],
|
|
1048
|
+
wakePayload: { policy: "bounded_redacted_window", may_include_message_content: true, max_events: 50 },
|
|
1049
|
+
}),
|
|
995
1050
|
hostedSessionTool({
|
|
996
1051
|
toolName: "send_message",
|
|
997
1052
|
operation: "send_message",
|
|
@@ -1279,7 +1334,7 @@ export function validateHostedSentiSessionConnectorContract(payload, { registryP
|
|
|
1279
1334
|
const registry = validateMcpToolRegistry(registryPayload);
|
|
1280
1335
|
const localToolNames = new Set(registry.tools.map((tool) => tool.name));
|
|
1281
1336
|
const missingLocalTools = parsed.tools
|
|
1282
|
-
.filter((tool) => tool.operation !== "subscribe_wake" && tool.operation !== "presence_renew" && tool.operation !== "runner_teardown")
|
|
1337
|
+
.filter((tool) => tool.operation !== "join_and_hydrate" && tool.operation !== "subscribe_wake" && tool.operation !== "presence_renew" && tool.operation !== "runner_teardown")
|
|
1283
1338
|
.map((tool) => tool.local_tool_name)
|
|
1284
1339
|
.filter((toolName) => !localToolNames.has(toolName));
|
|
1285
1340
|
if (missingLocalTools.length > 0) {
|
|
@@ -31,6 +31,8 @@ const SESSION_MCP_CONFIRM_PAGE_LIMIT = 200;
|
|
|
31
31
|
const SESSION_MCP_CONFIRM_MAX_PAGES = 10;
|
|
32
32
|
const SESSION_MCP_CONFIRM_TOTAL_TIMEOUT_MS = 10_000;
|
|
33
33
|
const SESSION_MCP_CONFIRM_REQUEST_TIMEOUT_MS = 2_000;
|
|
34
|
+
const SESSION_MCP_HISTORY_MAX_PAGES = 10;
|
|
35
|
+
const SESSION_MCP_HISTORY_MIN_RAW_PAGE_LIMIT = DEFAULT_TOOL_LIMIT;
|
|
34
36
|
const JSON_RPC_VERSION = "2.0";
|
|
35
37
|
const CONTENT_LENGTH_PREFIX = "content-length:";
|
|
36
38
|
const SESSION_MESSAGE_ACTION_TYPES = new Set([
|
|
@@ -444,6 +446,110 @@ function recentHumanActivityFromActions(remoteActions = null, { limit = DEFAULT_
|
|
|
444
446
|
: [];
|
|
445
447
|
}
|
|
446
448
|
|
|
449
|
+
function normalizeHistoryEvents(events = [], { includeControlEvents = false } = {}) {
|
|
450
|
+
return (Array.isArray(events) ? events : []).filter((event) => {
|
|
451
|
+
if (!includeControlEvents && isSessionControlEvent(event)) return false;
|
|
452
|
+
return true;
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function readSessionHistoryWindow({
|
|
457
|
+
sessionId,
|
|
458
|
+
targetPath,
|
|
459
|
+
cursor = null,
|
|
460
|
+
beforeSequence = null,
|
|
461
|
+
limit = DEFAULT_TOOL_LIMIT,
|
|
462
|
+
includeControlEvents = false,
|
|
463
|
+
forceCircuitProbe = false,
|
|
464
|
+
pollSessionEventsFn = pollSessionEvents,
|
|
465
|
+
pollSessionEventsBeforeFn = pollSessionEventsBefore,
|
|
466
|
+
} = {}) {
|
|
467
|
+
const source = cursor ? "after_cursor" : beforeSequence ? "before_sequence" : "latest_tail";
|
|
468
|
+
let pageCursor = cursor;
|
|
469
|
+
let pageBeforeSequence = beforeSequence;
|
|
470
|
+
let responseCursor = cursor;
|
|
471
|
+
let responseBeforeSequence = beforeSequence || null;
|
|
472
|
+
let rawEventCount = 0;
|
|
473
|
+
let pages = 0;
|
|
474
|
+
let events = [];
|
|
475
|
+
|
|
476
|
+
while (events.length < limit && pages < SESSION_MCP_HISTORY_MAX_PAGES) {
|
|
477
|
+
const remaining = Math.max(1, limit - events.length);
|
|
478
|
+
const pageLimit = Math.max(
|
|
479
|
+
remaining,
|
|
480
|
+
Math.min(MAX_TOOL_LIMIT, SESSION_MCP_HISTORY_MIN_RAW_PAGE_LIMIT),
|
|
481
|
+
);
|
|
482
|
+
const result = cursor
|
|
483
|
+
? await pollSessionEventsFn(sessionId, {
|
|
484
|
+
targetPath,
|
|
485
|
+
since: pageCursor,
|
|
486
|
+
limit: pageLimit,
|
|
487
|
+
forceCircuitProbe,
|
|
488
|
+
})
|
|
489
|
+
: await pollSessionEventsBeforeFn(sessionId, {
|
|
490
|
+
targetPath,
|
|
491
|
+
beforeSequence: pageBeforeSequence,
|
|
492
|
+
limit: pageLimit,
|
|
493
|
+
forceCircuitProbe,
|
|
494
|
+
});
|
|
495
|
+
if (!result?.ok) {
|
|
496
|
+
if (pages > 0) break;
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
reason: result?.reason || "read_history_failed",
|
|
500
|
+
source,
|
|
501
|
+
cursor: responseCursor,
|
|
502
|
+
beforeSequence: responseBeforeSequence,
|
|
503
|
+
events: [],
|
|
504
|
+
eventCount: 0,
|
|
505
|
+
pages,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
pages += 1;
|
|
510
|
+
const rawEvents = Array.isArray(result.events) ? result.events : [];
|
|
511
|
+
rawEventCount += rawEvents.length;
|
|
512
|
+
const materialEvents = normalizeHistoryEvents(rawEvents, { includeControlEvents });
|
|
513
|
+
if (cursor) {
|
|
514
|
+
events.push(...materialEvents);
|
|
515
|
+
const nextCursor = normalizeString(result.cursor) || pageCursor;
|
|
516
|
+
responseCursor = nextCursor || responseCursor;
|
|
517
|
+
if (!rawEvents.length || !nextCursor || nextCursor === pageCursor) break;
|
|
518
|
+
pageCursor = nextCursor;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
events = [...materialEvents, ...events];
|
|
523
|
+
if (pages === 1) {
|
|
524
|
+
responseCursor = normalizeString(result.cursor) || responseCursor;
|
|
525
|
+
}
|
|
526
|
+
const nextBeforeSequence = normalizePositiveInteger(result.beforeSequence || result.before_sequence, null);
|
|
527
|
+
responseBeforeSequence = nextBeforeSequence;
|
|
528
|
+
if (!rawEvents.length || !nextBeforeSequence || nextBeforeSequence === pageBeforeSequence) break;
|
|
529
|
+
pageBeforeSequence = nextBeforeSequence;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const windowEvents = cursor ? events.slice(0, limit) : events.slice(-limit);
|
|
533
|
+
const firstWindowEvent = windowEvents[0] || null;
|
|
534
|
+
const lastWindowEvent = windowEvents[windowEvents.length - 1] || null;
|
|
535
|
+
const windowCursor = normalizeString(lastWindowEvent?.cursor);
|
|
536
|
+
const windowBeforeSequence = normalizePositiveInteger(
|
|
537
|
+
firstWindowEvent?.sequenceId ?? firstWindowEvent?.sequence_id,
|
|
538
|
+
null,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
ok: true,
|
|
543
|
+
reason: "",
|
|
544
|
+
source,
|
|
545
|
+
cursor: windowCursor || responseCursor,
|
|
546
|
+
beforeSequence: cursor ? null : windowBeforeSequence || responseBeforeSequence,
|
|
547
|
+
events: windowEvents,
|
|
548
|
+
eventCount: rawEventCount,
|
|
549
|
+
pages,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
447
553
|
function requireSessionId(input = {}) {
|
|
448
554
|
const sessionId = normalizeString(input.sessionId || input.session_id || input.session);
|
|
449
555
|
if (!sessionId) {
|
|
@@ -807,6 +913,83 @@ export function createSessionMcpToolHandlers({
|
|
|
807
913
|
};
|
|
808
914
|
},
|
|
809
915
|
|
|
916
|
+
async read_history(input = {}) {
|
|
917
|
+
const sessionId = requireSessionId(input);
|
|
918
|
+
const cursor = normalizeString(input.cursor || input.after || input.since) || null;
|
|
919
|
+
const beforeSequence = normalizeOptionalPositiveInteger(
|
|
920
|
+
input.beforeSequence || input.before_sequence,
|
|
921
|
+
"beforeSequence",
|
|
922
|
+
);
|
|
923
|
+
if (cursor && beforeSequence) {
|
|
924
|
+
throw new Error("Use either cursor/after/since or beforeSequence, not both.");
|
|
925
|
+
}
|
|
926
|
+
const limit = normalizeLimit(input.limit || input.tail || input.maxEvents || input.max_events);
|
|
927
|
+
const actionLimit = normalizeLimit(input.actionLimit || input.action_limit || limit);
|
|
928
|
+
const includeActions = input.includeActions !== false && input.include_actions !== false;
|
|
929
|
+
const includeControlEvents = Boolean(input.includeControlEvents || input.include_control_events);
|
|
930
|
+
const includeRaw = Boolean(input.includeRaw || input.include_raw);
|
|
931
|
+
const forceCircuitProbe = input.forceCircuitProbe !== false && input.force_circuit_probe !== false;
|
|
932
|
+
|
|
933
|
+
const result = await readSessionHistoryWindow({
|
|
934
|
+
sessionId,
|
|
935
|
+
targetPath,
|
|
936
|
+
cursor,
|
|
937
|
+
beforeSequence,
|
|
938
|
+
limit,
|
|
939
|
+
includeControlEvents,
|
|
940
|
+
forceCircuitProbe,
|
|
941
|
+
pollSessionEventsFn,
|
|
942
|
+
pollSessionEventsBeforeFn,
|
|
943
|
+
});
|
|
944
|
+
if (!result?.ok) {
|
|
945
|
+
return {
|
|
946
|
+
ok: false,
|
|
947
|
+
reason: result?.reason || "read_history_failed",
|
|
948
|
+
sessionId,
|
|
949
|
+
source: result?.source || (cursor ? "after_cursor" : beforeSequence ? "before_sequence" : "latest_tail"),
|
|
950
|
+
cursor,
|
|
951
|
+
beforeSequence: beforeSequence || null,
|
|
952
|
+
events: [],
|
|
953
|
+
eventCount: 0,
|
|
954
|
+
materialEventCount: 0,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const events = Array.isArray(result.events) ? result.events : [];
|
|
959
|
+
const actionResult = includeActions
|
|
960
|
+
? await listSessionMessageActionsFn(sessionId, {
|
|
961
|
+
targetPath,
|
|
962
|
+
limit: actionLimit,
|
|
963
|
+
forceCircuitProbe,
|
|
964
|
+
})
|
|
965
|
+
: null;
|
|
966
|
+
const recentHumanActivity = actionResult?.ok
|
|
967
|
+
? recentHumanActivityFromActions(actionResult, { limit: actionLimit })
|
|
968
|
+
: [];
|
|
969
|
+
|
|
970
|
+
return {
|
|
971
|
+
ok: true,
|
|
972
|
+
reason: "",
|
|
973
|
+
sessionId,
|
|
974
|
+
source: result.source,
|
|
975
|
+
cursor: normalizeString(result.cursor) || cursor,
|
|
976
|
+
beforeSequence: normalizePositiveInteger(result.beforeSequence || result.before_sequence, null),
|
|
977
|
+
eventCount: normalizePositiveInteger(result.eventCount, 0),
|
|
978
|
+
materialEventCount: events.length,
|
|
979
|
+
pageCount: normalizePositiveInteger(result.pages, 0),
|
|
980
|
+
recentHumanActivityCount: recentHumanActivity.length,
|
|
981
|
+
recentHumanActivity,
|
|
982
|
+
actionProjection: actionResult
|
|
983
|
+
? {
|
|
984
|
+
ok: Boolean(actionResult.ok),
|
|
985
|
+
reason: actionResult.reason || "",
|
|
986
|
+
count: Array.isArray(actionResult.actions) ? actionResult.actions.length : 0,
|
|
987
|
+
}
|
|
988
|
+
: null,
|
|
989
|
+
events: includeRaw ? events : events.map((event) => summarizeSessionEvent(event)),
|
|
990
|
+
};
|
|
991
|
+
},
|
|
992
|
+
|
|
810
993
|
async send_message(input = {}) {
|
|
811
994
|
const sessionId = requireSessionId(input);
|
|
812
995
|
const agentId = requireAgentId(input);
|
|
@@ -1013,6 +1196,27 @@ export const SESSION_MCP_TOOLS = Object.freeze([
|
|
|
1013
1196
|
},
|
|
1014
1197
|
},
|
|
1015
1198
|
},
|
|
1199
|
+
{
|
|
1200
|
+
name: "read_history",
|
|
1201
|
+
title: "Read Senti History",
|
|
1202
|
+
description:
|
|
1203
|
+
"Hydrate a bounded recent, older, or after-cursor session transcript window without recipient filtering.",
|
|
1204
|
+
inputSchema: {
|
|
1205
|
+
type: "object",
|
|
1206
|
+
additionalProperties: false,
|
|
1207
|
+
required: ["sessionId"],
|
|
1208
|
+
properties: {
|
|
1209
|
+
sessionId: { type: "string", minLength: 1 },
|
|
1210
|
+
cursor: { type: "string" },
|
|
1211
|
+
beforeSequence: { type: "integer", minimum: 1 },
|
|
1212
|
+
limit: { type: "integer", minimum: 1, maximum: MAX_TOOL_LIMIT, default: DEFAULT_TOOL_LIMIT },
|
|
1213
|
+
actionLimit: { type: "integer", minimum: 1, maximum: MAX_TOOL_LIMIT, default: DEFAULT_TOOL_LIMIT },
|
|
1214
|
+
includeActions: { type: "boolean", default: true },
|
|
1215
|
+
includeControlEvents: { type: "boolean", default: false },
|
|
1216
|
+
includeRaw: { type: "boolean", default: false },
|
|
1217
|
+
},
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1016
1220
|
{
|
|
1017
1221
|
name: "send_message",
|
|
1018
1222
|
title: "Send Senti Message",
|
|
@@ -1285,7 +1489,7 @@ export async function handleMcpJsonRpcMessage(
|
|
|
1285
1489
|
version: "0.20.0",
|
|
1286
1490
|
},
|
|
1287
1491
|
instructions:
|
|
1288
|
-
"Use native Senti tools for coordination actions and generated sl.* bridge tools for the rest of SentinelLayer CLI.
|
|
1492
|
+
"Use native Senti tools for coordination actions and generated sl.* bridge tools for the rest of SentinelLayer CLI. Use poll_inbox for addressed wake-style delivery, read_history for recent/older transcript grounding, session_action/session_react/session_reply/session_lock/session_unlock/session_locks for low-noise session work, send_message for durable posts, and generated command tools such as sl.auth.status or sl.review.scan for ordinary CLI commands.",
|
|
1289
1493
|
});
|
|
1290
1494
|
}
|
|
1291
1495
|
|
package/src/scan/generator.js
CHANGED
|
@@ -230,18 +230,17 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
|
|
|
230
230
|
google_api_key:
|
|
231
231
|
"${{ secrets.GOOGLE_GEMINI_API_KEY != '' && secrets.GOOGLE_GEMINI_API_KEY || secrets.GOOGLE_API_KEY }}",
|
|
232
232
|
llm_provider:
|
|
233
|
-
"${{
|
|
233
|
+
"${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'google' || 'openai' }}",
|
|
234
234
|
sentinelayer_token: `\${{ secrets.${normalizedSecret} }}`,
|
|
235
235
|
sentinelayer_managed_llm: `\${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' && secrets.OPENAI_API_KEY == '' && secrets.${normalizedSecret} != '' }}`,
|
|
236
236
|
scan_mode: profile.scanMode || "deep",
|
|
237
237
|
severity_gate: profile.severityGate || "P1",
|
|
238
238
|
model:
|
|
239
|
-
"${{
|
|
239
|
+
"${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-5.3-codex' }}",
|
|
240
240
|
codex_model: "gpt-5.3-codex",
|
|
241
241
|
model_fallback:
|
|
242
242
|
"${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-4.1-mini' }}",
|
|
243
|
-
use_codex:
|
|
244
|
-
"${{ secrets.OPENAI_API_KEY != '' || (secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '') }}",
|
|
243
|
+
use_codex: "${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' }}",
|
|
245
244
|
codex_only: "false",
|
|
246
245
|
llm_failure_policy: "block",
|
|
247
246
|
max_daily_scans: "${{ vars.OMAR_MAX_DAILY_SCANS || '200' }}",
|
|
@@ -304,9 +303,9 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
|
|
|
304
303
|
"${{ secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '' }}",
|
|
305
304
|
OMAR_MANAGED_LLM: `\${{ secrets.GOOGLE_GEMINI_API_KEY == '' && secrets.GOOGLE_API_KEY == '' && secrets.OPENAI_API_KEY == '' && secrets.${normalizedSecret} != '' }}`,
|
|
306
305
|
OMAR_LLM_PROVIDER:
|
|
307
|
-
"${{
|
|
306
|
+
"${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'google' || 'openai' }}",
|
|
308
307
|
OMAR_MODEL:
|
|
309
|
-
"${{
|
|
308
|
+
"${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-5.3-codex' }}",
|
|
310
309
|
OMAR_MODEL_FALLBACK:
|
|
311
310
|
"${{ (secrets.GOOGLE_GEMINI_API_KEY != '' || secrets.GOOGLE_API_KEY != '') && 'gemini-3.1-flash-lite' || 'gpt-4.1-mini' }}",
|
|
312
311
|
},
|
|
@@ -353,7 +352,7 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
|
|
|
353
352
|
" 'llm_provider': env('OMAR_LLM_PROVIDER', 'openai'),",
|
|
354
353
|
" 'model': env('OMAR_MODEL', 'gpt-5.3-codex'),",
|
|
355
354
|
" 'model_fallback': env('OMAR_MODEL_FALLBACK', 'gpt-4.1-mini'),",
|
|
356
|
-
" 'llm_route': '
|
|
355
|
+
" 'llm_route': 'google_api_key' if bool_env('OMAR_GOOGLE_KEY_PRESENT') else ('openai_api_key' if bool_env('OMAR_OPENAI_KEY_PRESENT') else 'sentinelayer_managed'),",
|
|
357
356
|
" 'google_key_present': bool_env('OMAR_GOOGLE_KEY_PRESENT'),",
|
|
358
357
|
" 'openai_key_present': bool_env('OMAR_OPENAI_KEY_PRESENT'),",
|
|
359
358
|
" 'managed_llm': bool_env('OMAR_MANAGED_LLM'),",
|
|
@@ -5,7 +5,7 @@ export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
|
|
|
5
5
|
"When you have an agent grant, post agent updates with `sl session post-agent <id> \"status: <update>\" --agent <your-agent-id>` so they render as the agent, not the human relay.",
|
|
6
6
|
"Before implementation, post a short plan and file claims with `sl session say <id> \"plan: <scope>; files: <paths>\"`.",
|
|
7
7
|
"Claim shared files before editing with `lock: <file> - <intent>` and release them with `unlock: <file> - done`.",
|
|
8
|
-
"Run
|
|
8
|
+
"Run one background/secondary listener for replies with `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval 5 --emit ndjson --no-presence`; this idles at 60s, switches to 5s after human activity, and avoids durable listener heartbeat noise. `session listen` now refuses duplicate local listeners for the same session/agent by default; use `--force` only to stop and replace an existing local owner and `--allow-duplicate` only when you intentionally need parallel wake hooks. `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. For your primary interactive listener, omit `--no-presence` so other participants can see you online. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
|
|
9
9
|
"For long-lived rooms, make sure exactly one visible participant owns the Senti daemon: `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. If no durable recap/checkpoint is appearing, run `sl session recap now <id> --remote --agent <your-name> --json` before posting a long plan.",
|
|
10
10
|
"Use message actions for low-noise coordination before posting a new top-level message: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK is useful, `sl session action <id> working_on --target-sequence <n>` for ownership, and `sl session reply <id> <sequence> \"<message>\"` / `sl session comment <id> <sequence> \"<message>\"` for threaded responses. Read receipts are automatic when you use `sl session read <id> --remote --agent <your-name>`; reserve `sl session view <id> <sequence>` for repair/backfill. Run `sl session actions` for the full list.",
|
|
11
11
|
"Search before asking peers to restate context: `sl session search <id> \"<topic>\" --limit 10`.",
|
|
@@ -38,7 +38,7 @@ export function buildFirstSentiMessage({ sessionId } = {}) {
|
|
|
38
38
|
`7) sl session reply ${sid} <THIS_SEQ> "ACK <AGENT_ID> role=<role> model=<model> locks=none first_action=<one concrete action>" --agent <AGENT_ID>`,
|
|
39
39
|
"",
|
|
40
40
|
"STAY REACHABLE — two honest paths, use YOURS (always pass --agent <AGENT_ID> so the room knows who's listening):",
|
|
41
|
-
`- Persistent process (Codex/GPT): keep ONE listener alive
|
|
41
|
+
`- Persistent process (Codex/GPT): keep ONE listener alive. Re-running listen refuses a duplicate local pid by default; use --force only to stop and replace an existing local owner and --allow-duplicate only for intentional parallel wake hooks. Pair it with a 60s watchdog that restarts it if the pid dies (cursor catch-up replays anything missed):`,
|
|
42
42
|
` sl session listen --session ${sid} --agent <AGENT_ID> --active-interval 30 --active-window 300 --interval 60 --presence-interval 60 --model <MODEL> --display-name <AGENT_ID>`,
|
|
43
43
|
`- No socket (Claude Code): you are NOT continuously connected — you re-arm a wake each turn. 30-270s while actively waiting, 20-30min idle when quiet. Treat time-critical things as POSTED messages you catch next tick, never assumed instant.`,
|
|
44
44
|
`CADENCE both converge to: ~30s active; after 5min quiet → 60s; after another 5 → 90s; +30s every 5min to a floor. Wake IMMEDIATELY on: new human/ORCH msg, a direct reply/@mention, a lock conflict, a deploy/gate notice.`,
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
import { isProcessAlive } from "./daemon-spawn.js";
|
|
10
|
+
import { resolveSessionPaths } from "./paths.js";
|
|
11
|
+
|
|
12
|
+
const LISTENER_DIR_NAME = "listeners";
|
|
13
|
+
const GLOBAL_LISTENER_DIR_NAME = "session-listeners";
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
function normalizeString(value) {
|
|
17
|
+
return String(value || "").trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeListenerProcessKey(agentId = "") {
|
|
21
|
+
return (
|
|
22
|
+
normalizeString(agentId)
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
25
|
+
.replace(/^-+|-+$/g, "") || "agent"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeCommandLine(value) {
|
|
30
|
+
return normalizeString(value).toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function listenerCommandLineMatches(commandLine, { sessionId = "", agentId = "" } = {}) {
|
|
34
|
+
const normalized = normalizeCommandLine(commandLine);
|
|
35
|
+
if (!normalized) return false;
|
|
36
|
+
const normalizedSessionId = normalizeString(sessionId).toLowerCase();
|
|
37
|
+
const normalizedAgentId = normalizeString(agentId).toLowerCase();
|
|
38
|
+
const hasCliEntry =
|
|
39
|
+
normalized.includes("sentinelayer-cli") ||
|
|
40
|
+
/[\\/](?:sl|sentinelayer-cli)\.js\b/.test(normalized);
|
|
41
|
+
return (
|
|
42
|
+
hasCliEntry &&
|
|
43
|
+
normalized.includes("session") &&
|
|
44
|
+
normalized.includes("listen") &&
|
|
45
|
+
(!normalizedSessionId || normalized.includes(normalizedSessionId)) &&
|
|
46
|
+
(!normalizedAgentId || normalized.includes(normalizedAgentId))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readWindowsCommandLine(pid) {
|
|
51
|
+
const command = [
|
|
52
|
+
"$ErrorActionPreference = 'Stop';",
|
|
53
|
+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${Number(pid)}";`,
|
|
54
|
+
"if ($p) { $p.CommandLine }",
|
|
55
|
+
].join(" ");
|
|
56
|
+
const { stdout } = await execFileAsync(
|
|
57
|
+
"powershell.exe",
|
|
58
|
+
["-NoProfile", "-NonInteractive", "-Command", command],
|
|
59
|
+
{ windowsHide: true, timeout: 2000 },
|
|
60
|
+
);
|
|
61
|
+
return normalizeString(stdout);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readPosixCommandLine(pid) {
|
|
65
|
+
if (process.platform === "linux") {
|
|
66
|
+
try {
|
|
67
|
+
const raw = await fsp.readFile(`/proc/${Number(pid)}/cmdline`);
|
|
68
|
+
return normalizeString(raw.toString("utf8").replace(/\0/g, " "));
|
|
69
|
+
} catch {
|
|
70
|
+
// Fall through to ps(1), which also covers restricted /proc mounts.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const { stdout } = await execFileAsync("ps", ["-p", String(Number(pid)), "-o", "command="], {
|
|
74
|
+
timeout: 2000,
|
|
75
|
+
});
|
|
76
|
+
return normalizeString(stdout);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function readProcessCommandLine(pid) {
|
|
80
|
+
const numericPid = Number(pid);
|
|
81
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) return "";
|
|
82
|
+
try {
|
|
83
|
+
return process.platform === "win32"
|
|
84
|
+
? await readWindowsCommandLine(numericPid)
|
|
85
|
+
: await readPosixCommandLine(numericPid);
|
|
86
|
+
} catch {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolveListenerPidPath(
|
|
92
|
+
sessionId,
|
|
93
|
+
agentId,
|
|
94
|
+
{ targetPath = process.cwd() } = {},
|
|
95
|
+
) {
|
|
96
|
+
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
97
|
+
return path.join(
|
|
98
|
+
paths.sessionDir,
|
|
99
|
+
LISTENER_DIR_NAME,
|
|
100
|
+
`${normalizeListenerProcessKey(agentId)}.json`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveGlobalListenerPidPath(
|
|
105
|
+
sessionId,
|
|
106
|
+
agentId,
|
|
107
|
+
{ homeDir = os.homedir() } = {},
|
|
108
|
+
) {
|
|
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) {
|
|
119
|
+
try {
|
|
120
|
+
const raw = await fsp.readFile(pidPath, "utf-8");
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
122
|
+
if (!parsed || typeof parsed !== "object") {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return parsed;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
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
|
+
|
|
169
|
+
export async function writeListenerPidRecord(
|
|
170
|
+
sessionId,
|
|
171
|
+
agentId,
|
|
172
|
+
{
|
|
173
|
+
targetPath = process.cwd(),
|
|
174
|
+
pid = process.pid,
|
|
175
|
+
listenerId = "",
|
|
176
|
+
transport = "",
|
|
177
|
+
intervalSeconds = null,
|
|
178
|
+
activeIntervalSeconds = null,
|
|
179
|
+
presenceIntervalSeconds = null,
|
|
180
|
+
logFile = "",
|
|
181
|
+
homeDir = os.homedir(),
|
|
182
|
+
} = {},
|
|
183
|
+
) {
|
|
184
|
+
const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
|
|
185
|
+
const globalPidPath = resolveGlobalListenerPidPath(sessionId, agentId, { homeDir });
|
|
186
|
+
const record = {
|
|
187
|
+
pid: Number(pid),
|
|
188
|
+
sessionId: normalizeString(sessionId),
|
|
189
|
+
agentId: normalizeString(agentId),
|
|
190
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
191
|
+
listenerId: normalizeString(listenerId),
|
|
192
|
+
targetPath: path.resolve(String(targetPath || ".")),
|
|
193
|
+
transport: normalizeString(transport) || undefined,
|
|
194
|
+
intervalSeconds: Number(intervalSeconds) || undefined,
|
|
195
|
+
activeIntervalSeconds: Number(activeIntervalSeconds) || undefined,
|
|
196
|
+
presenceIntervalSeconds: Number(presenceIntervalSeconds) || undefined,
|
|
197
|
+
logFile: normalizeString(logFile) || undefined,
|
|
198
|
+
startedAt: new Date().toISOString(),
|
|
199
|
+
};
|
|
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
|
+
}
|
|
212
|
+
return record;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function removeListenerPidRecord(
|
|
216
|
+
sessionId,
|
|
217
|
+
agentId,
|
|
218
|
+
{ targetPath = process.cwd(), onlyForPid = null, homeDir = os.homedir() } = {},
|
|
219
|
+
) {
|
|
220
|
+
const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
|
|
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);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function listenerStatusFromRecord(
|
|
228
|
+
record,
|
|
229
|
+
{
|
|
230
|
+
sessionId,
|
|
231
|
+
agentId,
|
|
232
|
+
recordScope,
|
|
233
|
+
_readProcessCommandLine = readProcessCommandLine,
|
|
234
|
+
} = {},
|
|
235
|
+
) {
|
|
236
|
+
const alive = isProcessAlive(record.pid);
|
|
237
|
+
if (!alive) {
|
|
238
|
+
return {
|
|
239
|
+
running: false,
|
|
240
|
+
pid: null,
|
|
241
|
+
stale: true,
|
|
242
|
+
record,
|
|
243
|
+
recordScope,
|
|
244
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const commandLine = await _readProcessCommandLine(record.pid);
|
|
248
|
+
if (!listenerCommandLineMatches(commandLine, { sessionId, agentId })) {
|
|
249
|
+
return {
|
|
250
|
+
running: false,
|
|
251
|
+
pid: null,
|
|
252
|
+
stale: true,
|
|
253
|
+
reused: Boolean(commandLine),
|
|
254
|
+
unverified: !commandLine,
|
|
255
|
+
record,
|
|
256
|
+
recordScope,
|
|
257
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
258
|
+
commandLine,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
running: true,
|
|
263
|
+
pid: Number(record.pid),
|
|
264
|
+
stale: false,
|
|
265
|
+
record,
|
|
266
|
+
recordScope,
|
|
267
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
268
|
+
commandLine,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
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
|
+
|
|
317
|
+
export async function requestListenerProcessStop(
|
|
318
|
+
pid,
|
|
319
|
+
{ signal = "SIGTERM", timeoutMs = 3000, pollIntervalMs = 100 } = {},
|
|
320
|
+
) {
|
|
321
|
+
const numericPid = Number(pid);
|
|
322
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) {
|
|
323
|
+
return { requested: false, stopped: true, reason: "invalid_pid" };
|
|
324
|
+
}
|
|
325
|
+
if (numericPid === process.pid) {
|
|
326
|
+
return { requested: false, stopped: false, reason: "self" };
|
|
327
|
+
}
|
|
328
|
+
if (!isProcessAlive(numericPid)) {
|
|
329
|
+
return { requested: false, stopped: true, reason: "not_running" };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
process.kill(numericPid, signal);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if (error?.code === "ESRCH") {
|
|
336
|
+
return { requested: false, stopped: true, reason: "not_running" };
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
requested: false,
|
|
340
|
+
stopped: false,
|
|
341
|
+
reason: normalizeString(error?.code) || "signal_failed",
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const deadlineMs = Date.now() + Math.max(1, Number(timeoutMs) || 3000);
|
|
346
|
+
const sleepMs = Math.max(10, Number(pollIntervalMs) || 100);
|
|
347
|
+
while (Date.now() < deadlineMs) {
|
|
348
|
+
if (!isProcessAlive(numericPid)) {
|
|
349
|
+
return { requested: true, stopped: true, reason: "stopped" };
|
|
350
|
+
}
|
|
351
|
+
await sleep(sleepMs);
|
|
352
|
+
}
|
|
353
|
+
return { requested: true, stopped: false, reason: "timeout" };
|
|
354
|
+
}
|
package/src/session/listener.js
CHANGED
|
@@ -305,6 +305,8 @@ export async function listenSessionEvents({
|
|
|
305
305
|
const activeWindowMs =
|
|
306
306
|
Math.max(1, normalizePositiveInteger(activeWindowSeconds, DEFAULT_ACTIVE_WINDOW_SECONDS)) *
|
|
307
307
|
1000;
|
|
308
|
+
const autoStreamIdleTimeoutMs =
|
|
309
|
+
normalizedTransport === "auto" ? Math.max(5_000, Math.min(idleSleepMs, 30_000)) : 0;
|
|
308
310
|
|
|
309
311
|
if (fromNow) {
|
|
310
312
|
const latest = await _pollLatest(normalizedSessionId, {
|
|
@@ -507,9 +509,6 @@ export async function listenSessionEvents({
|
|
|
507
509
|
streamHeartbeatInFlight = false;
|
|
508
510
|
}
|
|
509
511
|
}, streamHeartbeatMs);
|
|
510
|
-
if (streamHeartbeatTimer && typeof streamHeartbeatTimer.unref === "function") {
|
|
511
|
-
streamHeartbeatTimer.unref();
|
|
512
|
-
}
|
|
513
512
|
}
|
|
514
513
|
|
|
515
514
|
await notifyLifecycle(
|
|
@@ -527,6 +526,7 @@ export async function listenSessionEvents({
|
|
|
527
526
|
const streamResult = await _stream(normalizedSessionId, {
|
|
528
527
|
targetPath,
|
|
529
528
|
since: cursor,
|
|
529
|
+
idleTimeoutMs: autoStreamIdleTimeoutMs,
|
|
530
530
|
signal,
|
|
531
531
|
onEvent: async (event) => {
|
|
532
532
|
await processEventBatch([event], normalizeString(event?.cursor) || cursor);
|
package/src/session/listeners.js
CHANGED
|
@@ -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,18 +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
|
|
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
|
+
{
|
|
157
|
+
targetPath = process.cwd(),
|
|
158
|
+
limit = DEFAULT_LISTENER_FETCH_LIMIT,
|
|
159
|
+
maxPages = DEFAULT_LISTENER_FETCH_MAX_PAGES,
|
|
160
|
+
nowMs = Date.now,
|
|
161
|
+
forceCircuitProbe = true,
|
|
162
|
+
poll = pollSessionEventsBefore,
|
|
163
|
+
} = {}
|
|
151
164
|
) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
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);
|
|
155
222
|
}
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
};
|
|
158
236
|
}
|
|
159
237
|
|
|
160
238
|
export function formatListenerLine(row) {
|
package/src/session/sync.js
CHANGED
|
@@ -1479,6 +1479,7 @@ export async function pollSessionEvents(
|
|
|
1479
1479
|
* @param {object} [options]
|
|
1480
1480
|
* @param {string|null} [options.since] - durable cursor to resume after
|
|
1481
1481
|
* @param {AbortSignal} [options.signal]
|
|
1482
|
+
* @param {number} [options.idleTimeoutMs] - abort a connected stream after this many silent milliseconds
|
|
1482
1483
|
* @param {(event: object) => Promise<void>|void} [options.onEvent]
|
|
1483
1484
|
* @param {(payload: object) => Promise<void>|void} [options.onError]
|
|
1484
1485
|
* @param {() => Promise<void>|void} [options.onHeartbeat]
|
|
@@ -1490,6 +1491,7 @@ export async function streamSessionEvents(
|
|
|
1490
1491
|
targetPath = process.cwd(),
|
|
1491
1492
|
since = null,
|
|
1492
1493
|
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1494
|
+
idleTimeoutMs = 0,
|
|
1493
1495
|
signal = undefined,
|
|
1494
1496
|
resolveAuthSession = resolveActiveAuthSession,
|
|
1495
1497
|
fetchImpl = fetch,
|
|
@@ -1545,10 +1547,26 @@ export async function streamSessionEvents(
|
|
|
1545
1547
|
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/stream${suffix}`;
|
|
1546
1548
|
const controller = new AbortController();
|
|
1547
1549
|
const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
1550
|
+
const normalizedIdleTimeoutMs = normalizePositiveInteger(idleTimeoutMs, 0);
|
|
1548
1551
|
const timeoutHandle = setTimeout(() => controller.abort(), normalizedTimeoutMs);
|
|
1549
1552
|
if (typeof timeoutHandle.unref === "function") {
|
|
1550
1553
|
timeoutHandle.unref();
|
|
1551
1554
|
}
|
|
1555
|
+
let idleTimeoutHandle = null;
|
|
1556
|
+
let idleTimedOut = false;
|
|
1557
|
+
const clearIdleTimeout = () => {
|
|
1558
|
+
if (!idleTimeoutHandle) return;
|
|
1559
|
+
clearTimeout(idleTimeoutHandle);
|
|
1560
|
+
idleTimeoutHandle = null;
|
|
1561
|
+
};
|
|
1562
|
+
const armIdleTimeout = () => {
|
|
1563
|
+
if (!normalizedIdleTimeoutMs) return;
|
|
1564
|
+
clearIdleTimeout();
|
|
1565
|
+
idleTimeoutHandle = setTimeout(() => {
|
|
1566
|
+
idleTimedOut = true;
|
|
1567
|
+
controller.abort();
|
|
1568
|
+
}, normalizedIdleTimeoutMs);
|
|
1569
|
+
};
|
|
1552
1570
|
const forwardAbort = () => controller.abort(signal?.reason);
|
|
1553
1571
|
if (signal) {
|
|
1554
1572
|
if (signal.aborted) {
|
|
@@ -1574,10 +1592,13 @@ export async function streamSessionEvents(
|
|
|
1574
1592
|
);
|
|
1575
1593
|
} catch (error) {
|
|
1576
1594
|
clearTimeout(timeoutHandle);
|
|
1595
|
+
clearIdleTimeout();
|
|
1577
1596
|
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
1578
1597
|
return {
|
|
1579
1598
|
ok: false,
|
|
1580
|
-
reason:
|
|
1599
|
+
reason: idleTimedOut
|
|
1600
|
+
? "stream_idle_timeout"
|
|
1601
|
+
: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
|
|
1581
1602
|
cursor: normalizedSince,
|
|
1582
1603
|
eventCount: 0,
|
|
1583
1604
|
errorCount: 0,
|
|
@@ -1621,7 +1642,9 @@ export async function streamSessionEvents(
|
|
|
1621
1642
|
};
|
|
1622
1643
|
}
|
|
1623
1644
|
|
|
1645
|
+
armIdleTimeout();
|
|
1624
1646
|
for await (const chunk of readResponseTextChunks(response)) {
|
|
1647
|
+
armIdleTimeout();
|
|
1625
1648
|
buffer += decoder.decode(chunk, { stream: true });
|
|
1626
1649
|
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1627
1650
|
const blocks = buffer.split("\n\n");
|
|
@@ -1637,13 +1660,16 @@ export async function streamSessionEvents(
|
|
|
1637
1660
|
} catch (error) {
|
|
1638
1661
|
return {
|
|
1639
1662
|
ok: false,
|
|
1640
|
-
reason:
|
|
1663
|
+
reason: idleTimedOut
|
|
1664
|
+
? "stream_idle_timeout"
|
|
1665
|
+
: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
|
|
1641
1666
|
cursor,
|
|
1642
1667
|
eventCount,
|
|
1643
1668
|
errorCount,
|
|
1644
1669
|
aborted: Boolean(signal?.aborted || isAbortLike(error)),
|
|
1645
1670
|
};
|
|
1646
1671
|
} finally {
|
|
1672
|
+
clearIdleTimeout();
|
|
1647
1673
|
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
1648
1674
|
}
|
|
1649
1675
|
|