sentinelayer-cli 0.41.0 → 0.42.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.
Files changed (44) hide show
  1. package/README.md +18 -0
  2. package/package.json +3 -2
  3. package/src/auth/admission-scope.js +34 -0
  4. package/src/auth/gate.js +6 -0
  5. package/src/auth/http.js +4 -0
  6. package/src/auth/service.js +6 -0
  7. package/src/board/done-gate.js +155 -0
  8. package/src/board/evidence-resolvers.js +138 -0
  9. package/src/cli.js +24 -1
  10. package/src/commands/board.js +116 -0
  11. package/src/commands/session.js +483 -47
  12. package/src/engram/build-state.js +183 -0
  13. package/src/engram/canonical.js +75 -0
  14. package/src/engram/document.js +190 -0
  15. package/src/engram/index.js +11 -2
  16. package/src/engram/ingest-document.js +107 -0
  17. package/src/engram/store.js +6 -1
  18. package/src/engram/tools.js +6 -0
  19. package/src/events/schema.js +18 -0
  20. package/src/legacy-cli.js +2 -2
  21. package/src/net/test-egress-guard.js +158 -0
  22. package/src/review/investor-dd-orchestrator.js +14 -2
  23. package/src/session/admission-auth.js +312 -0
  24. package/src/session/admission-preimage.js +90 -0
  25. package/src/session/admission.js +620 -0
  26. package/src/session/checkpoints.js +25 -1
  27. package/src/session/codebase-context.js +54 -0
  28. package/src/session/coordination-guidance.js +1 -1
  29. package/src/session/daemon-spawn.js +20 -2
  30. package/src/session/daemon.js +7 -1
  31. package/src/session/event-identity.js +9 -2
  32. package/src/session/first-message.js +11 -3
  33. package/src/session/listener.js +3 -0
  34. package/src/session/live-source.js +47 -16
  35. package/src/session/message-edits.js +89 -0
  36. package/src/session/recap.js +26 -4
  37. package/src/session/remote-hydrate.js +7 -19
  38. package/src/session/store.js +13 -14
  39. package/src/session/stream.js +23 -3
  40. package/src/session/sync.js +128 -11
  41. package/src/session/tickets.js +316 -0
  42. package/src/session/title-sync.js +2 -1
  43. package/src/session/usage-report.js +41 -2
  44. package/src/session/wake/resolve-target.js +10 -5
@@ -8,6 +8,7 @@ import {
8
8
  sessionEventIdentityKeys,
9
9
  } from "./event-identity.js";
10
10
  import { enrichEventWithMentions } from "./mentions.js";
11
+ import { messageRevision, projectSessionMessageEdits } from "./message-edits.js";
11
12
  import { resolveSessionPaths } from "./paths.js";
12
13
  import { redactEventPayload } from "./redact.js";
13
14
  import { fetchSessionFromApi, listSessionsFromApi, syncSessionEventToApi } from "./sync.js";
@@ -287,6 +288,23 @@ async function releaseLock(lockPath) {
287
288
  await fsp.rm(lockPath, { recursive: true, force: true }).catch(() => {});
288
289
  }
289
290
 
