sentinelayer-cli 0.18.2 → 0.20.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/ai/identity-lifecycle.js +14 -2
- package/src/commands/mcp.js +60 -0
- package/src/commands/session.js +1255 -25
- package/src/legacy-cli.js +16 -11
- package/src/mcp/registry.js +151 -0
- package/src/mcp/session-stdio-server.js +977 -0
- package/src/scan/generator.js +3 -2
- package/src/session/agent-registry.js +118 -0
- package/src/session/checkpoints.js +71 -1
- package/src/session/coordination-guidance.js +3 -2
- package/src/session/listener.js +302 -68
- package/src/session/pricing-ledger.js +34 -4
- package/src/session/recap.js +141 -4
- package/src/session/sync.js +296 -2
- package/src/session/transcript.js +86 -36
- package/src/session/usage.js +5 -5
- package/src/session/wake/claude.js +175 -0
- package/src/session/wake/codex.js +394 -0
- package/src/session/wake/cursor-store.js +69 -0
- package/src/session/wake/dispatcher.js +184 -0
- package/src/session/wake/pump.js +135 -0
- package/src/session/wake/registry.js +80 -0
- package/src/session/wake/resolve-target.js +146 -0
- package/src/session/wake/sentid.js +103 -0
package/src/session/recap.js
CHANGED
|
@@ -17,6 +17,9 @@ const DEFAULT_RECAP_INTERVAL_MS = 300_000;
|
|
|
17
17
|
const DEFAULT_RECAP_INACTIVITY_MS = 600_000;
|
|
18
18
|
const DEFAULT_RECAP_ACTIVITY_THRESHOLD = 5;
|
|
19
19
|
const DEFAULT_TASK_SUMMARY_LIMIT = 3;
|
|
20
|
+
const DEFAULT_WORK_PLAN_SUMMARY_LIMIT = 5;
|
|
21
|
+
const MAX_WORK_PLAN_BYTES = 128_000;
|
|
22
|
+
const WORK_PLAN_RELATIVE_PATH = "tasks/todo.md";
|
|
20
23
|
const RECAP_SOURCE_IGNORED_EVENTS = new Set([
|
|
21
24
|
"agent_heartbeat",
|
|
22
25
|
"agent_join",
|
|
@@ -399,6 +402,105 @@ function emptyTaskLedgerSummary() {
|
|
|
399
402
|
};
|
|
400
403
|
}
|
|
401
404
|
|
|
405
|
+
function emptyWorkPlanSummary() {
|
|
406
|
+
return {
|
|
407
|
+
path: WORK_PLAN_RELATIVE_PATH,
|
|
408
|
+
exists: false,
|
|
409
|
+
truncated: false,
|
|
410
|
+
total: 0,
|
|
411
|
+
open: 0,
|
|
412
|
+
completed: 0,
|
|
413
|
+
currentSection: "",
|
|
414
|
+
recentOpen: [],
|
|
415
|
+
recent: [],
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function shortWorkPlanText(value) {
|
|
420
|
+
const text = normalizeString(value)
|
|
421
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
422
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
423
|
+
.replace(/\s+/g, " ");
|
|
424
|
+
if (text.length <= 100) {
|
|
425
|
+
return text;
|
|
426
|
+
}
|
|
427
|
+
return `${text.slice(0, 97)}...`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function summarizeWorkPlanMarkdown(raw = "", { limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false } = {}) {
|
|
431
|
+
const summary = emptyWorkPlanSummary();
|
|
432
|
+
summary.exists = true;
|
|
433
|
+
summary.truncated = Boolean(truncated);
|
|
434
|
+
|
|
435
|
+
const records = [];
|
|
436
|
+
let section = "";
|
|
437
|
+
for (const line of String(raw || "").split(/\r?\n/)) {
|
|
438
|
+
const headingMatch = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
|
|
439
|
+
if (headingMatch) {
|
|
440
|
+
section = shortWorkPlanText(headingMatch[2]);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const taskMatch = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/.exec(line);
|
|
445
|
+
if (!taskMatch) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const completed = taskMatch[1].toLowerCase() === "x";
|
|
449
|
+
const record = {
|
|
450
|
+
status: completed ? "completed" : "open",
|
|
451
|
+
section,
|
|
452
|
+
task: shortWorkPlanText(taskMatch[2]),
|
|
453
|
+
};
|
|
454
|
+
if (!record.task) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
records.push(record);
|
|
458
|
+
summary.total += 1;
|
|
459
|
+
if (completed) {
|
|
460
|
+
summary.completed += 1;
|
|
461
|
+
} else {
|
|
462
|
+
summary.open += 1;
|
|
463
|
+
}
|
|
464
|
+
summary.currentSection = section || summary.currentSection;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const normalizedLimit = Math.max(1, normalizePositiveInteger(limit, DEFAULT_WORK_PLAN_SUMMARY_LIMIT));
|
|
468
|
+
summary.recentOpen = records
|
|
469
|
+
.filter((record) => record.status === "open")
|
|
470
|
+
.slice(-normalizedLimit);
|
|
471
|
+
summary.recent = records.slice(-normalizedLimit);
|
|
472
|
+
return summary;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function readWorkPlanSummary({ targetPath = process.cwd(), limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT } = {}) {
|
|
476
|
+
const filePath = path.join(path.resolve(String(targetPath || ".")), WORK_PLAN_RELATIVE_PATH);
|
|
477
|
+
try {
|
|
478
|
+
const stats = await fsp.stat(filePath);
|
|
479
|
+
let source = "";
|
|
480
|
+
let truncated = false;
|
|
481
|
+
if (stats.size > MAX_WORK_PLAN_BYTES) {
|
|
482
|
+
truncated = true;
|
|
483
|
+
const handle = await fsp.open(filePath, "r");
|
|
484
|
+
try {
|
|
485
|
+
const buffer = Buffer.alloc(MAX_WORK_PLAN_BYTES);
|
|
486
|
+
const position = Math.max(0, stats.size - MAX_WORK_PLAN_BYTES);
|
|
487
|
+
const { bytesRead } = await handle.read(buffer, 0, MAX_WORK_PLAN_BYTES, position);
|
|
488
|
+
source = buffer.subarray(0, bytesRead).toString("utf-8");
|
|
489
|
+
} finally {
|
|
490
|
+
await handle.close();
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
source = await fsp.readFile(filePath, "utf-8");
|
|
494
|
+
}
|
|
495
|
+
return summarizeWorkPlanMarkdown(source, { limit, truncated });
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
498
|
+
return emptyWorkPlanSummary();
|
|
499
|
+
}
|
|
500
|
+
return emptyWorkPlanSummary();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
402
504
|
function summarizeTaskLedger(tasks = [], { limit = DEFAULT_TASK_SUMMARY_LIMIT } = {}) {
|
|
403
505
|
const summary = emptyTaskLedgerSummary();
|
|
404
506
|
const owners = new Map();
|
|
@@ -541,6 +643,7 @@ function buildRecapText({
|
|
|
541
643
|
activeLocks = 0,
|
|
542
644
|
pendingTasks = 0,
|
|
543
645
|
taskLedger = emptyTaskLedgerSummary(),
|
|
646
|
+
workPlan = emptyWorkPlanSummary(),
|
|
544
647
|
usageSummary = normalizeUsageSummary(),
|
|
545
648
|
snippets = [],
|
|
546
649
|
} = {}) {
|
|
@@ -554,8 +657,9 @@ function buildRecapText({
|
|
|
554
657
|
pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
|
|
555
658
|
const taskText = buildTaskLedgerText(taskLedger);
|
|
556
659
|
const usageText = buildUsageLedgerText(usageSummary);
|
|
660
|
+
const workPlanText = buildWorkPlanText(workPlan);
|
|
557
661
|
const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
|
|
558
|
-
return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${usageText} ${snippetText}`.replace(
|
|
662
|
+
return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${workPlanText} ${usageText} ${snippetText}`.replace(
|
|
559
663
|
/\s+/g,
|
|
560
664
|
" "
|
|
561
665
|
).trim();
|
|
@@ -596,6 +700,31 @@ function buildTaskLedgerText(taskLedger = emptyTaskLedgerSummary()) {
|
|
|
596
700
|
.join(". ");
|
|
597
701
|
}
|
|
598
702
|
|
|
703
|
+
function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
|
|
704
|
+
if (!workPlan || typeof workPlan !== "object" || !workPlan.exists) {
|
|
705
|
+
return "";
|
|
706
|
+
}
|
|
707
|
+
const open = Number(workPlan.open || 0);
|
|
708
|
+
const completed = Number(workPlan.completed || 0);
|
|
709
|
+
const pathText = normalizeString(workPlan.path) || WORK_PLAN_RELATIVE_PATH;
|
|
710
|
+
const currentSection = normalizeString(workPlan.currentSection);
|
|
711
|
+
const currentText = currentSection ? ` Current: ${currentSection}.` : "";
|
|
712
|
+
const recentOpen = Array.isArray(workPlan.recentOpen) ? workPlan.recentOpen : [];
|
|
713
|
+
const nextText =
|
|
714
|
+
recentOpen.length > 0
|
|
715
|
+
? ` Next: ${recentOpen
|
|
716
|
+
.map((item) => {
|
|
717
|
+
const section = normalizeString(item.section);
|
|
718
|
+
const task = normalizeString(item.task);
|
|
719
|
+
return section ? `${section} - ${task}` : task;
|
|
720
|
+
})
|
|
721
|
+
.filter(Boolean)
|
|
722
|
+
.join("; ")}.`
|
|
723
|
+
: "";
|
|
724
|
+
const truncatedText = workPlan.truncated ? " Recent window only." : "";
|
|
725
|
+
return `Plan: ${open} open / ${completed} done in ${pathText}.${currentText}${nextText}${truncatedText}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
599
728
|
function roundCurrency(value) {
|
|
600
729
|
const normalized = Number(value || 0);
|
|
601
730
|
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
@@ -672,11 +801,13 @@ const AGENT_JOIN_RULES = [
|
|
|
672
801
|
"",
|
|
673
802
|
"**Reading the room** — When you join, the recap above summarizes activity since the last quiet stretch. To read further back, run `sl session read --remote --tail 50 --json` (bump `--tail` if you need more). Do this BEFORE responding so you don't repeat questions or miss a lock-and-claim someone else already opened.",
|
|
674
803
|
"",
|
|
675
|
-
"**Polling cadence** — Poll new events at most once per 60s (`sl session listen` or `sl session read --remote --tail N`). More frequent than that wastes budget and can hit per-user rate limits. Less frequent than ~5min and peers may think you went idle.",
|
|
804
|
+
"**Polling cadence** — Poll new events at most once per 60s (`sl session listen` or `sl session read --remote --tail N`). `session listen` is only a delivery cursor, not a grounding command; join or recap before acting. More frequent than that wastes budget and can hit per-user rate limits. Less frequent than ~5min and peers may think you went idle.",
|
|
805
|
+
"",
|
|
806
|
+
"**Session grounding** — Long-lived rooms should have one visible daemon owner running `sl session daemon --session <id> --recap-interval 300 --checkpoint-interval 60`. If no durable `session_recap` or `session_checkpoint` is appearing, run `sl session recap now <id> --remote --agent <your-name> --json` before posting a long plan.",
|
|
676
807
|
"",
|
|
677
808
|
"**Writing back** — You can use **markdown**: bold, italic, lists, fenced code, and `inline code`. The web dashboard renders it. Plain text also works. Keep posts terse and technical — link to the work, don't recap it.",
|
|
678
809
|
"",
|
|
679
|
-
"**Actions and threading** —
|
|
810
|
+
"**Actions and threading** — Use message actions instead of top-level ACK chatter: `sl session react <id> ack --target-sequence <n>` only when an explicit ACK matters, and `sl session action <id> working_on --target-sequence <n>` for ownership. Read receipts are automatic when you run `sl session read <id> --remote --agent <your-name>`; reserve `sl session view <id> <sequence>` for repair/backfill. Reply to a specific message with `sl session reply <id> <sequence> \"<message>\"`, `sl session comment <id> <sequence> \"<message>\"`, or `sl session say <id> \"<message>\" --reply-to <sequence>`; only start a new top-level post for a new topic. Run `sl session actions` for the full list.",
|
|
680
811
|
"",
|
|
681
812
|
"**Search before asking** — Use `sl session search <id> \"<topic>\" --limit 10` to recover old context before asking another agent to re-paste or summarize what is already in the transcript.",
|
|
682
813
|
"",
|
|
@@ -702,11 +833,12 @@ function buildPeriodicText(recap = {}) {
|
|
|
702
833
|
const lastActor = normalizeString(summary.lastActorId);
|
|
703
834
|
const actorText = lastActor ? `${lastActor} active` : "no active actor";
|
|
704
835
|
const taskText = buildTaskLedgerText(summary.taskLedger);
|
|
836
|
+
const workPlanText = buildWorkPlanText(summary.workPlan);
|
|
705
837
|
const usageText = buildUsageLedgerText({
|
|
706
838
|
totals: summary.usageTotals,
|
|
707
839
|
topAgents: summary.usageTopAgents,
|
|
708
840
|
});
|
|
709
|
-
return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${usageText} ${actorText}.`.replace(
|
|
841
|
+
return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${workPlanText} ${usageText} ${actorText}.`.replace(
|
|
710
842
|
/\s+/g,
|
|
711
843
|
" ",
|
|
712
844
|
).trim();
|
|
@@ -774,6 +906,9 @@ export async function buildSessionRecap(
|
|
|
774
906
|
const taskLedger = await readTaskLedgerSummary(normalizedSessionId, {
|
|
775
907
|
targetPath: normalizedTargetPath,
|
|
776
908
|
});
|
|
909
|
+
const workPlan = await readWorkPlanSummary({
|
|
910
|
+
targetPath: normalizedTargetPath,
|
|
911
|
+
});
|
|
777
912
|
const snippets = summarizeRecentActivity(visibleEvents, {
|
|
778
913
|
forAgentId: normalizedForAgentId,
|
|
779
914
|
limit: 2,
|
|
@@ -789,6 +924,7 @@ export async function buildSessionRecap(
|
|
|
789
924
|
activeLocks,
|
|
790
925
|
pendingTasks,
|
|
791
926
|
taskLedger,
|
|
927
|
+
workPlan,
|
|
792
928
|
usageSummary,
|
|
793
929
|
snippets,
|
|
794
930
|
});
|
|
@@ -809,6 +945,7 @@ export async function buildSessionRecap(
|
|
|
809
945
|
activeLocks,
|
|
810
946
|
pendingTasksForAgent: pendingTasks,
|
|
811
947
|
taskLedger,
|
|
948
|
+
workPlan,
|
|
812
949
|
usageTotals: usageSummary.totals,
|
|
813
950
|
usageTopAgents: usageSummary.topAgents,
|
|
814
951
|
snippets,
|
package/src/session/sync.js
CHANGED
|
@@ -243,6 +243,23 @@ function eventSequenceNumber(event = {}) {
|
|
|
243
243
|
return 0;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
function nextBeforeSequenceFromPayload(payload = {}, events = []) {
|
|
247
|
+
const explicit = Number(payload?.next_before_sequence ?? payload?.nextBeforeSequence);
|
|
248
|
+
if (Number.isFinite(explicit) && explicit > 0) {
|
|
249
|
+
return Math.floor(explicit);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let minimumSequence = 0;
|
|
253
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
254
|
+
const sequence = eventSequenceNumber(event);
|
|
255
|
+
if (sequence <= 0) continue;
|
|
256
|
+
if (minimumSequence === 0 || sequence < minimumSequence) {
|
|
257
|
+
minimumSequence = sequence;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return minimumSequence || null;
|
|
261
|
+
}
|
|
262
|
+
|
|
246
263
|
function chronologicalSessionEvents(events = []) {
|
|
247
264
|
return (Array.isArray(events) ? events : [])
|
|
248
265
|
.map((event, index) => ({ event, index }))
|
|
@@ -411,6 +428,95 @@ async function fetchWithTimeout(url, options, timeoutMs) {
|
|
|
411
428
|
}
|
|
412
429
|
}
|
|
413
430
|
|
|
431
|
+
function isAbortLike(error) {
|
|
432
|
+
return Boolean(error?.name === "AbortError" || error?.code === "ABORT_ERR");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function* readResponseTextChunks(response) {
|
|
436
|
+
const body = response?.body;
|
|
437
|
+
if (!body) return;
|
|
438
|
+
|
|
439
|
+
if (typeof body.getReader === "function") {
|
|
440
|
+
const reader = body.getReader();
|
|
441
|
+
try {
|
|
442
|
+
while (true) {
|
|
443
|
+
const { done, value } = await reader.read();
|
|
444
|
+
if (done) break;
|
|
445
|
+
if (value) yield value;
|
|
446
|
+
}
|
|
447
|
+
} finally {
|
|
448
|
+
try {
|
|
449
|
+
reader.releaseLock();
|
|
450
|
+
} catch {
|
|
451
|
+
// Best-effort cleanup only.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (typeof body[Symbol.asyncIterator] === "function") {
|
|
458
|
+
for await (const chunk of body) {
|
|
459
|
+
if (chunk) yield chunk;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function extractSseErrorReason(parsed) {
|
|
465
|
+
const error = parsed?.error && typeof parsed.error === "object" ? parsed.error : {};
|
|
466
|
+
return (
|
|
467
|
+
normalizeString(error.code) ||
|
|
468
|
+
normalizeString(error.message) ||
|
|
469
|
+
normalizeString(error.detail) ||
|
|
470
|
+
"session_stream_error"
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function processSseBlock(block, handlers) {
|
|
475
|
+
const normalizedBlock = normalizeString(block);
|
|
476
|
+
if (!normalizedBlock) return;
|
|
477
|
+
|
|
478
|
+
const dataLines = [];
|
|
479
|
+
let commentOnly = true;
|
|
480
|
+
for (const rawLine of String(block).split("\n")) {
|
|
481
|
+
const line = rawLine.trimEnd();
|
|
482
|
+
if (!line) continue;
|
|
483
|
+
if (line.startsWith(":")) continue;
|
|
484
|
+
commentOnly = false;
|
|
485
|
+
if (line.startsWith("data:")) {
|
|
486
|
+
dataLines.push(line.slice(5).trimStart());
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (dataLines.length === 0) {
|
|
491
|
+
if (commentOnly && typeof handlers.onHeartbeat === "function") {
|
|
492
|
+
await handlers.onHeartbeat();
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const rawData = dataLines.join("\n").trim();
|
|
498
|
+
if (!rawData) return;
|
|
499
|
+
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(rawData);
|
|
503
|
+
} catch {
|
|
504
|
+
await handlers.onError?.({ reason: "malformed_stream_event", cursor: handlers.cursor() });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (parsed?.type === "error") {
|
|
509
|
+
await handlers.onError?.({
|
|
510
|
+
reason: extractSseErrorReason(parsed),
|
|
511
|
+
cursor: handlers.cursor(),
|
|
512
|
+
error: parsed.error || null,
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await handlers.onEvent?.(parsed);
|
|
518
|
+
}
|
|
519
|
+
|
|
414
520
|
function sanitizeHumanMessage(rawMessage) {
|
|
415
521
|
const stripped = String(rawMessage || "")
|
|
416
522
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
@@ -1114,6 +1220,195 @@ export async function pollSessionEvents(
|
|
|
1114
1220
|
}
|
|
1115
1221
|
}
|
|
1116
1222
|
|
|
1223
|
+
/**
|
|
1224
|
+
* Consume the API's durable session SSE stream.
|
|
1225
|
+
*
|
|
1226
|
+
* This is the wakeup-first companion to `pollSessionEvents`: the stream uses
|
|
1227
|
+
* Redis wakeups server-side, while the listener can still fall back to durable
|
|
1228
|
+
* `/events` polling if the stream is unavailable or closes.
|
|
1229
|
+
*
|
|
1230
|
+
* @param {string} sessionId
|
|
1231
|
+
* @param {object} [options]
|
|
1232
|
+
* @param {string|null} [options.since] - durable cursor to resume after
|
|
1233
|
+
* @param {AbortSignal} [options.signal]
|
|
1234
|
+
* @param {(event: object) => Promise<void>|void} [options.onEvent]
|
|
1235
|
+
* @param {(payload: object) => Promise<void>|void} [options.onError]
|
|
1236
|
+
* @param {() => Promise<void>|void} [options.onHeartbeat]
|
|
1237
|
+
* @returns {Promise<{ok: boolean, reason: string, cursor: string|null, eventCount: number, errorCount: number, status?: number, aborted?: boolean}>}
|
|
1238
|
+
*/
|
|
1239
|
+
export async function streamSessionEvents(
|
|
1240
|
+
sessionId,
|
|
1241
|
+
{
|
|
1242
|
+
targetPath = process.cwd(),
|
|
1243
|
+
since = null,
|
|
1244
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1245
|
+
signal = undefined,
|
|
1246
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1247
|
+
fetchImpl = fetch,
|
|
1248
|
+
onEvent = async () => {},
|
|
1249
|
+
onError = async () => {},
|
|
1250
|
+
onHeartbeat = async () => {},
|
|
1251
|
+
} = {}
|
|
1252
|
+
) {
|
|
1253
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1254
|
+
const normalizedSince = normalizeString(since) || null;
|
|
1255
|
+
if (!normalizedSessionId) {
|
|
1256
|
+
return {
|
|
1257
|
+
ok: false,
|
|
1258
|
+
reason: "invalid_session_id",
|
|
1259
|
+
cursor: normalizedSince,
|
|
1260
|
+
eventCount: 0,
|
|
1261
|
+
errorCount: 0,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
let session = null;
|
|
1266
|
+
try {
|
|
1267
|
+
session = await resolveAuthSession({
|
|
1268
|
+
cwd: targetPath,
|
|
1269
|
+
env: process.env,
|
|
1270
|
+
autoRotate: false,
|
|
1271
|
+
});
|
|
1272
|
+
} catch {
|
|
1273
|
+
return {
|
|
1274
|
+
ok: false,
|
|
1275
|
+
reason: "no_session",
|
|
1276
|
+
cursor: normalizedSince,
|
|
1277
|
+
eventCount: 0,
|
|
1278
|
+
errorCount: 0,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
if (!session || !session.token) {
|
|
1282
|
+
return {
|
|
1283
|
+
ok: false,
|
|
1284
|
+
reason: "not_authenticated",
|
|
1285
|
+
cursor: normalizedSince,
|
|
1286
|
+
eventCount: 0,
|
|
1287
|
+
errorCount: 0,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1292
|
+
const query = new URLSearchParams();
|
|
1293
|
+
if (normalizedSince) {
|
|
1294
|
+
query.set("after", normalizedSince);
|
|
1295
|
+
}
|
|
1296
|
+
const suffix = query.toString() ? `?${query.toString()}` : "";
|
|
1297
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/stream${suffix}`;
|
|
1298
|
+
const controller = new AbortController();
|
|
1299
|
+
const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
1300
|
+
const timeoutHandle = setTimeout(() => controller.abort(), normalizedTimeoutMs);
|
|
1301
|
+
if (typeof timeoutHandle.unref === "function") {
|
|
1302
|
+
timeoutHandle.unref();
|
|
1303
|
+
}
|
|
1304
|
+
const forwardAbort = () => controller.abort(signal?.reason);
|
|
1305
|
+
if (signal) {
|
|
1306
|
+
if (signal.aborted) {
|
|
1307
|
+
controller.abort(signal.reason);
|
|
1308
|
+
} else {
|
|
1309
|
+
signal.addEventListener("abort", forwardAbort, { once: true });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
let response;
|
|
1314
|
+
try {
|
|
1315
|
+
response = await fetchImpl(
|
|
1316
|
+
endpoint,
|
|
1317
|
+
{
|
|
1318
|
+
method: "GET",
|
|
1319
|
+
headers: {
|
|
1320
|
+
Accept: "text/event-stream",
|
|
1321
|
+
Authorization: `Bearer ${session.token}`,
|
|
1322
|
+
},
|
|
1323
|
+
signal: controller.signal,
|
|
1324
|
+
},
|
|
1325
|
+
normalizedTimeoutMs
|
|
1326
|
+
);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
clearTimeout(timeoutHandle);
|
|
1329
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
1330
|
+
return {
|
|
1331
|
+
ok: false,
|
|
1332
|
+
reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
|
|
1333
|
+
cursor: normalizedSince,
|
|
1334
|
+
eventCount: 0,
|
|
1335
|
+
errorCount: 0,
|
|
1336
|
+
aborted: Boolean(signal?.aborted || isAbortLike(error)),
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
clearTimeout(timeoutHandle);
|
|
1340
|
+
|
|
1341
|
+
let cursor = normalizedSince;
|
|
1342
|
+
let eventCount = 0;
|
|
1343
|
+
let errorCount = 0;
|
|
1344
|
+
let lastErrorReason = "";
|
|
1345
|
+
const decoder = new TextDecoder();
|
|
1346
|
+
let buffer = "";
|
|
1347
|
+
|
|
1348
|
+
const handlers = {
|
|
1349
|
+
cursor: () => cursor,
|
|
1350
|
+
onHeartbeat,
|
|
1351
|
+
onError: async (payload) => {
|
|
1352
|
+
errorCount += 1;
|
|
1353
|
+
lastErrorReason = normalizeString(payload?.reason) || "session_stream_error";
|
|
1354
|
+
await onError(payload);
|
|
1355
|
+
},
|
|
1356
|
+
onEvent: async (event) => {
|
|
1357
|
+
const eventCursor = normalizeString(event?.cursor);
|
|
1358
|
+
if (eventCursor) cursor = eventCursor;
|
|
1359
|
+
eventCount += 1;
|
|
1360
|
+
await onEvent(event);
|
|
1361
|
+
},
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
try {
|
|
1365
|
+
if (!response || !response.ok || !response.body) {
|
|
1366
|
+
return {
|
|
1367
|
+
ok: false,
|
|
1368
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
1369
|
+
cursor,
|
|
1370
|
+
eventCount,
|
|
1371
|
+
errorCount,
|
|
1372
|
+
status: response?.status,
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
for await (const chunk of readResponseTextChunks(response)) {
|
|
1377
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1378
|
+
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1379
|
+
const blocks = buffer.split("\n\n");
|
|
1380
|
+
buffer = blocks.pop() || "";
|
|
1381
|
+
for (const block of blocks) {
|
|
1382
|
+
await processSseBlock(block, handlers);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
buffer += decoder.decode();
|
|
1386
|
+
if (normalizeString(buffer)) {
|
|
1387
|
+
await processSseBlock(buffer, handlers);
|
|
1388
|
+
}
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
return {
|
|
1391
|
+
ok: false,
|
|
1392
|
+
reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
|
|
1393
|
+
cursor,
|
|
1394
|
+
eventCount,
|
|
1395
|
+
errorCount,
|
|
1396
|
+
aborted: Boolean(signal?.aborted || isAbortLike(error)),
|
|
1397
|
+
};
|
|
1398
|
+
} finally {
|
|
1399
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return {
|
|
1403
|
+
ok: !lastErrorReason,
|
|
1404
|
+
reason: lastErrorReason,
|
|
1405
|
+
cursor,
|
|
1406
|
+
eventCount,
|
|
1407
|
+
errorCount,
|
|
1408
|
+
aborted: Boolean(signal?.aborted),
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1117
1412
|
/**
|
|
1118
1413
|
* Poll the latest durable session events page via the reverse-history endpoint.
|
|
1119
1414
|
*
|
|
@@ -1219,13 +1514,12 @@ export async function pollSessionEventsBefore(
|
|
|
1219
1514
|
|
|
1220
1515
|
const events = chronologicalSessionEvents(payload?.events || []);
|
|
1221
1516
|
const lastEvent = events[events.length - 1] || null;
|
|
1222
|
-
const firstEvent = events[0] || null;
|
|
1223
1517
|
return {
|
|
1224
1518
|
ok: true,
|
|
1225
1519
|
reason: "",
|
|
1226
1520
|
events,
|
|
1227
1521
|
cursor: normalizeString(lastEvent?.cursor) || null,
|
|
1228
|
-
beforeSequence:
|
|
1522
|
+
beforeSequence: nextBeforeSequenceFromPayload(payload, payload?.events || []),
|
|
1229
1523
|
};
|
|
1230
1524
|
} catch (error) {
|
|
1231
1525
|
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|