sentinelayer-cli 0.34.7 → 0.35.0
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 +70 -9
- 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 +259 -0
- package/src/session/listener.js +3 -3
- package/src/session/listeners.js +8 -2
- 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,
|
|
@@ -4538,6 +4544,14 @@ export function registerSessionCommand(program) {
|
|
|
4538
4544
|
.option("--from-now", "Advance the listen cursor to the latest durable event before polling")
|
|
4539
4545
|
.option("--replay", "Emit matching historical events on the first poll")
|
|
4540
4546
|
.option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
|
|
4547
|
+
.option(
|
|
4548
|
+
"--force",
|
|
4549
|
+
"Stop and replace an existing local listener process for this session/agent",
|
|
4550
|
+
)
|
|
4551
|
+
.option(
|
|
4552
|
+
"--allow-duplicate",
|
|
4553
|
+
"Allow multiple local listeners for the same session/agent (advanced; can duplicate wake hooks)",
|
|
4554
|
+
)
|
|
4541
4555
|
.option(
|
|
4542
4556
|
"--wake <command>",
|
|
4543
4557
|
"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 +4680,59 @@ export function registerSessionCommand(program) {
|
|
|
4666
4680
|
if (options.fromNow && options.since !== undefined) {
|
|
4667
4681
|
throw new Error("Use either --from-now or --since, not both.");
|
|
4668
4682
|
}
|
|
4669
|
-
const
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4683
|
+
const listenerId = `listener-${agentId}-${randomUUID()}`;
|
|
4684
|
+
const singletonEnabled = maxPolls === null && !options.allowDuplicate;
|
|
4685
|
+
let listenerPidRecordActive = false;
|
|
4686
|
+
if (singletonEnabled) {
|
|
4687
|
+
const existingListener = await getListenerProcessStatus(normalizedSessionId, agentId, {
|
|
4688
|
+
targetPath,
|
|
4689
|
+
});
|
|
4690
|
+
if (existingListener.running && !options.force) {
|
|
4691
|
+
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.`,
|
|
4693
|
+
);
|
|
4694
|
+
}
|
|
4695
|
+
if (existingListener.running && options.force) {
|
|
4696
|
+
const stopResult = await requestListenerProcessStop(existingListener.pid);
|
|
4697
|
+
if (!stopResult.stopped) {
|
|
4698
|
+
throw new Error(
|
|
4699
|
+
`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.`,
|
|
4700
|
+
);
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4703
|
+
await writeListenerPidRecord(normalizedSessionId, agentId, {
|
|
4704
|
+
targetPath,
|
|
4705
|
+
listenerId,
|
|
4706
|
+
transport: listenTransport,
|
|
4707
|
+
intervalSeconds,
|
|
4708
|
+
activeIntervalSeconds,
|
|
4709
|
+
presenceIntervalSeconds,
|
|
4710
|
+
logFile: listenLogPath,
|
|
4711
|
+
});
|
|
4712
|
+
listenerPidRecordActive = true;
|
|
4713
|
+
}
|
|
4714
|
+
let restoreConsoleLog = null;
|
|
4715
|
+
try {
|
|
4716
|
+
restoreConsoleLog = listenLogPath
|
|
4717
|
+
? installRotatingConsoleLog({
|
|
4718
|
+
logPath: listenLogPath,
|
|
4719
|
+
maxBytes: parsePositiveInteger(options.logMaxBytes, "log-max-bytes", DEFAULT_ROTATING_LOG_MAX_BYTES),
|
|
4720
|
+
maxFiles: parsePositiveInteger(options.logMaxFiles, "log-max-files", DEFAULT_ROTATING_LOG_MAX_FILES),
|
|
4721
|
+
tee: true,
|
|
4722
|
+
})
|
|
4723
|
+
: null;
|
|
4724
|
+
} catch (error) {
|
|
4725
|
+
if (listenerPidRecordActive) {
|
|
4726
|
+
await removeListenerPidRecord(normalizedSessionId, agentId, {
|
|
4727
|
+
targetPath,
|
|
4728
|
+
onlyForPid: process.pid,
|
|
4729
|
+
}).catch(() => {});
|
|
4730
|
+
}
|
|
4731
|
+
throw error;
|
|
4732
|
+
}
|
|
4677
4733
|
const ac = new AbortController();
|
|
4678
4734
|
const onSigint = () => ac.abort();
|
|
4679
4735
|
process.on("SIGINT", onSigint);
|
|
4680
|
-
const listenerId = `listener-${agentId}-${randomUUID()}`;
|
|
4681
4736
|
const durablePresenceEnabled = options.presence !== false;
|
|
4682
4737
|
const publishPresence = durablePresenceEnabled && canPublishListenerPresence(agentId);
|
|
4683
4738
|
const presenceIntervalMs = Math.max(1, presenceIntervalSeconds) * 1000;
|
|
@@ -4909,6 +4964,12 @@ export function registerSessionCommand(program) {
|
|
|
4909
4964
|
restoreConsoleLog();
|
|
4910
4965
|
}
|
|
4911
4966
|
process.removeListener("SIGINT", onSigint);
|
|
4967
|
+
if (listenerPidRecordActive) {
|
|
4968
|
+
await removeListenerPidRecord(normalizedSessionId, agentId, {
|
|
4969
|
+
targetPath,
|
|
4970
|
+
onlyForPid: process.pid,
|
|
4971
|
+
}).catch(() => {});
|
|
4972
|
+
}
|
|
4912
4973
|
}
|
|
4913
4974
|
});
|
|
4914
4975
|
|
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,259 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
import { isProcessAlive } from "./daemon-spawn.js";
|
|
9
|
+
import { resolveSessionPaths } from "./paths.js";
|
|
10
|
+
|
|
11
|
+
const LISTENER_DIR_NAME = "listeners";
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
function normalizeString(value) {
|
|
15
|
+
return String(value || "").trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeListenerProcessKey(agentId = "") {
|
|
19
|
+
return (
|
|
20
|
+
normalizeString(agentId)
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
23
|
+
.replace(/^-+|-+$/g, "") || "agent"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeCommandLine(value) {
|
|
28
|
+
return normalizeString(value).toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function listenerCommandLineMatches(commandLine, { sessionId = "", agentId = "" } = {}) {
|
|
32
|
+
const normalized = normalizeCommandLine(commandLine);
|
|
33
|
+
if (!normalized) return false;
|
|
34
|
+
const normalizedSessionId = normalizeString(sessionId).toLowerCase();
|
|
35
|
+
const normalizedAgentId = normalizeString(agentId).toLowerCase();
|
|
36
|
+
const hasCliEntry =
|
|
37
|
+
normalized.includes("sentinelayer-cli") ||
|
|
38
|
+
/[\\/](?:sl|sentinelayer-cli)\.js\b/.test(normalized);
|
|
39
|
+
return (
|
|
40
|
+
hasCliEntry &&
|
|
41
|
+
normalized.includes("session") &&
|
|
42
|
+
normalized.includes("listen") &&
|
|
43
|
+
(!normalizedSessionId || normalized.includes(normalizedSessionId)) &&
|
|
44
|
+
(!normalizedAgentId || normalized.includes(normalizedAgentId))
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readWindowsCommandLine(pid) {
|
|
49
|
+
const command = [
|
|
50
|
+
"$ErrorActionPreference = 'Stop';",
|
|
51
|
+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${Number(pid)}";`,
|
|
52
|
+
"if ($p) { $p.CommandLine }",
|
|
53
|
+
].join(" ");
|
|
54
|
+
const { stdout } = await execFileAsync(
|
|
55
|
+
"powershell.exe",
|
|
56
|
+
["-NoProfile", "-NonInteractive", "-Command", command],
|
|
57
|
+
{ windowsHide: true, timeout: 2000 },
|
|
58
|
+
);
|
|
59
|
+
return normalizeString(stdout);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readPosixCommandLine(pid) {
|
|
63
|
+
if (process.platform === "linux") {
|
|
64
|
+
try {
|
|
65
|
+
const raw = await fsp.readFile(`/proc/${Number(pid)}/cmdline`);
|
|
66
|
+
return normalizeString(raw.toString("utf8").replace(/\0/g, " "));
|
|
67
|
+
} catch {
|
|
68
|
+
// Fall through to ps(1), which also covers restricted /proc mounts.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const { stdout } = await execFileAsync("ps", ["-p", String(Number(pid)), "-o", "command="], {
|
|
72
|
+
timeout: 2000,
|
|
73
|
+
});
|
|
74
|
+
return normalizeString(stdout);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function readProcessCommandLine(pid) {
|
|
78
|
+
const numericPid = Number(pid);
|
|
79
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) return "";
|
|
80
|
+
try {
|
|
81
|
+
return process.platform === "win32"
|
|
82
|
+
? await readWindowsCommandLine(numericPid)
|
|
83
|
+
: await readPosixCommandLine(numericPid);
|
|
84
|
+
} catch {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveListenerPidPath(
|
|
90
|
+
sessionId,
|
|
91
|
+
agentId,
|
|
92
|
+
{ targetPath = process.cwd() } = {},
|
|
93
|
+
) {
|
|
94
|
+
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
95
|
+
return path.join(
|
|
96
|
+
paths.sessionDir,
|
|
97
|
+
LISTENER_DIR_NAME,
|
|
98
|
+
`${normalizeListenerProcessKey(agentId)}.json`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readListenerPidRecord(
|
|
103
|
+
sessionId,
|
|
104
|
+
agentId,
|
|
105
|
+
{ targetPath = process.cwd() } = {},
|
|
106
|
+
) {
|
|
107
|
+
const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
|
|
108
|
+
try {
|
|
109
|
+
const raw = await fsp.readFile(pidPath, "utf-8");
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
if (!parsed || typeof parsed !== "object") {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return parsed;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function writeListenerPidRecord(
|
|
121
|
+
sessionId,
|
|
122
|
+
agentId,
|
|
123
|
+
{
|
|
124
|
+
targetPath = process.cwd(),
|
|
125
|
+
pid = process.pid,
|
|
126
|
+
listenerId = "",
|
|
127
|
+
transport = "",
|
|
128
|
+
intervalSeconds = null,
|
|
129
|
+
activeIntervalSeconds = null,
|
|
130
|
+
presenceIntervalSeconds = null,
|
|
131
|
+
logFile = "",
|
|
132
|
+
} = {},
|
|
133
|
+
) {
|
|
134
|
+
const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
|
|
135
|
+
const record = {
|
|
136
|
+
pid: Number(pid),
|
|
137
|
+
sessionId: normalizeString(sessionId),
|
|
138
|
+
agentId: normalizeString(agentId),
|
|
139
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
140
|
+
listenerId: normalizeString(listenerId),
|
|
141
|
+
targetPath: path.resolve(String(targetPath || ".")),
|
|
142
|
+
transport: normalizeString(transport) || undefined,
|
|
143
|
+
intervalSeconds: Number(intervalSeconds) || undefined,
|
|
144
|
+
activeIntervalSeconds: Number(activeIntervalSeconds) || undefined,
|
|
145
|
+
presenceIntervalSeconds: Number(presenceIntervalSeconds) || undefined,
|
|
146
|
+
logFile: normalizeString(logFile) || undefined,
|
|
147
|
+
startedAt: new Date().toISOString(),
|
|
148
|
+
};
|
|
149
|
+
await fsp.mkdir(path.dirname(pidPath), { recursive: true });
|
|
150
|
+
await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
|
|
151
|
+
return record;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function removeListenerPidRecord(
|
|
155
|
+
sessionId,
|
|
156
|
+
agentId,
|
|
157
|
+
{ targetPath = process.cwd(), onlyForPid = null } = {},
|
|
158
|
+
) {
|
|
159
|
+
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
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function getListenerProcessStatus(
|
|
175
|
+
sessionId,
|
|
176
|
+
agentId,
|
|
177
|
+
{ targetPath = process.cwd(), _readProcessCommandLine = readProcessCommandLine } = {},
|
|
178
|
+
) {
|
|
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
|
+
const alive = isProcessAlive(record.pid);
|
|
190
|
+
if (!alive) {
|
|
191
|
+
return {
|
|
192
|
+
running: false,
|
|
193
|
+
pid: null,
|
|
194
|
+
stale: true,
|
|
195
|
+
record,
|
|
196
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const commandLine = await _readProcessCommandLine(record.pid);
|
|
200
|
+
if (!listenerCommandLineMatches(commandLine, { sessionId, agentId })) {
|
|
201
|
+
return {
|
|
202
|
+
running: false,
|
|
203
|
+
pid: null,
|
|
204
|
+
stale: true,
|
|
205
|
+
reused: Boolean(commandLine),
|
|
206
|
+
unverified: !commandLine,
|
|
207
|
+
record,
|
|
208
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
209
|
+
commandLine,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
running: true,
|
|
214
|
+
pid: Number(record.pid),
|
|
215
|
+
stale: false,
|
|
216
|
+
record,
|
|
217
|
+
listenerKey: normalizeListenerProcessKey(agentId),
|
|
218
|
+
commandLine,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function requestListenerProcessStop(
|
|
223
|
+
pid,
|
|
224
|
+
{ signal = "SIGTERM", timeoutMs = 3000, pollIntervalMs = 100 } = {},
|
|
225
|
+
) {
|
|
226
|
+
const numericPid = Number(pid);
|
|
227
|
+
if (!Number.isInteger(numericPid) || numericPid <= 0) {
|
|
228
|
+
return { requested: false, stopped: true, reason: "invalid_pid" };
|
|
229
|
+
}
|
|
230
|
+
if (numericPid === process.pid) {
|
|
231
|
+
return { requested: false, stopped: false, reason: "self" };
|
|
232
|
+
}
|
|
233
|
+
if (!isProcessAlive(numericPid)) {
|
|
234
|
+
return { requested: false, stopped: true, reason: "not_running" };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
process.kill(numericPid, signal);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error?.code === "ESRCH") {
|
|
241
|
+
return { requested: false, stopped: true, reason: "not_running" };
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
requested: false,
|
|
245
|
+
stopped: false,
|
|
246
|
+
reason: normalizeString(error?.code) || "signal_failed",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const deadlineMs = Date.now() + Math.max(1, Number(timeoutMs) || 3000);
|
|
251
|
+
const sleepMs = Math.max(10, Number(pollIntervalMs) || 100);
|
|
252
|
+
while (Date.now() < deadlineMs) {
|
|
253
|
+
if (!isProcessAlive(numericPid)) {
|
|
254
|
+
return { requested: true, stopped: true, reason: "stopped" };
|
|
255
|
+
}
|
|
256
|
+
await sleep(sleepMs);
|
|
257
|
+
}
|
|
258
|
+
return { requested: true, stopped: false, reason: "timeout" };
|
|
259
|
+
}
|
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
|
@@ -147,9 +147,15 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
|
|
|
147
147
|
*/
|
|
148
148
|
export async function fetchSessionListeners(
|
|
149
149
|
sessionId,
|
|
150
|
-
{
|
|
150
|
+
{
|
|
151
|
+
targetPath = process.cwd(),
|
|
152
|
+
limit = 200,
|
|
153
|
+
nowMs = Date.now,
|
|
154
|
+
forceCircuitProbe = true,
|
|
155
|
+
poll = pollSessionEventsBefore,
|
|
156
|
+
} = {}
|
|
151
157
|
) {
|
|
152
|
-
const result = await poll(sessionId, { targetPath, limit });
|
|
158
|
+
const result = await poll(sessionId, { targetPath, limit, forceCircuitProbe });
|
|
153
159
|
if (!result?.ok) {
|
|
154
160
|
return { ok: false, reason: normalizeString(result?.reason) || "fetch_failed", listeners: [] };
|
|
155
161
|
}
|
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
|
|