291
+ // Creation and append must coordinate on the SAME lock. In particular, two
292
+ // billing writers may both observe a missing session before either creates it.
293
+ // The callback must not append (append acquires this lock independently).
294
+ export async function withSessionStreamLock(sessionId, operation, {
295
+ targetPath = process.cwd(),
296
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
297
+ } = {}) {
298
+ const paths = resolveSessionPaths(sessionId, { targetPath });
299
+ await fsp.mkdir(paths.sessionDir, { recursive: true });
300
+ await acquireLock(paths.lockPath, { timeoutMs });
301
+ try {
302
+ return await operation();
303
+ } finally {
304
+ await releaseLock(paths.lockPath);
305
+ }
306
+ }
307
+
290
308
  async function readEventsFromFile(filePath) {
291
309
  try {
292
310
  const raw = await fsp.readFile(filePath, "utf-8");
@@ -359,7 +377,8 @@ function eventsShareIdentity(left = {}, right = {}) {
359
377
  }
360
378
 
361
379
  async function appendOrMergeCurrentStreamEvent(paths, canonicalEvent, { mergeExisting = false } = {}) {
362
- if (!mergeExisting) {
380
+ // Keep prior revisions in the raw audit log. Readers project the latest body.
381
+ if (!mergeExisting || messageRevision(canonicalEvent) > 1) {
363
382
  await fsp.appendFile(paths.streamPath, `${JSON.stringify(canonicalEvent)}\n`, "utf-8");
364
383
  return;
365
384
  }
@@ -427,6 +446,7 @@ export async function appendToStream(
427
446
  maxEvents = DEFAULT_MAX_STREAM_EVENTS,
428
447
  syncRemote = true,
429
448
  awaitRemoteSync = false,
449
+ lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
430
450
  mergeExisting = false,
431
451
  } = {}
432
452
  ) {
@@ -457,7 +477,7 @@ export async function appendToStream(
457
477
  const nowIso = new Date().toISOString();
458
478
  const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_MAX_STREAM_EVENTS);
459
479
 
460
- await acquireLock(paths.lockPath);
480
+ await acquireLock(paths.lockPath, { timeoutMs: lockTimeoutMs });
461
481
  try {
462
482
  await fsp.mkdir(paths.sessionDir, { recursive: true });
463
483
  await appendOrMergeCurrentStreamEvent(paths, canonicalEvent, { mergeExisting });
@@ -490,7 +510,7 @@ export async function readStream(
490
510
  { tail = 20, since = null, targetPath = process.cwd() } = {}
491
511
  ) {
492
512
  const paths = resolveSessionPaths(sessionId, { targetPath });
493
- const events = await readAllEvents(paths);
513
+ const events = projectSessionMessageEdits(await readAllEvents(paths));
494
514
  const filtered = filterBySince(events, since);
495
515
  const normalizedTail = Number(tail);
496
516
  if (!Number.isFinite(normalizedTail) || normalizedTail <= 0) {
@@ -7,6 +7,13 @@ import { randomUUID } from "node:crypto";
7
7
  import { resolveActiveAuthSession } from "../auth/service.js";
8
8
  import { createAgentEvent } from "../events/schema.js";
9
9
  import { isSessionControlEvent } from "./control-events.js";
10
+ import { createSessionMutationHeaders } from "./invitations.js";
11
+ import { messageRevision } from "./message-edits.js";
12
+ import { installTestEgressGuard } from "../net/test-egress-guard.js";
13
+
14
+ // No-op outside tests; inside a test process (or a child it spawned) every fetch
15
+ // to a non-loopback host is refused before a socket opens.
16
+ installTestEgressGuard();
10
17
 
11
18
  const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
12
19
  const DEFAULT_SYNC_TIMEOUT_MS = 5_000;
@@ -327,6 +334,11 @@ function isApiHostAllowed(urlString) {
327
334
  function resolveApiBaseUrl(session = {}) {
328
335
  const apiUrl = normalizeString(session.apiUrl) || DEFAULT_API_BASE_URL;
329
336
  const normalized = apiUrl.replace(/\/+$/, "");
337
+ if (!isApiHostAllowed(normalized) && session.source === "session_admission") {
338
+ // An admission credential is bound to the API that issued it. Redirecting it to
339
+ // the default host would hand it to an API it was never meant for: refuse.
340
+ throw new Error("Refusing to send an agent admission credential to a host other than the API that issued it.");
341
+ }
330
342
  if (!isApiHostAllowed(normalized)) {
331
343
  // Reject tampered session.apiUrl and fall back to the default.
332
344
  // Caller will see API calls land on the canonical host, not an attacker's.
@@ -634,12 +646,12 @@ async function readResponseJsonSafely(response, timeoutMs = DEFAULT_SYNC_TIMEOUT
634
646
  }
635
647
  }
636
648
 
637
- async function fetchJsonWithFullTimeout(
649
+ export async function fetchJsonWithFullTimeout(
638
650
  url,
639
651
  options,
640
652
  timeoutMs,
641
653
  fetchImpl = fetchWithTimeout,
642
- { readErrorBody = false } = {},
654
+ { readErrorBody = false, readSuccessBody = true, errorBodyStatuses = [] } = {},
643
655
  ) {
644
656
  const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
645
657
  const controller = new AbortController();
@@ -660,13 +672,15 @@ async function fetchJsonWithFullTimeout(
660
672
  );
661
673
 
662
674
  let payload = {};
663
- if (response?.ok || readErrorBody) {
675
+ if ((response?.ok && readSuccessBody) || readErrorBody || errorBodyStatuses.includes(response?.status)) {
664
676
  const elapsedMs = Math.max(0, Date.now() - startedAtMs);
665
677
  const remainingMs = Math.max(1, resolvedTimeoutMs - elapsedMs);
666
678
  payload = await readResponseJsonWithTimeout(response, remainingMs, {
667
679
  fallback: {},
668
680
  onTimeout: () => abortRequestController(controller, "response_body_timeout"),
669
681
  });
682
+ } else {
683
+ cancelResponseBody(response);
670
684
  }
671
685
  return { response, payload };
672
686
  }
@@ -867,6 +881,18 @@ function buildHumanRelayEvent(sessionId, message = {}) {
867
881
  agentId,
868
882
  sessionId,
869
883
  ts,
884
+ // The compatibility human-poll lane must preserve canonical revision and
885
+ // identity too; otherwise an edited body is mistaken for a fresh directive
886
+ // when its paired durable edit notification has not reached this poller.
887
+ id: message.eventId || message.event_id || undefined,
888
+ eventId: message.eventId || message.event_id || undefined,
889
+ cursor: message.cursor || undefined,
890
+ sequenceId: message.sequenceId ?? message.sequence_id,
891
+ messageRevision: messageRevision(message),
892
+ editedAt: message.editedAt,
893
+ editedBy: message.editedBy,
894
+ canEdit: message.canEdit,
895
+ revisionEvidence: message.revisionEvidence,
870
896
  payload: {
871
897
  message: sanitization.message,
872
898
  channel: "session",
@@ -982,9 +1008,14 @@ export async function syncSessionEventToApi(
982
1008
  body: requestBody,
983
1009
  };
984
1010
  const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
1011
+ const deadlineMs = Date.now() + resolvedTimeoutMs;
1012
+ const remainingMs = () => Math.max(1, deadlineMs - Date.now());
985
1013
 
986
1014
  try {
987
- const response = await fetchImpl(endpoint, requestInit, resolvedTimeoutMs);
1015
+ const { response, payload: forbiddenBody } = await fetchJsonWithFullTimeout(
1016
+ endpoint, requestInit, remainingMs(), fetchImpl,
1017
+ { readSuccessBody: false, errorBodyStatuses: [403] },
1018
+ );
988
1019
 
989
1020
  if (response && response.ok) {
990
1021
  recordCircuitSuccess(outboundCircuit);
@@ -1000,7 +1031,7 @@ export async function syncSessionEventToApi(
1000
1031
  // their agents "talking" locally while the API has zero record. We attempt
1001
1032
  // the grant once per agentId per process, then retry the event POST once.
1002
1033
  if (response && response.status === 403) {
1003
- const body = await readResponseJsonSafely(response);
1034
+ const body = forbiddenBody;
1004
1035
  if (isIdentityForgeryBody(body)) {
1005
1036
  const agentId = normalizeString(event?.agent?.id);
1006
1037
  if (!agentId || isReservedAgentIdForGrant(agentId)) {
@@ -1022,7 +1053,7 @@ export async function syncSessionEventToApi(
1022
1053
  const grantEndpoint = `${apiBaseUrl}/api/v1/sessions/agent-grants`;
1023
1054
  let grantResponse = null;
1024
1055
  try {
1025
- grantResponse = await fetchImpl(
1056
+ ({ response: grantResponse } = await fetchJsonWithFullTimeout(
1026
1057
  grantEndpoint,
1027
1058
  {
1028
1059
  method: "POST",
@@ -1035,8 +1066,8 @@ export async function syncSessionEventToApi(
1035
1066
  role: grantRole,
1036
1067
  }),
1037
1068
  },
1038
- resolvedTimeoutMs
1039
- );
1069
+ remainingMs(), fetchImpl, { readSuccessBody: false },
1070
+ ));
1040
1071
  } catch (grantError) {
1041
1072
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1042
1073
  return {
@@ -1060,7 +1091,9 @@ export async function syncSessionEventToApi(
1060
1091
  // Retry the original event POST exactly once.
1061
1092
  let retryResponse;
1062
1093
  try {
1063
- retryResponse = await fetchImpl(endpoint, requestInit, resolvedTimeoutMs);
1094
+ ({ response: retryResponse } = await fetchJsonWithFullTimeout(
1095
+ endpoint, requestInit, remainingMs(), fetchImpl, { readSuccessBody: false },
1096
+ ));
1064
1097
  } catch (retryError) {
1065
1098
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
1066
1099
  return {
@@ -1156,7 +1189,7 @@ async function syncSessionAuxPayload(
1156
1189
  const apiBaseUrl = resolveApiBaseUrl(session);
1157
1190
  const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}${pathSuffix}`;
1158
1191
  try {
1159
- const response = await fetchImpl(
1192
+ const { response } = await fetchJsonWithFullTimeout(
1160
1193
  endpoint,
1161
1194
  {
1162
1195
  method: "POST",
@@ -1169,7 +1202,9 @@ async function syncSessionAuxPayload(
1169
1202
  source: "cli",
1170
1203
  }),
1171
1204
  },
1172
- normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
1205
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1206
+ fetchImpl,
1207
+ { readSuccessBody: false },
1173
1208
  );
1174
1209
  if (!response || !response.ok) {
1175
1210
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
@@ -2755,6 +2790,88 @@ export async function fetchSessionPinnedMessages(
2755
2790
  };
2756
2791
  }
2757
2792
 
2793
+ // Native edits never fall back to POST /events or mutate an optimistic local row.
2794
+ export async function editSessionMessage(sessionId, {
2795
+ targetSequenceId = null,
2796
+ targetMessageId = "",
2797
+ targetActionId = "",
2798
+ text,
2799
+ expectedRevision = null,
2800
+ agentId = "",
2801
+ idempotencyKey = "",
2802
+ targetPath = process.cwd(),
2803
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
2804
+ signal,
2805
+ resolveAuthSession = resolveActiveAuthSession,
2806
+ fetchImpl = fetchWithTimeout,
2807
+ nowMs = Date.now,
2808
+ } = {}) {
2809
+ const sid = normalizeString(sessionId);
2810
+ const sequence = Number(targetSequenceId);
2811
+ const replyId = normalizeString(targetActionId);
2812
+ let messageId = normalizeString(targetMessageId);
2813
+ let revision = expectedRevision === null || expectedRevision === undefined ? null : Number(expectedRevision);
2814
+ const validSequence = Number.isSafeInteger(sequence) && sequence > 0;
2815
+ const mutationKey = normalizeString(idempotencyKey) || `sl-message-edit-${randomUUID()}`;
2816
+ if (!sid || typeof text !== "string" || !text.trim() || (!replyId && !messageId && !validSequence) ||
2817
+ (revision !== null && (!Number.isSafeInteger(revision) || revision <= 0)) || mutationKey.length > 128) {
2818
+ return { ok: false, reason: "invalid_input" };
2819
+ }
2820
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") return { ok: false, reason: "remote_sync_disabled_env" };
2821
+ const timestamp = Number(nowMs()) || Date.now();
2822
+ if (isCircuitOpen(outboundCircuit, timestamp)) return { ok: false, reason: "circuit_breaker_open" };
2823
+ let auth;
2824
+ try { auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false }); }
2825
+ catch { return { ok: false, reason: "no_session" }; }
2826
+ if (!auth?.token) return { ok: false, reason: "not_authenticated" };
2827
+ const base = `${resolveApiBaseUrl(auth)}/api/v1/sessions/${encodeURIComponent(sid)}`;
2828
+ const headers = { Authorization: `Bearer ${auth.token}` };
2829
+ const failure = (response, payload) => {
2830
+ const code = normalizeString(payload?.detail?.code || payload?.error?.code || payload?.code);
2831
+ // Client/schema/ownership conflicts are terminal and must not poison unrelated writes.
2832
+ if (!response || response.status >= 500) recordCircuitFailure(outboundCircuit, timestamp);
2833
+ return { ok: false, reason: code || `api_${response?.status || "no_response"}`, status: response?.status || null };
2834
+ };
2835
+ try {
2836
+ // Sequence resolution and omitted-revision lookup are one authenticated GET.
2837
+ if ((!replyId && !messageId) || revision === null) {
2838
+ const resource = replyId ? `replies/${encodeURIComponent(replyId)}` : messageId
2839
+ ? `messages/${encodeURIComponent(messageId)}` : `messages/by-sequence/${sequence}`;
2840
+ const authorQuery = normalizeString(agentId) ? `?agentId=${encodeURIComponent(normalizeString(agentId))}` : "";
2841
+ const { response, payload } = await fetchJsonWithFullTimeout(`${base}/${resource}${authorQuery}`, { method: "GET", headers, signal }, timeoutMs, fetchImpl, { readErrorBody: true });
2842
+ if (!response?.ok || payload?.ok === false) return failure(response, payload);
2843
+ const current = replyId ? payload?.reply : payload?.event;
2844
+ const fetchedRevision = Number(current?.messageRevision ?? 1);
2845
+ if (!current?.id || (replyId && current.id !== replyId) || !Number.isSafeInteger(fetchedRevision) || fetchedRevision <= 0) return { ok: false, reason: "invalid_edit_target" };
2846
+ if (current.canEdit === false) return { ok: false, reason: "MESSAGE_EDIT_FORBIDDEN", status: 403 };
2847
+ if (!replyId) messageId = current.id;
2848
+ if (revision === null) revision = fetchedRevision;
2849
+ }
2850
+ const resource = replyId ? `replies/${encodeURIComponent(replyId)}` : `messages/${encodeURIComponent(messageId)}`;
2851
+ const body = { text, expectedRevision: revision, ...(normalizeString(agentId) ? { agentId: normalizeString(agentId) } : {}) };
2852
+ const { response, payload } = await fetchJsonWithFullTimeout(`${base}/${resource}`, {
2853
+ method: "PATCH",
2854
+ headers: {
2855
+ ...createSessionMutationHeaders({ bearerToken: auth.token, sessionId: sid, routeId: replyId
2856
+ ? "PATCH /api/v1/sessions/{session_id}/replies/{action_id}"
2857
+ : "PATCH /api/v1/sessions/{session_id}/messages/{message_id}", idempotencyKey: mutationKey }),
2858
+ "Content-Type": "application/json", "Idempotency-Key": mutationKey,
2859
+ },
2860
+ body: JSON.stringify(body),
2861
+ signal,
2862
+ }, timeoutMs, fetchImpl, { readErrorBody: true });
2863
+ if (!response?.ok || payload?.ok !== true) return failure(response, payload);
2864
+ const current = replyId ? payload.reply : payload.event;
2865
+ const currentRevision = Number(current?.messageRevision ?? 1);
2866
+ if (!current?.id || current.id !== (replyId || messageId) || !Number.isSafeInteger(currentRevision) || currentRevision <= 0) return { ok: false, reason: "invalid_edit_response" };
2867
+ recordCircuitSuccess(outboundCircuit);
2868
+ return { ok: true, changed: Boolean(payload.changed), replayed: Boolean(payload.replayed), event: payload.event || null, reply: payload.reply || null, notification: payload.notification || null };
2869
+ } catch (error) {
2870
+ recordCircuitFailure(outboundCircuit, timestamp);
2871
+ return { ok: false, reason: normalizeString(error?.message) || "message_edit_failed" };
2872
+ }
2873
+ }
2874
+
2758
2875
  export async function createSessionMessageAction(
2759
2876
  sessionId,
2760
2877
  {
@@ -0,0 +1,316 @@
1
+ // tickets.js — the room's shared backlog from the CLI: list, claim, renew, release,
2
+ // submit, and the ticket event feed (API "Ticket Lease Contract (T2)").
3
+ //
4
+ // Credentials: every call resolves auth with resolveActiveAuthSession. Inside an
5
+ // admitted agent's command (the runCli choke point), that is the agent's admission
6
+ // credential and never the human token, so a ticket is claimed AS the agent.
7
+ //
8
+ // Lease state: a claim's { leaseId, fence, version, expiresAt } is kept per
9
+ // (session, ticket, identity) in a local file. Only a MISSING file means "no lease
10
+ // held here"; an unreadable one refuses, never reads as absent.
11
+ //
12
+ // Lost responses: a write's Idempotency-Key is stored BEFORE the request is sent. If
13
+ // the response is lost, re-running the same command reuses that key, and the server
14
+ // replays the original result instead of performing the operation again.
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import fsp from "node:fs/promises";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+
22
+ import { requestJson, requestJsonMutation } from "../auth/http.js";
23
+ import { resolveActiveAuthSession } from "../auth/service.js";
24
+
25
+ function normalizeString(value) {
26
+ return String(value ?? "").trim();
27
+ }
28
+
29
+ function segment(value, label) {
30
+ const text = normalizeString(value).toLowerCase();
31
+ if (!/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(text)) throw new Error(`${label} is not a valid identifier.`);
32
+ return text.replace(/:/g, "%3a");
33
+ }
34
+
35
+ export function ticketLeasePath(sessionId, ticketId, identity, { homeDir } = {}) {
36
+ return path.join(
37
+ homeDir || os.homedir(),
38
+ ".sentinelayer",
39
+ "agents",
40
+ segment(identity, "identity"),
41
+ "tickets",
42
+ segment(sessionId, "sessionId"),
43
+ `${segment(ticketId, "ticketId")}.json`
44
+ );
45
+ }
46
+
47
+ async function readState(filePath) {
48
+ let raw;
49
+ try {
50
+ raw = await fsp.readFile(filePath, "utf8");
51
+ } catch (error) {
52
+ if (error && error.code === "ENOENT") return null;
53
+ throw new Error(`The local ticket lease state at ${filePath} is unreadable; refusing to guess.`);
54
+ }
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
58
+ return parsed;
59
+ } catch {
60
+ throw new Error(`The local ticket lease state at ${filePath} is unreadable; refusing to guess.`);
61
+ }
62
+ }
63
+
64
+ async function writeState(filePath, state) {
65
+ await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
66
+ const tmp = `${filePath}.${randomUUID()}.tmp`;
67
+ await fsp.writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: "wx" });
68
+ await fsp.rename(tmp, filePath);
69
+ }
70
+
71
+ async function auth(targetPath, resolveAuthSession) {
72
+ const session = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
73
+ if (!session?.token || !session?.apiUrl) throw new Error("Not authenticated for this session.");
74
+ return { token: session.token, apiUrl: normalizeString(session.apiUrl).replace(/\/+$/, "") };
75
+ }
76
+
77
+ function ticketsUrl(apiUrl, sessionId, suffix = "") {
78
+ return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/tickets${suffix}`;
79
+ }
80
+
81
+ /** The Idempotency-Key for this logical write: reused until its response is stored. */
82
+ async function operationKey(statePath, operation, fingerprint) {
83
+ const state = (await readState(statePath)) || {};
84
+ const pending = state.pending;
85
+ if (pending && pending.operation === operation && pending.fingerprint === fingerprint && pending.key) {
86
+ return { key: pending.key, state };
87
+ }
88
+ const key = `${operation}-${randomUUID()}`;
89
+ const next = { ...state, pending: { operation, fingerprint, key } };
90
+ await writeState(statePath, next);
91
+ return { key, state: next };
92
+ }
93
+
94
+ function leaseState(response) {
95
+ return {
96
+ version: 1,
97
+ leaseId: response.lease.leaseId,
98
+ fence: response.lease.fence,
99
+ ticketVersion: response.version,
100
+ expiresAt: response.lease.expiresAt,
101
+ };
102
+ }
103
+
104
+ const SNAPSHOT_PAGE = 200;
105
+ const SNAPSHOT_MAX_PAGES = 50;
106
+
107
+ function malformedSnapshot(reason) {
108
+ return new Error(`The ticket snapshot is malformed (${reason}); refusing a partial list.`);
109
+ }
110
+
111
+ /**
112
+ * One snapshot page, validated before anything in it is believed. The snapshot is keyset by
113
+ * ticket id: ids on a page are STRICTLY ascending and above the previous continuation (so
114
+ * none repeats), and the continuation is exactly the LAST id returned -- a token that jumps
115
+ * ahead would silently skip every ticket in between. Anything else is not a complete list.
116
+ */
117
+ function validatedSnapshotPage(body, { first, afterId }) {
118
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw malformedSnapshot("a page is not an object");
119
+ if (!Array.isArray(body.items)) throw malformedSnapshot("a page has no items list");
120
+ if (typeof body.hasMore !== "boolean") throw malformedSnapshot("a page does not say whether there is more");
121
+ if (first && !(Number.isSafeInteger(body.cursor) && body.cursor >= 0)) {
122
+ throw malformedSnapshot("the first page has no room cursor to follow events from");
123
+ }
124
+ let previous = afterId;
125
+ for (const item of body.items) {
126
+ const id = item && typeof item === "object" ? item.id : null;
127
+ if (typeof id !== "string" || !id) throw malformedSnapshot("a ticket has no id");
128
+ if (previous && !(id > previous)) throw malformedSnapshot("ticket ids are not in ascending order");
129
+ previous = id;
130
+ }
131
+ if (body.hasMore) {
132
+ if (!body.items.length) throw malformedSnapshot("an empty page claims there is more");
133
+ const next = body.nextAfterId;
134
+ if (typeof next !== "string" || !next) throw malformedSnapshot("there is more but no way to continue");
135
+ if (next !== previous) throw malformedSnapshot("the continuation is not the last ticket returned");
136
+ }
137
+ return body;
138
+ }
139
+
140
+ /**
141
+ * The room's COMPLETE backlog, from GET /tickets/snapshot paged to the end. The board read
142
+ * (GET /tickets) is a bounded, newest-first page: filtering it for "available" could miss
143
+ * claimable work beyond the page. `cursor` is the FIRST page's room cursor, taken with that
144
+ * page; `sl session ticket events --after <cursor>` continues from exactly there. A snapshot
145
+ * that cannot be completed is refused, never returned partial.
146
+ */
147
+ export async function listTickets(
148
+ sessionId,
149
+ {
150
+ targetPath = process.cwd(),
151
+ available = false,
152
+ resolveAuthSession = resolveActiveAuthSession,
153
+ requestRead = requestJson,
154
+ pageSize = SNAPSHOT_PAGE,
155
+ maxPages = SNAPSHOT_MAX_PAGES,
156
+ } = {}
157
+ ) {
158
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
159
+ const items = [];
160
+ let cursor = null;
161
+ let afterId = null;
162
+ let complete = false;
163
+ for (let page = 0; page < maxPages && !complete; page += 1) {
164
+ const query = new URLSearchParams({ limit: String(pageSize) });
165
+ if (afterId) query.set("afterId", afterId);
166
+ const body = validatedSnapshotPage(
167
+ await requestRead(`${ticketsUrl(apiUrl, sessionId, "/snapshot")}?${query}`, {
168
+ method: "GET",
169
+ headers: { Authorization: `Bearer ${token}` },
170
+ }),
171
+ { first: page === 0, afterId },
172
+ );
173
+ if (page === 0) cursor = body.cursor; // ONLY the first page's cursor is the baseline
174
+ items.push(...body.items);
175
+ if (!body.hasMore) complete = true;
176
+ else afterId = body.nextAfterId;
177
+ }
178
+ if (!complete) {
179
+ throw new Error(`The room has more than ${maxPages * pageSize} tickets; refusing a partial list.`);
180
+ }
181
+ // Newest first for people; the snapshot itself is in id order.
182
+ items.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")) || String(a.id).localeCompare(String(b.id)));
183
+ // Available = claimable by someone now: open, or working with an expired lease.
184
+ // Never blocked work: the MVP has no dependencies, and blocked is never available.
185
+ const filtered = available
186
+ ? items.filter((t) => t.status === "open" || (t.status === "working" && t.lease && !t.lease.live))
187
+ : items;
188
+ return { items: filtered, cursor, count: filtered.length, complete: true };
189
+ }
190
+
191
+ export async function ticketEvents(
192
+ sessionId,
193
+ { after = 0, targetPath = process.cwd(), resolveAuthSession = resolveActiveAuthSession, requestRead = requestJson } = {}
194
+ ) {
195
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
196
+ return requestRead(`${ticketsUrl(apiUrl, sessionId, "/events")}?after=${encodeURIComponent(String(after))}`, {
197
+ method: "GET",
198
+ headers: { Authorization: `Bearer ${token}` },
199
+ });
200
+ }
201
+
202
+ async function mutate(url, { token, key, operation, body, requestMutation }) {
203
+ return requestMutation(url, {
204
+ method: "POST",
205
+ operationName: `session.ticket_${operation}`,
206
+ idempotencyKey: key,
207
+ headers: { Authorization: `Bearer ${token}`, "Idempotency-Key": key },
208
+ body,
209
+ });
210
+ }
211
+
212
+ export async function claimTicket(
213
+ sessionId,
214
+ ticketId,
215
+ {
216
+ identity,
217
+ expectedVersion = null,
218
+ targetPath = process.cwd(),
219
+ homeDir,
220
+ resolveAuthSession = resolveActiveAuthSession,
221
+ requestMutation = requestJsonMutation,
222
+ } = {}
223
+ ) {
224
+ const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
225
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
226
+ const { key } = await operationKey(statePath, "claim", String(expectedVersion ?? "any"));
227
+ const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/claim`), {
228
+ token, key, operation: "claim", requestMutation,
229
+ body: expectedVersion == null ? {} : { expectedVersion: Number(expectedVersion) },
230
+ });
231
+ // The lease is recorded here only if the server says it is held by the identity that
232
+ // asked: an agent by its own id, a person as a human. Anything else is not this lease.
233
+ const holder = response?.ticket?.lease;
234
+ const heldByIdentity =
235
+ identity === "human" ? holder?.holderKind === "human" : holder?.holderKind === "agent" && holder?.holder === identity;
236
+ if (!heldByIdentity) {
237
+ throw new Error(
238
+ `The server recorded this lease for ${holder?.holderKind || "nobody"} "${holder?.holder || ""}", not for "${identity}". ` +
239
+ "Nothing was recorded locally; release it from the web if it is not yours."
240
+ );
241
+ }
242
+ await writeState(statePath, leaseState(response));
243
+ return response;
244
+ }
245
+
246
+ async function heldLease(statePath) {
247
+ const state = await readState(statePath);
248
+ if (!state || !state.leaseId) {
249
+ throw new Error("This identity holds no lease on that ticket here. Claim it first.");
250
+ }
251
+ return state;
252
+ }
253
+
254
+ export async function renewTicketLease(
255
+ sessionId,
256
+ ticketId,
257
+ { identity, targetPath = process.cwd(), homeDir, resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation } = {}
258
+ ) {
259
+ const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
260
+ const held = await heldLease(statePath);
261
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
262
+ const { key } = await operationKey(statePath, "renew", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
263
+ const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/lease/renew`), {
264
+ token, key, operation: "renew", requestMutation, body: { leaseId: held.leaseId, fence: held.fence },
265
+ });
266
+ await writeState(statePath, leaseState(response));
267
+ return response;
268
+ }
269
+
270
+ export async function releaseTicketLease(
271
+ sessionId,
272
+ ticketId,
273
+ { identity, reason = null, targetPath = process.cwd(), homeDir, resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation } = {}
274
+ ) {
275
+ const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
276
+ const held = await heldLease(statePath);
277
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
278
+ const { key } = await operationKey(statePath, "release", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
279
+ const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/lease/release`), {
280
+ token, key, operation: "release", requestMutation,
281
+ body: { leaseId: held.leaseId, fence: held.fence, expectedVersion: held.ticketVersion, ...(reason ? { reason } : {}) },
282
+ });
283
+ await fsp.rm(statePath, { force: true });
284
+ return response;
285
+ }
286
+
287
+ export async function submitTicket(
288
+ sessionId,
289
+ ticketId,
290
+ {
291
+ identity,
292
+ implementationSha = null,
293
+ evidence = [],
294
+ targetPath = process.cwd(),
295
+ homeDir,
296
+ resolveAuthSession = resolveActiveAuthSession,
297
+ requestMutation = requestJsonMutation,
298
+ } = {}
299
+ ) {
300
+ const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
301
+ const held = await heldLease(statePath);
302
+ const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
303
+ const { key } = await operationKey(statePath, "submit", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
304
+ const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/submit`), {
305
+ token, key, operation: "submit", requestMutation,
306
+ body: {
307
+ leaseId: held.leaseId,
308
+ fence: held.fence,
309
+ expectedVersion: held.ticketVersion,
310
+ ...(implementationSha ? { implementationSha } : {}),
311
+ evidence,
312
+ },
313
+ });
314
+ await fsp.rm(statePath, { force: true });
315
+ return response;
316
+ }
@@ -38,6 +38,7 @@ export async function pushSessionTitleToApi(
38
38
  timeoutMs = DEFAULT_TITLE_SYNC_TIMEOUT_MS,
39
39
  maxRetries = 1,
40
40
  retryDelayMs = DEFAULT_TITLE_SYNC_RETRY_DELAY_MS,
41
+ autoRotate = true,
41
42
  resolveAuthSession = resolveActiveAuthSession,
42
43
  requestMutation = requestJsonMutation,
43
44
  recordRemoteTitleSync = recordSessionRemoteTitleSync,
@@ -63,7 +64,7 @@ export async function pushSessionTitleToApi(
63
64
  const session = await resolveAuthSession({
64
65
  cwd: targetPath,
65
66
  env,
66
- autoRotate: true,
67
+ autoRotate,
67
68
  });
68
69
  if (!session?.token || !session?.apiUrl) {
69
70
  await recordRemoteTitleSync(normalizedSessionId, {