sentinelayer-cli 0.9.0 → 0.9.2
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 +565 -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/remote-hydrate.js +223 -8
- package/src/session/store.js +63 -0
- 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,
|
|
@@ -50,16 +50,24 @@ import {
|
|
|
50
50
|
updateSessionTitle,
|
|
51
51
|
} from "../session/store.js";
|
|
52
52
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
53
|
+
import {
|
|
54
|
+
addSessionEventIdentityKeys,
|
|
55
|
+
dedupeSessionEvents,
|
|
56
|
+
sessionEventHasKnownIdentity,
|
|
57
|
+
} from "../session/event-identity.js";
|
|
53
58
|
import { readSessionPreview } from "../session/preview.js";
|
|
54
59
|
import {
|
|
55
60
|
listSessionsFromApi,
|
|
56
61
|
probeSessionAccess,
|
|
62
|
+
pollSessionEventsBefore,
|
|
63
|
+
syncSessionEventToApi,
|
|
57
64
|
syncSessionMetadataToApi,
|
|
58
65
|
} from "../session/sync.js";
|
|
59
66
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
60
67
|
import { mergeLiveSources } from "../session/live-source.js";
|
|
61
68
|
import { listenSessionEvents } from "../session/listener.js";
|
|
62
69
|
import { deriveSessionTitle } from "../session/senti-naming.js";
|
|
70
|
+
import { pushSessionTitleToApi } from "../session/title-sync.js";
|
|
63
71
|
import {
|
|
64
72
|
buildDashboardUrl,
|
|
65
73
|
buildTemplateLaunchPlan,
|
|
@@ -111,6 +119,10 @@ function remoteSessionLookupDisabled() {
|
|
|
111
119
|
return String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
function sentiAutostartDisabled() {
|
|
123
|
+
return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
function mergeResumeCandidate(existing, incoming) {
|
|
115
127
|
if (!existing) return incoming;
|
|
116
128
|
const existingActivity = Number(existing._activityMs || 0);
|
|
@@ -194,29 +206,157 @@ async function findReusableSessionCandidate({
|
|
|
194
206
|
return candidates[0] || null;
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
209
|
+
// Verify that a session id is reachable for the active user via the API
|
|
210
|
+
// singleton endpoint added in API PR #483 (`GET /api/v1/sessions/{id}`).
|
|
211
|
+
//
|
|
212
|
+
// Carter's complaint: "I can't create a session from the web and still have
|
|
213
|
+
// it available for you guys in CLI" — the historical CLI flow assumed the
|
|
214
|
+
// session was created locally first, so attaching to a web/peer-created
|
|
215
|
+
// session left the agent guessing about access. Singleton GET resolves
|
|
216
|
+
// that with one round-trip and gives us metadata for friendly output.
|
|
217
|
+
//
|
|
218
|
+
// Behaviour contract:
|
|
219
|
+
// - Returns `{ ok: true, source, session, status }` on success.
|
|
220
|
+
// - Returns `{ ok: false, reason: "not_found", status: 404 }` when the
|
|
221
|
+
// session genuinely isn't visible to the caller (404 + list fallback
|
|
222
|
+
// also empty). Callers should map this to a friendly "not found" exit.
|
|
223
|
+
// - Returns `{ ok: false, reason: "forbidden", status: 403 }` for explicit
|
|
224
|
+
// deny (caller is authenticated but not a member).
|
|
225
|
+
// - On 5xx: retries ONCE, then surfaces `{ ok: false, reason: "api_5xx" }`.
|
|
226
|
+
// - On 404 from the singleton: falls back to filtering the list endpoint
|
|
227
|
+
// so users on stale prod servers (pre-#483) aren't blocked. If the list
|
|
228
|
+
// contains the session id we treat it as success and return that row.
|
|
229
|
+
// - When `SENTINELAYER_SKIP_REMOTE_SYNC=1` (test bootstrap), short-circuits
|
|
230
|
+
// to `{ ok: true, source: "skipped", session: null }` so unit tests
|
|
231
|
+
// can exercise the local materialization path without a real API.
|
|
232
|
+
async function verifyRemoteSession(sessionId, { targetPath } = {}) {
|
|
233
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
234
|
+
if (!normalizedSessionId) {
|
|
235
|
+
return { ok: false, reason: "invalid_session_id" };
|
|
236
|
+
}
|
|
237
|
+
if (remoteSessionLookupDisabled()) {
|
|
238
|
+
return { ok: true, source: "skipped", session: null };
|
|
239
|
+
}
|
|
240
|
+
let auth;
|
|
200
241
|
try {
|
|
201
|
-
|
|
202
|
-
cwd: targetPath,
|
|
242
|
+
auth = await resolveActiveAuthSession({
|
|
243
|
+
cwd: targetPath || process.cwd(),
|
|
203
244
|
env: process.env,
|
|
204
245
|
autoRotate: false,
|
|
205
246
|
});
|
|
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
247
|
} catch {
|
|
218
|
-
|
|
248
|
+
return { ok: false, reason: "no_session" };
|
|
249
|
+
}
|
|
250
|
+
if (!auth || !auth.token) {
|
|
251
|
+
return { ok: false, reason: "not_authenticated", status: 401 };
|
|
252
|
+
}
|
|
253
|
+
const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
|
|
254
|
+
if (!apiUrl) {
|
|
255
|
+
return { ok: false, reason: "no_api_url" };
|
|
256
|
+
}
|
|
257
|
+
const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
|
|
258
|
+
const headers = { Authorization: `Bearer ${auth.token}` };
|
|
259
|
+
let lastReason = "unknown";
|
|
260
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
261
|
+
let response;
|
|
262
|
+
try {
|
|
263
|
+
response = await fetch(endpoint, { method: "GET", headers });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
lastReason = normalizeString(err?.message) || "fetch_failed";
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (response && response.ok) {
|
|
269
|
+
const body = await response.json().catch(() => ({}));
|
|
270
|
+
const sessionPayload = body && body.session && typeof body.session === "object"
|
|
271
|
+
? body.session
|
|
272
|
+
: body && typeof body === "object"
|
|
273
|
+
? body
|
|
274
|
+
: null;
|
|
275
|
+
return {
|
|
276
|
+
ok: true,
|
|
277
|
+
source: "singleton",
|
|
278
|
+
session: sessionPayload,
|
|
279
|
+
status: response.status,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (!response) {
|
|
283
|
+
lastReason = "no_response";
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (response.status === 404) {
|
|
287
|
+
// Pre-#483 fallback: scan the list endpoint once for the same id.
|
|
288
|
+
const listResult = await listSessionsFromApi({
|
|
289
|
+
targetPath,
|
|
290
|
+
includeArchived: false,
|
|
291
|
+
limit: 50,
|
|
292
|
+
}).catch(() => null);
|
|
293
|
+
if (listResult && listResult.ok) {
|
|
294
|
+
const found = (listResult.sessions || []).find(
|
|
295
|
+
(entry) => normalizeString(entry?.sessionId) === normalizedSessionId,
|
|
296
|
+
);
|
|
297
|
+
if (found) {
|
|
298
|
+
return { ok: true, source: "list_fallback", session: found, status: 200 };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { ok: false, reason: "not_found", status: 404 };
|
|
302
|
+
}
|
|
303
|
+
if (response.status === 403) {
|
|
304
|
+
return { ok: false, reason: "forbidden", status: 403 };
|
|
305
|
+
}
|
|
306
|
+
if (response.status >= 500 && response.status < 600) {
|
|
307
|
+
lastReason = `api_${response.status}`;
|
|
308
|
+
continue; // retry once on 5xx
|
|
309
|
+
}
|
|
310
|
+
return { ok: false, reason: `api_${response.status}`, status: response.status };
|
|
219
311
|
}
|
|
312
|
+
return { ok: false, reason: lastReason };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Render an absolute ISO timestamp as a coarse "Nm ago" / "Nh ago" / "Nd ago"
|
|
316
|
+
// label for human-readable join output. Returns `"never"` for missing input
|
|
317
|
+
// and `"just now"` for sub-minute deltas.
|
|
318
|
+
function formatRelativeAge(isoTimestamp) {
|
|
319
|
+
const epoch = Date.parse(normalizeString(isoTimestamp));
|
|
320
|
+
if (!Number.isFinite(epoch)) return "never";
|
|
321
|
+
const deltaMs = Date.now() - epoch;
|
|
322
|
+
if (deltaMs < 60_000) return "just now";
|
|
323
|
+
const minutes = Math.floor(deltaMs / 60_000);
|
|
324
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
325
|
+
const hours = Math.floor(minutes / 60);
|
|
326
|
+
if (hours < 24) return `${hours}h ago`;
|
|
327
|
+
const days = Math.floor(hours / 24);
|
|
328
|
+
return `${days}d ago`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function ensureLocalSessionForRemoteCommand(
|
|
332
|
+
sessionId,
|
|
333
|
+
{ targetPath, title = "", skipRemoteProbe = false } = {},
|
|
334
|
+
) {
|
|
335
|
+
const existing = await getSession(sessionId, { targetPath });
|
|
336
|
+
if (existing) {
|
|
337
|
+
return { materialized: false, session: existing };
|
|
338
|
+
}
|
|
339
|
+
// `skipRemoteProbe` is set by callers that have already verified the session
|
|
340
|
+
// via `verifyRemoteSession` (the singleton GET) — re-probing the legacy
|
|
341
|
+
// `/events?limit=1` endpoint here would be a redundant round-trip and, for
|
|
342
|
+
// tests that mock only the singleton, would spuriously 404.
|
|
343
|
+
if (!skipRemoteProbe) {
|
|
344
|
+
const access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
|
|
345
|
+
accessible: false,
|
|
346
|
+
reason: normalizeString(error?.message) || "probe_failed",
|
|
347
|
+
}));
|
|
348
|
+
if (!access?.accessible) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Session '${sessionId}' was not found locally and remote access failed (${access?.reason || "unknown"}).`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const created = await createSession({
|
|
355
|
+
targetPath,
|
|
356
|
+
sessionId,
|
|
357
|
+
title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
|
|
358
|
+
});
|
|
359
|
+
return { materialized: true, session: created };
|
|
220
360
|
}
|
|
221
361
|
|
|
222
362
|
async function ensureWorkspaceSession({
|
|
@@ -296,13 +436,16 @@ async function ensureWorkspaceSession({
|
|
|
296
436
|
|
|
297
437
|
const effectiveTitle = titleArg || normalizeString(created.title) || fallbackTitle;
|
|
298
438
|
const titleAuto = !titleArg && !resumedCandidate;
|
|
439
|
+
const pendingTitleSync = Boolean(created.remoteTitleSync?.pending && effectiveTitle);
|
|
299
440
|
const shouldPushTitle = Boolean(
|
|
300
441
|
titleArg ||
|
|
301
442
|
titleAuto ||
|
|
443
|
+
pendingTitleSync ||
|
|
302
444
|
(resumedCandidate && effectiveTitle && !normalizeString(resumedCandidate.title))
|
|
303
445
|
);
|
|
446
|
+
let titleSync = null;
|
|
304
447
|
if (shouldPushTitle) {
|
|
305
|
-
|
|
448
|
+
titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
|
|
306
449
|
}
|
|
307
450
|
|
|
308
451
|
return {
|
|
@@ -315,6 +458,7 @@ async function ensureWorkspaceSession({
|
|
|
315
458
|
durationMs: Date.now() - startedAt,
|
|
316
459
|
title: effectiveTitle || null,
|
|
317
460
|
titleAuto,
|
|
461
|
+
titleSync,
|
|
318
462
|
};
|
|
319
463
|
}
|
|
320
464
|
|
|
@@ -326,6 +470,58 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
|
|
|
326
470
|
return normalized || fallbackValue;
|
|
327
471
|
}
|
|
328
472
|
|
|
473
|
+
// Derive a stable, human-friendly fallback agent id from the active auth
|
|
474
|
+
// session — `human-<github_username>` if logged in via GitHub, else
|
|
475
|
+
// `human-<email-localpart>` as a last resort. We resolve this lazily and
|
|
476
|
+
// cache per process so repeated `sl session say` calls don't churn auth.
|
|
477
|
+
//
|
|
478
|
+
// Carter's complaint: "we aren't auto naming these agents per joining,
|
|
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 "";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Wrapper that prefers the auth-derived id over the literal `cli-user`
|
|
513
|
+
// placeholder when the caller didn't pass --name/--agent. Callers that
|
|
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");
|
|
523
|
+
}
|
|
524
|
+
|
|
329
525
|
async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
|
|
330
526
|
const normalizedItems = Array.isArray(items) ? items : [];
|
|
331
527
|
const normalizedConcurrency = Math.max(
|
|
@@ -579,6 +775,7 @@ export function registerSessionCommand(program) {
|
|
|
579
775
|
resumed,
|
|
580
776
|
title: effectiveTitle || null,
|
|
581
777
|
titleAuto: Boolean(ensured.titleAuto),
|
|
778
|
+
titleSync: ensured.titleSync || undefined,
|
|
582
779
|
};
|
|
583
780
|
|
|
584
781
|
// Best-effort admin visibility sync. Session creation remains local-first.
|
|
@@ -594,6 +791,20 @@ export function registerSessionCommand(program) {
|
|
|
594
791
|
codebaseContext: created.codebaseContext,
|
|
595
792
|
}).catch(() => {});
|
|
596
793
|
|
|
794
|
+
// Auto-start the Senti orchestrator daemon. Without this, every
|
|
795
|
+
// session ran with `Senti actions: 1` (just the welcome alert)
|
|
796
|
+
// because nothing kicked the daemon ticking — agents joining
|
|
797
|
+
// never got greeted, mentions never routed, recaps never fired.
|
|
798
|
+
// Best-effort + non-blocking: the daemon registers itself in an
|
|
799
|
+
// in-memory map keyed by (sessionId, targetPath) and tolerates
|
|
800
|
+
// being started for an already-active session (returns the
|
|
801
|
+
// existing handle). If the daemon fails to start (unauth env,
|
|
802
|
+
// missing model proxy), the session keeps working — Senti just
|
|
803
|
+
// stays quiet, same as before this change.
|
|
804
|
+
if (!sentiAutostartDisabled()) {
|
|
805
|
+
void startSenti(created.sessionId, { targetPath }).catch(() => {});
|
|
806
|
+
}
|
|
807
|
+
|
|
597
808
|
if (shouldEmitJson(options, command)) {
|
|
598
809
|
console.log(JSON.stringify(payload, null, 2));
|
|
599
810
|
return;
|
|
@@ -653,6 +864,10 @@ export function registerSessionCommand(program) {
|
|
|
653
864
|
.command("ensure")
|
|
654
865
|
.description("Join or create the canonical session for this workspace and emit JSON")
|
|
655
866
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
867
|
+
.option(
|
|
868
|
+
"--session <id>",
|
|
869
|
+
"Attach to an explicit remote-created session id (verifies + materializes local state, like `session join`).",
|
|
870
|
+
)
|
|
656
871
|
.option("--title <title>", "Title applied if a new or unnamed resumed session needs one")
|
|
657
872
|
.option(
|
|
658
873
|
"--ttl-seconds <seconds>",
|
|
@@ -686,6 +901,54 @@ export function registerSessionCommand(program) {
|
|
|
686
901
|
"reuse-window-seconds",
|
|
687
902
|
3600,
|
|
688
903
|
);
|
|
904
|
+
|
|
905
|
+
// --session <id> short-circuit: behave like `session join`. This is the
|
|
906
|
+
// path Carter cared about — "create on web, share id, attach in CLI".
|
|
907
|
+
// We verify the session is reachable, materialize a minimal local
|
|
908
|
+
// NDJSON if missing, and emit the same `{sessionId, title, resumed}`
|
|
909
|
+
// contract callers already consume from `ensure`.
|
|
910
|
+
const explicitSessionId = normalizeString(options.session);
|
|
911
|
+
if (explicitSessionId) {
|
|
912
|
+
const verification = await verifyRemoteSession(explicitSessionId, { targetPath });
|
|
913
|
+
if (!verification.ok) {
|
|
914
|
+
if (verification.status === 404 || verification.reason === "not_found") {
|
|
915
|
+
throw new Error(
|
|
916
|
+
`Session not found, archived, or not accessible to your account. (id=${explicitSessionId})`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
if (verification.status === 403 || verification.reason === "forbidden") {
|
|
920
|
+
throw new Error(
|
|
921
|
+
`Session '${explicitSessionId}' exists but your account is not a member.`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
if (verification.reason === "not_authenticated") {
|
|
925
|
+
throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
|
|
926
|
+
}
|
|
927
|
+
throw new Error(
|
|
928
|
+
`Failed to verify session '${explicitSessionId}' (${verification.reason || "unknown"}). Try again in a moment.`,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
const remoteSession = verification.session || {};
|
|
932
|
+
const localSession = await ensureLocalSessionForRemoteCommand(explicitSessionId, {
|
|
933
|
+
targetPath,
|
|
934
|
+
title: normalizeString(remoteSession.title),
|
|
935
|
+
skipRemoteProbe: true,
|
|
936
|
+
});
|
|
937
|
+
const payload = {
|
|
938
|
+
command: "session ensure",
|
|
939
|
+
targetPath,
|
|
940
|
+
sessionId: explicitSessionId,
|
|
941
|
+
title: normalizeString(remoteSession.title) || localSession?.session?.title || null,
|
|
942
|
+
resumed: true,
|
|
943
|
+
attached: true,
|
|
944
|
+
materializedLocalSession: localSession.materialized,
|
|
945
|
+
verificationSource: verification.source,
|
|
946
|
+
dashboardUrl: buildDashboardUrl(explicitSessionId),
|
|
947
|
+
};
|
|
948
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
689
952
|
const ensured = await ensureWorkspaceSession({
|
|
690
953
|
targetPath,
|
|
691
954
|
ttlSeconds,
|
|
@@ -701,6 +964,7 @@ export function registerSessionCommand(program) {
|
|
|
701
964
|
title: ensured.title || null,
|
|
702
965
|
resumed: Boolean(ensured.resumedCandidate),
|
|
703
966
|
dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
|
|
967
|
+
titleSync: ensured.titleSync || undefined,
|
|
704
968
|
};
|
|
705
969
|
console.log(JSON.stringify(payload, null, 2));
|
|
706
970
|
});
|
|
@@ -832,8 +1096,14 @@ export function registerSessionCommand(program) {
|
|
|
832
1096
|
|
|
833
1097
|
session
|
|
834
1098
|
.command("join <sessionId>")
|
|
835
|
-
.description(
|
|
836
|
-
|
|
1099
|
+
.description(
|
|
1100
|
+
"Attach to a remote-created session for posting and listening, materializing minimal local state on demand.",
|
|
1101
|
+
)
|
|
1102
|
+
.option("--name <name>", "Agent display name (legacy alias for --agent)")
|
|
1103
|
+
.option(
|
|
1104
|
+
"--agent <id>",
|
|
1105
|
+
"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.",
|
|
1106
|
+
)
|
|
837
1107
|
.option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
|
|
838
1108
|
.option("--model <model>", "Agent model hint", "cli")
|
|
839
1109
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
@@ -844,27 +1114,104 @@ export function registerSessionCommand(program) {
|
|
|
844
1114
|
throw new Error("session id is required.");
|
|
845
1115
|
}
|
|
846
1116
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1117
|
+
|
|
1118
|
+
// PR #483 contract: verify the session exists and the caller has access
|
|
1119
|
+
// BEFORE materializing local cache state. Without this we'd silently
|
|
1120
|
+
// create a phantom local NDJSON for a session that's archived or owned
|
|
1121
|
+
// by another tenant — which is the bug Carter reported when asking for
|
|
1122
|
+
// a clean "share an id from web → join in CLI" flow.
|
|
1123
|
+
const verification = await verifyRemoteSession(normalizedSessionId, { targetPath });
|
|
1124
|
+
if (!verification.ok) {
|
|
1125
|
+
if (verification.status === 404 || verification.reason === "not_found") {
|
|
1126
|
+
throw new Error(
|
|
1127
|
+
`Session not found, archived, or not accessible to your account. (id=${normalizedSessionId})`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
if (verification.status === 403 || verification.reason === "forbidden") {
|
|
1131
|
+
throw new Error(
|
|
1132
|
+
`Session '${normalizedSessionId}' exists but your account is not a member.`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
if (verification.reason === "not_authenticated") {
|
|
1136
|
+
throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
|
|
1137
|
+
}
|
|
1138
|
+
throw new Error(
|
|
1139
|
+
`Failed to verify session '${normalizedSessionId}' (${verification.reason || "unknown"}). Try again in a moment.`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const remoteSession = verification.session || {};
|
|
1144
|
+
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
1145
|
+
targetPath,
|
|
1146
|
+
title: normalizeString(remoteSession.title),
|
|
1147
|
+
skipRemoteProbe: true,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
const explicitAgent = normalizeString(options.agent);
|
|
1151
|
+
const agentSeed = explicitAgent || normalizeString(options.name);
|
|
1152
|
+
const resolvedAgentId = await defaultAgentId(agentSeed, targetPath);
|
|
1153
|
+
const role = normalizeString(options.role) || "coder";
|
|
1154
|
+
const model = normalizeString(options.model) || "cli";
|
|
1155
|
+
|
|
1156
|
+
// `registerAgent` already writes the canonical `agent_join` event to the
|
|
1157
|
+
// local NDJSON and best-effort relays it to /events via appendToStream
|
|
1158
|
+
// → syncSessionEventToApi. That gives us the exact `post-agent` parity
|
|
1159
|
+
// the spec calls for when `--agent <granted>` is provided. We don't
|
|
1160
|
+
// double-emit; we just record whether the explicit agent path was used
|
|
1161
|
+
// so the JSON output can advertise it to callers (and tests).
|
|
847
1162
|
const joined = await registerAgent(normalizedSessionId, {
|
|
848
1163
|
targetPath,
|
|
849
|
-
agentId:
|
|
850
|
-
model
|
|
851
|
-
role
|
|
1164
|
+
agentId: resolvedAgentId,
|
|
1165
|
+
model,
|
|
1166
|
+
role,
|
|
852
1167
|
});
|
|
1168
|
+
const agentJoinRelayed =
|
|
1169
|
+
Boolean(explicitAgent) &&
|
|
1170
|
+
Boolean(resolvedAgentId) &&
|
|
1171
|
+
resolvedAgentId !== "cli-user" &&
|
|
1172
|
+
resolvedAgentId !== "unknown" &&
|
|
1173
|
+
!resolvedAgentId.startsWith("human-");
|
|
1174
|
+
|
|
1175
|
+
const eventCount = Number(remoteSession.eventCount ?? remoteSession.events ?? 0);
|
|
1176
|
+
const agents = Array.isArray(remoteSession.agents) ? remoteSession.agents : [];
|
|
1177
|
+
const agentCount = Number(remoteSession.agentCount ?? agents.length ?? 0);
|
|
1178
|
+
const lastActivityIso =
|
|
1179
|
+
normalizeString(remoteSession.lastInteractionAt) ||
|
|
1180
|
+
normalizeString(remoteSession.lastActivityAt) ||
|
|
1181
|
+
normalizeString(remoteSession.updatedAt) ||
|
|
1182
|
+
normalizeString(remoteSession.createdAt) ||
|
|
1183
|
+
"";
|
|
1184
|
+
const remoteTitle = normalizeString(remoteSession.title);
|
|
1185
|
+
|
|
853
1186
|
const payload = {
|
|
854
1187
|
command: "session join",
|
|
1188
|
+
joined: true,
|
|
855
1189
|
targetPath,
|
|
856
1190
|
sessionId: normalizedSessionId,
|
|
1191
|
+
title: remoteTitle || null,
|
|
857
1192
|
agentId: joined.agentId,
|
|
858
1193
|
role: joined.role,
|
|
859
1194
|
model: joined.model,
|
|
860
1195
|
status: joined.status,
|
|
861
1196
|
joinedAt: joined.joinedAt,
|
|
1197
|
+
materializedLocalSession: localSession.materialized,
|
|
1198
|
+
verificationSource: verification.source,
|
|
1199
|
+
eventCount: Number.isFinite(eventCount) ? eventCount : 0,
|
|
1200
|
+
agentCount: Number.isFinite(agentCount) ? agentCount : 0,
|
|
1201
|
+
lastActivityAt: lastActivityIso || null,
|
|
1202
|
+
agentJoinRelayed,
|
|
862
1203
|
};
|
|
863
1204
|
if (shouldEmitJson(options, command)) {
|
|
864
1205
|
console.log(JSON.stringify(payload, null, 2));
|
|
865
1206
|
return;
|
|
866
1207
|
}
|
|
867
|
-
|
|
1208
|
+
const titleLabel = remoteTitle ? `"${remoteTitle}"` : "(untitled)";
|
|
1209
|
+
const ageLabel = lastActivityIso ? formatRelativeAge(lastActivityIso) : "never";
|
|
1210
|
+
console.log(
|
|
1211
|
+
pc.bold(
|
|
1212
|
+
`Joined session ${titleLabel} (${normalizedSessionId}) — ${payload.eventCount} events, ${payload.agentCount} agents, last activity ${ageLabel}`,
|
|
1213
|
+
),
|
|
1214
|
+
);
|
|
868
1215
|
console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
|
|
869
1216
|
});
|
|
870
1217
|
|
|
@@ -885,7 +1232,10 @@ export function registerSessionCommand(program) {
|
|
|
885
1232
|
throw new Error("message is required.");
|
|
886
1233
|
}
|
|
887
1234
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
888
|
-
const agentId =
|
|
1235
|
+
const agentId = await defaultAgentId(options.agent, targetPath);
|
|
1236
|
+
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
1237
|
+
targetPath,
|
|
1238
|
+
});
|
|
889
1239
|
const to = normalizeString(options.to);
|
|
890
1240
|
const eventPayload = {
|
|
891
1241
|
message: normalizedMessage,
|
|
@@ -894,12 +1244,29 @@ export function registerSessionCommand(program) {
|
|
|
894
1244
|
if (to) {
|
|
895
1245
|
eventPayload.to = to;
|
|
896
1246
|
}
|
|
1247
|
+
const clientMessageId = `cli-${randomUUID()}`;
|
|
897
1248
|
const event = createAgentEvent({
|
|
898
1249
|
event: "session_message",
|
|
899
1250
|
agentId,
|
|
900
1251
|
sessionId: normalizedSessionId,
|
|
901
1252
|
payload: eventPayload,
|
|
902
1253
|
});
|
|
1254
|
+
event.eventId = clientMessageId;
|
|
1255
|
+
event.idempotencyToken = clientMessageId;
|
|
1256
|
+
let remoteSync = null;
|
|
1257
|
+
if (localSession.materialized) {
|
|
1258
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1259
|
+
remoteSync = await syncSessionEventToApi(normalizedSessionId, event, {
|
|
1260
|
+
targetPath,
|
|
1261
|
+
});
|
|
1262
|
+
if (remoteSync?.synced) break;
|
|
1263
|
+
}
|
|
1264
|
+
if (!remoteSync?.synced) {
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
`Remote send failed (${remoteSync?.reason || "unknown"}); local cache was not updated.`,
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
903
1270
|
const persisted = await appendToStream(normalizedSessionId, event, {
|
|
904
1271
|
targetPath,
|
|
905
1272
|
});
|
|
@@ -909,6 +1276,95 @@ export function registerSessionCommand(program) {
|
|
|
909
1276
|
sessionId: normalizedSessionId,
|
|
910
1277
|
agentId,
|
|
911
1278
|
event: persisted,
|
|
1279
|
+
materializedLocalSession: localSession.materialized,
|
|
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
|
+
remoteSync,
|
|
912
1368
|
};
|
|
913
1369
|
if (shouldEmitJson(options, command)) {
|
|
914
1370
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -926,7 +1382,17 @@ export function registerSessionCommand(program) {
|
|
|
926
1382
|
"Agent id to receive messages for",
|
|
927
1383
|
process.env.SENTINELAYER_AGENT_ID || "cli-user",
|
|
928
1384
|
)
|
|
929
|
-
.option("--interval <seconds>", "
|
|
1385
|
+
.option("--interval <seconds>", "Idle polling interval in seconds (default 60)", "60")
|
|
1386
|
+
.option(
|
|
1387
|
+
"--active-interval <seconds>",
|
|
1388
|
+
"Polling interval after recent human activity (default 5)",
|
|
1389
|
+
"5",
|
|
1390
|
+
)
|
|
1391
|
+
.option(
|
|
1392
|
+
"--active-window <seconds>",
|
|
1393
|
+
"Seconds after a human message to keep the active interval (default 300)",
|
|
1394
|
+
"300",
|
|
1395
|
+
)
|
|
930
1396
|
.option("--emit <format>", "Output format: ndjson or text", "ndjson")
|
|
931
1397
|
.option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
|
|
932
1398
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
@@ -938,6 +1404,12 @@ export function registerSessionCommand(program) {
|
|
|
938
1404
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
939
1405
|
const agentId = normalizeAgentId(options.agent, "cli-user");
|
|
940
1406
|
const intervalSeconds = parsePositiveInteger(options.interval, "interval", 60);
|
|
1407
|
+
const activeIntervalSeconds = parsePositiveInteger(
|
|
1408
|
+
options.activeInterval,
|
|
1409
|
+
"active-interval",
|
|
1410
|
+
5,
|
|
1411
|
+
);
|
|
1412
|
+
const activeWindowSeconds = parsePositiveInteger(options.activeWindow, "active-window", 300);
|
|
941
1413
|
const limit = parsePositiveInteger(options.limit, "limit", 200);
|
|
942
1414
|
const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
|
|
943
1415
|
if (!["ndjson", "text"].includes(emitFormat)) {
|
|
@@ -955,7 +1427,7 @@ export function registerSessionCommand(program) {
|
|
|
955
1427
|
if (emitFormat === "text") {
|
|
956
1428
|
console.log(
|
|
957
1429
|
pc.gray(
|
|
958
|
-
`Listening to session ${normalizedSessionId} as ${agentId};
|
|
1430
|
+
`Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
|
|
959
1431
|
),
|
|
960
1432
|
);
|
|
961
1433
|
}
|
|
@@ -966,6 +1438,8 @@ export function registerSessionCommand(program) {
|
|
|
966
1438
|
targetPath,
|
|
967
1439
|
agentId,
|
|
968
1440
|
intervalSeconds,
|
|
1441
|
+
activeIntervalSeconds,
|
|
1442
|
+
activeWindowSeconds,
|
|
969
1443
|
limit,
|
|
970
1444
|
since,
|
|
971
1445
|
replay: Boolean(options.replay),
|
|
@@ -1029,11 +1503,17 @@ export function registerSessionCommand(program) {
|
|
|
1029
1503
|
const emitJson = shouldEmitJson(options, command);
|
|
1030
1504
|
|
|
1031
1505
|
let hydration = null;
|
|
1506
|
+
let remoteTail = null;
|
|
1032
1507
|
if (options.remote) {
|
|
1033
1508
|
hydration = await hydrateSessionFromRemote({
|
|
1034
1509
|
sessionId: normalizedSessionId,
|
|
1035
1510
|
targetPath,
|
|
1036
1511
|
});
|
|
1512
|
+
remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
|
|
1513
|
+
targetPath,
|
|
1514
|
+
limit: tail,
|
|
1515
|
+
timeoutMs: 15_000,
|
|
1516
|
+
});
|
|
1037
1517
|
if (!emitJson) {
|
|
1038
1518
|
if (hydration.ok) {
|
|
1039
1519
|
console.log(
|
|
@@ -1041,6 +1521,13 @@ export function registerSessionCommand(program) {
|
|
|
1041
1521
|
`Hydrated from remote: relayed=${hydration.relayed} dropped=${hydration.dropped}.`,
|
|
1042
1522
|
),
|
|
1043
1523
|
);
|
|
1524
|
+
if (hydration.eventsBackfillComplete === false) {
|
|
1525
|
+
console.log(
|
|
1526
|
+
pc.yellow(
|
|
1527
|
+
`Remote backfill still has more pages (${hydration.eventsBackfillReason || "incomplete"}); latest tail was fetched directly.`,
|
|
1528
|
+
),
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1044
1531
|
} else {
|
|
1045
1532
|
console.log(
|
|
1046
1533
|
pc.yellow(
|
|
@@ -1052,10 +1539,34 @@ export function registerSessionCommand(program) {
|
|
|
1052
1539
|
}
|
|
1053
1540
|
|
|
1054
1541
|
if (!options.follow) {
|
|
1055
|
-
const
|
|
1542
|
+
const allEvents = await readStream(normalizedSessionId, {
|
|
1056
1543
|
targetPath,
|
|
1057
|
-
tail,
|
|
1544
|
+
tail: 0,
|
|
1058
1545
|
});
|
|
1546
|
+
const displayEvents = [...allEvents];
|
|
1547
|
+
if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
|
|
1548
|
+
const knownKeys = new Set();
|
|
1549
|
+
for (const event of allEvents) {
|
|
1550
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
1551
|
+
}
|
|
1552
|
+
for (const event of remoteTail.events) {
|
|
1553
|
+
if (sessionEventHasKnownIdentity(event, knownKeys)) {
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
try {
|
|
1557
|
+
const appended = await appendToStream(normalizedSessionId, event, {
|
|
1558
|
+
targetPath,
|
|
1559
|
+
syncRemote: false,
|
|
1560
|
+
});
|
|
1561
|
+
displayEvents.push(appended);
|
|
1562
|
+
addSessionEventIdentityKeys(knownKeys, appended);
|
|
1563
|
+
} catch {
|
|
1564
|
+
displayEvents.push(event);
|
|
1565
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
const events = dedupeSessionEvents(displayEvents).slice(-tail);
|
|
1059
1570
|
const payload = {
|
|
1060
1571
|
command: "session read",
|
|
1061
1572
|
targetPath,
|
|
@@ -1063,7 +1574,19 @@ export function registerSessionCommand(program) {
|
|
|
1063
1574
|
tail,
|
|
1064
1575
|
count: events.length,
|
|
1065
1576
|
events,
|
|
1066
|
-
remote: hydration
|
|
1577
|
+
remote: hydration
|
|
1578
|
+
? {
|
|
1579
|
+
...hydration,
|
|
1580
|
+
tailProbe: remoteTail
|
|
1581
|
+
? {
|
|
1582
|
+
ok: Boolean(remoteTail.ok),
|
|
1583
|
+
reason: remoteTail.reason || "",
|
|
1584
|
+
count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
|
|
1585
|
+
cursor: remoteTail.cursor || null,
|
|
1586
|
+
}
|
|
1587
|
+
: null,
|
|
1588
|
+
}
|
|
1589
|
+
: hydration,
|
|
1067
1590
|
};
|
|
1068
1591
|
if (emitJson) {
|
|
1069
1592
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -1121,10 +1644,15 @@ export function registerSessionCommand(program) {
|
|
|
1121
1644
|
if (!emitJson) {
|
|
1122
1645
|
console.log(pc.gray(`Following session ${normalizedSessionId}... Press Ctrl+C to stop.`));
|
|
1123
1646
|
}
|
|
1647
|
+
const seenFollowEvents = new Set();
|
|
1124
1648
|
for await (const event of tailStream(normalizedSessionId, {
|
|
1125
1649
|
targetPath,
|
|
1126
1650
|
replayTail: tail,
|
|
1127
1651
|
})) {
|
|
1652
|
+
if (sessionEventHasKnownIdentity(event, seenFollowEvents)) {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
addSessionEventIdentityKeys(seenFollowEvents, event);
|
|
1128
1656
|
if (emitJson) {
|
|
1129
1657
|
console.log(JSON.stringify(event));
|
|
1130
1658
|
} else {
|
|
@@ -1178,6 +1706,11 @@ export function registerSessionCommand(program) {
|
|
|
1178
1706
|
dropped: result.dropped,
|
|
1179
1707
|
cursor: result.cursor,
|
|
1180
1708
|
persistedCursor: result.persistedCursor,
|
|
1709
|
+
humanRelayed: result.humanRelayed,
|
|
1710
|
+
eventsRelayed: result.eventsRelayed,
|
|
1711
|
+
eventsCursor: result.eventsCursor,
|
|
1712
|
+
materializedLocalSession: result.materializedLocalSession,
|
|
1713
|
+
localAppendComplete: result.localAppendComplete,
|
|
1181
1714
|
access: access || undefined,
|
|
1182
1715
|
};
|
|
1183
1716
|
if (shouldEmitJson(options, command)) {
|
|
@@ -1517,7 +2050,7 @@ export function registerSessionCommand(program) {
|
|
|
1517
2050
|
throw new Error("session id is required.");
|
|
1518
2051
|
}
|
|
1519
2052
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1520
|
-
const agentId =
|
|
2053
|
+
const agentId = await defaultAgentId(options.agent, targetPath);
|
|
1521
2054
|
const left = await unregisterAgent(normalizedSessionId, agentId, {
|
|
1522
2055
|
reason: options.reason || "manual",
|
|
1523
2056
|
targetPath,
|