sentinelayer-cli 0.9.2 → 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.
- package/package.json +1 -1
- package/src/commands/session.js +52 -51
- package/src/session/recap.js +35 -0
- package/src/session/remote-hydrate.js +34 -5
- package/src/session/store.js +53 -1
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -44,9 +44,11 @@ import {
|
|
|
44
44
|
createSession,
|
|
45
45
|
DEFAULT_TTL_SECONDS,
|
|
46
46
|
getSession,
|
|
47
|
+
isSessionCacheExpired,
|
|
47
48
|
listActiveSessions,
|
|
48
49
|
listAllSessions,
|
|
49
50
|
recordSessionProvisionedIdentities,
|
|
51
|
+
refreshSessionCacheForRemoteActivity,
|
|
50
52
|
updateSessionTitle,
|
|
51
53
|
} from "../session/store.js";
|
|
52
54
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
@@ -330,11 +332,44 @@ function formatRelativeAge(isoTimestamp) {
|
|
|
330
332
|
|
|
331
333
|
async function ensureLocalSessionForRemoteCommand(
|
|
332
334
|
sessionId,
|
|
333
|
-
{ targetPath, title = "", skipRemoteProbe = false } = {},
|
|
335
|
+
{ targetPath, title = "", skipRemoteProbe = false, remoteSession = null } = {},
|
|
334
336
|
) {
|
|
335
337
|
const existing = await getSession(sessionId, { targetPath });
|
|
336
338
|
if (existing) {
|
|
337
|
-
|
|
339
|
+
if (!isSessionCacheExpired(existing)) {
|
|
340
|
+
return { materialized: false, refreshed: false, session: existing };
|
|
341
|
+
}
|
|
342
|
+
const existingStatus = normalizeString(existing.status).toLowerCase();
|
|
343
|
+
const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
|
|
344
|
+
if (locallyClosedByStatus && !skipRemoteProbe) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Session '${sessionId}' is ${existingStatus} locally; run \`sl session join ${sessionId}\` to verify remote access before posting.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let access = { accessible: Boolean(skipRemoteProbe), reason: "" };
|
|
351
|
+
if (!skipRemoteProbe) {
|
|
352
|
+
access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
|
|
353
|
+
accessible: false,
|
|
354
|
+
reason: normalizeString(error?.message) || "probe_failed",
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
if (!access?.accessible) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`Session '${sessionId}' is expired locally and remote access failed (${access?.reason || "unknown"}).`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
|
|
364
|
+
targetPath,
|
|
365
|
+
title,
|
|
366
|
+
lastInteractionAt:
|
|
367
|
+
normalizeString(remoteSession?.lastInteractionAt) ||
|
|
368
|
+
normalizeString(remoteSession?.lastActivityAt) ||
|
|
369
|
+
normalizeString(remoteSession?.updatedAt) ||
|
|
370
|
+
normalizeString(remoteSession?.createdAt),
|
|
371
|
+
});
|
|
372
|
+
return { materialized: false, refreshed: Boolean(refreshed), session: refreshed || existing };
|
|
338
373
|
}
|
|
339
374
|
// `skipRemoteProbe` is set by callers that have already verified the session
|
|
340
375
|
// via `verifyRemoteSession` (the singleton GET) — re-probing the legacy
|
|
@@ -356,7 +391,7 @@ async function ensureLocalSessionForRemoteCommand(
|
|
|
356
391
|
sessionId,
|
|
357
392
|
title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
|
|
358
393
|
});
|
|
359
|
-
return { materialized: true, session: created };
|
|
394
|
+
return { materialized: true, refreshed: false, session: created };
|
|
360
395
|
}
|
|
361
396
|
|
|
362
397
|
async function ensureWorkspaceSession({
|
|
@@ -470,56 +505,16 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
|
|
|
470
505
|
return normalized || fallbackValue;
|
|
471
506
|
}
|
|
472
507
|
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// `
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// we need to figure out a fingerprint for them somehow.. maybe at joining
|
|
480
|
-
// we ask for name?" — auth-derived names are the cleanest deterministic
|
|
481
|
-
// fingerprint we already have. Fall through to "cli-user" only if the
|
|
482
|
-
// CLI is genuinely unauthenticated (CI fixture, fresh checkout).
|
|
483
|
-
let _cachedAuthAgentId = undefined; // undefined = not yet resolved
|
|
484
|
-
async function _resolveAuthAgentId(targetPath) {
|
|
485
|
-
if (_cachedAuthAgentId !== undefined) return _cachedAuthAgentId;
|
|
486
|
-
try {
|
|
487
|
-
const auth = await resolveActiveAuthSession({
|
|
488
|
-
cwd: targetPath || process.cwd(),
|
|
489
|
-
env: process.env,
|
|
490
|
-
autoRotate: false,
|
|
491
|
-
});
|
|
492
|
-
const username = normalizeString(auth?.user?.githubUsername).toLowerCase();
|
|
493
|
-
if (username) {
|
|
494
|
-
_cachedAuthAgentId = `human-${username.replace(/[^a-z0-9._-]+/g, "-")}`;
|
|
495
|
-
return _cachedAuthAgentId;
|
|
496
|
-
}
|
|
497
|
-
const email = normalizeString(auth?.user?.email).toLowerCase();
|
|
498
|
-
if (email) {
|
|
499
|
-
const local = email.split("@")[0].replace(/[^a-z0-9._-]+/g, "-");
|
|
500
|
-
if (local) {
|
|
501
|
-
_cachedAuthAgentId = `human-${local}`;
|
|
502
|
-
return _cachedAuthAgentId;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
} catch {
|
|
506
|
-
/* unauthenticated → fall through */
|
|
507
|
-
}
|
|
508
|
-
_cachedAuthAgentId = "";
|
|
509
|
-
return "";
|
|
508
|
+
// Preserve the literal default identity for `session say`. This command is
|
|
509
|
+
// often used by agents as a low-friction relay; silently rewriting the default
|
|
510
|
+
// `cli-user` to the authenticated human makes a forgotten --agent flag look
|
|
511
|
+
// like the workspace owner authored the message.
|
|
512
|
+
export function resolveSessionSayAgentId(value) {
|
|
513
|
+
return normalizeAgentId(value, "cli-user");
|
|
510
514
|
}
|
|
511
515
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// supplied a name keep round-tripping verbatim.
|
|
515
|
-
async function defaultAgentId(value, targetPath) {
|
|
516
|
-
const explicit = normalizeString(value);
|
|
517
|
-
if (explicit && explicit.toLowerCase() !== "cli-user") {
|
|
518
|
-
return normalizeAgentId(value, "cli-user");
|
|
519
|
-
}
|
|
520
|
-
const authId = await _resolveAuthAgentId(targetPath);
|
|
521
|
-
if (authId) return authId;
|
|
522
|
-
return normalizeAgentId(value, "cli-user");
|
|
516
|
+
async function defaultAgentId(value, _targetPath) {
|
|
517
|
+
return resolveSessionSayAgentId(value);
|
|
523
518
|
}
|
|
524
519
|
|
|
525
520
|
async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
|
|
@@ -933,6 +928,7 @@ export function registerSessionCommand(program) {
|
|
|
933
928
|
targetPath,
|
|
934
929
|
title: normalizeString(remoteSession.title),
|
|
935
930
|
skipRemoteProbe: true,
|
|
931
|
+
remoteSession,
|
|
936
932
|
});
|
|
937
933
|
const payload = {
|
|
938
934
|
command: "session ensure",
|
|
@@ -942,6 +938,7 @@ export function registerSessionCommand(program) {
|
|
|
942
938
|
resumed: true,
|
|
943
939
|
attached: true,
|
|
944
940
|
materializedLocalSession: localSession.materialized,
|
|
941
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
945
942
|
verificationSource: verification.source,
|
|
946
943
|
dashboardUrl: buildDashboardUrl(explicitSessionId),
|
|
947
944
|
};
|
|
@@ -1145,6 +1142,7 @@ export function registerSessionCommand(program) {
|
|
|
1145
1142
|
targetPath,
|
|
1146
1143
|
title: normalizeString(remoteSession.title),
|
|
1147
1144
|
skipRemoteProbe: true,
|
|
1145
|
+
remoteSession,
|
|
1148
1146
|
});
|
|
1149
1147
|
|
|
1150
1148
|
const explicitAgent = normalizeString(options.agent);
|
|
@@ -1195,6 +1193,7 @@ export function registerSessionCommand(program) {
|
|
|
1195
1193
|
status: joined.status,
|
|
1196
1194
|
joinedAt: joined.joinedAt,
|
|
1197
1195
|
materializedLocalSession: localSession.materialized,
|
|
1196
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1198
1197
|
verificationSource: verification.source,
|
|
1199
1198
|
eventCount: Number.isFinite(eventCount) ? eventCount : 0,
|
|
1200
1199
|
agentCount: Number.isFinite(agentCount) ? agentCount : 0,
|
|
@@ -1277,6 +1276,7 @@ export function registerSessionCommand(program) {
|
|
|
1277
1276
|
agentId,
|
|
1278
1277
|
event: persisted,
|
|
1279
1278
|
materializedLocalSession: localSession.materialized,
|
|
1279
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1280
1280
|
remoteSync: remoteSync || undefined,
|
|
1281
1281
|
};
|
|
1282
1282
|
if (shouldEmitJson(options, command)) {
|
|
@@ -1364,6 +1364,7 @@ export function registerSessionCommand(program) {
|
|
|
1364
1364
|
agentId,
|
|
1365
1365
|
event: persisted,
|
|
1366
1366
|
materializedLocalSession: localSession.materialized,
|
|
1367
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1367
1368
|
remoteSync,
|
|
1368
1369
|
};
|
|
1369
1370
|
if (shouldEmitJson(options, command)) {
|
package/src/session/recap.js
CHANGED
|
@@ -198,6 +198,35 @@ function buildRecapText({
|
|
|
198
198
|
).trim();
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// Multi-agent session etiquette + read-path rules surfaced in the
|
|
202
|
+
// context_briefing payload an agent receives on first join. Web
|
|
203
|
+
// renders this as markdown (see sentinelayer-web Session.tsx
|
|
204
|
+
// SessionMessage), so headers/lists/inline code are intentional.
|
|
205
|
+
//
|
|
206
|
+
// Keep this short and operationally actionable. Anything that's
|
|
207
|
+
// purely doctrinal belongs in AGENTS.md, not the per-join briefing.
|
|
208
|
+
const AGENT_JOIN_RULES = [
|
|
209
|
+
"**Welcome to this session.** Quick rules so we coordinate cleanly:",
|
|
210
|
+
"",
|
|
211
|
+
"**Reading the room** — When you join, the recap above summarizes activity since the last quiet stretch. To read further back, run `sl session read --remote --tail 50 --json` (bump `--tail` if you need more). Do this BEFORE responding so you don't repeat questions or miss a lock-and-claim someone else already opened.",
|
|
212
|
+
"",
|
|
213
|
+
"**Polling cadence** — Poll new events at most once per 60s (`sl session listen` or `sl session read --remote --tail N`). More frequent than that wastes budget and can hit per-user rate limits. Less frequent than ~5min and peers may think you went idle.",
|
|
214
|
+
"",
|
|
215
|
+
"**Writing back** — You can use **markdown**: bold, italic, lists, fenced code, and `inline code`. The web dashboard renders it. Plain text also works. Keep posts terse and technical — link to the work, don't recap it.",
|
|
216
|
+
"",
|
|
217
|
+
"**Coordination** — Lock-and-claim before you start a scope another agent could be on. If you push back on someone's approach, cite the specific assumption you disagree with and the file:line evidence.",
|
|
218
|
+
"",
|
|
219
|
+
"**Stop conditions** — If the human asks you to stop, stop. If 60+ minutes of total session silence, stop polling.",
|
|
220
|
+
].join("\n");
|
|
221
|
+
|
|
222
|
+
function buildAgentJoinBriefingText({ recap = "", forAgent = "" } = {}) {
|
|
223
|
+
const trimmedRecap = normalizeString(recap);
|
|
224
|
+
const trimmedAgent = normalizeString(forAgent);
|
|
225
|
+
const greeting = trimmedAgent ? `**${trimmedAgent}** joined. ${trimmedRecap}` : trimmedRecap;
|
|
226
|
+
const recapBlock = greeting || "Welcome — no prior session activity to summarize yet.";
|
|
227
|
+
return `${recapBlock}\n\n---\n\n${AGENT_JOIN_RULES}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
201
230
|
function buildPeriodicText(recap = {}) {
|
|
202
231
|
const summary = recap.summary && typeof recap.summary === "object" ? recap.summary : {};
|
|
203
232
|
const elapsedMinutes = Number(summary.elapsedMinutes || 0);
|
|
@@ -303,6 +332,7 @@ export async function emitContextBriefing(
|
|
|
303
332
|
maxEvents = DEFAULT_RECAP_MAX_EVENTS,
|
|
304
333
|
targetPath = process.cwd(),
|
|
305
334
|
nowIso = new Date().toISOString(),
|
|
335
|
+
includeJoinRules = true,
|
|
306
336
|
} = {}
|
|
307
337
|
) {
|
|
308
338
|
const recap = await buildSessionRecap(sessionId, {
|
|
@@ -311,6 +341,9 @@ export async function emitContextBriefing(
|
|
|
311
341
|
targetPath,
|
|
312
342
|
nowIso,
|
|
313
343
|
});
|
|
344
|
+
const briefingMessage = includeJoinRules
|
|
345
|
+
? buildAgentJoinBriefingText({ recap: recap.text, forAgent: forAgentId })
|
|
346
|
+
: recap.text;
|
|
314
347
|
const event = createAgentEvent({
|
|
315
348
|
event: "context_briefing",
|
|
316
349
|
agentId: SENTI_AGENT_ID,
|
|
@@ -319,7 +352,9 @@ export async function emitContextBriefing(
|
|
|
319
352
|
ts: recap.generatedAt,
|
|
320
353
|
payload: {
|
|
321
354
|
forAgent: normalizeString(forAgentId) || null,
|
|
355
|
+
message: briefingMessage,
|
|
322
356
|
recap: recap.text,
|
|
357
|
+
rules: includeJoinRules ? AGENT_JOIN_RULES : null,
|
|
323
358
|
ephemeral: true,
|
|
324
359
|
style: RECAP_STYLE,
|
|
325
360
|
generatedAt: recap.generatedAt,
|
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
|
|
21
21
|
import { listSessionsFromApi, pollHumanMessages, pollSessionEvents } from "./sync.js";
|
|
22
22
|
import { appendToStream, readStream } from "./stream.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createSession,
|
|
25
|
+
getSession,
|
|
26
|
+
isSessionCacheExpired,
|
|
27
|
+
refreshSessionCacheForRemoteActivity,
|
|
28
|
+
} from "./store.js";
|
|
24
29
|
import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
25
30
|
import {
|
|
26
31
|
addSessionEventIdentityKeys,
|
|
@@ -42,10 +47,8 @@ async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } =
|
|
|
42
47
|
|
|
43
48
|
async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
|
|
44
49
|
const existing = await getSession(sessionId, { targetPath });
|
|
45
|
-
if (existing) {
|
|
46
|
-
return { materialized: false, session: existing };
|
|
47
|
-
}
|
|
48
50
|
let remoteStatus = "";
|
|
51
|
+
let remoteSession = null;
|
|
49
52
|
const remoteList = await listSessionsFromApi({
|
|
50
53
|
targetPath,
|
|
51
54
|
includeArchived: true,
|
|
@@ -53,14 +56,40 @@ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() }
|
|
|
53
56
|
}).catch(() => null);
|
|
54
57
|
if (remoteList?.ok) {
|
|
55
58
|
const match = (remoteList.sessions || []).find((entry) => entry?.sessionId === sessionId);
|
|
59
|
+
remoteSession = match || null;
|
|
56
60
|
remoteStatus = String(match?.archiveStatus || match?.status || "").trim().toLowerCase();
|
|
57
61
|
}
|
|
62
|
+
if (existing) {
|
|
63
|
+
const existingStatus = String(existing.status || "").trim().toLowerCase();
|
|
64
|
+
const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
|
|
65
|
+
const remoteAllowsRefresh =
|
|
66
|
+
["active", "pending"].includes(remoteStatus) || (!remoteStatus && !locallyClosedByStatus);
|
|
67
|
+
if (isSessionCacheExpired(existing) && remoteAllowsRefresh) {
|
|
68
|
+
const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
|
|
69
|
+
targetPath,
|
|
70
|
+
title: remoteSession?.title || "",
|
|
71
|
+
lastInteractionAt:
|
|
72
|
+
remoteSession?.lastInteractionAt ||
|
|
73
|
+
remoteSession?.lastActivityAt ||
|
|
74
|
+
remoteSession?.updatedAt ||
|
|
75
|
+
remoteSession?.createdAt ||
|
|
76
|
+
"",
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
materialized: false,
|
|
80
|
+
refreshed: Boolean(refreshed),
|
|
81
|
+
session: refreshed || existing,
|
|
82
|
+
remoteStatus,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { materialized: false, refreshed: false, session: existing, remoteStatus };
|
|
86
|
+
}
|
|
58
87
|
const created = await createSession({
|
|
59
88
|
targetPath,
|
|
60
89
|
sessionId,
|
|
61
90
|
title: `remote-${String(sessionId).slice(0, 8)}`,
|
|
62
91
|
});
|
|
63
|
-
return { materialized: true, session: created, remoteStatus };
|
|
92
|
+
return { materialized: true, refreshed: false, session: created, remoteStatus };
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
|
package/src/session/store.js
CHANGED
|
@@ -388,7 +388,8 @@ function normalizeMetadata(raw = {}, { sessionId, targetPath, nowIso } = {}) {
|
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
function isExpired(metadata, nowIso = new Date().toISOString()) {
|
|
391
|
-
|
|
391
|
+
const status = normalizeSessionStatus(metadata?.status);
|
|
392
|
+
if (!metadata || status === SESSION_STATUS_EXPIRED || status === SESSION_STATUS_ARCHIVED) {
|
|
392
393
|
return true;
|
|
393
394
|
}
|
|
394
395
|
const expiryEpoch = Date.parse(normalizeIsoTimestamp(metadata.expiresAt, nowIso));
|
|
@@ -399,6 +400,10 @@ function isExpired(metadata, nowIso = new Date().toISOString()) {
|
|
|
399
400
|
return nowEpoch >= expiryEpoch;
|
|
400
401
|
}
|
|
401
402
|
|
|
403
|
+
export function isSessionCacheExpired(metadata, nowIso = new Date().toISOString()) {
|
|
404
|
+
return isExpired(metadata, nowIso);
|
|
405
|
+
}
|
|
406
|
+
|
|
402
407
|
function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString()) {
|
|
403
408
|
return {
|
|
404
409
|
sessionId: metadata.sessionId,
|
|
@@ -526,6 +531,53 @@ export async function updateSessionTitle(
|
|
|
526
531
|
return buildSessionPayload(saved, loaded.paths, nowIso);
|
|
527
532
|
}
|
|
528
533
|
|
|
534
|
+
export async function refreshSessionCacheForRemoteActivity(
|
|
535
|
+
sessionId,
|
|
536
|
+
{
|
|
537
|
+
targetPath = process.cwd(),
|
|
538
|
+
title = "",
|
|
539
|
+
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
540
|
+
lastInteractionAt = "",
|
|
541
|
+
nowIso = new Date().toISOString(),
|
|
542
|
+
} = {}
|
|
543
|
+
) {
|
|
544
|
+
const loaded = await loadMetadata(sessionId, { targetPath });
|
|
545
|
+
if (!loaded) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
const normalizedTtlSeconds = normalizePositiveInteger(ttlSeconds, DEFAULT_TTL_SECONDS);
|
|
549
|
+
const remoteInteractionIso = normalizeIsoTimestamp(
|
|
550
|
+
lastInteractionAt,
|
|
551
|
+
loaded.metadata.lastInteractionAt || loaded.metadata.createdAt || nowIso
|
|
552
|
+
);
|
|
553
|
+
const currentInteractionEpoch = Date.parse(
|
|
554
|
+
normalizeIsoTimestamp(loaded.metadata.lastInteractionAt, loaded.metadata.createdAt || nowIso)
|
|
555
|
+
);
|
|
556
|
+
const remoteInteractionEpoch = Date.parse(remoteInteractionIso);
|
|
557
|
+
const lastInteractionIso =
|
|
558
|
+
Number.isFinite(remoteInteractionEpoch) &&
|
|
559
|
+
(!Number.isFinite(currentInteractionEpoch) || remoteInteractionEpoch > currentInteractionEpoch)
|
|
560
|
+
? remoteInteractionIso
|
|
561
|
+
: normalizeIsoTimestamp(loaded.metadata.lastInteractionAt, nowIso);
|
|
562
|
+
|
|
563
|
+
const metadata = {
|
|
564
|
+
...loaded.metadata,
|
|
565
|
+
updatedAt: nowIso,
|
|
566
|
+
expiresAt: toIsoAfterSeconds(nowIso, normalizedTtlSeconds),
|
|
567
|
+
ttlSeconds: normalizedTtlSeconds,
|
|
568
|
+
status: SESSION_STATUS_ACTIVE,
|
|
569
|
+
expiredAt: null,
|
|
570
|
+
lastInteractionAt: lastInteractionIso,
|
|
571
|
+
};
|
|
572
|
+
const normalizedTitle = normalizeString(title);
|
|
573
|
+
if (normalizedTitle) {
|
|
574
|
+
metadata.title = normalizedTitle;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const saved = await saveMetadata(metadata, loaded.paths);
|
|
578
|
+
return buildSessionPayload(saved, loaded.paths, nowIso);
|
|
579
|
+
}
|
|
580
|
+
|
|
529
581
|
export async function recordSessionRemoteTitleSync(
|
|
530
582
|
sessionId,
|
|
531
583
|
{
|