sentinelayer-cli 0.8.12 → 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 +7 -2
- package/src/agents/backend/tools/timeout-audit.js +33 -17
- package/src/agents/devtestbot/config/definition.js +100 -0
- package/src/agents/devtestbot/config/system-prompt.js +92 -0
- package/src/agents/devtestbot/index.js +9 -0
- package/src/agents/devtestbot/runner.js +775 -0
- package/src/agents/devtestbot/tool.js +707 -0
- package/src/commands/legacy-args.js +4 -0
- package/src/commands/omargate.js +4 -0
- package/src/commands/session.js +960 -159
- package/src/commands/swarm.js +11 -2
- package/src/guide/generator.js +14 -0
- package/src/legacy-cli.js +35 -18
- package/src/prompt/generator.js +4 -16
- package/src/review/ai-review.js +95 -6
- package/src/review/dd-report-email-client.js +148 -0
- package/src/review/investor-dd-devtestbot.js +599 -0
- package/src/review/investor-dd-orchestrator.js +135 -3
- package/src/review/omargate-orchestrator.js +20 -2
- package/src/review/persona-prompts.js +34 -1
- package/src/review/report.js +61 -2
- package/src/scan/generator.js +1 -1
- package/src/session/coordination-guidance.js +49 -0
- package/src/session/daemon.js +3 -2
- package/src/session/event-identity.js +139 -0
- package/src/session/listener.js +330 -0
- 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/setup-guides.js +3 -15
- package/src/session/store.js +117 -5
- package/src/session/stream.js +17 -7
- package/src/session/sync.js +375 -26
- package/src/session/title-sync.js +107 -0
- package/src/spec/generator.js +8 -10
- package/src/swarm/registry.js +20 -0
- package/src/swarm/runtime.js +139 -1
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,
|
|
@@ -47,17 +47,27 @@ import {
|
|
|
47
47
|
listActiveSessions,
|
|
48
48
|
listAllSessions,
|
|
49
49
|
recordSessionProvisionedIdentities,
|
|
50
|
+
updateSessionTitle,
|
|
50
51
|
} from "../session/store.js";
|
|
51
52
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
53
|
+
import {
|
|
54
|
+
addSessionEventIdentityKeys,
|
|
55
|
+
dedupeSessionEvents,
|
|
56
|
+
sessionEventHasKnownIdentity,
|
|
57
|
+
} from "../session/event-identity.js";
|
|
52
58
|
import { readSessionPreview } from "../session/preview.js";
|
|
53
59
|
import {
|
|
54
60
|
listSessionsFromApi,
|
|
55
61
|
probeSessionAccess,
|
|
62
|
+
pollSessionEventsBefore,
|
|
63
|
+
syncSessionEventToApi,
|
|
56
64
|
syncSessionMetadataToApi,
|
|
57
65
|
} from "../session/sync.js";
|
|
58
66
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
59
67
|
import { mergeLiveSources } from "../session/live-source.js";
|
|
68
|
+
import { listenSessionEvents } from "../session/listener.js";
|
|
60
69
|
import { deriveSessionTitle } from "../session/senti-naming.js";
|
|
70
|
+
import { pushSessionTitleToApi } from "../session/title-sync.js";
|
|
61
71
|
import {
|
|
62
72
|
buildDashboardUrl,
|
|
63
73
|
buildTemplateLaunchPlan,
|
|
@@ -89,6 +99,369 @@ function parsePositiveInteger(rawValue, field, fallbackValue) {
|
|
|
89
99
|
return Math.floor(normalized);
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
function normalizeComparablePath(value) {
|
|
103
|
+
return String(value || "")
|
|
104
|
+
.trim()
|
|
105
|
+
.replace(/\\/g, "/")
|
|
106
|
+
.replace(/\/+$/g, "")
|
|
107
|
+
.toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function latestSessionActivityMs(entry = {}) {
|
|
111
|
+
for (const key of ["lastInteractionAt", "lastActivityAt", "createdAt"]) {
|
|
112
|
+
const epoch = Date.parse(normalizeString(entry[key]));
|
|
113
|
+
if (Number.isFinite(epoch)) return epoch;
|
|
114
|
+
}
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function remoteSessionLookupDisabled() {
|
|
119
|
+
return String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sentiAutostartDisabled() {
|
|
123
|
+
return String(process.env.SENTINELAYER_SKIP_SENTI_AUTOSTART || "").trim() === "1";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mergeResumeCandidate(existing, incoming) {
|
|
127
|
+
if (!existing) return incoming;
|
|
128
|
+
const existingActivity = Number(existing._activityMs || 0);
|
|
129
|
+
const incomingActivity = Number(incoming._activityMs || 0);
|
|
130
|
+
const preferIncomingPaths = existing._source !== "local" && incoming._source === "local";
|
|
131
|
+
const base = preferIncomingPaths ? incoming : existing;
|
|
132
|
+
const other = preferIncomingPaths ? existing : incoming;
|
|
133
|
+
return {
|
|
134
|
+
...base,
|
|
135
|
+
title: normalizeString(base.title) || normalizeString(other.title) || null,
|
|
136
|
+
lastActivityAt:
|
|
137
|
+
normalizeString(incoming.lastActivityAt) || normalizeString(existing.lastActivityAt) || null,
|
|
138
|
+
lastInteractionAt:
|
|
139
|
+
normalizeString(incoming.lastInteractionAt) || normalizeString(existing.lastInteractionAt) || null,
|
|
140
|
+
_activityMs: Math.max(existingActivity, incomingActivity),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function findReusableSessionCandidate({
|
|
145
|
+
targetPath,
|
|
146
|
+
reuseWindowSeconds = 3600,
|
|
147
|
+
resume = true,
|
|
148
|
+
forceNew = false,
|
|
149
|
+
} = {}) {
|
|
150
|
+
if (forceNew || resume === false) return null;
|
|
151
|
+
const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
|
|
152
|
+
const byId = new Map();
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const active = await listActiveSessions({ targetPath });
|
|
156
|
+
for (const entry of active) {
|
|
157
|
+
const activityMs = latestSessionActivityMs(entry);
|
|
158
|
+
if (!activityMs || activityMs < cutoffMs) continue;
|
|
159
|
+
const candidate = {
|
|
160
|
+
...entry,
|
|
161
|
+
_source: "local",
|
|
162
|
+
_activityMs: activityMs,
|
|
163
|
+
};
|
|
164
|
+
byId.set(entry.sessionId, mergeResumeCandidate(byId.get(entry.sessionId), candidate));
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
/* local lookup failure is non-fatal */
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!remoteSessionLookupDisabled()) {
|
|
171
|
+
try {
|
|
172
|
+
const remote = await listSessionsFromApi({
|
|
173
|
+
targetPath,
|
|
174
|
+
includeArchived: false,
|
|
175
|
+
limit: 50,
|
|
176
|
+
});
|
|
177
|
+
if (remote && remote.ok) {
|
|
178
|
+
const normalizedTarget = normalizeComparablePath(targetPath);
|
|
179
|
+
for (const entry of remote.sessions || []) {
|
|
180
|
+
const codebase = normalizeComparablePath(entry.codebasePath || entry.targetPath);
|
|
181
|
+
if (!codebase || codebase !== normalizedTarget) continue;
|
|
182
|
+
if (entry.archiveStatus && entry.archiveStatus !== "active") continue;
|
|
183
|
+
const activityMs = latestSessionActivityMs(entry);
|
|
184
|
+
if (!activityMs || activityMs < cutoffMs) continue;
|
|
185
|
+
const candidate = {
|
|
186
|
+
sessionId: entry.sessionId,
|
|
187
|
+
createdAt: entry.createdAt,
|
|
188
|
+
lastActivityAt: entry.lastActivityAt,
|
|
189
|
+
expiresAt: entry.expiresAt,
|
|
190
|
+
status: entry.status || "active",
|
|
191
|
+
template: entry.templateName || null,
|
|
192
|
+
title: entry.title || null,
|
|
193
|
+
_source: "remote",
|
|
194
|
+
_activityMs: activityMs,
|
|
195
|
+
};
|
|
196
|
+
byId.set(entry.sessionId, mergeResumeCandidate(byId.get(entry.sessionId), candidate));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
/* remote lookup failure is non-fatal */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const candidates = [...byId.values()];
|
|
205
|
+
candidates.sort((left, right) => Number(right._activityMs || 0) - Number(left._activityMs || 0));
|
|
206
|
+
return candidates[0] || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
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;
|
|
241
|
+
try {
|
|
242
|
+
auth = await resolveActiveAuthSession({
|
|
243
|
+
cwd: targetPath || process.cwd(),
|
|
244
|
+
env: process.env,
|
|
245
|
+
autoRotate: false,
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
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 };
|
|
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 };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function ensureWorkspaceSession({
|
|
363
|
+
targetPath,
|
|
364
|
+
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
365
|
+
template = null,
|
|
366
|
+
title = "",
|
|
367
|
+
resume = true,
|
|
368
|
+
forceNew = false,
|
|
369
|
+
reuseWindowSeconds = 3600,
|
|
370
|
+
} = {}) {
|
|
371
|
+
const titleArg = normalizeString(title);
|
|
372
|
+
const fallbackTitle = deriveSessionTitle(targetPath);
|
|
373
|
+
const startedAt = Date.now();
|
|
374
|
+
const resumedCandidate = await findReusableSessionCandidate({
|
|
375
|
+
targetPath,
|
|
376
|
+
reuseWindowSeconds,
|
|
377
|
+
resume,
|
|
378
|
+
forceNew,
|
|
379
|
+
});
|
|
380
|
+
let created;
|
|
381
|
+
const resumeTitle =
|
|
382
|
+
titleArg || normalizeString(resumedCandidate?.title) || fallbackTitle;
|
|
383
|
+
|
|
384
|
+
if (resumedCandidate) {
|
|
385
|
+
if (resumedCandidate._source === "remote" && !resumedCandidate.sessionDir) {
|
|
386
|
+
created = await createSession({
|
|
387
|
+
targetPath,
|
|
388
|
+
ttlSeconds,
|
|
389
|
+
sessionId: resumedCandidate.sessionId,
|
|
390
|
+
title: resumeTitle,
|
|
391
|
+
createdAt: resumedCandidate.createdAt,
|
|
392
|
+
expiresAt: resumedCandidate.expiresAt,
|
|
393
|
+
lastInteractionAt:
|
|
394
|
+
resumedCandidate.lastInteractionAt ||
|
|
395
|
+
resumedCandidate.lastActivityAt ||
|
|
396
|
+
resumedCandidate.createdAt,
|
|
397
|
+
});
|
|
398
|
+
} else {
|
|
399
|
+
created = {
|
|
400
|
+
sessionId: resumedCandidate.sessionId,
|
|
401
|
+
sessionDir: resumedCandidate.sessionDir || null,
|
|
402
|
+
metadataPath: resumedCandidate.metadataPath || null,
|
|
403
|
+
streamPath: resumedCandidate.streamPath || null,
|
|
404
|
+
createdAt: resumedCandidate.createdAt,
|
|
405
|
+
updatedAt: resumedCandidate.updatedAt || null,
|
|
406
|
+
lastInteractionAt: resumedCandidate.lastInteractionAt || null,
|
|
407
|
+
expiresAt: resumedCandidate.expiresAt,
|
|
408
|
+
elapsedTimer: resumedCandidate.elapsedTimer || 0,
|
|
409
|
+
renewalCount: resumedCandidate.renewalCount || 0,
|
|
410
|
+
status: resumedCandidate.status || "active",
|
|
411
|
+
template: resumedCandidate.template || null,
|
|
412
|
+
title: normalizeString(resumedCandidate.title) || null,
|
|
413
|
+
codebaseContext: resumedCandidate.codebaseContext || null,
|
|
414
|
+
};
|
|
415
|
+
if (resumeTitle && resumeTitle !== created.title) {
|
|
416
|
+
const updated = await updateSessionTitle(created.sessionId, {
|
|
417
|
+
targetPath,
|
|
418
|
+
title: resumeTitle,
|
|
419
|
+
}).catch(() => null);
|
|
420
|
+
if (updated) {
|
|
421
|
+
created = {
|
|
422
|
+
...created,
|
|
423
|
+
...updated,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
created = await createSession({
|
|
430
|
+
targetPath,
|
|
431
|
+
ttlSeconds,
|
|
432
|
+
template,
|
|
433
|
+
title: titleArg || fallbackTitle,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const effectiveTitle = titleArg || normalizeString(created.title) || fallbackTitle;
|
|
438
|
+
const titleAuto = !titleArg && !resumedCandidate;
|
|
439
|
+
const pendingTitleSync = Boolean(created.remoteTitleSync?.pending && effectiveTitle);
|
|
440
|
+
const shouldPushTitle = Boolean(
|
|
441
|
+
titleArg ||
|
|
442
|
+
titleAuto ||
|
|
443
|
+
pendingTitleSync ||
|
|
444
|
+
(resumedCandidate && effectiveTitle && !normalizeString(resumedCandidate.title))
|
|
445
|
+
);
|
|
446
|
+
let titleSync = null;
|
|
447
|
+
if (shouldPushTitle) {
|
|
448
|
+
titleSync = await pushSessionTitleToApi(created.sessionId, effectiveTitle, { targetPath });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
created: {
|
|
453
|
+
...created,
|
|
454
|
+
title: effectiveTitle || null,
|
|
455
|
+
resumed: Boolean(resumedCandidate),
|
|
456
|
+
},
|
|
457
|
+
resumedCandidate,
|
|
458
|
+
durationMs: Date.now() - startedAt,
|
|
459
|
+
title: effectiveTitle || null,
|
|
460
|
+
titleAuto,
|
|
461
|
+
titleSync,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
92
465
|
function normalizeAgentId(value, fallbackValue = "cli-user") {
|
|
93
466
|
const normalized = normalizeString(value)
|
|
94
467
|
.toLowerCase()
|
|
@@ -97,6 +470,58 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
|
|
|
97
470
|
return normalized || fallbackValue;
|
|
98
471
|
}
|
|
99
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
|
+
|
|
100
525
|
async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
|
|
101
526
|
const normalizedItems = Array.isArray(items) ? items : [];
|
|
102
527
|
const normalizedConcurrency = Math.max(
|
|
@@ -279,6 +704,15 @@ export function registerSessionCommand(program) {
|
|
|
279
704
|
"--force-new",
|
|
280
705
|
"Always create a new session even if a recent active one exists for this workspace",
|
|
281
706
|
)
|
|
707
|
+
.option(
|
|
708
|
+
"--resume",
|
|
709
|
+
"Reuse the most recent active session for this workspace when one is inside the reuse window",
|
|
710
|
+
true,
|
|
711
|
+
)
|
|
712
|
+
.option(
|
|
713
|
+
"--no-resume",
|
|
714
|
+
"Disable automatic resume and mint a new session unless --force-new is also present",
|
|
715
|
+
)
|
|
282
716
|
.option(
|
|
283
717
|
"--reuse-window-seconds <seconds>",
|
|
284
718
|
"Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
|
|
@@ -302,149 +736,22 @@ export function registerSessionCommand(program) {
|
|
|
302
736
|
"reuse-window-seconds",
|
|
303
737
|
3600,
|
|
304
738
|
);
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// `--force-new` opts back into the old "always mint" behavior.
|
|
319
|
-
let resumed = null;
|
|
320
|
-
if (!options.forceNew) {
|
|
321
|
-
const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
|
|
322
|
-
const candidates = [];
|
|
323
|
-
try {
|
|
324
|
-
const active = await listActiveSessions({ targetPath });
|
|
325
|
-
for (const entry of active) {
|
|
326
|
-
const createdMs = Date.parse(entry.createdAt || "");
|
|
327
|
-
if (Number.isFinite(createdMs) && createdMs >= cutoffMs) {
|
|
328
|
-
candidates.push({ ...entry, _source: "local" });
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
} catch {
|
|
332
|
-
/* local lookup failure is non-fatal */
|
|
333
|
-
}
|
|
334
|
-
try {
|
|
335
|
-
const remote = await listSessionsFromApi({
|
|
336
|
-
targetPath,
|
|
337
|
-
includeArchived: false,
|
|
338
|
-
limit: 50,
|
|
339
|
-
});
|
|
340
|
-
if (remote && remote.ok) {
|
|
341
|
-
const normalizedTarget = String(targetPath).toLowerCase();
|
|
342
|
-
for (const entry of remote.sessions || []) {
|
|
343
|
-
const codebase = String(entry.codebasePath || "").toLowerCase();
|
|
344
|
-
if (!codebase || codebase !== normalizedTarget) continue;
|
|
345
|
-
if (entry.archiveStatus && entry.archiveStatus !== "active") continue;
|
|
346
|
-
const lastMs = Date.parse(entry.lastActivityAt || entry.createdAt || "");
|
|
347
|
-
if (Number.isFinite(lastMs) && lastMs >= cutoffMs) {
|
|
348
|
-
candidates.push({
|
|
349
|
-
sessionId: entry.sessionId,
|
|
350
|
-
createdAt: entry.createdAt,
|
|
351
|
-
lastActivityAt: entry.lastActivityAt,
|
|
352
|
-
expiresAt: entry.expiresAt,
|
|
353
|
-
status: entry.status || "active",
|
|
354
|
-
template: entry.templateName || null,
|
|
355
|
-
title: entry.title || null,
|
|
356
|
-
_source: "remote",
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
} catch {
|
|
362
|
-
/* remote lookup failure is non-fatal */
|
|
363
|
-
}
|
|
364
|
-
if (candidates.length > 0) {
|
|
365
|
-
// Prefer the most recent activity. Local + remote may name the
|
|
366
|
-
// same session; dedupe on sessionId before picking.
|
|
367
|
-
const seen = new Set();
|
|
368
|
-
const deduped = [];
|
|
369
|
-
for (const entry of candidates) {
|
|
370
|
-
if (seen.has(entry.sessionId)) continue;
|
|
371
|
-
seen.add(entry.sessionId);
|
|
372
|
-
deduped.push(entry);
|
|
373
|
-
}
|
|
374
|
-
deduped.sort((a, b) =>
|
|
375
|
-
String(b.lastActivityAt || b.createdAt || "").localeCompare(
|
|
376
|
-
String(a.lastActivityAt || a.createdAt || ""),
|
|
377
|
-
),
|
|
378
|
-
);
|
|
379
|
-
resumed = deduped[0];
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const startedAt = Date.now();
|
|
384
|
-
let created;
|
|
385
|
-
if (resumed) {
|
|
386
|
-
// Surface the resumed session's metadata in the same shape
|
|
387
|
-
// createSession returns so downstream code stays unchanged.
|
|
388
|
-
created = {
|
|
389
|
-
sessionId: resumed.sessionId,
|
|
390
|
-
sessionDir: resumed.sessionDir || null,
|
|
391
|
-
metadataPath: resumed.metadataPath || null,
|
|
392
|
-
streamPath: resumed.streamPath || null,
|
|
393
|
-
createdAt: resumed.createdAt,
|
|
394
|
-
expiresAt: resumed.expiresAt,
|
|
395
|
-
elapsedTimer: 0,
|
|
396
|
-
renewalCount: resumed.renewalCount || 0,
|
|
397
|
-
status: resumed.status || "active",
|
|
398
|
-
template: resumed.template || null,
|
|
399
|
-
codebaseContext: resumed.codebaseContext || null,
|
|
400
|
-
resumed: true,
|
|
401
|
-
};
|
|
402
|
-
} else {
|
|
403
|
-
created = await createSession({
|
|
404
|
-
targetPath,
|
|
405
|
-
ttlSeconds,
|
|
406
|
-
template,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
const durationMs = Date.now() - startedAt;
|
|
739
|
+
const titleArg = normalizeString(options.title);
|
|
740
|
+
const ensured = await ensureWorkspaceSession({
|
|
741
|
+
targetPath,
|
|
742
|
+
ttlSeconds,
|
|
743
|
+
template,
|
|
744
|
+
title: titleArg,
|
|
745
|
+
resume: options.resume !== false,
|
|
746
|
+
forceNew: Boolean(options.forceNew),
|
|
747
|
+
reuseWindowSeconds,
|
|
748
|
+
});
|
|
749
|
+
const created = ensured.created;
|
|
750
|
+
const resumed = Boolean(ensured.resumedCandidate);
|
|
751
|
+
const durationMs = ensured.durationMs;
|
|
410
752
|
const launchPlan = template ? buildTemplateLaunchPlan(created.sessionId, template) : [];
|
|
411
753
|
const dashboardUrl = buildDashboardUrl(created.sessionId);
|
|
412
|
-
|
|
413
|
-
// never fills with anonymous "<null>" rows. The caller can still
|
|
414
|
-
// override with --title. We skip the auto-title for resumed sessions
|
|
415
|
-
// because the room already has a name we don't want to clobber.
|
|
416
|
-
const titleArg = normalizeString(options.title);
|
|
417
|
-
const autoTitle = !resumed && !titleArg ? deriveSessionTitle(targetPath) : "";
|
|
418
|
-
const effectiveTitle = titleArg || autoTitle;
|
|
419
|
-
|
|
420
|
-
// If a title needs to land on the dashboard, push it. We always push
|
|
421
|
-
// when the caller passed --title, AND we push the auto-derived title
|
|
422
|
-
// for fresh (non-resumed) sessions so the room is never anonymous on
|
|
423
|
-
// the web. Best-effort, non-blocking.
|
|
424
|
-
if (effectiveTitle) {
|
|
425
|
-
void (async () => {
|
|
426
|
-
try {
|
|
427
|
-
const session = await resolveActiveAuthSession({
|
|
428
|
-
cwd: targetPath,
|
|
429
|
-
env: process.env,
|
|
430
|
-
autoRotate: false,
|
|
431
|
-
});
|
|
432
|
-
if (!session?.token || !session?.apiUrl) return;
|
|
433
|
-
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
434
|
-
await requestJsonMutation(
|
|
435
|
-
`${apiUrl}/api/v1/sessions/${encodeURIComponent(created.sessionId)}/title`,
|
|
436
|
-
{
|
|
437
|
-
method: "POST",
|
|
438
|
-
operationName: "session.set_title",
|
|
439
|
-
headers: { Authorization: `Bearer ${session.token}` },
|
|
440
|
-
body: { title: effectiveTitle },
|
|
441
|
-
},
|
|
442
|
-
);
|
|
443
|
-
} catch (_error) {
|
|
444
|
-
/* best-effort */
|
|
445
|
-
}
|
|
446
|
-
})();
|
|
447
|
-
}
|
|
754
|
+
const effectiveTitle = ensured.title;
|
|
448
755
|
|
|
449
756
|
const payload = {
|
|
450
757
|
command: "session start",
|
|
@@ -455,7 +762,9 @@ export function registerSessionCommand(program) {
|
|
|
455
762
|
metadataPath: created.metadataPath,
|
|
456
763
|
streamPath: created.streamPath,
|
|
457
764
|
createdAt: created.createdAt,
|
|
765
|
+
updatedAt: created.updatedAt,
|
|
458
766
|
expiresAt: created.expiresAt,
|
|
767
|
+
lastInteractionAt: created.lastInteractionAt,
|
|
459
768
|
ttlSeconds,
|
|
460
769
|
elapsedTimer: created.elapsedTimer,
|
|
461
770
|
renewalCount: created.renewalCount,
|
|
@@ -463,9 +772,10 @@ export function registerSessionCommand(program) {
|
|
|
463
772
|
template: created.template,
|
|
464
773
|
launchPlan,
|
|
465
774
|
dashboardUrl,
|
|
466
|
-
resumed
|
|
775
|
+
resumed,
|
|
467
776
|
title: effectiveTitle || null,
|
|
468
|
-
titleAuto: Boolean(
|
|
777
|
+
titleAuto: Boolean(ensured.titleAuto),
|
|
778
|
+
titleSync: ensured.titleSync || undefined,
|
|
469
779
|
};
|
|
470
780
|
|
|
471
781
|
// Best-effort admin visibility sync. Session creation remains local-first.
|
|
@@ -475,11 +785,26 @@ export function registerSessionCommand(program) {
|
|
|
475
785
|
status: created.status,
|
|
476
786
|
createdAt: created.createdAt,
|
|
477
787
|
expiresAt: created.expiresAt,
|
|
788
|
+
title: effectiveTitle || null,
|
|
478
789
|
ttlSeconds,
|
|
479
790
|
template: created.template,
|
|
480
791
|
codebaseContext: created.codebaseContext,
|
|
481
792
|
}).catch(() => {});
|
|
482
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
|
+
|
|
483
808
|
if (shouldEmitJson(options, command)) {
|
|
484
809
|
console.log(JSON.stringify(payload, null, 2));
|
|
485
810
|
return;
|
|
@@ -535,6 +860,115 @@ export function registerSessionCommand(program) {
|
|
|
535
860
|
await program.parseAsync(args, { from: "user" });
|
|
536
861
|
});
|
|
537
862
|
|
|
863
|
+
session
|
|
864
|
+
.command("ensure")
|
|
865
|
+
.description("Join or create the canonical session for this workspace and emit JSON")
|
|
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
|
+
)
|
|
871
|
+
.option("--title <title>", "Title applied if a new or unnamed resumed session needs one")
|
|
872
|
+
.option(
|
|
873
|
+
"--ttl-seconds <seconds>",
|
|
874
|
+
`Session time-to-live in seconds when a new session is minted (default ${DEFAULT_TTL_SECONDS})`
|
|
875
|
+
)
|
|
876
|
+
.option(
|
|
877
|
+
"--force-new",
|
|
878
|
+
"Always create a new session even if a recent active one exists for this workspace",
|
|
879
|
+
)
|
|
880
|
+
.option(
|
|
881
|
+
"--resume",
|
|
882
|
+
"Reuse the most recent active session for this workspace when one is inside the reuse window",
|
|
883
|
+
true,
|
|
884
|
+
)
|
|
885
|
+
.option("--no-resume", "Disable automatic resume and mint a new session")
|
|
886
|
+
.option(
|
|
887
|
+
"--reuse-window-seconds <seconds>",
|
|
888
|
+
"Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
|
|
889
|
+
"3600",
|
|
890
|
+
)
|
|
891
|
+
.option("--json", "Emit machine-readable output (default for this command)")
|
|
892
|
+
.action(async (options) => {
|
|
893
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
894
|
+
const ttlSeconds = parsePositiveInteger(
|
|
895
|
+
options.ttlSeconds,
|
|
896
|
+
"ttl-seconds",
|
|
897
|
+
DEFAULT_TTL_SECONDS,
|
|
898
|
+
);
|
|
899
|
+
const reuseWindowSeconds = parsePositiveInteger(
|
|
900
|
+
options.reuseWindowSeconds,
|
|
901
|
+
"reuse-window-seconds",
|
|
902
|
+
3600,
|
|
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
|
+
|
|
952
|
+
const ensured = await ensureWorkspaceSession({
|
|
953
|
+
targetPath,
|
|
954
|
+
ttlSeconds,
|
|
955
|
+
title: normalizeString(options.title),
|
|
956
|
+
resume: options.resume !== false,
|
|
957
|
+
forceNew: Boolean(options.forceNew),
|
|
958
|
+
reuseWindowSeconds,
|
|
959
|
+
});
|
|
960
|
+
const payload = {
|
|
961
|
+
command: "session ensure",
|
|
962
|
+
targetPath,
|
|
963
|
+
sessionId: ensured.created.sessionId,
|
|
964
|
+
title: ensured.title || null,
|
|
965
|
+
resumed: Boolean(ensured.resumedCandidate),
|
|
966
|
+
dashboardUrl: buildDashboardUrl(ensured.created.sessionId),
|
|
967
|
+
titleSync: ensured.titleSync || undefined,
|
|
968
|
+
};
|
|
969
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
970
|
+
});
|
|
971
|
+
|
|
538
972
|
session
|
|
539
973
|
.command("set-title <sessionId> <title>")
|
|
540
974
|
.description("Set the human-readable title on a session (visible in web sidebar + transcript).")
|
|
@@ -564,10 +998,15 @@ export function registerSessionCommand(program) {
|
|
|
564
998
|
body: { title: normalizedTitle },
|
|
565
999
|
},
|
|
566
1000
|
);
|
|
1001
|
+
const localUpdated = await updateSessionTitle(normalizedSessionId, {
|
|
1002
|
+
targetPath,
|
|
1003
|
+
title: normalizedTitle,
|
|
1004
|
+
}).catch(() => null);
|
|
567
1005
|
const payload = {
|
|
568
1006
|
command: "session set-title",
|
|
569
1007
|
sessionId: normalizedSessionId,
|
|
570
1008
|
title: normalizedTitle,
|
|
1009
|
+
localUpdated: Boolean(localUpdated),
|
|
571
1010
|
result,
|
|
572
1011
|
};
|
|
573
1012
|
if (shouldEmitJson(options, command)) {
|
|
@@ -657,8 +1096,14 @@ export function registerSessionCommand(program) {
|
|
|
657
1096
|
|
|
658
1097
|
session
|
|
659
1098
|
.command("join <sessionId>")
|
|
660
|
-
.description(
|
|
661
|
-
|
|
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
|
+
)
|
|
662
1107
|
.option("--role <role>", "Agent role: coder, reviewer, tester, observer", "coder")
|
|
663
1108
|
.option("--model <model>", "Agent model hint", "cli")
|
|
664
1109
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
@@ -669,27 +1114,104 @@ export function registerSessionCommand(program) {
|
|
|
669
1114
|
throw new Error("session id is required.");
|
|
670
1115
|
}
|
|
671
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).
|
|
672
1162
|
const joined = await registerAgent(normalizedSessionId, {
|
|
673
1163
|
targetPath,
|
|
674
|
-
agentId:
|
|
675
|
-
model
|
|
676
|
-
role
|
|
1164
|
+
agentId: resolvedAgentId,
|
|
1165
|
+
model,
|
|
1166
|
+
role,
|
|
677
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
|
+
|
|
678
1186
|
const payload = {
|
|
679
1187
|
command: "session join",
|
|
1188
|
+
joined: true,
|
|
680
1189
|
targetPath,
|
|
681
1190
|
sessionId: normalizedSessionId,
|
|
1191
|
+
title: remoteTitle || null,
|
|
682
1192
|
agentId: joined.agentId,
|
|
683
1193
|
role: joined.role,
|
|
684
1194
|
model: joined.model,
|
|
685
1195
|
status: joined.status,
|
|
686
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,
|
|
687
1203
|
};
|
|
688
1204
|
if (shouldEmitJson(options, command)) {
|
|
689
1205
|
console.log(JSON.stringify(payload, null, 2));
|
|
690
1206
|
return;
|
|
691
1207
|
}
|
|
692
|
-
|
|
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
|
+
);
|
|
693
1215
|
console.log(pc.gray(`agent=${joined.agentId} role=${joined.role} model=${joined.model}`));
|
|
694
1216
|
});
|
|
695
1217
|
|
|
@@ -697,6 +1219,7 @@ export function registerSessionCommand(program) {
|
|
|
697
1219
|
.command("say <sessionId> <message>")
|
|
698
1220
|
.description("Send a message to the session")
|
|
699
1221
|
.option("--agent <id>", "Agent id to emit from", "cli-user")
|
|
1222
|
+
.option("--to <agent>", "Direct the message to a specific agent id")
|
|
700
1223
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
701
1224
|
.option("--json", "Emit machine-readable output")
|
|
702
1225
|
.action(async (sessionId, message, options, command) => {
|
|
@@ -709,16 +1232,41 @@ export function registerSessionCommand(program) {
|
|
|
709
1232
|
throw new Error("message is required.");
|
|
710
1233
|
}
|
|
711
1234
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
712
|
-
const agentId =
|
|
1235
|
+
const agentId = await defaultAgentId(options.agent, targetPath);
|
|
1236
|
+
const localSession = await ensureLocalSessionForRemoteCommand(normalizedSessionId, {
|
|
1237
|
+
targetPath,
|
|
1238
|
+
});
|
|
1239
|
+
const to = normalizeString(options.to);
|
|
1240
|
+
const eventPayload = {
|
|
1241
|
+
message: normalizedMessage,
|
|
1242
|
+
channel: "session",
|
|
1243
|
+
};
|
|
1244
|
+
if (to) {
|
|
1245
|
+
eventPayload.to = to;
|
|
1246
|
+
}
|
|
1247
|
+
const clientMessageId = `cli-${randomUUID()}`;
|
|
713
1248
|
const event = createAgentEvent({
|
|
714
1249
|
event: "session_message",
|
|
715
1250
|
agentId,
|
|
716
1251
|
sessionId: normalizedSessionId,
|
|
717
|
-
payload:
|
|
718
|
-
message: normalizedMessage,
|
|
719
|
-
channel: "session",
|
|
720
|
-
},
|
|
1252
|
+
payload: eventPayload,
|
|
721
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
|
+
}
|
|
722
1270
|
const persisted = await appendToStream(normalizedSessionId, event, {
|
|
723
1271
|
targetPath,
|
|
724
1272
|
});
|
|
@@ -728,6 +1276,8 @@ export function registerSessionCommand(program) {
|
|
|
728
1276
|
sessionId: normalizedSessionId,
|
|
729
1277
|
agentId,
|
|
730
1278
|
event: persisted,
|
|
1279
|
+
materializedLocalSession: localSession.materialized,
|
|
1280
|
+
remoteSync: remoteSync || undefined,
|
|
731
1281
|
};
|
|
732
1282
|
if (shouldEmitJson(options, command)) {
|
|
733
1283
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -736,6 +1286,198 @@ export function registerSessionCommand(program) {
|
|
|
736
1286
|
console.log(formatEventLine(persisted));
|
|
737
1287
|
});
|
|
738
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,
|
|
1368
|
+
};
|
|
1369
|
+
if (shouldEmitJson(options, command)) {
|
|
1370
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
console.log(formatEventLine(persisted));
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
session
|
|
1377
|
+
.command("listen")
|
|
1378
|
+
.description("Background-poll a session for events addressed to this agent or broadcast")
|
|
1379
|
+
.requiredOption("--session <id>", "Session id to listen to")
|
|
1380
|
+
.option(
|
|
1381
|
+
"--agent <id>",
|
|
1382
|
+
"Agent id to receive messages for",
|
|
1383
|
+
process.env.SENTINELAYER_AGENT_ID || "cli-user",
|
|
1384
|
+
)
|
|
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
|
+
)
|
|
1396
|
+
.option("--emit <format>", "Output format: ndjson or text", "ndjson")
|
|
1397
|
+
.option("--limit <n>", "Maximum events to request per poll (default 200)", "200")
|
|
1398
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
1399
|
+
.option("--since <cursor>", "Override the persisted listen cursor")
|
|
1400
|
+
.option("--replay", "Emit matching historical events on the first poll")
|
|
1401
|
+
.option("--max-polls <n>", "Stop after N poll cycles (useful for tests and smoke checks)")
|
|
1402
|
+
.action(async (options) => {
|
|
1403
|
+
const normalizedSessionId = resolveSessionIdOption(options);
|
|
1404
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1405
|
+
const agentId = normalizeAgentId(options.agent, "cli-user");
|
|
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);
|
|
1413
|
+
const limit = parsePositiveInteger(options.limit, "limit", 200);
|
|
1414
|
+
const emitFormat = normalizeString(options.emit).toLowerCase() || "ndjson";
|
|
1415
|
+
if (!["ndjson", "text"].includes(emitFormat)) {
|
|
1416
|
+
throw new Error("--emit must be one of: ndjson, text.");
|
|
1417
|
+
}
|
|
1418
|
+
const maxPolls =
|
|
1419
|
+
options.maxPolls === undefined
|
|
1420
|
+
? null
|
|
1421
|
+
: parsePositiveInteger(options.maxPolls, "max-polls", 1);
|
|
1422
|
+
const since = options.since === undefined ? undefined : String(options.since);
|
|
1423
|
+
const ac = new AbortController();
|
|
1424
|
+
const onSigint = () => ac.abort();
|
|
1425
|
+
process.on("SIGINT", onSigint);
|
|
1426
|
+
|
|
1427
|
+
if (emitFormat === "text") {
|
|
1428
|
+
console.log(
|
|
1429
|
+
pc.gray(
|
|
1430
|
+
`Listening to session ${normalizedSessionId} as ${agentId}; idle=${intervalSeconds}s active=${activeIntervalSeconds}s/${activeWindowSeconds}s. Press Ctrl+C to stop.`,
|
|
1431
|
+
),
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
try {
|
|
1436
|
+
await listenSessionEvents({
|
|
1437
|
+
sessionId: normalizedSessionId,
|
|
1438
|
+
targetPath,
|
|
1439
|
+
agentId,
|
|
1440
|
+
intervalSeconds,
|
|
1441
|
+
activeIntervalSeconds,
|
|
1442
|
+
activeWindowSeconds,
|
|
1443
|
+
limit,
|
|
1444
|
+
since,
|
|
1445
|
+
replay: Boolean(options.replay),
|
|
1446
|
+
maxPolls,
|
|
1447
|
+
signal: ac.signal,
|
|
1448
|
+
onEvent: async (event) => {
|
|
1449
|
+
if (emitFormat === "ndjson") {
|
|
1450
|
+
console.log(JSON.stringify(event));
|
|
1451
|
+
} else {
|
|
1452
|
+
console.log(formatEventLine(event));
|
|
1453
|
+
}
|
|
1454
|
+
},
|
|
1455
|
+
onError: async (result) => {
|
|
1456
|
+
const reason = normalizeString(result?.reason) || "poll_failed";
|
|
1457
|
+
if (emitFormat === "ndjson") {
|
|
1458
|
+
console.log(
|
|
1459
|
+
JSON.stringify(
|
|
1460
|
+
createAgentEvent({
|
|
1461
|
+
event: "session_listen_error",
|
|
1462
|
+
agentId,
|
|
1463
|
+
sessionId: normalizedSessionId,
|
|
1464
|
+
payload: {
|
|
1465
|
+
reason,
|
|
1466
|
+
cursor: result?.cursor || null,
|
|
1467
|
+
},
|
|
1468
|
+
}),
|
|
1469
|
+
),
|
|
1470
|
+
);
|
|
1471
|
+
} else {
|
|
1472
|
+
console.log(pc.yellow(`Listen poll skipped (${reason}).`));
|
|
1473
|
+
}
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
} finally {
|
|
1477
|
+
process.removeListener("SIGINT", onSigint);
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
|
|
739
1481
|
session
|
|
740
1482
|
.command("read <sessionId>")
|
|
741
1483
|
.description("Read recent session messages")
|
|
@@ -761,11 +1503,17 @@ export function registerSessionCommand(program) {
|
|
|
761
1503
|
const emitJson = shouldEmitJson(options, command);
|
|
762
1504
|
|
|
763
1505
|
let hydration = null;
|
|
1506
|
+
let remoteTail = null;
|
|
764
1507
|
if (options.remote) {
|
|
765
1508
|
hydration = await hydrateSessionFromRemote({
|
|
766
1509
|
sessionId: normalizedSessionId,
|
|
767
1510
|
targetPath,
|
|
768
1511
|
});
|
|
1512
|
+
remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
|
|
1513
|
+
targetPath,
|
|
1514
|
+
limit: tail,
|
|
1515
|
+
timeoutMs: 15_000,
|
|
1516
|
+
});
|
|
769
1517
|
if (!emitJson) {
|
|
770
1518
|
if (hydration.ok) {
|
|
771
1519
|
console.log(
|
|
@@ -773,6 +1521,13 @@ export function registerSessionCommand(program) {
|
|
|
773
1521
|
`Hydrated from remote: relayed=${hydration.relayed} dropped=${hydration.dropped}.`,
|
|
774
1522
|
),
|
|
775
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
|
+
}
|
|
776
1531
|
} else {
|
|
777
1532
|
console.log(
|
|
778
1533
|
pc.yellow(
|
|
@@ -784,10 +1539,34 @@ export function registerSessionCommand(program) {
|
|
|
784
1539
|
}
|
|
785
1540
|
|
|
786
1541
|
if (!options.follow) {
|
|
787
|
-
const
|
|
1542
|
+
const allEvents = await readStream(normalizedSessionId, {
|
|
788
1543
|
targetPath,
|
|
789
|
-
tail,
|
|
1544
|
+
tail: 0,
|
|
790
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);
|
|
791
1570
|
const payload = {
|
|
792
1571
|
command: "session read",
|
|
793
1572
|
targetPath,
|
|
@@ -795,7 +1574,19 @@ export function registerSessionCommand(program) {
|
|
|
795
1574
|
tail,
|
|
796
1575
|
count: events.length,
|
|
797
1576
|
events,
|
|
798
|
-
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,
|
|
799
1590
|
};
|
|
800
1591
|
if (emitJson) {
|
|
801
1592
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -853,10 +1644,15 @@ export function registerSessionCommand(program) {
|
|
|
853
1644
|
if (!emitJson) {
|
|
854
1645
|
console.log(pc.gray(`Following session ${normalizedSessionId}... Press Ctrl+C to stop.`));
|
|
855
1646
|
}
|
|
1647
|
+
const seenFollowEvents = new Set();
|
|
856
1648
|
for await (const event of tailStream(normalizedSessionId, {
|
|
857
1649
|
targetPath,
|
|
858
1650
|
replayTail: tail,
|
|
859
1651
|
})) {
|
|
1652
|
+
if (sessionEventHasKnownIdentity(event, seenFollowEvents)) {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
addSessionEventIdentityKeys(seenFollowEvents, event);
|
|
860
1656
|
if (emitJson) {
|
|
861
1657
|
console.log(JSON.stringify(event));
|
|
862
1658
|
} else {
|
|
@@ -910,6 +1706,11 @@ export function registerSessionCommand(program) {
|
|
|
910
1706
|
dropped: result.dropped,
|
|
911
1707
|
cursor: result.cursor,
|
|
912
1708
|
persistedCursor: result.persistedCursor,
|
|
1709
|
+
humanRelayed: result.humanRelayed,
|
|
1710
|
+
eventsRelayed: result.eventsRelayed,
|
|
1711
|
+
eventsCursor: result.eventsCursor,
|
|
1712
|
+
materializedLocalSession: result.materializedLocalSession,
|
|
1713
|
+
localAppendComplete: result.localAppendComplete,
|
|
913
1714
|
access: access || undefined,
|
|
914
1715
|
};
|
|
915
1716
|
if (shouldEmitJson(options, command)) {
|
|
@@ -1249,7 +2050,7 @@ export function registerSessionCommand(program) {
|
|
|
1249
2050
|
throw new Error("session id is required.");
|
|
1250
2051
|
}
|
|
1251
2052
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1252
|
-
const agentId =
|
|
2053
|
+
const agentId = await defaultAgentId(options.agent, targetPath);
|
|
1253
2054
|
const left = await unregisterAgent(normalizedSessionId, agentId, {
|
|
1254
2055
|
reason: options.reason || "manual",
|
|
1255
2056
|
targetPath,
|