sentinelayer-cli 0.8.8 → 0.8.10
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 +60 -1
- package/src/session/remote-hydrate.js +75 -21
- package/src/session/senti-naming.js +180 -0
- package/src/session/sync-cursor.js +12 -6
- package/src/session/sync.js +137 -0
- package/src/session/transcript.js +7 -1
- package/src/session/usage.js +213 -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
|
|
@@ -7,6 +7,11 @@ import { STUCK_THRESHOLDS } from "../agents/jules/pulse.js";
|
|
|
7
7
|
import { createAgentEvent } from "../events/schema.js";
|
|
8
8
|
import { resolveSessionPaths } from "./paths.js";
|
|
9
9
|
import { emitContextBriefing } from "./recap.js";
|
|
10
|
+
import {
|
|
11
|
+
assignFriendlyName,
|
|
12
|
+
buildSentiWelcome,
|
|
13
|
+
shouldAutoRenameInRegistry,
|
|
14
|
+
} from "./senti-naming.js";
|
|
10
15
|
import { appendToStream } from "./stream.js";
|
|
11
16
|
|
|
12
17
|
const AGENT_SNAPSHOT_SCHEMA_VERSION = "1.0.0";
|
|
@@ -181,7 +186,26 @@ export async function registerAgent(
|
|
|
181
186
|
) {
|
|
182
187
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
183
188
|
const nowIso = new Date().toISOString();
|
|
184
|
-
const
|
|
189
|
+
const originalCallerAgentId = normalizeString(agentId);
|
|
190
|
+
let resolvedAgentId = originalCallerAgentId || generateAgentId(model);
|
|
191
|
+
let renamedFrom = "";
|
|
192
|
+
|
|
193
|
+
// Senti orchestrator hook: when the caller didn't supply an id, or
|
|
194
|
+
// supplied an explicit placeholder (`cli-user`, `agent-…`, `guest-…`),
|
|
195
|
+
// pick a friendly sequential name like `claude-3` / `codex-2` /
|
|
196
|
+
// `guest-1` so participants have a "face" in the transcript instead of
|
|
197
|
+
// a random hex blob. Caller-supplied real ids are NEVER renamed
|
|
198
|
+
// (kill tests like PR 348/351 register `codex-task-holder-1` with
|
|
199
|
+
// model="" and need the id to round-trip verbatim).
|
|
200
|
+
if (shouldAutoRenameInRegistry({ originalCallerAgentId })) {
|
|
201
|
+
const existingAgents = await listAgentsInternal(paths);
|
|
202
|
+
const friendly = assignFriendlyName({ model, existingAgents });
|
|
203
|
+
if (friendly && friendly !== resolvedAgentId.toLowerCase()) {
|
|
204
|
+
renamedFrom = resolvedAgentId;
|
|
205
|
+
resolvedAgentId = friendly;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
const snapshotPath = buildAgentSnapshotPath(paths, resolvedAgentId);
|
|
186
210
|
|
|
187
211
|
const snapshot = normalizeAgentSnapshot(
|
|
@@ -210,6 +234,21 @@ export async function registerAgent(
|
|
|
210
234
|
role: snapshot.role,
|
|
211
235
|
status: snapshot.status,
|
|
212
236
|
}, { targetPath });
|
|
237
|
+
|
|
238
|
+
if (renamedFrom) {
|
|
239
|
+
const welcome = buildSentiWelcome({
|
|
240
|
+
agentId: snapshot.agentId,
|
|
241
|
+
model: snapshot.model,
|
|
242
|
+
role: snapshot.role,
|
|
243
|
+
wasAnonymous: true,
|
|
244
|
+
originalAgentId: renamedFrom,
|
|
245
|
+
});
|
|
246
|
+
await emitAgentEvent(paths.sessionId, "agent_identified", {
|
|
247
|
+
...welcome,
|
|
248
|
+
sessionId: paths.sessionId,
|
|
249
|
+
}, { targetPath });
|
|
250
|
+
}
|
|
251
|
+
|
|
213
252
|
if (normalizeString(snapshot.agentId).toLowerCase() !== "senti") {
|
|
214
253
|
await emitContextBriefing(paths.sessionId, {
|
|
215
254
|
forAgentId: snapshot.agentId,
|
|
@@ -223,6 +262,26 @@ export async function registerAgent(
|
|
|
223
262
|
};
|
|
224
263
|
}
|
|
225
264
|
|
|
265
|
+
async function listAgentsInternal(paths) {
|
|
266
|
+
try {
|
|
267
|
+
const entries = await fsp.readdir(paths.agentsDir, { withFileTypes: true });
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
271
|
+
const raw = await readAgentSnapshot(path.join(paths.agentsDir, entry.name));
|
|
272
|
+
if (raw && typeof raw === "object" && raw.agentId) {
|
|
273
|
+
out.push({ agentId: raw.agentId, model: raw.model || "" });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
226
285
|
export async function heartbeatAgent(
|
|
227
286
|
sessionId,
|
|
228
287
|
agentId,
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Senti — auto-name + welcome anonymous participants.
|
|
3
|
+
*
|
|
4
|
+
* When an agent joins without a clear name + model, Senti steps in:
|
|
5
|
+
*
|
|
6
|
+
* 1. `assignFriendlyName({ model, existingAgents })` — generates a
|
|
7
|
+
* stable, human-readable id like "guest-3", "claude-2",
|
|
8
|
+
* "codex-anon-1" derived from the model family + the next free
|
|
9
|
+
* ordinal in the session. Sequential beats hex-suffix for the
|
|
10
|
+
* ChatGPT-style "everyone has a face" UX Carter asked for.
|
|
11
|
+
*
|
|
12
|
+
* 2. `buildSentiWelcome({ agentId, model, role })` — produces the
|
|
13
|
+
* payload for an `agent_identified` event Senti emits in the
|
|
14
|
+
* stream so the new participant + everyone watching sees the
|
|
15
|
+
* auto-assignment + how to override it.
|
|
16
|
+
*
|
|
17
|
+
* 3. `isAnonymousAgent({ agentId, model })` — single-source check
|
|
18
|
+
* for "this registration didn't carry real identity" used by
|
|
19
|
+
* callers to decide whether to invoke (1) and (2). Generic
|
|
20
|
+
* prefixes (`agent-…`, `cli-user`) and unknown models qualify.
|
|
21
|
+
*
|
|
22
|
+
* This module never touches the network or the disk; it's pure naming
|
|
23
|
+
* logic that the agent-registry wires into the registration path.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ANONYMOUS_AGENT_PREFIXES = Object.freeze(["agent-", "cli-user", "guest-"]);
|
|
27
|
+
const ANONYMOUS_MODELS = Object.freeze(["", "unknown", "cli", "anonymous"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Strict: should the agent-registry auto-rename this registration?
|
|
31
|
+
*
|
|
32
|
+
* The hook's contract is "if a name is already there, leave it alone; if
|
|
33
|
+
* not, give them one." So we ONLY auto-rename when the caller gave us
|
|
34
|
+
* nothing OR the literal default placeholder `cli-user`. Any other
|
|
35
|
+
* caller-supplied id — even ones that *look* generic like `agent-alpha`,
|
|
36
|
+
* `guest-team`, or `codex-task-holder-1` — was an intentional choice and
|
|
37
|
+
* round-trips verbatim.
|
|
38
|
+
*
|
|
39
|
+
* Why so strict:
|
|
40
|
+
* - e2e test #91 (CLI session commands flow) does `session join
|
|
41
|
+
* --name agent-alpha` and asserts the id round-trips. The previous
|
|
42
|
+
* rule (`agent-` prefix => rename) clobbered it.
|
|
43
|
+
* - PR 348/351 kill tests register `codex-task-holder-1` with model=""
|
|
44
|
+
* and need verbatim round-trip.
|
|
45
|
+
* - `isAnonymousAgent` is intentionally separate and stays permissive
|
|
46
|
+
* (model can flag) for downstream callers that decide whether to
|
|
47
|
+
* *welcome* a participant; the registry hook is stricter.
|
|
48
|
+
*
|
|
49
|
+
* @param {{originalCallerAgentId: string}} params
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function shouldAutoRenameInRegistry({ originalCallerAgentId = "" } = {}) {
|
|
53
|
+
const id = normalize(originalCallerAgentId).toLowerCase();
|
|
54
|
+
if (!id) return true;
|
|
55
|
+
return id === "cli-user";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} AgentLike
|
|
60
|
+
* @property {string} agentId
|
|
61
|
+
* @property {string} [model]
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
function normalize(value) {
|
|
65
|
+
return String(value == null ? "" : value).trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function familyFromModel(modelName) {
|
|
69
|
+
const lower = normalize(modelName).toLowerCase();
|
|
70
|
+
if (!lower || lower === "unknown" || lower === "anonymous") return "guest";
|
|
71
|
+
if (lower.includes("claude") || lower.includes("sonnet") || lower.includes("opus")) {
|
|
72
|
+
return "claude";
|
|
73
|
+
}
|
|
74
|
+
if (lower.includes("codex") || lower.includes("gpt-")) return "codex";
|
|
75
|
+
if (lower.includes("gemini")) return "gemini";
|
|
76
|
+
if (lower.includes("senti") || lower.includes("sentinel")) return "senti";
|
|
77
|
+
if (lower === "cli") return "guest";
|
|
78
|
+
// Otherwise use the first sanitized token so distinct providers stay
|
|
79
|
+
// distinct even when we don't recognize them.
|
|
80
|
+
const token = lower.split(/[\s:/_-]+/).find(Boolean) || "guest";
|
|
81
|
+
return token.replace(/[^a-z0-9]/g, "") || "guest";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Given the existing agent roster + the model the new participant
|
|
86
|
+
* declared (which may be empty/unknown), pick the next free ordinal
|
|
87
|
+
* within that family and return `<family>-<ordinal>`. Stable across
|
|
88
|
+
* runs because we pass the existing agents in.
|
|
89
|
+
*
|
|
90
|
+
* @param {{model?: string, existingAgents?: Array<AgentLike>}} params
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function assignFriendlyName({ model = "", existingAgents = [] } = {}) {
|
|
94
|
+
const family = familyFromModel(model);
|
|
95
|
+
const taken = new Set(
|
|
96
|
+
(Array.isArray(existingAgents) ? existingAgents : [])
|
|
97
|
+
.map((agent) => normalize(agent && agent.agentId).toLowerCase())
|
|
98
|
+
.filter(Boolean),
|
|
99
|
+
);
|
|
100
|
+
for (let n = 1; n <= 9999; n += 1) {
|
|
101
|
+
const candidate = `${family}-${n}`;
|
|
102
|
+
if (!taken.has(candidate)) return candidate;
|
|
103
|
+
}
|
|
104
|
+
// Pathological fallback — should never hit in practice, but a
|
|
105
|
+
// 4-digit ceiling without an escape would be a footgun.
|
|
106
|
+
return `${family}-${Date.now().toString(36)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decide whether the registration looks anonymous and therefore needs
|
|
111
|
+
* Senti to step in with a friendly name. We treat any of:
|
|
112
|
+
*
|
|
113
|
+
* - empty / fallback agentId (`agent-…`, `cli-user`, `guest-…`)
|
|
114
|
+
* - empty / unknown / cli model
|
|
115
|
+
*
|
|
116
|
+
* as a signal. Either alone is enough — the cli-user default agent
|
|
117
|
+
* still wants Senti's welcome the first time.
|
|
118
|
+
*
|
|
119
|
+
* @param {AgentLike} agent
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isAnonymousAgent(agent = {}) {
|
|
123
|
+
const id = normalize(agent.agentId).toLowerCase();
|
|
124
|
+
const model = normalize(agent.model).toLowerCase();
|
|
125
|
+
const idAnonymous =
|
|
126
|
+
!id ||
|
|
127
|
+
ANONYMOUS_AGENT_PREFIXES.some((prefix) => id.startsWith(prefix));
|
|
128
|
+
const modelAnonymous = ANONYMOUS_MODELS.includes(model);
|
|
129
|
+
return idAnonymous || modelAnonymous;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the payload Senti emits as `agent_identified` when it has
|
|
134
|
+
* stepped in to name a participant. Consumers (CLI / web) render it
|
|
135
|
+
* verbatim; the `instructions` line tells the user how to override.
|
|
136
|
+
*
|
|
137
|
+
* @param {{
|
|
138
|
+
* agentId: string,
|
|
139
|
+
* model?: string,
|
|
140
|
+
* role?: string,
|
|
141
|
+
* sessionId?: string,
|
|
142
|
+
* wasAnonymous: boolean,
|
|
143
|
+
* originalAgentId?: string,
|
|
144
|
+
* }} params
|
|
145
|
+
* @returns {{
|
|
146
|
+
* alert: "agent_identified",
|
|
147
|
+
* agentId: string,
|
|
148
|
+
* model: string,
|
|
149
|
+
* role: string,
|
|
150
|
+
* wasAnonymous: boolean,
|
|
151
|
+
* originalAgentId: string,
|
|
152
|
+
* message: string,
|
|
153
|
+
* instructions: string,
|
|
154
|
+
* }}
|
|
155
|
+
*/
|
|
156
|
+
export function buildSentiWelcome({
|
|
157
|
+
agentId,
|
|
158
|
+
model = "unknown",
|
|
159
|
+
role = "observer",
|
|
160
|
+
wasAnonymous = false,
|
|
161
|
+
originalAgentId = "",
|
|
162
|
+
} = {}) {
|
|
163
|
+
const cleanModel = normalize(model) || "unknown";
|
|
164
|
+
const cleanRole = normalize(role) || "observer";
|
|
165
|
+
const cleanId = normalize(agentId);
|
|
166
|
+
const message = wasAnonymous
|
|
167
|
+
? `Welcome ${cleanId}. I auto-named you because you joined without a name; introduce yourself anytime.`
|
|
168
|
+
: `Welcome ${cleanId}. You're in as ${cleanRole}.`;
|
|
169
|
+
const instructions = `Update with: sl session rename <sessionId> ${cleanId} --to <new-id> [--model <model>]`;
|
|
170
|
+
return {
|
|
171
|
+
alert: "agent_identified",
|
|
172
|
+
agentId: cleanId,
|
|
173
|
+
model: cleanModel,
|
|
174
|
+
role: cleanRole,
|
|
175
|
+
wasAnonymous: Boolean(wasAnonymous),
|
|
176
|
+
originalAgentId: normalize(originalAgentId),
|
|
177
|
+
message,
|
|
178
|
+
instructions,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -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
|
*
|
|
@@ -41,6 +41,7 @@ const TRANSCRIPT_EVENT_KINDS = new Set([
|
|
|
41
41
|
"session_message",
|
|
42
42
|
"session_say",
|
|
43
43
|
"agent_response",
|
|
44
|
+
"session_usage",
|
|
44
45
|
"human_relay",
|
|
45
46
|
"agent_join",
|
|
46
47
|
"agent_left",
|
|
@@ -153,9 +154,14 @@ function eventTimestamp(event) {
|
|
|
153
154
|
|
|
154
155
|
function eventBody(event) {
|
|
155
156
|
const payload = event && typeof event.payload === "object" ? event.payload : {};
|
|
157
|
+
// session_usage carries the response inside payload.response.text
|
|
158
|
+
const responseText =
|
|
159
|
+
typeof payload.response === "object" && payload.response
|
|
160
|
+
? payload.response.text
|
|
161
|
+
: payload.response;
|
|
156
162
|
const text =
|
|
157
163
|
payload.message ||
|
|
158
|
-
|
|
164
|
+
responseText ||
|
|
159
165
|
payload.text ||
|
|
160
166
|
payload.alert ||
|
|
161
167
|
payload.reason ||
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session usage emitter — records every LLM interaction inside a session
|
|
3
|
+
* as a `session_usage` event so consumers (web dashboard, transcript
|
|
4
|
+
* download, telemetry sync) can surface live, accurate token + cost
|
|
5
|
+
* counters per-agent + session-wide.
|
|
6
|
+
*
|
|
7
|
+
* Senti orchestrator philosophy: "tokens on point every time any LLM
|
|
8
|
+
* interacts." Every persona / Jules / Codex / Claude call inside a
|
|
9
|
+
* session should land here so the running tally is authoritative.
|
|
10
|
+
*
|
|
11
|
+
* Event shape:
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* event: "session_usage",
|
|
15
|
+
* ts: ISO8601,
|
|
16
|
+
* agent: { id, model },
|
|
17
|
+
* payload: {
|
|
18
|
+
* interactionId, // stable id for the LLM call
|
|
19
|
+
* agentId, model, role,
|
|
20
|
+
* inputTokens, outputTokens, totalTokens,
|
|
21
|
+
* costUsd,
|
|
22
|
+
* durationMs, // wall-clock duration of the call
|
|
23
|
+
* prompt: { tokens, chars },
|
|
24
|
+
* response: { tokens, chars, text? },
|
|
25
|
+
* usage: { // mirrors transcript.js payload.usage
|
|
26
|
+
* totalTokens,
|
|
27
|
+
* costUsd,
|
|
28
|
+
* inputTokens,
|
|
29
|
+
* outputTokens,
|
|
30
|
+
* },
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Design choice: emit BOTH the convenient flat fields AND a
|
|
35
|
+
* `payload.usage` block, so transcript.js's existing usage roll-up
|
|
36
|
+
* picks it up without changes, while web UIs can display the structured
|
|
37
|
+
* fields directly without re-parsing.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import process from "node:process";
|
|
41
|
+
import { randomUUID } from "node:crypto";
|
|
42
|
+
|
|
43
|
+
import { createAgentEvent } from "../events/schema.js";
|
|
44
|
+
import { resolveSessionPaths } from "./paths.js";
|
|
45
|
+
import { appendToStream } from "./stream.js";
|
|
46
|
+
|
|
47
|
+
const SESSION_USAGE_EVENT = "session_usage";
|
|
48
|
+
|
|
49
|
+
function n(value) {
|
|
50
|
+
return String(value == null ? "" : value).trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function num(value) {
|
|
54
|
+
const v = Number(value);
|
|
55
|
+
return Number.isFinite(v) && v >= 0 ? v : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clipText(text, max = 4000) {
|
|
59
|
+
const s = n(text);
|
|
60
|
+
if (s.length <= max) return s;
|
|
61
|
+
return `${s.slice(0, max)}…`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Emit a `session_usage` event into the session's NDJSON stream.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} sessionId
|
|
68
|
+
* @param {object} params
|
|
69
|
+
* @param {string} params.agentId
|
|
70
|
+
* @param {string} [params.agentModel]
|
|
71
|
+
* @param {string} [params.role]
|
|
72
|
+
* @param {number} [params.inputTokens]
|
|
73
|
+
* @param {number} [params.outputTokens]
|
|
74
|
+
* @param {number} [params.costUsd]
|
|
75
|
+
* @param {number} [params.durationMs]
|
|
76
|
+
* @param {string} [params.prompt] full prompt text (clipped)
|
|
77
|
+
* @param {string} [params.response] full response text (clipped)
|
|
78
|
+
* @param {string} [params.interactionId] opaque id for cross-event correlation
|
|
79
|
+
* @param {string} [params.targetPath] workspace path (default cwd)
|
|
80
|
+
* @returns {Promise<{ event: string, interactionId: string, totalTokens: number, costUsd: number }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function emitLLMInteraction(
|
|
83
|
+
sessionId,
|
|
84
|
+
{
|
|
85
|
+
agentId,
|
|
86
|
+
agentModel = "",
|
|
87
|
+
role = "",
|
|
88
|
+
inputTokens = 0,
|
|
89
|
+
outputTokens = 0,
|
|
90
|
+
costUsd = 0,
|
|
91
|
+
durationMs = 0,
|
|
92
|
+
prompt = "",
|
|
93
|
+
response = "",
|
|
94
|
+
interactionId = "",
|
|
95
|
+
targetPath = process.cwd(),
|
|
96
|
+
} = {},
|
|
97
|
+
) {
|
|
98
|
+
const sid = n(sessionId);
|
|
99
|
+
if (!sid) throw new Error("sessionId is required.");
|
|
100
|
+
const aid = n(agentId);
|
|
101
|
+
if (!aid) throw new Error("agentId is required.");
|
|
102
|
+
|
|
103
|
+
const paths = resolveSessionPaths(sid, { targetPath });
|
|
104
|
+
const ts = new Date().toISOString();
|
|
105
|
+
const id = n(interactionId) || randomUUID();
|
|
106
|
+
const inT = Math.floor(num(inputTokens));
|
|
107
|
+
const outT = Math.floor(num(outputTokens));
|
|
108
|
+
const totalT = inT + outT;
|
|
109
|
+
const cost = Math.round(num(costUsd) * 1_000_000) / 1_000_000;
|
|
110
|
+
|
|
111
|
+
const promptText = clipText(prompt);
|
|
112
|
+
const responseText = clipText(response);
|
|
113
|
+
|
|
114
|
+
const payload = {
|
|
115
|
+
interactionId: id,
|
|
116
|
+
agentId: aid,
|
|
117
|
+
model: n(agentModel) || "unknown",
|
|
118
|
+
role: n(role) || "observer",
|
|
119
|
+
inputTokens: inT,
|
|
120
|
+
outputTokens: outT,
|
|
121
|
+
totalTokens: totalT,
|
|
122
|
+
costUsd: cost,
|
|
123
|
+
durationMs: Math.max(0, Math.floor(num(durationMs))),
|
|
124
|
+
prompt: { tokens: inT, chars: promptText.length },
|
|
125
|
+
response: {
|
|
126
|
+
tokens: outT,
|
|
127
|
+
chars: responseText.length,
|
|
128
|
+
text: responseText || undefined,
|
|
129
|
+
},
|
|
130
|
+
// Mirror into payload.usage so transcript.js + telemetry sync pick
|
|
131
|
+
// it up via the same code path used for ad-hoc agent_response usage.
|
|
132
|
+
usage: {
|
|
133
|
+
totalTokens: totalT,
|
|
134
|
+
costUsd: cost,
|
|
135
|
+
inputTokens: inT,
|
|
136
|
+
outputTokens: outT,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const envelope = createAgentEvent({
|
|
141
|
+
event: SESSION_USAGE_EVENT,
|
|
142
|
+
agentId: aid,
|
|
143
|
+
agentModel: n(agentModel) || "unknown",
|
|
144
|
+
sessionId: paths.sessionId,
|
|
145
|
+
payload,
|
|
146
|
+
ts,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await appendToStream(paths.sessionId, envelope, { targetPath });
|
|
150
|
+
return {
|
|
151
|
+
event: SESSION_USAGE_EVENT,
|
|
152
|
+
interactionId: id,
|
|
153
|
+
totalTokens: totalT,
|
|
154
|
+
costUsd: cost,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Aggregate `session_usage` events into a per-agent + global tally.
|
|
160
|
+
* Pure helper for renderers that want a snapshot at a point in time.
|
|
161
|
+
*
|
|
162
|
+
* @param {Array<object>} events
|
|
163
|
+
* @returns {{
|
|
164
|
+
* perAgent: Map<string, { agentId, model, totalTokens, inputTokens, outputTokens, costUsd, interactions }>,
|
|
165
|
+
* totals: { totalTokens, inputTokens, outputTokens, costUsd, interactions },
|
|
166
|
+
* }}
|
|
167
|
+
*/
|
|
168
|
+
export function aggregateSessionUsage(events = []) {
|
|
169
|
+
const perAgent = new Map();
|
|
170
|
+
const totals = {
|
|
171
|
+
totalTokens: 0,
|
|
172
|
+
inputTokens: 0,
|
|
173
|
+
outputTokens: 0,
|
|
174
|
+
costUsd: 0,
|
|
175
|
+
interactions: 0,
|
|
176
|
+
};
|
|
177
|
+
for (const event of events) {
|
|
178
|
+
if (!event || event.event !== SESSION_USAGE_EVENT) continue;
|
|
179
|
+
const payload = event.payload || {};
|
|
180
|
+
const agentId = n(payload.agentId || event.agent?.id);
|
|
181
|
+
if (!agentId) continue;
|
|
182
|
+
if (!perAgent.has(agentId)) {
|
|
183
|
+
perAgent.set(agentId, {
|
|
184
|
+
agentId,
|
|
185
|
+
model: n(payload.model || event.agent?.model) || "unknown",
|
|
186
|
+
totalTokens: 0,
|
|
187
|
+
inputTokens: 0,
|
|
188
|
+
outputTokens: 0,
|
|
189
|
+
costUsd: 0,
|
|
190
|
+
interactions: 0,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const record = perAgent.get(agentId);
|
|
194
|
+
record.totalTokens += num(payload.totalTokens);
|
|
195
|
+
record.inputTokens += num(payload.inputTokens);
|
|
196
|
+
record.outputTokens += num(payload.outputTokens);
|
|
197
|
+
record.costUsd += num(payload.costUsd);
|
|
198
|
+
record.interactions += 1;
|
|
199
|
+
|
|
200
|
+
totals.totalTokens += num(payload.totalTokens);
|
|
201
|
+
totals.inputTokens += num(payload.inputTokens);
|
|
202
|
+
totals.outputTokens += num(payload.outputTokens);
|
|
203
|
+
totals.costUsd += num(payload.costUsd);
|
|
204
|
+
totals.interactions += 1;
|
|
205
|
+
}
|
|
206
|
+
totals.costUsd = Math.round(totals.costUsd * 1_000_000) / 1_000_000;
|
|
207
|
+
for (const record of perAgent.values()) {
|
|
208
|
+
record.costUsd = Math.round(record.costUsd * 1_000_000) / 1_000_000;
|
|
209
|
+
}
|
|
210
|
+
return { perAgent, totals };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const SESSION_USAGE_EVENT_KIND = SESSION_USAGE_EVENT;
|