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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -261,8 +261,11 @@ export function registerSessionCommand(program) {
261
261
 
262
262
  session
263
263
  .command("start")
264
- .description("Create a new persistent session with metadata + NDJSON stream")
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
- const created = await createSession({
289
- targetPath,
290
- ttlSeconds,
291
- template,
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(`Session ${created.sessionId} created (template: ${template.id})`);
335
- if (launchPlan.length > 0) {
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(`Stream: ${created.streamPath}`));
350
- console.log(pc.gray(`Created in ${durationMs}ms`));
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 resolvedAgentId = normalizeString(agentId) || generateAgentId(model);
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
- * Wraps `pollHumanMessages` (which speaks to the SentinelLayer API) with
5
- * a persisted cursor + `appendToStream`, so a CLI invocation can pull
6
- * web-posted messages into the local session log on demand. The
7
- * background daemon already does this on a poll loop; this module is
8
- * the synchronous counterpart that powers `slc session sync` and
9
- * `slc session read --remote`.
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
- const startCursor =
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
- const polled = await _poll(sessionId, {
55
- targetPath,
56
- since: startCursor,
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
- if (!polled || !polled.ok) {
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: polled?.reason || "poll_failed",
105
+ reason: humanResult?.reason || eventsResult?.reason || "poll_failed",
63
106
  relayed: 0,
64
- dropped: Array.isArray(polled?.dropped) ? polled.dropped.length : 0,
65
- cursor: typeof polled?.cursor === "string" ? polled.cursor : startCursor || null,
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 polled.events || []) {
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 polled.cursor === "string" && polled.cursor.trim()) {
83
- const result = await writeSyncCursor(sessionId, polled.cursor, { targetPath }).catch(() => null);
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(polled.dropped) ? polled.dropped.length : 0,
92
- cursor: typeof polled.cursor === "string" ? polled.cursor : startCursor || null,
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
- return path.join(resolveSessionDir(sessionId, { targetPath }), "remote-sync-cursor.json");
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 };
@@ -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
- payload.response ||
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;