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.
- package/package.json +1 -1
- package/src/agents/backend/tools/timeout-audit.js +33 -17
- package/src/agents/devtestbot/runner.js +11 -5
- package/src/commands/session.js +566 -32
- package/src/legacy-cli.js +1 -1
- package/src/scan/generator.js +1 -1
- package/src/session/coordination-guidance.js +2 -1
- package/src/session/event-identity.js +139 -0
- package/src/session/listener.js +96 -2
- package/src/session/live-source.js +11 -2
- package/src/session/mentions.js +130 -0
- package/src/session/recap.js +35 -0
- package/src/session/remote-hydrate.js +252 -8
- package/src/session/store.js +116 -1
- package/src/session/stream.js +17 -7
- package/src/session/sync.js +375 -26
- package/src/session/title-sync.js +107 -0
package/src/commands/session.js
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
registerAgent,
|
|
30
30
|
unregisterAgent,
|
|
31
31
|
} from "../session/agent-registry.js";
|
|
32
|
-
import { stopSenti } from "../session/daemon.js";
|
|
32
|
+
import { startSenti, stopSenti } from "../session/daemon.js";
|
|
33
33
|
import { listRuntimeRuns } from "../session/runtime-bridge.js";
|
|
34
34
|
import {
|
|
35
35
|
listFileLocks,
|
|
@@ -44,22 +44,32 @@ 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";
|
|
55
|
+
import {
|
|
56
|
+
addSessionEventIdentityKeys,
|
|
57
|
+
dedupeSessionEvents,
|
|
58
|
+
sessionEventHasKnownIdentity,
|
|
59
|
+
} from "../session/event-identity.js";
|
|
53
60
|
import { readSessionPreview } from "../session/preview.js";
|
|
54
61
|
import {
|
|
55
62
|
listSessionsFromApi,
|
|
56
63
|
probeSessionAccess,
|
|
64
|
+
pollSessionEventsBefore,
|
|
65
|
+
syncSessionEventToApi,
|
|
57
66
|
syncSessionMetadataToApi,
|
|
58
67
|
} from "../session/sync.js";
|
|
59
68
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
60
69
|
import { mergeLiveSources } from "../session/live-source.js";
|
|
61
70
|
import { listenSessionEvents } from "../session/listener.js";
|
|
62
71
|
import { deriveSessionTitle } from "../session/senti-naming.js";
|
|
72
|
+
import { pushSessionTitleToApi } from "../session/title-sync.js";
|
|
63
73
|
import {
|
|
64
74
|
buildDashboardUrl,
|
|
65
75
|
buildTemplateLaunchPlan,
|
|
@@ -111,6 +121,10 @@ function remoteSessionLookupDisabled() {
|
|
|
111
121
|
return String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
|
|
112
122
|
}
|
|
113
123
|
|
|
124
|
+
function sentiAutostartDisabled() {
|
|
125
|
+
return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
function mergeResumeCandidate(existing, incoming) {
|
|
115
129
|
if (!existing) return incoming;
|
|
116
130
|
const existingActivity = Number(existing._activityMs || 0);
|
|
@@ -194,29 +208,190 @@ async function findReusableSessionCandidate({
|
|
|
194
208
|
return candidates[0] || null;
|
|
195
209
|
}
|
|
196
210
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
211
|
+
// Verify that a session id is reachable for the active user via the API
|
|
212
|
+
// singleton endpoint added in API PR #483 (`GET /api/v1/sessions/{id}`).
|
|
213
|
+
//
|
|
214
|
+
// Carter's complaint: "I can't create a session from the web and still have
|
|
215
|
+
// it available for you guys in CLI" — the historical CLI flow assumed the
|
|
216
|
+
// session was created locally first, so attaching to a web/peer-created
|
|
217
|
+
// session left the agent guessing about access. Singleton GET resolves
|
|
218
|
+
// that with one round-trip and gives us metadata for friendly output.
|
|
219
|
+
//
|
|
220
|
+
// Behaviour contract:
|
|
221
|
+
// - Returns `{ ok: true, source, session, status }` on success.
|
|
222
|
+
// - Returns `{ ok: false, reason: "not_found", status: 404 }` when the
|
|
223
|
+
// session genuinely isn't visible to the caller (404 + list fallback
|
|
224
|
+
// also empty). Callers should map this to a friendly "not found" exit.
|
|
225
|
+
// - Returns `{ ok: false, reason: "forbidden", status: 403 }` for explicit
|
|
226
|
+
// deny (caller is authenticated but not a member).
|
|
227
|
+
// - On 5xx: retries ONCE, then surfaces `{ ok: false, reason: "api_5xx" }`.
|
|
228
|
+
// - On 404 from the singleton: falls back to filtering the list endpoint
|
|
229
|
+
// so users on stale prod servers (pre-#483) aren't blocked. If the list
|
|
230
|
+
// contains the session id we treat it as success and return that row.
|
|
231
|
+
// - When `SENTINELAYER_SKIP_REMOTE_SYNC=1` (test bootstrap), short-circuits
|
|
232
|
+
// to `{ ok: true, source: "skipped", session: null }` so unit tests
|
|
233
|
+
// can exercise the local materialization path without a real API.
|
|
234
|
+
async function verifyRemoteSession(sessionId, { targetPath } = {}) {
|
|
235
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
236
|
+
if (!normalizedSessionId) {
|
|
237
|
+
return { ok: false, reason: "invalid_session_id" };
|
|
238
|
+
}
|
|
239
|
+
if (remoteSessionLookupDisabled()) {
|
|
240
|
+
return { ok: true, source: "skipped", session: null };
|
|
241
|
+
}
|
|
242
|
+
let auth;
|
|
200
243
|
try {
|
|
201
|
-
|
|
202
|
-
cwd: targetPath,
|
|
244
|
+
auth = await resolveActiveAuthSession({
|
|
245
|
+
cwd: targetPath || process.cwd(),
|
|
203
246
|
env: process.env,
|
|
204
247
|
autoRotate: false,
|
|
205
248
|
});
|
|
206
|
-
if (!session?.token || !session?.apiUrl) return;
|
|
207
|
-
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
208
|
-
await requestJsonMutation(
|
|
209
|
-
`${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/title`,
|
|
210
|
-
{
|
|
211
|
-
method: "POST",
|
|
212
|
-
operationName: "session.set_title",
|
|
213
|
-
headers: { Authorization: `Bearer ${session.token}` },
|
|
214
|
-
body: { title: normalizedTitle },
|
|
215
|
-
},
|
|
216
|
-
);
|
|
217
249
|
} catch {
|
|
218
|
-
|
|
250
|
+
return { ok: false, reason: "no_session" };
|
|
251
|
+
}
|
|
252
|
+
if (!auth || !auth.token) {
|
|
253
|
+
return { ok: false, reason: "not_authenticated", status: 401 };
|
|
254
|
+
}
|
|
255
|
+
const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
|
|
256
|
+
if (!apiUrl) {
|
|
257
|
+
return { ok: false, reason: "no_api_url" };
|
|
258
|
+
}
|
|
259
|
+
const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
|
|
260
|
+
const headers = { Authorization: `Bearer ${auth.token}` };
|
|
261
|
+
let lastReason = "unknown";
|
|
262
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
263
|
+
let response;
|
|
264
|
+
try {
|
|
265
|
+
response = await fetch(endpoint, { method: "GET", headers });
|
|
266
|
+
} catch (err) {
|
|
267
|
+
lastReason = normalizeString(err?.message) || "fetch_failed";
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (response && response.ok) {
|
|
271
|
+
const body = await response.json().catch(() => ({}));
|
|
272
|
+
const sessionPayload = body && body.session && typeof body.session === "object"
|
|
273
|
+
? body.session
|
|
274
|
+
: body && typeof body === "object"
|
|
275
|
+
? body
|
|
276
|
+
: null;
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
source: "singleton",
|
|
280
|
+
session: sessionPayload,
|
|
281
|
+
status: response.status,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (!response) {
|
|
285
|
+
lastReason = "no_response";
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (response.status === 404) {
|
|
289
|
+
// Pre-#483 fallback: scan the list endpoint once for the same id.
|
|
290
|
+
const listResult = await listSessionsFromApi({
|
|
291
|
+
targetPath,
|
|
292
|
+
includeArchived: false,
|
|
293
|
+
limit: 50,
|
|
294
|
+
}).catch(() => null);
|
|
295
|
+
if (listResult && listResult.ok) {
|
|
296
|
+
const found = (listResult.sessions || []).find(
|
|
297
|
+
(entry) => normalizeString(entry?.sessionId) === normalizedSessionId,
|
|
298
|
+
);
|
|
299
|
+
if (found) {
|
|
300
|
+
return { ok: true, source: "list_fallback", session: found, status: 200 };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { ok: false, reason: "not_found", status: 404 };
|
|
304
|
+
}
|
|
305
|
+
if (response.status === 403) {
|
|
306
|
+
return { ok: false, reason: "forbidden", status: 403 };
|
|
307
|
+
}
|
|
308
|
+
if (response.status >= 500 && response.status < 600) {
|
|
309
|
+
lastReason = `api_${response.status}`;
|
|
310
|
+
continue; // retry once on 5xx
|
|
311
|
+
}
|
|
312
|
+
return { ok: false, reason: `api_${response.status}`, status: response.status };
|
|
219
313
|
}
|
|
314
|
+
return { ok: false, reason: lastReason };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Render an absolute ISO timestamp as a coarse "Nm ago" / "Nh ago" / "Nd ago"
|
|
318
|
+
// label for human-readable join output. Returns `"never"` for missing input
|
|
319
|
+
// and `"just now"` for sub-minute deltas.
|
|
320
|
+
function formatRelativeAge(isoTimestamp) {
|
|
321
|
+
const epoch = Date.parse(normalizeString(isoTimestamp));
|
|
322
|
+
if (!Number.isFinite(epoch)) return "never";
|
|
323
|
+
const deltaMs = Date.now() - epoch;
|
|
324
|
+
if (deltaMs < 60_000) return "just now";
|
|
325
|
+
const minutes = Math.floor(deltaMs / 60_000);
|
|
326
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
327
|
+
const hours = Math.floor(minutes / 60);
|
|
328
|
+
if (hours < 24) return `${hours}h ago`;
|
|
329
|
+
const days = Math.floor(hours / 24);
|
|
330
|
+
return `${days}d ago`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function ensureLocalSessionForRemoteCommand(
|
|
334
|
+
sessionId,
|
|
335
|
+
{ targetPath, title = "", skipRemoteProbe = false, remoteSession = null } = {},
|
|
336
|
+
) {
|
|
337
|
+
const existing = await getSession(sessionId, { targetPath });
|
|
338
|
+
if (existing) {
|
|
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 };
|
|
373
|
+
}
|
|
374
|
+
// `skipRemoteProbe` is set by callers that have already verified the session
|
|
375
|
+
// via `verifyRemoteSession` (the singleton GET) — re-probing the legacy
|
|
376
|
+
// `/events?limit=1` endpoint here would be a redundant round-trip and, for
|
|
377
|
+
// tests that mock only the singleton, would spuriously 404.
|
|
378
|
+
if (!skipRemoteProbe) {
|
|
379
|
+
const access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
|
|
380
|
+
accessible: false,
|
|
381
|
+
reason: normalizeString(error?.message) || "probe_failed",
|
|
382
|
+
}));
|
|
383
|
+
if (!access?.accessible) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Session '${sessionId}' was not found locally and remote access failed (${access?.reason || "unknown"}).`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const created = await createSession({
|
|
390
|
+
targetPath,
|
|
391
|
+
sessionId,
|
|
392
|
+
title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
|
|
393
|
+
});
|
|
394
|
+
return { materialized: true, refreshed: false, session: created };
|
|
220
395
|
}
|
|
221
396
|
|
|
222
397
|
async function ensureWorkspaceSession({
|
|
@@ -296,13 +471,16 @@ async function ensureWorkspaceSession({
|
|
|
296
471
|
|
|
297
472
|
const effectiveTitle = titleArg || normalizeString(created.title) || fallbackTitle;
|
|
298
473
|
const titleAuto = !titleArg && !resumedCandidate;
|
|
474
|
+
const pendingTitleSync = Boolean(created.remoteTitleSync?.pending && effectiveTitle);
|
|
299
475
|
const shouldPushTitle = Boolean(
|
|
300
476
|
titleArg ||
|
|
301
477
|
titleAuto ||
|
|
478
|
+
pendingTitleSync ||
|
|
302
479
|
(resumedCandidate && effectiveTitle && !normalizeString(resumedCandidate.title))
|
|
303
480
|
);
|
|
481
|
+
let titleSync = null;
|
|
304
482
|
if (shouldPushTitle) {
|
|
305
|
-
|
|
483
|
+
titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
|
|
306
484
|
}
|
|
307
485
|
|
|
308
486
|
return {
|
|
@@ -315,6 +493,7 @@ async function ensureWorkspaceSession({
|
|
|
315
493
|
durationMs: Date.now() - startedAt,
|
|
316
494
|
title: effectiveTitle || null,
|
|
317
495
|
titleAuto,
|
|
496
|
+
titleSync,
|
|
318
497
|
};
|
|
319
498
|
}
|
|
320
499
|
|
|
@@ -326,6 +505,18 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
|
|
|
326
505
|
return normalized || fallbackValue;
|
|
327
506
|
}
|
|
328
507
|
|
|
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");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function defaultAgentId(value, _targetPath) {
|
|
517
|
+
return resolveSessionSayAgentId(value);
|
|
518
|
+
}
|
|
519
|
+
|
|
329
520
|
async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
|
|
330
521
|
const normalizedItems = Array.isArray(items) ? items : [];
|
|
331
522
|
const normalizedConcurrency = Math.max(
|
|
@@ -579,6 +770,7 @@ export function registerSessionCommand(program) {
|
|
|
579
770
|
resumed,
|
|
580
771
|
title: effectiveTitle || null,
|
|
581
772
|
titleAuto: Boolean(ensured.titleAuto),
|
|
773
|
+
titleSync: ensured.titleSync || undefined,
|
|
582
774
|
};
|
|
583
775
|
|
|
584
776
|
// Best-effort admin visibility sync. Session creation remains local-first.
|
|
@@ -594,6 +786,20 @@ export function registerSessionCommand(program) {
|
|
|
594
786
|
codebaseContext: created.codebaseContext,
|
|
595
787
|
}).catch(() => {});
|
|
596
788
|
|
|
789
|
+
// Auto-start the Senti orchestrator daemon. Without this, every
|
|
790
|
+
// session ran with `Senti actions: 1` (just the welcome alert)
|
|
791
|
+
// because nothing kicked the daemon ticking — agents joining
|
|
792
|
+
// never got greeted, mentions never routed, recaps never fired.
|
|
793
|
+
// Best-effort + non-blocking: the daemon registers itself in an
|
|
794
|
+
// in-memory map keyed by (sessionId, targetPath) and tolerates
|
|
795
|
+
// being started for an already-active session (returns the
|
|
796
|
+
// existing handle). If the daemon fails to start (unauth env,
|
|
797
|
+
// missing model proxy), the session keeps working — Senti just
|
|
798
|
+
// stays quiet, same as before this change.
|
|
799
|
+
if (!sentiAutostartDisabled()) {
|
|
800
|
+
void startSenti(created.sessionId, { targetPath }).catch(() => {});
|
|
801
|
+
}
|
|
802
|
+
|
|
597
803
|
if (shouldEmitJson(options, command)) {
|
|
598
804
|
console.log(JSON.stringify(payload, null, 2));
|
|
599
805
|
return;
|
|
@@ -653,6 +859,10 @@ export function registerSessionCommand(program) {
|
|
|
653
859
|
.command("ensure")
|
|
654
860
|
.description("Join or create the canonical session for this workspace and emit JSON")
|
|
655
861
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
862
|
+
.option(
|
|
863
|
+
"--session <id>",
|
|
864
|
+
"Attach to an explicit remote-created session id (verifies + materializes local state, like `session join`).",
|
|
865
|
+
)
|
|
656
866
|
.option("--title <title>", "Title applied if a new or unnamed resumed session needs one")
|
|
657
867
|
.option(
|
|
658
868
|
"--ttl-seconds <seconds>",
|
|
@@ -686,6 +896,56 @@ export function registerSessionCommand(program) {
|
|
|
686
896
|
"reuse-window-seconds",
|
|
687
897
|
3600,
|
|
688
898
|
);
|
|
899
|
+
|
|
900
|
+
// --session <id> short-circuit: behave like `session join`. This is the
|
|
901
|
+
// path Carter cared about — "create on web, share id, attach in CLI".
|
|
902
|
+
// We verify the session is reachable, materialize a minimal local
|
|
903
|
+
// NDJSON if missing, and emit the same `{sessionId, title, resumed}`
|
|
904
|
+
// contract callers already consume from `ensure`.
|
|
905
|
+
const explicitSessionId = normalizeString(options.session);
|
|
906
|
+
if (explicitSessionId) {
|
|
907
|
+
const verification = await verifyRemoteSession(explicitSessionId, { targetPath });
|
|
908
|
+
if (!verification.ok) {
|
|
909
|
+
if (verification.status === 404 || verification.reason === "not_found") {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`Session not found, archived, or not accessible to your account. (id=${explicitSessionId})`,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
if (verification.status === 403 || verification.reason === "forbidden") {
|
|
915
|
+
throw new Error(
|
|
916
|
+
`Session '${explicitSessionId}' exists but your account is not a member.`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
if (verification.reason === "not_authenticated") {
|
|
920
|
+
throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
|
|
921
|
+
}
|
|
922
|
+
throw new Error(
|
|
923
|
+
`Failed to verify session '${explicitSessionId}' (${verification.reason || "unknown"}). Try again in a moment.`,
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
const remoteSession = verification.session || {};
|
|
927
|
+
const localSession = await ensureLocalSessionForRemoteCommand(explicitSessionId, {
|
|
928
|
+
targetPath,
|
|
929
|
+
title: normalizeString(remoteSession.title),
|
|
930
|
+
skipRemoteProbe: true,
|
|
931
|
+
remoteSession,
|
|
932
|
+
});
|
|
933
|
+
const payload = {
|
|
934
|
+
command: "session ensure",
|
|
935
|
+
targetPath,
|
|
936
|
+
sessionId: explicitSessionId,
|
|
937
|
+
title: normalizeString(remoteSession.title) || localSession?.session?.title || null,
|
|
938
|
+
resumed: true,
|
|
939
|
+
attached: true,
|
|
940
|
+
materializedLocalSession: localSession.materialized,
|
|
941
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
942
|
+
verificationSource: verification.source,
|
|
943
|
+
dashboardUrl: buildDashboardUrl(explicitSessionId),
|
|
944
|
+
};
|
|
945
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
689
949
|
const ensured = await ensureWorkspaceSession({
|
|
690
950
|
targetPath,
|
|
691
951
|
ttlSeconds,
|
|
@@ -701,6 +961,7 @@ export function registerSessionCommand(program) {
|
|
|
701
961
|
title: ensured.title || null,
|
|
702
962
|
resumed: Boolean(ensured.resumedCandidate),
|
|
703
963
|
dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
|
|
964
|
+
titleSync: ensured.titleSync || undefined,
|
|
704
965
|
};
|
|
705
966
|
console.log(JSON.stringify(payload, null, 2));
|
|
706
967
|
});
|
|
@@ -832,8 +1093,14 @@ export function registerSessionCommand(program) {
|
|
|
832
1093
|
|
|
833
1094
|
session
|
|
834
1095
|
.command("join <sessionId>")
|
|
835
|
-
.description(
|
|
836
|
-
|
|
1096
|
+
.description(
|
|
1097
|
+
"Attach to a remote-created session for posting and listening, materializing minimal local state on demand.",
|
|
1098
|
+
)
|
|
1099
|
+
.option("--name <name>", "Agent display name (legacy alias for --agent)")
|
|
1100
|
+
.option(
|
|
1101
|
+
"--agent <id>",
|
|
1102
|
+
"Granted agent id to emit an agent_join event as. Behaves like post-agent for human/placeholder ids — those are recorded in the local registry only.",
|
|
1103
|
+
)
|
|
837
1104
|
.option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
|
|
838
1105
|
.option("--model <model>", "Agent model hint", "cli")
|
|
839
1106
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
@@ -844,27 +1111,106 @@ export function registerSessionCommand(program) {
|
|
|
844
1111
|
throw new Error("session id is required.");
|
|
845
1112
|
}
|
|
846
1113
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1114
|
+
|
|
1115
|
+
// PR #483 contract: verify the session exists and the caller has access
|
|
1116
|
+
// BEFORE materializing local cache state. Without this we'd silently
|
|
1117
|
+
// create a phantom local NDJSON for a session that's archived or owned
|
|
1118
|
+
// by another tenant — which is the bug Carter reported when asking for
|
|
1119
|
+
// a clean "share an id from web → join in CLI" flow.
|
|
1120
|
+
const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
|
|
1121
|
+
if (!verification.ok) {
|
|
1122
|
+
if (verification.status === 404 || verification.reason === "not_found") {
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
`Session not found, archived, or not accessible to your account. (id=${normalizedSessionId})`,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
if (verification.status === 403 || verification.reason === "forbidden") {
|
|
1128
|
+
throw new Error(
|
|
1129
|
+
`Session '${normalizedSessionId}' exists but your account is not a member.`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
if (verification.reason === "not_authenticated") {
|
|
1133
|
+
throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
|
|
1134
|
+
}
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
`Failed to verify session '${normalizedSessionId}' (${verification.reason || "unknown"}). Try again in a moment.`,
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const remoteSession = verification.session || {};
|
|
1141
|
+
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
1142
|
+
targetPath,
|
|
1143
|
+
title: normalizeString(remoteSession.title),
|
|
1144
|
+
skipRemoteProbe: true,
|
|
1145
|
+
remoteSession,
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
const explicitAgent = normalizeString(options.agent);
|
|
1149
|
+
const agentSeed = explicitAgent || normalizeString(options.name);
|
|
1150
|
+
const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
|
|
1151
|
+
const role = normalizeString(options.role) || "coder";
|
|
1152
|
+
const model = normalizeString(options.model) || "cli";
|
|
1153
|
+
|
|
1154
|
+
// `registerAgent` already writes the canonical `agent_join` event to the
|
|
1155
|
+
// local NDJSON and best-effort relays it to /events via appendToStream
|
|
1156
|
+
// → syncSessionEventToApi. That gives us the exact `post-agent` parity
|
|
1157
|
+
// the spec calls for when `--agent <granted>` is provided. We don't
|
|
1158
|
+
// double-emit; we just record whether the explicit agent path was used
|
|
1159
|
+
// so the JSON output can advertise it to callers (and tests).
|
|
847
1160
|
const joined = await registerAgent(normalizedSessionId, {
|
|
848
1161
|
targetPath,
|
|
849
|
-
agentId:
|
|
850
|
-
model
|
|
851
|
-
role
|
|
1162
|
+
agentId: resolvedAgentId,
|
|
1163
|
+
model,
|
|
1164
|
+
role,
|
|
852
1165
|
});
|
|
1166
|
+
const agentJoinRelayed =
|
|
1167
|
+
Boolean(explicitAgent) &&
|
|
1168
|
+
Boolean(resolvedAgentId) &&
|
|
1169
|
+
resolvedAgentId !== "cli-user" &&
|
|
1170
|
+
resolvedAgentId !== "unknown" &&
|
|
1171
|
+
!resolvedAgentId.startsWith("human-");
|
|
1172
|
+
|
|
1173
|
+
const eventCount = Number(remoteSession.eventCount ?? remoteSession.events ?? 0);
|
|
1174
|
+
const agents = Array.isArray(remoteSession.agents) ? remoteSession.agents : [];
|
|
1175
|
+
const agentCount = Number(remoteSession.agentCount ?? agents.length ?? 0);
|
|
1176
|
+
const lastActivityIso =
|
|
1177
|
+
normalizeString(remoteSession.lastInteractionAt) ||
|
|
1178
|
+
normalizeString(remoteSession.lastActivityAt) ||
|
|
1179
|
+
normalizeString(remoteSession.updatedAt) ||
|
|
1180
|
+
normalizeString(remoteSession.createdAt) ||
|
|
1181
|
+
"";
|
|
1182
|
+
const remoteTitle = normalizeString(remoteSession.title);
|
|
1183
|
+
|
|
853
1184
|
const payload = {
|
|
854
1185
|
command: "session join",
|
|
1186
|
+
joined: true,
|
|
855
1187
|
targetPath,
|
|
856
1188
|
sessionId: normalizedSessionId,
|
|
1189
|
+
title: remoteTitle || null,
|
|
857
1190
|
agentId: joined.agentId,
|
|
858
1191
|
role: joined.role,
|
|
859
1192
|
model: joined.model,
|
|
860
1193
|
status: joined.status,
|
|
861
1194
|
joinedAt: joined.joinedAt,
|
|
1195
|
+
materializedLocalSession: localSession.materialized,
|
|
1196
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1197
|
+
verificationSource: verification.source,
|
|
1198
|
+
eventCount: Number.isFinite(eventCount) ? eventCount : 0,
|
|
1199
|
+
agentCount: Number.isFinite(agentCount) ? agentCount : 0,
|
|
1200
|
+
lastActivityAt: lastActivityIso || null,
|
|
1201
|
+
agentJoinRelayed,
|
|
862
1202
|
};
|
|
863
1203
|
if (shouldEmitJson(options, command)) {
|
|
864
1204
|
console.log(JSON.stringify(payload, null, 2));
|
|
865
1205
|
return;
|
|
866
1206
|
}
|
|
867
|
-
|
|
1207
|
+
const titleLabel = remoteTitle ? `"${remoteTitle}"` : "(untitled)";
|
|
1208
|
+
const ageLabel = lastActivityIso ? formatRelativeAge(lastActivityIso) : "never";
|
|
1209
|
+
console.log(
|
|
1210
|
+
pc.bold(
|
|
1211
|
+
`Joined session ${titleLabel} (${normalizedSessionId}) — ${payload.eventCount} events, ${payload.agentCount} agents, last activity ${ageLabel}`,
|
|
1212
|
+
),
|
|
1213
|
+
);
|
|
868
1214
|
console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
|
|
869
1215
|
});
|
|
870
1216
|
|
|
@@ -885,7 +1231,10 @@ export function registerSessionCommand(program) {
|
|
|
885
1231
|
throw new Error("message is required.");
|
|
886
1232
|
}
|
|
887
1233
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
888
|
-
const agentId =
|
|
1234
|
+
const agentId = await defaultAgentId(options.agent, targetPath);
|
|
1235
|
+
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
1236
|
+
targetPath,
|
|
1237
|
+
});
|
|
889
1238
|
const to = normalizeString(options.to);
|
|
890
1239
|
const eventPayload = {
|
|
891
1240
|
message: normalizedMessage,
|
|
@@ -894,12 +1243,29 @@ export function registerSessionCommand(program) {
|
|
|
894
1243
|
if (to) {
|
|
895
1244
|
eventPayload.to = to;
|
|
896
1245
|
}
|
|
1246
|
+
const clientMessageId = `cli-${randomUUID()}`;
|
|
897
1247
|
const event = createAgentEvent({
|
|
898
1248
|
event: "session_message",
|
|
899
1249
|
agentId,
|
|
900
1250
|
sessionId: normalizedSessionId,
|
|
901
1251
|
payload: eventPayload,
|
|
902
1252
|
});
|
|
1253
|
+
event.eventId = clientMessageId;
|
|
1254
|
+
event.idempotencyToken = clientMessageId;
|
|
1255
|
+
let remoteSync = null;
|
|
1256
|
+
if (localSession.materialized) {
|
|
1257
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1258
|
+
remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
|
|
1259
|
+
targetPath,
|
|
1260
|
+
});
|
|
1261
|
+
if (remoteSync?.synced) break;
|
|
1262
|
+
}
|
|
1263
|
+
if (!remoteSync?.synced) {
|
|
1264
|
+
throw new Error(
|
|
1265
|
+
`Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated.`,
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
903
1269
|
const persisted = await appendToStream(normalizedSessionId, event, {
|
|
904
1270
|
targetPath,
|
|
905
1271
|
});
|
|
@@ -909,6 +1275,97 @@ export function registerSessionCommand(program) {
|
|
|
909
1275
|
sessionId: normalizedSessionId,
|
|
910
1276
|
agentId,
|
|
911
1277
|
event: persisted,
|
|
1278
|
+
materializedLocalSession: localSession.materialized,
|
|
1279
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1280
|
+
remoteSync: remoteSync || undefined,
|
|
1281
|
+
};
|
|
1282
|
+
if (shouldEmitJson(options, command)) {
|
|
1283
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
console.log(formatEventLine(persisted));
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
session
|
|
1290
|
+
.command("post-agent <sessionId> <message>")
|
|
1291
|
+
.description("Post an authenticated agent message through the canonical session event API")
|
|
1292
|
+
.requiredOption("--agent <id>", "Granted agent id to post as")
|
|
1293
|
+
.option("--model <model>", "Agent model/provider hint", "cli")
|
|
1294
|
+
.option("--display-name <name>", "Human-readable agent display name")
|
|
1295
|
+
.option("--role <role>", "Agent role metadata: coder, reviewer, tester, observer", "coder")
|
|
1296
|
+
.option("--to <agent>", "Direct the message to a specific agent id")
|
|
1297
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
1298
|
+
.option("--json", "Emit machine-readable output")
|
|
1299
|
+
.action(async (sessionId, message, options, command) => {
|
|
1300
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1301
|
+
if (!normalizedSessionId) {
|
|
1302
|
+
throw new Error("session id is required.");
|
|
1303
|
+
}
|
|
1304
|
+
const normalizedMessage = normalizeString(message);
|
|
1305
|
+
if (!normalizedMessage) {
|
|
1306
|
+
throw new Error("message is required.");
|
|
1307
|
+
}
|
|
1308
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1309
|
+
const agentId = normalizeAgentId(options.agent, "");
|
|
1310
|
+
if (!agentId || agentId === "cli-user" || agentId === "unknown" || agentId.startsWith("human-")) {
|
|
1311
|
+
throw new Error("post-agent requires a granted non-human agent id.");
|
|
1312
|
+
}
|
|
1313
|
+
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
1314
|
+
targetPath,
|
|
1315
|
+
});
|
|
1316
|
+
const to = normalizeString(options.to);
|
|
1317
|
+
const eventPayload = {
|
|
1318
|
+
message: normalizedMessage,
|
|
1319
|
+
channel: "session",
|
|
1320
|
+
source: "agent",
|
|
1321
|
+
clientKind: "cli",
|
|
1322
|
+
};
|
|
1323
|
+
if (to) {
|
|
1324
|
+
eventPayload.to = to;
|
|
1325
|
+
}
|
|
1326
|
+
const agent = {
|
|
1327
|
+
id: agentId,
|
|
1328
|
+
model: normalizeString(options.model) || "cli",
|
|
1329
|
+
displayName: normalizeString(options.displayName) || undefined,
|
|
1330
|
+
role: normalizeString(options.role) || "coder",
|
|
1331
|
+
clientKind: "cli",
|
|
1332
|
+
};
|
|
1333
|
+
const clientMessageId = `cli-agent-${randomUUID()}`;
|
|
1334
|
+
const event = createAgentEvent({
|
|
1335
|
+
event: "session_message",
|
|
1336
|
+
agent,
|
|
1337
|
+
sessionId: normalizedSessionId,
|
|
1338
|
+
payload: eventPayload,
|
|
1339
|
+
});
|
|
1340
|
+
event.eventId = clientMessageId;
|
|
1341
|
+
event.idempotencyToken = clientMessageId;
|
|
1342
|
+
|
|
1343
|
+
let remoteSync = null;
|
|
1344
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1345
|
+
remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
|
|
1346
|
+
targetPath,
|
|
1347
|
+
});
|
|
1348
|
+
if (remoteSync?.synced) break;
|
|
1349
|
+
}
|
|
1350
|
+
if (!remoteSync?.synced) {
|
|
1351
|
+
throw new Error(
|
|
1352
|
+
`Agent post failed (${remoteSync?.reason || "unknown"}). Ensure this user has an active grant for '${agentId}'.`,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const persisted = await appendToStream(normalizedSessionId, event, {
|
|
1357
|
+
targetPath,
|
|
1358
|
+
syncRemote: false,
|
|
1359
|
+
});
|
|
1360
|
+
const payload = {
|
|
1361
|
+
command: "session post-agent",
|
|
1362
|
+
targetPath,
|
|
1363
|
+
sessionId: normalizedSessionId,
|
|
1364
|
+
agentId,
|
|
1365
|
+
event: persisted,
|
|
1366
|
+
materializedLocalSession: localSession.materialized,
|
|
1367
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1368
|
+
remoteSync,
|
|
912
1369
|
};
|
|
913
1370
|
if (shouldEmitJson(options, command)) {
|
|
914
1371
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -926,7 +1383,17 @@ export function registerSessionCommand(program) {
|
|
|
926
1383
|
"Agent id to receive messages for",
|
|
927
1384
|
process.env.SENTINELAYER_AGENT_ID || "cli-user",
|
|
928
1385
|
)
|
|
929
|
-
.option("--interval <seconds>", "
|
|
1386
|
+
.option("--interval <seconds>", "Idle polling interval in seconds (default 60)", "60")
|
|
1387
|
+
.option(
|
|
1388
|
+
"--active-interval <seconds>",
|
|
1389
|
+
"Polling interval after recent human activity (default 5)",
|
|
1390
|
+
"5",
|
|
1391
|
+
)
|
|
1392
|
+
.option(
|
|
1393
|
+
"--active-window <seconds>",
|
|
1394
|
+
"Seconds after a human message to keep the active interval (default 300)",
|
|
1395
|
+
"300",
|
|
1396
|
+
)
|
|
930
1397
|
.option("--emit <format>", "Output format: ndjson or text", "ndjson")
|
|
931
1398
|
.option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
|
|
932
1399
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
@@ -938,6 +1405,12 @@ export function registerSessionCommand(program) {
|
|
|
938
1405
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
939
1406
|
const agentId = normalizeAgentId(options.agent, "cli-user");
|
|
940
1407
|
const intervalSeconds = parsePositiveInteger(options.interval, "interval", 60);
|
|
1408
|
+
const activeIntervalSeconds = parsePositiveInteger(
|
|
1409
|
+
options.activeInterval,
|
|
1410
|
+
"active-interval",
|
|
1411
|
+
5,
|
|
1412
|
+
);
|
|
1413
|
+
const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
|
|
941
1414
|
const limit = parsePositiveInteger(options.limit, "limit", 200);
|
|
942
1415
|
const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
|
|
943
1416
|
if (!["ndjson", "text"].includes(emitFormat)) {
|
|
@@ -955,7 +1428,7 @@ export function registerSessionCommand(program) {
|
|
|
955
1428
|
if (emitFormat === "text") {
|
|
956
1429
|
console.log(
|
|
957
1430
|
pc.gray(
|
|
958
|
-
`Listening to session ${normalizedSessionId} as ${agentId};
|
|
1431
|
+
`Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
|
|
959
1432
|
),
|
|
960
1433
|
);
|
|
961
1434
|
}
|
|
@@ -966,6 +1439,8 @@ export function registerSessionCommand(program) {
|
|
|
966
1439
|
targetPath,
|
|
967
1440
|
agentId,
|
|
968
1441
|
intervalSeconds,
|
|
1442
|
+
activeIntervalSeconds,
|
|
1443
|
+
activeWindowSeconds,
|
|
969
1444
|
limit,
|
|
970
1445
|
since,
|
|
971
1446
|
replay: Boolean(options.replay),
|
|
@@ -1029,11 +1504,17 @@ export function registerSessionCommand(program) {
|
|
|
1029
1504
|
const emitJson = shouldEmitJson(options, command);
|
|
1030
1505
|
|
|
1031
1506
|
let hydration = null;
|
|
1507
|
+
let remoteTail = null;
|
|
1032
1508
|
if (options.remote) {
|
|
1033
1509
|
hydration = await hydrateSessionFromRemote({
|
|
1034
1510
|
sessionId: normalizedSessionId,
|
|
1035
1511
|
targetPath,
|
|
1036
1512
|
});
|
|
1513
|
+
remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
|
|
1514
|
+
targetPath,
|
|
1515
|
+
limit: tail,
|
|
1516
|
+
timeoutMs: 15_000,
|
|
1517
|
+
});
|
|
1037
1518
|
if (!emitJson) {
|
|
1038
1519
|
if (hydration.ok) {
|
|
1039
1520
|
console.log(
|
|
@@ -1041,6 +1522,13 @@ export function registerSessionCommand(program) {
|
|
|
1041
1522
|
`Hydrated from remote: relayed=${hydration.relayed} dropped=${hydration.dropped}.`,
|
|
1042
1523
|
),
|
|
1043
1524
|
);
|
|
1525
|
+
if (hydration.eventsBackfillComplete === false) {
|
|
1526
|
+
console.log(
|
|
1527
|
+
pc.yellow(
|
|
1528
|
+
`Remote backfill still has more pages (${hydration.eventsBackfillReason || "incomplete"}); latest tail was fetched directly.`,
|
|
1529
|
+
),
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1044
1532
|
} else {
|
|
1045
1533
|
console.log(
|
|
1046
1534
|
pc.yellow(
|
|
@@ -1052,10 +1540,34 @@ export function registerSessionCommand(program) {
|
|
|
1052
1540
|
}
|
|
1053
1541
|
|
|
1054
1542
|
if (!options.follow) {
|
|
1055
|
-
const
|
|
1543
|
+
const allEvents = await readStream(normalizedSessionId, {
|
|
1056
1544
|
targetPath,
|
|
1057
|
-
tail,
|
|
1545
|
+
tail: 0,
|
|
1058
1546
|
});
|
|
1547
|
+
const displayEvents = [...allEvents];
|
|
1548
|
+
if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
|
|
1549
|
+
const knownKeys = new Set();
|
|
1550
|
+
for (const event of allEvents) {
|
|
1551
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
1552
|
+
}
|
|
1553
|
+
for (const event of remoteTail.events) {
|
|
1554
|
+
if (sessionEventHasKnownIdentity(event, knownKeys)) {
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
try {
|
|
1558
|
+
const appended = await appendToStream(normalizedSessionId, event, {
|
|
1559
|
+
targetPath,
|
|
1560
|
+
syncRemote: false,
|
|
1561
|
+
});
|
|
1562
|
+
displayEvents.push(appended);
|
|
1563
|
+
addSessionEventIdentityKeys(knownKeys, appended);
|
|
1564
|
+
} catch {
|
|
1565
|
+
displayEvents.push(event);
|
|
1566
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
const events = dedupeSessionEvents(displayEvents).slice(-tail);
|
|
1059
1571
|
const payload = {
|
|
1060
1572
|
command: "session read",
|
|
1061
1573
|
targetPath,
|
|
@@ -1063,7 +1575,19 @@ export function registerSessionCommand(program) {
|
|
|
1063
1575
|
tail,
|
|
1064
1576
|
count: events.length,
|
|
1065
1577
|
events,
|
|
1066
|
-
remote: hydration
|
|
1578
|
+
remote: hydration
|
|
1579
|
+
? {
|
|
1580
|
+
...hydration,
|
|
1581
|
+
tailProbe: remoteTail
|
|
1582
|
+
? {
|
|
1583
|
+
ok: Boolean(remoteTail.ok),
|
|
1584
|
+
reason: remoteTail.reason || "",
|
|
1585
|
+
count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
|
|
1586
|
+
cursor: remoteTail.cursor || null,
|
|
1587
|
+
}
|
|
1588
|
+
: null,
|
|
1589
|
+
}
|
|
1590
|
+
: hydration,
|
|
1067
1591
|
};
|
|
1068
1592
|
if (emitJson) {
|
|
1069
1593
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -1121,10 +1645,15 @@ export function registerSessionCommand(program) {
|
|
|
1121
1645
|
if (!emitJson) {
|
|
1122
1646
|
console.log(pc.gray(`Following session ${normalizedSessionId}... Press Ctrl+C to stop.`));
|
|
1123
1647
|
}
|
|
1648
|
+
const seenFollowEvents = new Set();
|
|
1124
1649
|
for await (const event of tailStream(normalizedSessionId, {
|
|
1125
1650
|
targetPath,
|
|
1126
1651
|
replayTail: tail,
|
|
1127
1652
|
})) {
|
|
1653
|
+
if (sessionEventHasKnownIdentity(event, seenFollowEvents)) {
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
addSessionEventIdentityKeys(seenFollowEvents, event);
|
|
1128
1657
|
if (emitJson) {
|
|
1129
1658
|
console.log(JSON.stringify(event));
|
|
1130
1659
|
} else {
|
|
@@ -1178,6 +1707,11 @@ export function registerSessionCommand(program) {
|
|
|
1178
1707
|
dropped: result.dropped,
|
|
1179
1708
|
cursor: result.cursor,
|
|
1180
1709
|
persistedCursor: result.persistedCursor,
|
|
1710
|
+
humanRelayed: result.humanRelayed,
|
|
1711
|
+
eventsRelayed: result.eventsRelayed,
|
|
1712
|
+
eventsCursor: result.eventsCursor,
|
|
1713
|
+
materializedLocalSession: result.materializedLocalSession,
|
|
1714
|
+
localAppendComplete: result.localAppendComplete,
|
|
1181
1715
|
access: access || undefined,
|
|
1182
1716
|
};
|
|
1183
1717
|
if (shouldEmitJson(options, command)) {
|
|
@@ -1517,7 +2051,7 @@ export function registerSessionCommand(program) {
|
|
|
1517
2051
|
throw new Error("session id is required.");
|
|
1518
2052
|
}
|
|
1519
2053
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1520
|
-
const agentId =
|
|
2054
|
+
const agentId = await defaultAgentId(options.agent, targetPath);
|
|
1521
2055
|
const left = await unregisterAgent(normalizedSessionId, agentId, {
|
|
1522
2056
|
reason: options.reason || "manual",
|
|
1523
2057
|
targetPath,
|