sentinelayer-cli 0.8.9 → 0.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/session.js +234 -12
- package/src/session/agent-registry.js +59 -0
- package/src/session/remote-hydrate.js +75 -21
- package/src/session/sync-cursor.js +12 -6
- package/src/session/sync.js +137 -0
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -261,8 +261,11 @@ export function registerSessionCommand(program) {
|
|
|
261
261
|
|
|
262
262
|
session
|
|
263
263
|
.command("start")
|
|
264
|
-
.description(
|
|
264
|
+
.description(
|
|
265
|
+
"Start (or resume) a persistent session. By default reuses the most recent active session for this workspace; pass --force-new to always mint a fresh id.",
|
|
266
|
+
)
|
|
265
267
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
268
|
+
.option("--title <title>", "Human-readable label (shown in web sidebar + transcript)")
|
|
266
269
|
.option(
|
|
267
270
|
"--template <name>",
|
|
268
271
|
"Optional quick-start template (code-review, security-audit, e2e-test, incident-response, standup)"
|
|
@@ -271,6 +274,15 @@ export function registerSessionCommand(program) {
|
|
|
271
274
|
"--ttl-seconds <seconds>",
|
|
272
275
|
`Session time-to-live in seconds (default ${DEFAULT_TTL_SECONDS}; template defaults override when omitted)`
|
|
273
276
|
)
|
|
277
|
+
.option(
|
|
278
|
+
"--force-new",
|
|
279
|
+
"Always create a new session even if a recent active one exists for this workspace",
|
|
280
|
+
)
|
|
281
|
+
.option(
|
|
282
|
+
"--reuse-window-seconds <seconds>",
|
|
283
|
+
"Window in which an existing active session for this workspace will be reused (default 3600 = 1h)",
|
|
284
|
+
"3600",
|
|
285
|
+
)
|
|
274
286
|
.option("--json", "Emit machine-readable output")
|
|
275
287
|
.action(async (options, command) => {
|
|
276
288
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
@@ -284,15 +296,96 @@ export function registerSessionCommand(program) {
|
|
|
284
296
|
"ttl-seconds",
|
|
285
297
|
templateDefaultTtlSeconds
|
|
286
298
|
);
|
|
299
|
+
const reuseWindowSeconds = parsePositiveInteger(
|
|
300
|
+
options.reuseWindowSeconds,
|
|
301
|
+
"reuse-window-seconds",
|
|
302
|
+
3600,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// Auto-resume: if there's an active local session for this same
|
|
306
|
+
// workspace path created in the last `reuseWindowSeconds`, reuse
|
|
307
|
+
// it instead of minting a new id. Kills the orphan-creation
|
|
308
|
+
// pattern where every CLI invocation produced a fresh empty
|
|
309
|
+
// session. `--force-new` opts back into the old behavior.
|
|
310
|
+
let resumed = null;
|
|
311
|
+
if (!options.forceNew) {
|
|
312
|
+
try {
|
|
313
|
+
const active = await listActiveSessions({ targetPath });
|
|
314
|
+
const cutoffMs = Date.now() - reuseWindowSeconds * 1000;
|
|
315
|
+
const candidates = active.filter((entry) => {
|
|
316
|
+
const createdMs = Date.parse(entry.createdAt || "");
|
|
317
|
+
return Number.isFinite(createdMs) && createdMs >= cutoffMs;
|
|
318
|
+
});
|
|
319
|
+
candidates.sort((a, b) =>
|
|
320
|
+
String(b.lastActivityAt || b.createdAt || "").localeCompare(
|
|
321
|
+
String(a.lastActivityAt || a.createdAt || ""),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
if (candidates.length > 0) {
|
|
325
|
+
resumed = candidates[0];
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
// listActiveSessions failure is non-fatal; fall through to fresh create.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
287
332
|
const startedAt = Date.now();
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
333
|
+
let created;
|
|
334
|
+
if (resumed) {
|
|
335
|
+
// Surface the resumed session's metadata in the same shape
|
|
336
|
+
// createSession returns so downstream code stays unchanged.
|
|
337
|
+
created = {
|
|
338
|
+
sessionId: resumed.sessionId,
|
|
339
|
+
sessionDir: resumed.sessionDir || null,
|
|
340
|
+
metadataPath: resumed.metadataPath || null,
|
|
341
|
+
streamPath: resumed.streamPath || null,
|
|
342
|
+
createdAt: resumed.createdAt,
|
|
343
|
+
expiresAt: resumed.expiresAt,
|
|
344
|
+
elapsedTimer: 0,
|
|
345
|
+
renewalCount: resumed.renewalCount || 0,
|
|
346
|
+
status: resumed.status || "active",
|
|
347
|
+
template: resumed.template || null,
|
|
348
|
+
codebaseContext: resumed.codebaseContext || null,
|
|
349
|
+
resumed: true,
|
|
350
|
+
};
|
|
351
|
+
} else {
|
|
352
|
+
created = await createSession({
|
|
353
|
+
targetPath,
|
|
354
|
+
ttlSeconds,
|
|
355
|
+
template,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
293
358
|
const durationMs = Date.now() - startedAt;
|
|
294
359
|
const launchPlan = template ? buildTemplateLaunchPlan(created.sessionId, template) : [];
|
|
295
360
|
const dashboardUrl = buildDashboardUrl(created.sessionId);
|
|
361
|
+
const titleArg = normalizeString(options.title);
|
|
362
|
+
|
|
363
|
+
// If the caller passed --title, push it to the API so the web
|
|
364
|
+
// sidebar shows the label (best-effort, non-blocking).
|
|
365
|
+
if (titleArg) {
|
|
366
|
+
void (async () => {
|
|
367
|
+
try {
|
|
368
|
+
const session = await resolveActiveAuthSession({
|
|
369
|
+
cwd: targetPath,
|
|
370
|
+
env: process.env,
|
|
371
|
+
autoRotate: false,
|
|
372
|
+
});
|
|
373
|
+
if (!session?.token || !session?.apiUrl) return;
|
|
374
|
+
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
375
|
+
await requestJsonMutation(
|
|
376
|
+
`${apiUrl}/api/v1/sessions/${encodeURIComponent(created.sessionId)}/title`,
|
|
377
|
+
{
|
|
378
|
+
method: "POST",
|
|
379
|
+
operationName: "session.set_title",
|
|
380
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
381
|
+
body: { title: titleArg },
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
} catch (_error) {
|
|
385
|
+
/* best-effort */
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
}
|
|
296
389
|
|
|
297
390
|
const payload = {
|
|
298
391
|
command: "session start",
|
|
@@ -311,6 +404,8 @@ export function registerSessionCommand(program) {
|
|
|
311
404
|
template: created.template,
|
|
312
405
|
launchPlan,
|
|
313
406
|
dashboardUrl,
|
|
407
|
+
resumed: Boolean(resumed),
|
|
408
|
+
title: titleArg || null,
|
|
314
409
|
};
|
|
315
410
|
|
|
316
411
|
// Best-effort admin visibility sync. Session creation remains local-first.
|
|
@@ -331,8 +426,12 @@ export function registerSessionCommand(program) {
|
|
|
331
426
|
}
|
|
332
427
|
|
|
333
428
|
if (template) {
|
|
334
|
-
console.log(
|
|
335
|
-
|
|
429
|
+
console.log(
|
|
430
|
+
resumed
|
|
431
|
+
? `Resumed session ${created.sessionId} (template: ${template.id})`
|
|
432
|
+
: `Session ${created.sessionId} created (template: ${template.id})`,
|
|
433
|
+
);
|
|
434
|
+
if (launchPlan.length > 0 && !resumed) {
|
|
336
435
|
console.log("");
|
|
337
436
|
console.log("Launch your agents:");
|
|
338
437
|
for (const slot of launchPlan) {
|
|
@@ -344,13 +443,136 @@ export function registerSessionCommand(program) {
|
|
|
344
443
|
return;
|
|
345
444
|
}
|
|
346
445
|
|
|
347
|
-
console.log(pc.bold("Session created"));
|
|
446
|
+
console.log(pc.bold(resumed ? "Session resumed" : "Session created"));
|
|
348
447
|
console.log(pc.gray(`Session: ${created.sessionId}`));
|
|
349
|
-
console.log(pc.gray(`
|
|
350
|
-
console.log(pc.gray(`
|
|
448
|
+
if (titleArg) console.log(pc.gray(`Title: ${titleArg}`));
|
|
449
|
+
if (created.streamPath) console.log(pc.gray(`Stream: ${created.streamPath}`));
|
|
450
|
+
console.log(pc.gray(`${resumed ? "Resumed" : "Created"} in ${durationMs}ms`));
|
|
351
451
|
console.log(
|
|
352
|
-
`status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}
|
|
452
|
+
`status=${created.status} created_at=${created.createdAt} expires_at=${created.expiresAt} ttl_seconds=${ttlSeconds}`,
|
|
353
453
|
);
|
|
454
|
+
if (!resumed) {
|
|
455
|
+
console.log(
|
|
456
|
+
pc.gray(
|
|
457
|
+
"Tip: subsequent `slc session start` in this workspace within an hour will resume this session. Pass --force-new to override.",
|
|
458
|
+
),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
session
|
|
464
|
+
.command("continue")
|
|
465
|
+
.description("Alias for `session start --resume` — resume the most recent active session for this workspace, or create one if none exists.")
|
|
466
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
467
|
+
.option("--title <title>", "Title applied if a new session is created")
|
|
468
|
+
.option("--json", "Emit machine-readable output")
|
|
469
|
+
.action(async (options, command) => {
|
|
470
|
+
// Delegate to session start without --force-new. Commander parses
|
|
471
|
+
// the args for us via the parent action; here we just shell out.
|
|
472
|
+
const args = ["session", "start", "--path", String(options.path || ".")];
|
|
473
|
+
if (options.title) args.push("--title", String(options.title));
|
|
474
|
+
if (shouldEmitJson(options, command)) args.push("--json");
|
|
475
|
+
await program.parseAsync(args, { from: "user" });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
session
|
|
479
|
+
.command("set-title <sessionId> <title>")
|
|
480
|
+
.description("Set the human-readable title on a session (visible in web sidebar + transcript).")
|
|
481
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
482
|
+
.option("--json", "Emit machine-readable output")
|
|
483
|
+
.action(async (sessionId, title, options, command) => {
|
|
484
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
485
|
+
if (!normalizedSessionId) throw new Error("session id is required.");
|
|
486
|
+
const normalizedTitle = normalizeString(title);
|
|
487
|
+
if (!normalizedTitle) throw new Error("title is required.");
|
|
488
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
489
|
+
const session = await resolveActiveAuthSession({
|
|
490
|
+
cwd: targetPath,
|
|
491
|
+
env: process.env,
|
|
492
|
+
autoRotate: false,
|
|
493
|
+
});
|
|
494
|
+
if (!session?.token || !session?.apiUrl) {
|
|
495
|
+
throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
|
|
496
|
+
}
|
|
497
|
+
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
498
|
+
const result = await requestJsonMutation(
|
|
499
|
+
`${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/title`,
|
|
500
|
+
{
|
|
501
|
+
method: "POST",
|
|
502
|
+
operationName: "session.set_title",
|
|
503
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
504
|
+
body: { title: normalizedTitle },
|
|
505
|
+
},
|
|
506
|
+
);
|
|
507
|
+
const payload = {
|
|
508
|
+
command: "session set-title",
|
|
509
|
+
sessionId: normalizedSessionId,
|
|
510
|
+
title: normalizedTitle,
|
|
511
|
+
result,
|
|
512
|
+
};
|
|
513
|
+
if (shouldEmitJson(options, command)) {
|
|
514
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
console.log(pc.bold(`Title set on ${normalizedSessionId}`));
|
|
518
|
+
console.log(pc.gray(`title=${normalizedTitle}`));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
session
|
|
522
|
+
.command("cleanup")
|
|
523
|
+
.description("Bulk-archive empty stale sessions on the SentinelLayer dashboard. Targets sessions with ≤1 events older than --cutoff-minutes.")
|
|
524
|
+
.option("--cutoff-minutes <n>", "Age threshold in minutes (default 60)", "60")
|
|
525
|
+
.option("--max-events <n>", "Max events to still treat as empty (default 1)", "1")
|
|
526
|
+
.option("--apply", "Actually archive (default is dry-run)")
|
|
527
|
+
.option("--path <path>", "Workspace path", ".")
|
|
528
|
+
.option("--json", "Emit machine-readable output")
|
|
529
|
+
.action(async (options, command) => {
|
|
530
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
531
|
+
const cutoffMinutes = parsePositiveInteger(options.cutoffMinutes, "cutoff-minutes", 60);
|
|
532
|
+
const maxEvents = parsePositiveInteger(options.maxEvents, "max-events", 1);
|
|
533
|
+
const dryRun = !options.apply;
|
|
534
|
+
const session = await resolveActiveAuthSession({
|
|
535
|
+
cwd: targetPath,
|
|
536
|
+
env: process.env,
|
|
537
|
+
autoRotate: false,
|
|
538
|
+
});
|
|
539
|
+
if (!session?.token || !session?.apiUrl) {
|
|
540
|
+
throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
|
|
541
|
+
}
|
|
542
|
+
const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
|
|
543
|
+
const result = await requestJsonMutation(
|
|
544
|
+
`${apiUrl}/api/v1/sessions/sweep`,
|
|
545
|
+
{
|
|
546
|
+
method: "POST",
|
|
547
|
+
operationName: "session.sweep_empty",
|
|
548
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
549
|
+
body: {
|
|
550
|
+
cutoffMinutes,
|
|
551
|
+
maxEvents,
|
|
552
|
+
dryRun,
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
);
|
|
556
|
+
const payload = {
|
|
557
|
+
command: "session cleanup",
|
|
558
|
+
dryRun,
|
|
559
|
+
cutoffMinutes,
|
|
560
|
+
maxEvents,
|
|
561
|
+
result,
|
|
562
|
+
};
|
|
563
|
+
if (shouldEmitJson(options, command)) {
|
|
564
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const scanned = result?.scanned || 0;
|
|
568
|
+
const archived = result?.archived || 0;
|
|
569
|
+
console.log(pc.bold(dryRun ? "Cleanup dry-run" : "Cleanup applied"));
|
|
570
|
+
console.log(
|
|
571
|
+
pc.gray(`scanned=${scanned} archived=${archived} cutoff=${cutoffMinutes}m max-events=${maxEvents}`),
|
|
572
|
+
);
|
|
573
|
+
if (dryRun && scanned > 0) {
|
|
574
|
+
console.log(pc.gray(`Re-run with --apply to archive these ${scanned} sessions.`));
|
|
575
|
+
}
|
|
354
576
|
});
|
|
355
577
|
|
|
356
578
|
session
|
|
@@ -180,6 +180,62 @@ export function generateAgentId(modelName) {
|
|
|
180
180
|
return `${prefix}-${suffix}`;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// In-process registry of agents registered by *this* CLI process. The
|
|
184
|
+
// dashboard treats any participant without a terminal agent_leave /
|
|
185
|
+
// agent_killed / session_killed event as "active". When a CLI exits via
|
|
186
|
+
// SIGINT/SIGTERM/crash without explicitly leaving, the dashboard shows
|
|
187
|
+
// "Last activity: 15h ago — active" indefinitely. This registry lets a
|
|
188
|
+
// single process-wide exit hook flush leave events for every agent it
|
|
189
|
+
// owns so the participant roster stays honest.
|
|
190
|
+
const _localAgents = new Map(); // key: `${sessionId}::${agentId}` -> { sessionId, agentId, targetPath }
|
|
191
|
+
let _exitHooksInstalled = false;
|
|
192
|
+
|
|
193
|
+
function _agentKey(sessionId, agentId) {
|
|
194
|
+
return `${sessionId}::${agentId}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _trackLocalAgent(sessionId, agentId, targetPath) {
|
|
198
|
+
_localAgents.set(_agentKey(sessionId, agentId), { sessionId, agentId, targetPath });
|
|
199
|
+
_ensureExitHooksInstalled();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function _untrackLocalAgent(sessionId, agentId) {
|
|
203
|
+
_localAgents.delete(_agentKey(sessionId, agentId));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function _emitLeaveForAllLocalAgents(reason) {
|
|
207
|
+
const entries = [..._localAgents.values()];
|
|
208
|
+
_localAgents.clear();
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
try {
|
|
211
|
+
await emitAgentEvent(
|
|
212
|
+
entry.sessionId,
|
|
213
|
+
"agent_leave",
|
|
214
|
+
{ agentId: entry.agentId, reason, model: "unknown", role: "participant" },
|
|
215
|
+
{ targetPath: entry.targetPath },
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
// Best-effort: a stuck filesystem or network shouldn't block exit.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _ensureExitHooksInstalled() {
|
|
224
|
+
if (_exitHooksInstalled) return;
|
|
225
|
+
_exitHooksInstalled = true;
|
|
226
|
+
const onSignal = (signal) => {
|
|
227
|
+
void _emitLeaveForAllLocalAgents("manual").finally(() => {
|
|
228
|
+
process.removeListener(signal, onSignal);
|
|
229
|
+
process.kill(process.pid, signal);
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
process.on("SIGINT", onSignal);
|
|
233
|
+
process.on("SIGTERM", onSignal);
|
|
234
|
+
process.on("beforeExit", () => {
|
|
235
|
+
void _emitLeaveForAllLocalAgents("manual");
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
183
239
|
export async function registerAgent(
|
|
184
240
|
sessionId,
|
|
185
241
|
{ agentId = "", model = "", role = "observer", targetPath = process.cwd() } = {}
|
|
@@ -234,6 +290,7 @@ export async function registerAgent(
|
|
|
234
290
|
role: snapshot.role,
|
|
235
291
|
status: snapshot.status,
|
|
236
292
|
}, { targetPath });
|
|
293
|
+
_trackLocalAgent(paths.sessionId, snapshot.agentId, targetPath);
|
|
237
294
|
|
|
238
295
|
if (renamedFrom) {
|
|
239
296
|
const welcome = buildSentiWelcome({
|
|
@@ -347,6 +404,8 @@ export async function unregisterAgent(
|
|
|
347
404
|
role: snapshot.role,
|
|
348
405
|
model: snapshot.model,
|
|
349
406
|
}, { targetPath });
|
|
407
|
+
// Already left explicitly — don't double-emit on process exit.
|
|
408
|
+
_untrackLocalAgent(paths.sessionId, snapshot.agentId);
|
|
350
409
|
|
|
351
410
|
return {
|
|
352
411
|
...snapshot,
|
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One-shot remote hydrator for the local NDJSON stream.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Pulls events from the SentinelLayer API (BOTH human-posted messages
|
|
5
|
+
* AND agent-posted events) and appends them to the local session log
|
|
6
|
+
* with a persisted cursor. Powers `slc session sync` and
|
|
7
|
+
* `slc session read --remote`. The background daemon does the same thing
|
|
8
|
+
* on a poll loop; this is the synchronous counterpart.
|
|
9
|
+
*
|
|
10
|
+
* Why two pollers: Carter caught a multi-agent design bug in the
|
|
11
|
+
* standup session — agents polling via `pollHumanMessages` only saw
|
|
12
|
+
* web-posted human messages, never each other's `session_message` /
|
|
13
|
+
* `agent_response` events. Codex and claude talked past each other
|
|
14
|
+
* for hours ("Apologies — I missed your 5 updates"). Fix: also poll
|
|
15
|
+
* the durable `/sessions/{id}/events` endpoint (added in API #467)
|
|
16
|
+
* which returns ALL events. Per-source cursors keep the two pollers
|
|
17
|
+
* independent so a stuck human-message read doesn't block agent-event
|
|
18
|
+
* sync, and vice-versa.
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
|
-
import { pollHumanMessages } from "./sync.js";
|
|
21
|
+
import { pollHumanMessages, pollSessionEvents } from "./sync.js";
|
|
13
22
|
import { appendToStream } from "./stream.js";
|
|
14
23
|
import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
15
24
|
|
|
25
|
+
const EVENTS_CURSOR_SUFFIX = "events";
|
|
26
|
+
|
|
16
27
|
/**
|
|
17
28
|
* Fetch new human messages for a session, append them to the local
|
|
18
29
|
* stream, and advance the persisted cursor. Returns a structured
|
|
@@ -33,6 +44,7 @@ export async function hydrateSessionFromRemote({
|
|
|
33
44
|
targetPath = process.cwd(),
|
|
34
45
|
since = undefined,
|
|
35
46
|
_poll = pollHumanMessages,
|
|
47
|
+
_pollEvents = pollSessionEvents,
|
|
36
48
|
_append = appendToStream,
|
|
37
49
|
} = {}) {
|
|
38
50
|
if (!sessionId || typeof sessionId !== "string") {
|
|
@@ -46,29 +58,61 @@ export async function hydrateSessionFromRemote({
|
|
|
46
58
|
};
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
|
|
61
|
+
// Per-source cursors. The legacy human-message cursor is in the
|
|
62
|
+
// session's metadata file; the new agent-events cursor is in a
|
|
63
|
+
// sibling slot. Keeping them separate prevents a stuck/truncated
|
|
64
|
+
// poll on one source from poisoning the other.
|
|
65
|
+
const humanCursor =
|
|
50
66
|
typeof since === "string" || since === null
|
|
51
67
|
? since
|
|
52
68
|
: await readSyncCursor(sessionId, { targetPath });
|
|
69
|
+
const eventsCursor =
|
|
70
|
+
typeof since === "string" || since === null
|
|
71
|
+
? since
|
|
72
|
+
: await readSyncCursor(sessionId, { targetPath, suffix: EVENTS_CURSOR_SUFFIX });
|
|
53
73
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
74
|
+
// Run both pollers in parallel — they hit different endpoints and
|
|
75
|
+
// are independent. A human-only poll stays fast even when the
|
|
76
|
+
// events poll is heavy.
|
|
77
|
+
const [humanResult, eventsResult] = await Promise.all([
|
|
78
|
+
_poll(sessionId, { targetPath, since: humanCursor }),
|
|
79
|
+
_pollEvents(sessionId, { targetPath, since: eventsCursor }),
|
|
80
|
+
]);
|
|
58
81
|
|
|
59
|
-
|
|
82
|
+
// Dedup across sources — both endpoints can return the same event
|
|
83
|
+
// (e.g. a human relay event). Cursor values are unique per event.
|
|
84
|
+
const seenCursors = new Set();
|
|
85
|
+
const merged = [];
|
|
86
|
+
for (const e of humanResult?.events || []) {
|
|
87
|
+
const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
|
|
88
|
+
if (c && seenCursors.has(c)) continue;
|
|
89
|
+
if (c) seenCursors.add(c);
|
|
90
|
+
merged.push(e);
|
|
91
|
+
}
|
|
92
|
+
for (const e of eventsResult?.events || []) {
|
|
93
|
+
const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
|
|
94
|
+
if (c && seenCursors.has(c)) continue;
|
|
95
|
+
if (c) seenCursors.add(c);
|
|
96
|
+
merged.push(e);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// If BOTH pollers failed, surface the human-message failure (the
|
|
100
|
+
// legacy contract) so existing callers see no behavior change. If
|
|
101
|
+
// only one fails, treat the relay as partial-but-successful.
|
|
102
|
+
if (!humanResult?.ok && !eventsResult?.ok) {
|
|
60
103
|
return {
|
|
61
104
|
ok: false,
|
|
62
|
-
reason:
|
|
105
|
+
reason: humanResult?.reason || eventsResult?.reason || "poll_failed",
|
|
63
106
|
relayed: 0,
|
|
64
|
-
dropped: Array.isArray(
|
|
65
|
-
cursor:
|
|
107
|
+
dropped: Array.isArray(humanResult?.dropped) ? humanResult.dropped.length : 0,
|
|
108
|
+
cursor:
|
|
109
|
+
typeof humanResult?.cursor === "string" ? humanResult.cursor : humanCursor || null,
|
|
66
110
|
persistedCursor: false,
|
|
67
111
|
};
|
|
68
112
|
}
|
|
69
113
|
|
|
70
114
|
let relayed = 0;
|
|
71
|
-
for (const event of
|
|
115
|
+
for (const event of merged) {
|
|
72
116
|
try {
|
|
73
117
|
await _append(sessionId, event, { targetPath });
|
|
74
118
|
relayed += 1;
|
|
@@ -79,17 +123,27 @@ export async function hydrateSessionFromRemote({
|
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
let persistedCursor = false;
|
|
82
|
-
if (typeof
|
|
83
|
-
const result = await writeSyncCursor(sessionId,
|
|
126
|
+
if (typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
|
|
127
|
+
const result = await writeSyncCursor(sessionId, humanResult.cursor, { targetPath }).catch(() => null);
|
|
84
128
|
persistedCursor = Boolean(result && result.written);
|
|
85
129
|
}
|
|
130
|
+
if (typeof eventsResult?.cursor === "string" && eventsResult.cursor.trim()) {
|
|
131
|
+
await writeSyncCursor(sessionId, eventsResult.cursor, {
|
|
132
|
+
targetPath,
|
|
133
|
+
suffix: EVENTS_CURSOR_SUFFIX,
|
|
134
|
+
}).catch(() => null);
|
|
135
|
+
}
|
|
86
136
|
|
|
87
137
|
return {
|
|
88
138
|
ok: true,
|
|
89
139
|
reason: "",
|
|
90
140
|
relayed,
|
|
91
|
-
dropped: Array.isArray(
|
|
92
|
-
cursor: typeof
|
|
141
|
+
dropped: Array.isArray(humanResult?.dropped) ? humanResult.dropped.length : 0,
|
|
142
|
+
cursor: typeof humanResult?.cursor === "string" ? humanResult.cursor : humanCursor || null,
|
|
93
143
|
persistedCursor,
|
|
144
|
+
humanRelayed: (humanResult?.events || []).length,
|
|
145
|
+
eventsRelayed: (eventsResult?.events || []).length,
|
|
146
|
+
eventsCursor:
|
|
147
|
+
typeof eventsResult?.cursor === "string" ? eventsResult.cursor : eventsCursor || null,
|
|
94
148
|
};
|
|
95
149
|
}
|
|
@@ -13,8 +13,14 @@ import path from "node:path";
|
|
|
13
13
|
|
|
14
14
|
import { resolveSessionDir } from "./paths.js";
|
|
15
15
|
|
|
16
|
-
function cursorPath(sessionId, { targetPath } = {}) {
|
|
17
|
-
|
|
16
|
+
function cursorPath(sessionId, { targetPath, suffix = "" } = {}) {
|
|
17
|
+
// Multiple cursors per session — the legacy file is human-messages,
|
|
18
|
+
// and `suffix="events"` tracks the agent-events poller separately
|
|
19
|
+
// so a stuck or skewed read on one source doesn't block the other.
|
|
20
|
+
const slug = typeof suffix === "string" && suffix.trim()
|
|
21
|
+
? `remote-sync-cursor-${suffix.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-")}.json`
|
|
22
|
+
: "remote-sync-cursor.json";
|
|
23
|
+
return path.join(resolveSessionDir(sessionId, { targetPath }), slug);
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
/**
|
|
@@ -26,9 +32,9 @@ function cursorPath(sessionId, { targetPath } = {}) {
|
|
|
26
32
|
* @param {{targetPath?: string}} [options]
|
|
27
33
|
* @returns {Promise<string|null>}
|
|
28
34
|
*/
|
|
29
|
-
export async function readSyncCursor(sessionId, { targetPath } = {}) {
|
|
35
|
+
export async function readSyncCursor(sessionId, { targetPath, suffix = "" } = {}) {
|
|
30
36
|
if (!sessionId) return null;
|
|
31
|
-
const filePath = cursorPath(sessionId, { targetPath });
|
|
37
|
+
const filePath = cursorPath(sessionId, { targetPath, suffix });
|
|
32
38
|
try {
|
|
33
39
|
const raw = await fsp.readFile(filePath, "utf-8");
|
|
34
40
|
const parsed = JSON.parse(raw);
|
|
@@ -49,8 +55,8 @@ export async function readSyncCursor(sessionId, { targetPath } = {}) {
|
|
|
49
55
|
* @param {{targetPath?: string}} [options]
|
|
50
56
|
* @returns {Promise<{written: boolean, path: string}>}
|
|
51
57
|
*/
|
|
52
|
-
export async function writeSyncCursor(sessionId, cursor, { targetPath } = {}) {
|
|
53
|
-
const filePath = cursorPath(sessionId, { targetPath });
|
|
58
|
+
export async function writeSyncCursor(sessionId, cursor, { targetPath, suffix = "" } = {}) {
|
|
59
|
+
const filePath = cursorPath(sessionId, { targetPath, suffix });
|
|
54
60
|
const normalized = typeof cursor === "string" ? cursor.trim() : "";
|
|
55
61
|
if (!sessionId || !normalized) {
|
|
56
62
|
return { written: false, path: filePath };
|
package/src/session/sync.js
CHANGED
|
@@ -735,6 +735,143 @@ export async function pollHumanMessages(
|
|
|
735
735
|
}
|
|
736
736
|
}
|
|
737
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Poll the durable session-events endpoint for ALL events (not just
|
|
740
|
+
* human-posted ones). Fixes the cross-agent blind spot Carter caught
|
|
741
|
+
* in the standup session: agents polling via `pollHumanMessages` only
|
|
742
|
+
* saw web-posted human messages, never each other's `session_message`
|
|
743
|
+
* / `agent_response` events. The result was codex and claude talking
|
|
744
|
+
* past each other ("Apologies — I missed your 5 updates").
|
|
745
|
+
*
|
|
746
|
+
* Endpoint contract: `GET /api/v1/sessions/{id}/events?after=<cursor>&limit=N`.
|
|
747
|
+
* The API returns events in chronological order with cursor-based
|
|
748
|
+
* pagination. We map each row to the local NDJSON envelope shape so
|
|
749
|
+
* `appendToStream` accepts it without modification.
|
|
750
|
+
*
|
|
751
|
+
* @param {string} sessionId
|
|
752
|
+
* @param {object} [options]
|
|
753
|
+
* @param {string} [options.targetPath]
|
|
754
|
+
* @param {string|null} [options.since] - cursor to start after; null = full history
|
|
755
|
+
* @param {number} [options.limit] - default 200 (max from API)
|
|
756
|
+
* @param {number} [options.timeoutMs] - per-request deadline
|
|
757
|
+
* @returns {Promise<{ok: boolean, reason: string, events: Array<object>, cursor: string|null}>}
|
|
758
|
+
*/
|
|
759
|
+
export async function pollSessionEvents(
|
|
760
|
+
sessionId,
|
|
761
|
+
{
|
|
762
|
+
targetPath = process.cwd(),
|
|
763
|
+
since = null,
|
|
764
|
+
limit = 200,
|
|
765
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
766
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
767
|
+
fetchImpl = fetchWithTimeout,
|
|
768
|
+
nowMs = Date.now,
|
|
769
|
+
} = {}
|
|
770
|
+
) {
|
|
771
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
772
|
+
if (!normalizedSessionId) {
|
|
773
|
+
return {
|
|
774
|
+
ok: false,
|
|
775
|
+
reason: "invalid_session_id",
|
|
776
|
+
events: [],
|
|
777
|
+
cursor: normalizeString(since) || null,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
782
|
+
if (isCircuitOpen(inboundCircuit, normalizedNowMs)) {
|
|
783
|
+
return {
|
|
784
|
+
ok: false,
|
|
785
|
+
reason: "circuit_breaker_open",
|
|
786
|
+
events: [],
|
|
787
|
+
cursor: normalizeString(since) || null,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
let session = null;
|
|
792
|
+
try {
|
|
793
|
+
session = await resolveAuthSession({
|
|
794
|
+
cwd: targetPath,
|
|
795
|
+
env: process.env,
|
|
796
|
+
autoRotate: false,
|
|
797
|
+
});
|
|
798
|
+
} catch {
|
|
799
|
+
return {
|
|
800
|
+
ok: false,
|
|
801
|
+
reason: "no_session",
|
|
802
|
+
events: [],
|
|
803
|
+
cursor: normalizeString(since) || null,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (!session || !session.token) {
|
|
807
|
+
return {
|
|
808
|
+
ok: false,
|
|
809
|
+
reason: "not_authenticated",
|
|
810
|
+
events: [],
|
|
811
|
+
cursor: normalizeString(since) || null,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
816
|
+
const query = new URLSearchParams();
|
|
817
|
+
const normalizedSince = normalizeString(since);
|
|
818
|
+
if (normalizedSince) {
|
|
819
|
+
query.set("after", normalizedSince);
|
|
820
|
+
}
|
|
821
|
+
query.set("limit", String(Math.max(1, Math.min(200, normalizePositiveInteger(limit, 200)))));
|
|
822
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/events?${query.toString()}`;
|
|
823
|
+
|
|
824
|
+
try {
|
|
825
|
+
const response = await fetchImpl(
|
|
826
|
+
endpoint,
|
|
827
|
+
{
|
|
828
|
+
method: "GET",
|
|
829
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
830
|
+
},
|
|
831
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
|
|
832
|
+
);
|
|
833
|
+
if (!response || !response.ok) {
|
|
834
|
+
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
835
|
+
return {
|
|
836
|
+
ok: false,
|
|
837
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
838
|
+
events: [],
|
|
839
|
+
cursor: normalizedSince || null,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
const payload = await response.json().catch(() => ({}));
|
|
843
|
+
recordCircuitSuccess(inboundCircuit);
|
|
844
|
+
|
|
845
|
+
const items = Array.isArray(payload?.events) ? payload.events : [];
|
|
846
|
+
const acceptedEvents = [];
|
|
847
|
+
let lastCursor = normalizedSince || null;
|
|
848
|
+
for (const item of items) {
|
|
849
|
+
if (!item || typeof item !== "object") continue;
|
|
850
|
+
const cursor = normalizeString(item.cursor);
|
|
851
|
+
if (cursor) lastCursor = cursor;
|
|
852
|
+
// Pass through verbatim — the API already returns the NDJSON
|
|
853
|
+
// envelope shape that appendToStream expects.
|
|
854
|
+
acceptedEvents.push(item);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return {
|
|
858
|
+
ok: true,
|
|
859
|
+
reason: "",
|
|
860
|
+
events: acceptedEvents,
|
|
861
|
+
cursor: lastCursor,
|
|
862
|
+
};
|
|
863
|
+
} catch (error) {
|
|
864
|
+
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
865
|
+
return {
|
|
866
|
+
ok: false,
|
|
867
|
+
reason: normalizeString(error?.message) || "poll_failed",
|
|
868
|
+
events: [],
|
|
869
|
+
cursor: normalizedSince || null,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
|
|
738
875
|
/**
|
|
739
876
|
* List sessions owned by the active user via `GET /api/v1/sessions`.
|
|
740
877
|
*
|