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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 active sessions")
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 sessions = await listActiveSessions({
626
- targetPath,
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(pc.yellow("No active sessions."));
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 sessions) {
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
- `${item.sessionId} status=${item.status} created_at=${item.createdAt} expires_at=${item.expiresAt}`
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
+ }
@@ -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
+ }