sentinelayer-cli 0.9.0 → 0.9.3

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.
@@ -14,6 +14,7 @@ const SESSION_INGEST_LIMIT_PER_MINUTE = 500;
14
14
  const HUMAN_MESSAGE_LIMIT_PER_MINUTE = 10;
15
15
  const HUMAN_MESSAGE_MAX_LENGTH = 2_000;
16
16
  const HUMAN_MESSAGE_FETCH_LIMIT = 50;
17
+ const SESSION_EVENT_FETCH_LIMIT = 200;
17
18
 
18
19
  // Audit §2.9: crash-recovery contract for in-memory circuit state.
19
20
  // Persist outbound/inbound circuit state to disk so a process restart
@@ -103,6 +104,96 @@ const inboundCircuit = {
103
104
  const sessionIngestWindowBySessionId = new Map();
104
105
  const humanRelayWindowBySessionId = new Map();
105
106
 
107
+ // Per-process record of agent identities for which we have already issued an
108
+ // auto-grant attempt against `POST /api/v1/sessions/agent-grants`. Server-side
109
+ // agent-identity enforcement (PR #478) returns 403 IDENTITY_FORGERY when the
110
+ // active user has not granted the `agent.id` carried on a session event. The
111
+ // CLI auto-grants on the first 403 and retries the event POST exactly once,
112
+ // but if the grant itself fails (e.g. 422 from a malformed agent_id), we MUST
113
+ // NOT loop. This Set is the loop-breaker: insertion order is preserved (Set
114
+ // semantics in V8) so an LRU-ish trim drops the oldest entries first when the
115
+ // cap is exceeded. SessionId-agnostic by design — once a user grants
116
+ // `agent.id="codex"`, the grant is global to their account.
117
+ const AUTO_GRANT_ATTEMPT_CACHE_MAX = 50;
118
+ const autoGrantAttemptedAgentIds = new Set();
119
+
120
+ // Reserved agent_id values that the API treats specially — granting these
121
+ // is either a no-op or rejected outright. Skip the auto-grant round-trip and
122
+ // return the original 403 cleanly.
123
+ const AUTO_GRANT_RESERVED_PREFIXES = ["human-"];
124
+ const AUTO_GRANT_RESERVED_EXACT = new Set(["", "cli-user", "unknown"]);
125
+
126
+ // API role enum (per PR #478 server contract). Anything not in this set
127
+ // (notably the legacy `orchestrator` role we still emit locally) falls back
128
+ // to `coder`. This is a forward-compat workaround pending an API enum
129
+ // extension that adds `orchestrator`.
130
+ const AUTO_GRANT_VALID_ROLES = new Set([
131
+ "auditor",
132
+ "coder",
133
+ "coordinator",
134
+ "observer",
135
+ "reviewer",
136
+ ]);
137
+ const AUTO_GRANT_DEFAULT_ROLE = "coder";
138
+
139
+ function rememberAutoGrantAttempt(agentId) {
140
+ if (!agentId) return;
141
+ if (autoGrantAttemptedAgentIds.has(agentId)) {
142
+ // Refresh insertion order so the most recently used entry survives
143
+ // LRU eviction when the cap is hit.
144
+ autoGrantAttemptedAgentIds.delete(agentId);
145
+ autoGrantAttemptedAgentIds.add(agentId);
146
+ return;
147
+ }
148
+ autoGrantAttemptedAgentIds.add(agentId);
149
+ while (autoGrantAttemptedAgentIds.size > AUTO_GRANT_ATTEMPT_CACHE_MAX) {
150
+ const oldest = autoGrantAttemptedAgentIds.values().next().value;
151
+ if (oldest === undefined) break;
152
+ autoGrantAttemptedAgentIds.delete(oldest);
153
+ }
154
+ }
155
+
156
+ function isReservedAgentIdForGrant(agentId) {
157
+ const id = normalizeString(agentId);
158
+ if (AUTO_GRANT_RESERVED_EXACT.has(id)) return true;
159
+ for (const prefix of AUTO_GRANT_RESERVED_PREFIXES) {
160
+ if (id.startsWith(prefix)) return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ function resolveGrantRole(rawRole) {
166
+ const normalized = normalizeString(rawRole).toLowerCase();
167
+ if (AUTO_GRANT_VALID_ROLES.has(normalized)) {
168
+ return normalized;
169
+ }
170
+ return AUTO_GRANT_DEFAULT_ROLE;
171
+ }
172
+
173
+ async function readResponseJsonSafely(response) {
174
+ if (!response || typeof response.json !== "function") {
175
+ return null;
176
+ }
177
+ try {
178
+ return await response.json();
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function isIdentityForgeryBody(body) {
185
+ if (!body || typeof body !== "object") return false;
186
+ const error = body.error;
187
+ if (!error || typeof error !== "object") return false;
188
+ return normalizeString(error.code) === "IDENTITY_FORGERY";
189
+ }
190
+
191
+ // Test-only: clear the per-process auto-grant attempt cache so unit tests
192
+ // exercising the loop-breaker don't bleed state across runs.
193
+ export function __resetAutoGrantCacheForTests() {
194
+ autoGrantAttemptedAgentIds.clear();
195
+ }
196
+
106
197
  const SECRET_LIKE_PATTERN =
107
198
  /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]+PRIVATE KEY-----|SENTINELAYER_TOKEN|AIDENID_API_KEY|NPM_TOKEN|xox[baprs]-[A-Za-z0-9-]+)/i;
108
199
 
@@ -130,6 +221,39 @@ function normalizePositiveInteger(value, fallbackValue) {
130
221
  return Math.floor(normalized);
131
222
  }
132
223
 
224
+ function eventTimestampMs(event = {}) {
225
+ for (const key of ["ts", "timestamp", "createdAt", "at"]) {
226
+ const epoch = Date.parse(normalizeString(event?.[key]));
227
+ if (Number.isFinite(epoch)) {
228
+ return epoch;
229
+ }
230
+ }
231
+ return 0;
232
+ }
233
+
234
+ function eventSequenceNumber(event = {}) {
235
+ for (const key of ["sequenceId", "sequence_id", "sequence"]) {
236
+ const value = Number(event?.[key]);
237
+ if (Number.isFinite(value)) {
238
+ return value;
239
+ }
240
+ }
241
+ return 0;
242
+ }
243
+
244
+ function chronologicalSessionEvents(events = []) {
245
+ return (Array.isArray(events) ? events : [])
246
+ .map((event, index) => ({ event, index }))
247
+ .sort((left, right) => {
248
+ const timeDiff = eventTimestampMs(left.event) - eventTimestampMs(right.event);
249
+ if (timeDiff !== 0) return timeDiff;
250
+ const sequenceDiff = eventSequenceNumber(left.event) - eventSequenceNumber(right.event);
251
+ if (sequenceDiff !== 0) return sequenceDiff;
252
+ return left.index - right.index;
253
+ })
254
+ .map((entry) => entry.event);
255
+ }
256
+
133
257
  // Session-apiUrl allowlist. A session file is an untrusted input: if an
134
258
  // attacker can write to .sentinelayer/sessions/<id>/meta.json (or trick a user
135
259
  // into joining a crafted session), they can redirect every outbound fetch.
@@ -435,7 +559,11 @@ export async function syncSessionEventToApi(
435
559
  };
436
560
  }
437
561
 
438
- if (event?.payload?.relayedFromApi) {
562
+ if (
563
+ event?.payload?.relayedFromApi ||
564
+ normalizeString(event?.cursor) ||
565
+ normalizeString(event?.sequenceId || event?.sequence_id)
566
+ ) {
439
567
  return { synced: false, reason: "relay_event_skip" };
440
568
  }
441
569
 
@@ -456,35 +584,125 @@ export async function syncSessionEventToApi(
456
584
 
457
585
  const apiBaseUrl = resolveApiBaseUrl(session);
458
586
  const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/events`;
587
+ const requestBody = JSON.stringify({
588
+ event,
589
+ source: "cli",
590
+ });
591
+ const requestInit = {
592
+ method: "POST",
593
+ headers: {
594
+ "Content-Type": "application/json",
595
+ Authorization: `Bearer ${session.token}`,
596
+ },
597
+ body: requestBody,
598
+ };
599
+ const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
459
600
 
460
601
  try {
461
- const response = await fetchImpl(
462
- endpoint,
463
- {
464
- method: "POST",
465
- headers: {
466
- "Content-Type": "application/json",
467
- Authorization: `Bearer ${session.token}`,
468
- },
469
- body: JSON.stringify({
470
- event,
471
- source: "cli",
472
- }),
473
- },
474
- normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
475
- );
602
+ const response = await fetchImpl(endpoint, requestInit, resolvedTimeoutMs);
476
603
 
477
- if (!response || !response.ok) {
478
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
604
+ if (response && response.ok) {
605
+ recordCircuitSuccess(outboundCircuit);
479
606
  return {
480
- synced: false,
481
- reason: `api_${response ? response.status : "no_response"}`,
607
+ synced: true,
608
+ status: response.status,
482
609
  };
483
610
  }
484
- recordCircuitSuccess(outboundCircuit);
611
+
612
+ // Handle PR #478 IDENTITY_FORGERY: server now requires the active user to
613
+ // have explicitly granted any `agent.id` posted to /events. Without this
614
+ // auto-grant, every CLI-driven agent post returns 403 and the user sees
615
+ // their agents "talking" locally while the API has zero record. We attempt
616
+ // the grant once per agentId per process, then retry the event POST once.
617
+ if (response && response.status === 403) {
618
+ const body = await readResponseJsonSafely(response);
619
+ if (isIdentityForgeryBody(body)) {
620
+ const agentId = normalizeString(event?.agent?.id);
621
+ if (!agentId || isReservedAgentIdForGrant(agentId)) {
622
+ // Reserved or empty — server enforcement is intentional, no grant
623
+ // attempt is meaningful. Treat as a normal 403 failure.
624
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
625
+ return { synced: false, reason: "api_403" };
626
+ }
627
+ if (autoGrantAttemptedAgentIds.has(agentId)) {
628
+ // We already tried to grant this identity in a prior event in this
629
+ // process and either it succeeded but the server still says no, or
630
+ // the grant failed. Either way, don't loop — surface the 403.
631
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
632
+ return { synced: false, reason: "api_403" };
633
+ }
634
+ rememberAutoGrantAttempt(agentId);
635
+
636
+ const grantRole = resolveGrantRole(event?.agent?.role);
637
+ const grantEndpoint = `${apiBaseUrl}/api/v1/sessions/agent-grants`;
638
+ let grantResponse = null;
639
+ try {
640
+ grantResponse = await fetchImpl(
641
+ grantEndpoint,
642
+ {
643
+ method: "POST",
644
+ headers: {
645
+ "Content-Type": "application/json",
646
+ Authorization: `Bearer ${session.token}`,
647
+ },
648
+ body: JSON.stringify({
649
+ agent_id: agentId,
650
+ role: grantRole,
651
+ }),
652
+ },
653
+ resolvedTimeoutMs
654
+ );
655
+ } catch (grantError) {
656
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
657
+ return {
658
+ synced: false,
659
+ reason: normalizeString(grantError?.message) || "grant_failed",
660
+ };
661
+ }
662
+
663
+ const grantStatus = grantResponse ? grantResponse.status : 0;
664
+ const grantOk = Boolean(grantResponse && grantResponse.ok);
665
+ // Treat 409 (already granted) as success — idempotent on the server.
666
+ const grantIdempotent = grantStatus === 409;
667
+ if (!grantOk && !grantIdempotent) {
668
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
669
+ return {
670
+ synced: false,
671
+ reason: `grant_failed_${grantStatus || "no_response"}`,
672
+ };
673
+ }
674
+
675
+ // Retry the original event POST exactly once.
676
+ let retryResponse;
677
+ try {
678
+ retryResponse = await fetchImpl(endpoint, requestInit, resolvedTimeoutMs);
679
+ } catch (retryError) {
680
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
681
+ return {
682
+ synced: false,
683
+ reason: normalizeString(retryError?.message) || "sync_failed",
684
+ };
685
+ }
686
+ if (retryResponse && retryResponse.ok) {
687
+ recordCircuitSuccess(outboundCircuit);
688
+ return {
689
+ synced: true,
690
+ status: retryResponse.status,
691
+ autoGranted: true,
692
+ };
693
+ }
694
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
695
+ return {
696
+ synced: false,
697
+ reason: `api_${retryResponse ? retryResponse.status : "no_response"}`,
698
+ };
699
+ }
700
+ }
701
+
702
+ recordCircuitFailure(outboundCircuit, normalizedNowMs);
485
703
  return {
486
- synced: true,
487
- status: response.status,
704
+ synced: false,
705
+ reason: `api_${response ? response.status : "no_response"}`,
488
706
  };
489
707
  } catch (error) {
490
708
  recordCircuitFailure(outboundCircuit, normalizedNowMs);
@@ -622,6 +840,7 @@ export async function pollHumanMessages(
622
840
  since = null,
623
841
  timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
624
842
  limit = HUMAN_MESSAGE_FETCH_LIMIT,
843
+ forceCircuitProbe = false,
625
844
  resolveAuthSession = resolveActiveAuthSession,
626
845
  fetchImpl = fetchWithTimeout,
627
846
  nowMs = Date.now,
@@ -638,7 +857,7 @@ export async function pollHumanMessages(
638
857
  }
639
858
 
640
859
  const normalizedNowMs = Number(nowMs()) || Date.now();
641
- if (isCircuitOpen(inboundCircuit, normalizedNowMs)) {
860
+ if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
642
861
  return {
643
862
  ok: false,
644
863
  reason: "circuit_breaker_open",
@@ -781,6 +1000,7 @@ export async function pollSessionEvents(
781
1000
  since = null,
782
1001
  limit = 200,
783
1002
  timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1003
+ forceCircuitProbe = false,
784
1004
  resolveAuthSession = resolveActiveAuthSession,
785
1005
  fetchImpl = fetchWithTimeout,
786
1006
  nowMs = Date.now,
@@ -797,7 +1017,7 @@ export async function pollSessionEvents(
797
1017
  }
798
1018
 
799
1019
  const normalizedNowMs = Number(nowMs()) || Date.now();
800
- if (isCircuitOpen(inboundCircuit, normalizedNowMs)) {
1020
+ if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
801
1021
  return {
802
1022
  ok: false,
803
1023
  reason: "circuit_breaker_open",
@@ -836,7 +1056,10 @@ export async function pollSessionEvents(
836
1056
  if (normalizedSince) {
837
1057
  query.set("after", normalizedSince);
838
1058
  }
839
- query.set("limit", String(Math.max(1, Math.min(200, normalizePositiveInteger(limit, 200)))));
1059
+ query.set(
1060
+ "limit",
1061
+ String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, SESSION_EVENT_FETCH_LIMIT))))
1062
+ );
840
1063
  const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/events?${query.toString()}`;
841
1064
 
842
1065
  try {
@@ -889,6 +1112,131 @@ export async function pollSessionEvents(
889
1112
  }
890
1113
  }
891
1114
 
1115
+ /**
1116
+ * Poll the latest durable session events page via the reverse-history endpoint.
1117
+ *
1118
+ * This powers `sl session read --remote --tail`: a forward cursor can be many
1119
+ * pages behind in long rooms, but a tail read must show the latest messages now.
1120
+ * The API returns newest-first for `/events/before`; callers get chronological
1121
+ * order so appending/displaying matches the local NDJSON stream.
1122
+ */
1123
+ export async function pollSessionEventsBefore(
1124
+ sessionId,
1125
+ {
1126
+ targetPath = process.cwd(),
1127
+ beforeSequence = null,
1128
+ limit = 50,
1129
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1130
+ forceCircuitProbe = false,
1131
+ resolveAuthSession = resolveActiveAuthSession,
1132
+ fetchImpl = fetchWithTimeout,
1133
+ nowMs = Date.now,
1134
+ } = {}
1135
+ ) {
1136
+ const normalizedSessionId = normalizeString(sessionId);
1137
+ if (!normalizedSessionId) {
1138
+ return {
1139
+ ok: false,
1140
+ reason: "invalid_session_id",
1141
+ events: [],
1142
+ cursor: null,
1143
+ beforeSequence: null,
1144
+ };
1145
+ }
1146
+
1147
+ const normalizedNowMs = Number(nowMs()) || Date.now();
1148
+ if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
1149
+ return {
1150
+ ok: false,
1151
+ reason: "circuit_breaker_open",
1152
+ events: [],
1153
+ cursor: null,
1154
+ beforeSequence: null,
1155
+ };
1156
+ }
1157
+
1158
+ let session = null;
1159
+ try {
1160
+ session = await resolveAuthSession({
1161
+ cwd: targetPath,
1162
+ env: process.env,
1163
+ autoRotate: false,
1164
+ });
1165
+ } catch {
1166
+ return {
1167
+ ok: false,
1168
+ reason: "no_session",
1169
+ events: [],
1170
+ cursor: null,
1171
+ beforeSequence: null,
1172
+ };
1173
+ }
1174
+ if (!session || !session.token) {
1175
+ return {
1176
+ ok: false,
1177
+ reason: "not_authenticated",
1178
+ events: [],
1179
+ cursor: null,
1180
+ beforeSequence: null,
1181
+ };
1182
+ }
1183
+
1184
+ const apiBaseUrl = resolveApiBaseUrl(session);
1185
+ const query = new URLSearchParams();
1186
+ const normalizedBeforeSequence = Number(beforeSequence);
1187
+ if (Number.isFinite(normalizedBeforeSequence) && normalizedBeforeSequence > 0) {
1188
+ query.set("beforeSequence", String(Math.floor(normalizedBeforeSequence)));
1189
+ }
1190
+ query.set(
1191
+ "limit",
1192
+ String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, 50))))
1193
+ );
1194
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/events/before?${query.toString()}`;
1195
+
1196
+ try {
1197
+ const response = await fetchImpl(
1198
+ endpoint,
1199
+ {
1200
+ method: "GET",
1201
+ headers: { Authorization: `Bearer ${session.token}` },
1202
+ },
1203
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
1204
+ );
1205
+ if (!response || !response.ok) {
1206
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1207
+ return {
1208
+ ok: false,
1209
+ reason: `api_${response ? response.status : "no_response"}`,
1210
+ events: [],
1211
+ cursor: null,
1212
+ beforeSequence: Number.isFinite(normalizedBeforeSequence) ? normalizedBeforeSequence : null,
1213
+ };
1214
+ }
1215
+ const payload = await response.json().catch(() => ({}));
1216
+ recordCircuitSuccess(inboundCircuit);
1217
+
1218
+ const events = chronologicalSessionEvents(payload?.events || []);
1219
+ const lastEvent = events[events.length - 1] || null;
1220
+ const firstEvent = events[0] || null;
1221
+ return {
1222
+ ok: true,
1223
+ reason: "",
1224
+ events,
1225
+ cursor: normalizeString(lastEvent?.cursor) || null,
1226
+ beforeSequence: eventSequenceNumber(firstEvent) || null,
1227
+ };
1228
+ } catch (error) {
1229
+ recordCircuitFailure(inboundCircuit, normalizedNowMs);
1230
+ return {
1231
+ ok: false,
1232
+ reason: normalizeString(error?.message) || "poll_failed",
1233
+ events: [],
1234
+ cursor: null,
1235
+ beforeSequence: Number.isFinite(normalizedBeforeSequence) ? normalizedBeforeSequence : null,
1236
+ };
1237
+ }
1238
+ }
1239
+
892
1240
 
893
1241
  /**
894
1242
  * List sessions owned by the active user via `GET /api/v1/sessions`.
@@ -1052,6 +1400,7 @@ export function resetSessionSyncStateForTests() {
1052
1400
  inboundCircuit.openedAtMs = 0;
1053
1401
  sessionIngestWindowBySessionId.clear();
1054
1402
  humanRelayWindowBySessionId.clear();
1403
+ autoGrantAttemptedAgentIds.clear();
1055
1404
  // Tests that exercise the network path explicitly need the
1056
1405
  // SENTINELAYER_SKIP_REMOTE_SYNC guard off — otherwise the function
1057
1406
  // short-circuits before the mocked fetchImpl is ever called. Tests that
@@ -0,0 +1,107 @@
1
+ import process from "node:process";
2
+
3
+ import { SentinelayerApiError, requestJsonMutation } from "../auth/http.js";
4
+ import { resolveActiveAuthSession } from "../auth/service.js";
5
+ import { recordSessionRemoteTitleSync } from "./store.js";
6
+
7
+ const DEFAULT_TITLE_SYNC_TIMEOUT_MS = 2_000;
8
+ const DEFAULT_TITLE_SYNC_RETRY_DELAY_MS = 200;
9
+
10
+ function normalizeString(value) {
11
+ return String(value || "").trim();
12
+ }
13
+
14
+ function remoteSessionSyncDisabled(env = process.env) {
15
+ return String(env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
16
+ }
17
+
18
+ function normalizeFailureReason(error) {
19
+ if (error instanceof SentinelayerApiError) {
20
+ return error.code || `api_${error.status || "error"}`;
21
+ }
22
+ return normalizeString(error?.message) || "sync_failed";
23
+ }
24
+
25
+ function isTerminalTitleSyncError(error) {
26
+ return (
27
+ error instanceof SentinelayerApiError &&
28
+ (error.status === 422 || error.code === "INVALID_SESSION_TITLE")
29
+ );
30
+ }
31
+
32
+ export async function pushSessionTitleToApi(
33
+ sessionId,
34
+ title,
35
+ {
36
+ targetPath,
37
+ env = process.env,
38
+ timeoutMs = DEFAULT_TITLE_SYNC_TIMEOUT_MS,
39
+ maxRetries = 1,
40
+ retryDelayMs = DEFAULT_TITLE_SYNC_RETRY_DELAY_MS,
41
+ resolveAuthSession = resolveActiveAuthSession,
42
+ requestMutation = requestJsonMutation,
43
+ recordRemoteTitleSync = recordSessionRemoteTitleSync,
44
+ } = {},
45
+ ) {
46
+ const normalizedSessionId = normalizeString(sessionId);
47
+ const normalizedTitle = normalizeString(title);
48
+ if (!normalizedSessionId || !normalizedTitle) {
49
+ return { synced: false, reason: "invalid_input" };
50
+ }
51
+ if (remoteSessionSyncDisabled(env)) {
52
+ return { synced: false, reason: "remote_sync_disabled" };
53
+ }
54
+
55
+ await recordRemoteTitleSync(normalizedSessionId, {
56
+ targetPath,
57
+ title: normalizedTitle,
58
+ pending: true,
59
+ failureReason: "pending",
60
+ }).catch(() => null);
61
+
62
+ try {
63
+ const session = await resolveAuthSession({
64
+ cwd: targetPath,
65
+ env,
66
+ autoRotate: true,
67
+ });
68
+ if (!session?.token || !session?.apiUrl) {
69
+ await recordRemoteTitleSync(normalizedSessionId, {
70
+ targetPath,
71
+ title: normalizedTitle,
72
+ pending: true,
73
+ failureReason: "not_authenticated",
74
+ }).catch(() => null);
75
+ return { synced: false, reason: "not_authenticated" };
76
+ }
77
+ const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
78
+ const result = await requestMutation(
79
+ `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/title`,
80
+ {
81
+ method: "POST",
82
+ operationName: "session.set_title",
83
+ headers: { Authorization: `Bearer ${session.token}` },
84
+ body: { title: normalizedTitle },
85
+ timeoutMs,
86
+ maxRetries,
87
+ retryDelayMs,
88
+ },
89
+ );
90
+ await recordRemoteTitleSync(normalizedSessionId, {
91
+ targetPath,
92
+ title: normalizedTitle,
93
+ pending: false,
94
+ }).catch(() => null);
95
+ return { synced: true, status: result?.status || 200 };
96
+ } catch (error) {
97
+ const reason = normalizeFailureReason(error);
98
+ const terminal = isTerminalTitleSyncError(error);
99
+ await recordRemoteTitleSync(normalizedSessionId, {
100
+ targetPath,
101
+ title: normalizedTitle,
102
+ pending: !terminal,
103
+ failureReason: reason,
104
+ }).catch(() => null);
105
+ return { synced: false, reason, terminal };
106
+ }
107
+ }