sentinelayer-cli 0.8.3 → 0.8.5
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 +248 -8
- package/src/session/remote-hydrate.js +95 -0
- package/src/session/store.js +46 -0
- package/src/session/sync-cursor.js +62 -0
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -44,10 +45,12 @@ import {
|
|
|
44
45
|
DEFAULT_TTL_SECONDS,
|
|
45
46
|
getSession,
|
|
46
47
|
listActiveSessions,
|
|
48
|
+
listAllSessions,
|
|
47
49
|
recordSessionProvisionedIdentities,
|
|
48
50
|
} from "../session/store.js";
|
|
49
51
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
50
52
|
import { syncSessionMetadataToApi } from "../session/sync.js";
|
|
53
|
+
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
51
54
|
import {
|
|
52
55
|
buildDashboardUrl,
|
|
53
56
|
buildTemplateLaunchPlan,
|
|
@@ -450,6 +453,10 @@ export function registerSessionCommand(program) {
|
|
|
450
453
|
.description("Read recent session messages")
|
|
451
454
|
.option("--tail <n>", "Number of recent events", "20")
|
|
452
455
|
.option("--follow", "Continuously follow new events")
|
|
456
|
+
.option(
|
|
457
|
+
"--remote",
|
|
458
|
+
"Hydrate from the SentinelLayer API before reading (pulls web-posted messages into the local NDJSON)",
|
|
459
|
+
)
|
|
453
460
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
454
461
|
.option("--json", "Emit machine-readable output")
|
|
455
462
|
.action(async (sessionId, options, command) => {
|
|
@@ -461,6 +468,29 @@ export function registerSessionCommand(program) {
|
|
|
461
468
|
const tail = parsePositiveInteger(options.tail, "tail", 20);
|
|
462
469
|
const emitJson = shouldEmitJson(options, command);
|
|
463
470
|
|
|
471
|
+
let hydration = null;
|
|
472
|
+
if (options.remote) {
|
|
473
|
+
hydration = await hydrateSessionFromRemote({
|
|
474
|
+
sessionId: normalizedSessionId,
|
|
475
|
+
targetPath,
|
|
476
|
+
});
|
|
477
|
+
if (!emitJson) {
|
|
478
|
+
if (hydration.ok) {
|
|
479
|
+
console.log(
|
|
480
|
+
pc.gray(
|
|
481
|
+
`Hydrated from remote: relayed=${hydration.relayed} dropped=${hydration.dropped}.`,
|
|
482
|
+
),
|
|
483
|
+
);
|
|
484
|
+
} else {
|
|
485
|
+
console.log(
|
|
486
|
+
pc.yellow(
|
|
487
|
+
`Remote hydrate skipped (${hydration.reason}); showing local stream only.`,
|
|
488
|
+
),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
464
494
|
if (!options.follow) {
|
|
465
495
|
const events = await readStream(normalizedSessionId, {
|
|
466
496
|
targetPath,
|
|
@@ -473,6 +503,7 @@ export function registerSessionCommand(program) {
|
|
|
473
503
|
tail,
|
|
474
504
|
count: events.length,
|
|
475
505
|
events,
|
|
506
|
+
remote: hydration,
|
|
476
507
|
};
|
|
477
508
|
if (emitJson) {
|
|
478
509
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -499,6 +530,59 @@ export function registerSessionCommand(program) {
|
|
|
499
530
|
}
|
|
500
531
|
});
|
|
501
532
|
|
|
533
|
+
session
|
|
534
|
+
.command("sync <sessionId>")
|
|
535
|
+
.description(
|
|
536
|
+
"Pull human messages from the SentinelLayer API into the local NDJSON stream",
|
|
537
|
+
)
|
|
538
|
+
.option(
|
|
539
|
+
"--since <iso>",
|
|
540
|
+
"Override the persisted cursor and start from this ISO timestamp",
|
|
541
|
+
)
|
|
542
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
543
|
+
.option("--json", "Emit machine-readable output")
|
|
544
|
+
.action(async (sessionId, options, command) => {
|
|
545
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
546
|
+
if (!normalizedSessionId) {
|
|
547
|
+
throw new Error("session id is required.");
|
|
548
|
+
}
|
|
549
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
550
|
+
const sinceArg = options.since == null ? undefined : String(options.since);
|
|
551
|
+
|
|
552
|
+
const result = await hydrateSessionFromRemote({
|
|
553
|
+
sessionId: normalizedSessionId,
|
|
554
|
+
targetPath,
|
|
555
|
+
since: sinceArg,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const payload = {
|
|
559
|
+
command: "session sync",
|
|
560
|
+
targetPath,
|
|
561
|
+
sessionId: normalizedSessionId,
|
|
562
|
+
ok: result.ok,
|
|
563
|
+
reason: result.reason || "",
|
|
564
|
+
relayed: result.relayed,
|
|
565
|
+
dropped: result.dropped,
|
|
566
|
+
cursor: result.cursor,
|
|
567
|
+
persistedCursor: result.persistedCursor,
|
|
568
|
+
};
|
|
569
|
+
if (shouldEmitJson(options, command)) {
|
|
570
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (result.ok) {
|
|
574
|
+
console.log(
|
|
575
|
+
`Hydrated session ${normalizedSessionId}: relayed=${result.relayed} dropped=${result.dropped}.`,
|
|
576
|
+
);
|
|
577
|
+
} else {
|
|
578
|
+
console.log(
|
|
579
|
+
pc.yellow(
|
|
580
|
+
`Hydrate skipped (${result.reason}). Local stream is unchanged; cursor=${result.cursor || "<none>"}.`,
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
502
586
|
session
|
|
503
587
|
.command("status <sessionId>")
|
|
504
588
|
.description("Show session status, agents, and health")
|
|
@@ -581,6 +665,94 @@ export function registerSessionCommand(program) {
|
|
|
581
665
|
}
|
|
582
666
|
});
|
|
583
667
|
|
|
668
|
+
session
|
|
669
|
+
.command("export <sessionId>")
|
|
670
|
+
.description(
|
|
671
|
+
"Export full transcript + metadata + agents + tasks as JSON (compliance / portability / context handoff)",
|
|
672
|
+
)
|
|
673
|
+
.option(
|
|
674
|
+
"--format <fmt>",
|
|
675
|
+
"Output format: json (single object) or ndjson (one event per line)",
|
|
676
|
+
"json",
|
|
677
|
+
)
|
|
678
|
+
.option("--out <file>", "Write to file instead of stdout")
|
|
679
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
680
|
+
.action(async (sessionId, options) => {
|
|
681
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
682
|
+
if (!normalizedSessionId) {
|
|
683
|
+
throw new Error("session id is required.");
|
|
684
|
+
}
|
|
685
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
686
|
+
const format = String(options.format || "json").trim().toLowerCase();
|
|
687
|
+
if (format !== "json" && format !== "ndjson") {
|
|
688
|
+
throw new Error(`--format must be 'json' or 'ndjson' (received '${format}').`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const sessionPayload = await getSession(normalizedSessionId, { targetPath });
|
|
692
|
+
if (!sessionPayload) {
|
|
693
|
+
throw new Error(`Session '${normalizedSessionId}' was not found.`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const [agents, events, tasks] = await Promise.all([
|
|
697
|
+
listAgents(normalizedSessionId, {
|
|
698
|
+
targetPath,
|
|
699
|
+
includeInactive: true,
|
|
700
|
+
}),
|
|
701
|
+
readStream(normalizedSessionId, {
|
|
702
|
+
targetPath,
|
|
703
|
+
tail: 0,
|
|
704
|
+
}),
|
|
705
|
+
listSessionTasks(normalizedSessionId, {
|
|
706
|
+
targetPath,
|
|
707
|
+
limit: 5_000,
|
|
708
|
+
}),
|
|
709
|
+
]);
|
|
710
|
+
|
|
711
|
+
let output;
|
|
712
|
+
if (format === "ndjson") {
|
|
713
|
+
const lines = [];
|
|
714
|
+
lines.push(JSON.stringify({ kind: "session", value: sessionPayload }));
|
|
715
|
+
for (const agent of agents) lines.push(JSON.stringify({ kind: "agent", value: agent }));
|
|
716
|
+
for (const event of events) lines.push(JSON.stringify({ kind: "event", value: event }));
|
|
717
|
+
for (const task of tasks.tasks || []) lines.push(JSON.stringify({ kind: "task", value: task }));
|
|
718
|
+
output = `${lines.join("\n")}\n`;
|
|
719
|
+
} else {
|
|
720
|
+
output = `${JSON.stringify(
|
|
721
|
+
{
|
|
722
|
+
command: "session export",
|
|
723
|
+
exportedAt: new Date().toISOString(),
|
|
724
|
+
session: sessionPayload,
|
|
725
|
+
agents,
|
|
726
|
+
events,
|
|
727
|
+
tasks: tasks.tasks || [],
|
|
728
|
+
counts: {
|
|
729
|
+
agents: agents.length,
|
|
730
|
+
events: events.length,
|
|
731
|
+
tasks: (tasks.tasks || []).length,
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
null,
|
|
735
|
+
2,
|
|
736
|
+
)}\n`;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const outArg = normalizeString(options.out);
|
|
740
|
+
if (outArg) {
|
|
741
|
+
const outPath = path.resolve(process.cwd(), outArg);
|
|
742
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true });
|
|
743
|
+
await fsp.writeFile(outPath, output, "utf-8");
|
|
744
|
+
console.log(
|
|
745
|
+
pc.gray(
|
|
746
|
+
`Exported ${events.length} events / ${agents.length} agents / ${
|
|
747
|
+
(tasks.tasks || []).length
|
|
748
|
+
} tasks → ${outPath}`,
|
|
749
|
+
),
|
|
750
|
+
);
|
|
751
|
+
} else {
|
|
752
|
+
process.stdout.write(output);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
584
756
|
session
|
|
585
757
|
.command("leave <sessionId>")
|
|
586
758
|
.description("Leave a session")
|
|
@@ -617,31 +789,99 @@ export function registerSessionCommand(program) {
|
|
|
617
789
|
|
|
618
790
|
session
|
|
619
791
|
.command("list")
|
|
620
|
-
.description("List
|
|
792
|
+
.description("List sessions in the local workspace cache")
|
|
793
|
+
.option(
|
|
794
|
+
"--include-archived",
|
|
795
|
+
"Include archived/expired sessions (past conversations)",
|
|
796
|
+
)
|
|
797
|
+
.option(
|
|
798
|
+
"--limit <n>",
|
|
799
|
+
"Maximum sessions to return (default 50; ignored on --json)",
|
|
800
|
+
"50",
|
|
801
|
+
)
|
|
621
802
|
.option("--path <path>", "Workspace path for sessions", ".")
|
|
622
803
|
.option("--json", "Emit machine-readable output")
|
|
623
804
|
.action(async (options, command) => {
|
|
624
805
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
806
|
+
const includeArchived = Boolean(options.includeArchived);
|
|
807
|
+
const limit = parsePositiveInteger(options.limit, "limit", 50);
|
|
808
|
+
const sessions = includeArchived
|
|
809
|
+
? await listAllSessions({ targetPath })
|
|
810
|
+
: await listActiveSessions({ targetPath });
|
|
811
|
+
const trimmed = shouldEmitJson(options, command) ? sessions : sessions.slice(0, limit);
|
|
628
812
|
const payload = {
|
|
629
813
|
command: "session list",
|
|
630
814
|
targetPath,
|
|
815
|
+
includeArchived,
|
|
631
816
|
count: sessions.length,
|
|
632
|
-
sessions,
|
|
817
|
+
sessions: trimmed,
|
|
633
818
|
};
|
|
634
819
|
if (shouldEmitJson(options, command)) {
|
|
635
820
|
console.log(JSON.stringify(payload, null, 2));
|
|
636
821
|
return;
|
|
637
822
|
}
|
|
638
823
|
if (sessions.length === 0) {
|
|
639
|
-
console.log(
|
|
824
|
+
console.log(
|
|
825
|
+
pc.yellow(
|
|
826
|
+
includeArchived
|
|
827
|
+
? "No sessions in cache."
|
|
828
|
+
: "No active sessions. Run with --include-archived to see history.",
|
|
829
|
+
),
|
|
830
|
+
);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
for (const item of trimmed) {
|
|
834
|
+
const archive = item.archiveStatus ? ` archive=${item.archiveStatus}` : "";
|
|
835
|
+
console.log(
|
|
836
|
+
`${item.sessionId} status=${item.status}${archive} created=${item.createdAt} expires=${item.expiresAt}`,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
if (sessions.length > trimmed.length) {
|
|
840
|
+
console.log(
|
|
841
|
+
pc.gray(
|
|
842
|
+
`… ${sessions.length - trimmed.length} more (raise --limit or use --json).`,
|
|
843
|
+
),
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
session
|
|
849
|
+
.command("history")
|
|
850
|
+
.description("Past conversations — alias for `session list --include-archived`")
|
|
851
|
+
.option("--limit <n>", "Maximum sessions to return", "50")
|
|
852
|
+
.option("--path <path>", "Workspace path for sessions", ".")
|
|
853
|
+
.option("--json", "Emit machine-readable output")
|
|
854
|
+
.action(async (options, command) => {
|
|
855
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
856
|
+
const limit = parsePositiveInteger(options.limit, "limit", 50);
|
|
857
|
+
const sessions = await listAllSessions({ targetPath });
|
|
858
|
+
const trimmed = shouldEmitJson(options, command) ? sessions : sessions.slice(0, limit);
|
|
859
|
+
const payload = {
|
|
860
|
+
command: "session history",
|
|
861
|
+
targetPath,
|
|
862
|
+
count: sessions.length,
|
|
863
|
+
sessions: trimmed,
|
|
864
|
+
};
|
|
865
|
+
if (shouldEmitJson(options, command)) {
|
|
866
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (sessions.length === 0) {
|
|
870
|
+
console.log(pc.yellow("No sessions in cache."));
|
|
640
871
|
return;
|
|
641
872
|
}
|
|
642
|
-
for (const item of
|
|
873
|
+
for (const item of trimmed) {
|
|
874
|
+
console.log(
|
|
875
|
+
`${item.archiveStatus.padEnd(8)} ${item.sessionId} created=${item.createdAt}${
|
|
876
|
+
item.archivedAt ? ` archived=${item.archivedAt}` : ""
|
|
877
|
+
}`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
if (sessions.length > trimmed.length) {
|
|
643
881
|
console.log(
|
|
644
|
-
|
|
882
|
+
pc.gray(
|
|
883
|
+
`… ${sessions.length - trimmed.length} more (raise --limit or use --json).`,
|
|
884
|
+
),
|
|
645
885
|
);
|
|
646
886
|
}
|
|
647
887
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot remote hydrator for the local NDJSON stream.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `pollHumanMessages` (which speaks to the SentinelLayer API) with
|
|
5
|
+
* a persisted cursor + `appendToStream`, so a CLI invocation can pull
|
|
6
|
+
* web-posted messages into the local session log on demand. The
|
|
7
|
+
* background daemon already does this on a poll loop; this module is
|
|
8
|
+
* the synchronous counterpart that powers `slc session sync` and
|
|
9
|
+
* `slc session read --remote`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { pollHumanMessages } from "./sync.js";
|
|
13
|
+
import { appendToStream } from "./stream.js";
|
|
14
|
+
import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch new human messages for a session, append them to the local
|
|
18
|
+
* stream, and advance the persisted cursor. Returns a structured
|
|
19
|
+
* summary the CLI can render directly. Failures degrade — we never
|
|
20
|
+
* throw out of this helper for transient/auth issues so wrappers can
|
|
21
|
+
* still serve a local-only read.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} params
|
|
24
|
+
* @param {string} params.sessionId
|
|
25
|
+
* @param {string} [params.targetPath]
|
|
26
|
+
* @param {string|null} [params.since] - Override persisted cursor.
|
|
27
|
+
* @param {Function} [params._poll] - Test seam.
|
|
28
|
+
* @param {Function} [params._append] - Test seam.
|
|
29
|
+
* @returns {Promise<{ok: boolean, reason: string, relayed: number, dropped: number, cursor: string|null, persistedCursor: boolean}>}
|
|
30
|
+
*/
|
|
31
|
+
export async function hydrateSessionFromRemote({
|
|
32
|
+
sessionId,
|
|
33
|
+
targetPath = process.cwd(),
|
|
34
|
+
since = undefined,
|
|
35
|
+
_poll = pollHumanMessages,
|
|
36
|
+
_append = appendToStream,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: "invalid_session_id",
|
|
42
|
+
relayed: 0,
|
|
43
|
+
dropped: 0,
|
|
44
|
+
cursor: null,
|
|
45
|
+
persistedCursor: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const startCursor =
|
|
50
|
+
typeof since === "string" || since === null
|
|
51
|
+
? since
|
|
52
|
+
: await readSyncCursor(sessionId, { targetPath });
|
|
53
|
+
|
|
54
|
+
const polled = await _poll(sessionId, {
|
|
55
|
+
targetPath,
|
|
56
|
+
since: startCursor,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!polled || !polled.ok) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: polled?.reason || "poll_failed",
|
|
63
|
+
relayed: 0,
|
|
64
|
+
dropped: Array.isArray(polled?.dropped) ? polled.dropped.length : 0,
|
|
65
|
+
cursor: typeof polled?.cursor === "string" ? polled.cursor : startCursor || null,
|
|
66
|
+
persistedCursor: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let relayed = 0;
|
|
71
|
+
for (const event of polled.events || []) {
|
|
72
|
+
try {
|
|
73
|
+
await _append(sessionId, event, { targetPath });
|
|
74
|
+
relayed += 1;
|
|
75
|
+
} catch {
|
|
76
|
+
// Append errors are observable via the stream but should not
|
|
77
|
+
// abort the rest of the batch — partial relay is still progress.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let persistedCursor = false;
|
|
82
|
+
if (typeof polled.cursor === "string" && polled.cursor.trim()) {
|
|
83
|
+
const result = await writeSyncCursor(sessionId, polled.cursor, { targetPath }).catch(() => null);
|
|
84
|
+
persistedCursor = Boolean(result && result.written);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
reason: "",
|
|
90
|
+
relayed,
|
|
91
|
+
dropped: Array.isArray(polled.dropped) ? polled.dropped.length : 0,
|
|
92
|
+
cursor: typeof polled.cursor === "string" ? polled.cursor : startCursor || null,
|
|
93
|
+
persistedCursor,
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/session/store.js
CHANGED
|
@@ -484,6 +484,52 @@ export async function listActiveSessions({ targetPath = process.cwd() } = {}) {
|
|
|
484
484
|
return sessions;
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
/**
|
|
488
|
+
* List every session known to the local cache. Unlike
|
|
489
|
+
* `listActiveSessions`, this includes archived AND expired sessions so
|
|
490
|
+
* the CLI can surface past conversations the way ChatGPT exposes its
|
|
491
|
+
* left-rail history.
|
|
492
|
+
*
|
|
493
|
+
* Each entry carries `archiveStatus` (`"active"` | `"archived"` |
|
|
494
|
+
* `"expired"`) so the consumer can group/filter without re-deriving
|
|
495
|
+
* lifecycle from the raw timestamps.
|
|
496
|
+
*
|
|
497
|
+
* @param {{targetPath?: string}} [options]
|
|
498
|
+
* @returns {Promise<Array<object>>}
|
|
499
|
+
*/
|
|
500
|
+
export async function listAllSessions({ targetPath = process.cwd() } = {}) {
|
|
501
|
+
const resolvedTargetPath = path.resolve(String(targetPath || "."));
|
|
502
|
+
const sessionsRoot = resolveSessionsRoot({ targetPath: resolvedTargetPath });
|
|
503
|
+
let entries = [];
|
|
504
|
+
try {
|
|
505
|
+
entries = await fsp.readdir(sessionsRoot, { withFileTypes: true });
|
|
506
|
+
} catch (error) {
|
|
507
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const sessions = [];
|
|
514
|
+
for (const entry of entries) {
|
|
515
|
+
if (!entry.isDirectory()) continue;
|
|
516
|
+
const loaded = await loadMetadata(entry.name, { targetPath: resolvedTargetPath });
|
|
517
|
+
if (!loaded) continue;
|
|
518
|
+
|
|
519
|
+
const payload = buildSessionPayload(loaded.metadata, loaded.paths);
|
|
520
|
+
let archiveStatus = "active";
|
|
521
|
+
if (loaded.metadata.status === SESSION_STATUS_ARCHIVED) {
|
|
522
|
+
archiveStatus = "archived";
|
|
523
|
+
} else if (isExpired(loaded.metadata)) {
|
|
524
|
+
archiveStatus = "expired";
|
|
525
|
+
}
|
|
526
|
+
sessions.push({ ...payload, archiveStatus });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
sessions.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
530
|
+
return sessions;
|
|
531
|
+
}
|
|
532
|
+
|
|
487
533
|
export async function renewSession(sessionId, { targetPath = process.cwd() } = {}) {
|
|
488
534
|
const loaded = await loadMetadata(sessionId, { targetPath });
|
|
489
535
|
if (!loaded) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session sync cursor persistence.
|
|
3
|
+
*
|
|
4
|
+
* The daemon keeps the human-message poll cursor in memory; one-shot CLI
|
|
5
|
+
* commands (`slc session sync`, `slc session read --remote`) need a
|
|
6
|
+
* cursor that survives across invocations so successive runs only fetch
|
|
7
|
+
* what is new. We persist the cursor next to the stream NDJSON in the
|
|
8
|
+
* session directory.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fsp from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { resolveSessionDir } from "./paths.js";
|
|
15
|
+
|
|
16
|
+
function cursorPath(sessionId, { targetPath } = {}) {
|
|
17
|
+
return path.join(resolveSessionDir(sessionId, { targetPath }), "remote-sync-cursor.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the persisted human-message cursor for a session. Returns `null`
|
|
22
|
+
* when no cursor has been recorded yet, or when the file is missing,
|
|
23
|
+
* empty, or malformed — callers should treat that as "first sync".
|
|
24
|
+
*
|
|
25
|
+
* @param {string} sessionId
|
|
26
|
+
* @param {{targetPath?: string}} [options]
|
|
27
|
+
* @returns {Promise<string|null>}
|
|
28
|
+
*/
|
|
29
|
+
export async function readSyncCursor(sessionId, { targetPath } = {}) {
|
|
30
|
+
if (!sessionId) return null;
|
|
31
|
+
const filePath = cursorPath(sessionId, { targetPath });
|
|
32
|
+
try {
|
|
33
|
+
const raw = await fsp.readFile(filePath, "utf-8");
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
const cursor = typeof parsed?.cursor === "string" ? parsed.cursor.trim() : "";
|
|
36
|
+
return cursor || null;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err && err.code === "ENOENT") return null;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persist the human-message cursor for a session. No-op when cursor is
|
|
45
|
+
* empty so we never overwrite a real value with an empty one.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} sessionId
|
|
48
|
+
* @param {string|null|undefined} cursor
|
|
49
|
+
* @param {{targetPath?: string}} [options]
|
|
50
|
+
* @returns {Promise<{written: boolean, path: string}>}
|
|
51
|
+
*/
|
|
52
|
+
export async function writeSyncCursor(sessionId, cursor, { targetPath } = {}) {
|
|
53
|
+
const filePath = cursorPath(sessionId, { targetPath });
|
|
54
|
+
const normalized = typeof cursor === "string" ? cursor.trim() : "";
|
|
55
|
+
if (!sessionId || !normalized) {
|
|
56
|
+
return { written: false, path: filePath };
|
|
57
|
+
}
|
|
58
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
59
|
+
const payload = { cursor: normalized, updatedAt: new Date().toISOString() };
|
|
60
|
+
await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
|
|
61
|
+
return { written: true, path: filePath };
|
|
62
|
+
}
|