sentinelayer-cli 0.19.0 → 0.20.0
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/ai/identity-lifecycle.js +14 -2
- package/src/commands/mcp.js +60 -0
- package/src/commands/session.js +1255 -25
- package/src/legacy-cli.js +16 -11
- package/src/mcp/registry.js +151 -0
- package/src/mcp/session-stdio-server.js +977 -0
- package/src/scan/generator.js +3 -2
- package/src/session/agent-registry.js +118 -0
- package/src/session/checkpoints.js +71 -1
- package/src/session/coordination-guidance.js +3 -2
- package/src/session/listener.js +302 -68
- package/src/session/pricing-ledger.js +34 -4
- package/src/session/recap.js +4 -2
- package/src/session/sync.js +278 -0
- package/src/session/transcript.js +86 -36
- package/src/session/usage.js +5 -5
- package/src/session/wake/claude.js +175 -0
- package/src/session/wake/codex.js +394 -0
- package/src/session/wake/cursor-store.js +69 -0
- package/src/session/wake/dispatcher.js +184 -0
- package/src/session/wake/pump.js +135 -0
- package/src/session/wake/registry.js +80 -0
- package/src/session/wake/resolve-target.js +146 -0
- package/src/session/wake/sentid.js +103 -0
package/src/session/sync.js
CHANGED
|
@@ -428,6 +428,95 @@ async function fetchWithTimeout(url, options, timeoutMs) {
|
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
function isAbortLike(error) {
|
|
432
|
+
return Boolean(error?.name === "AbortError" || error?.code === "ABORT_ERR");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function* readResponseTextChunks(response) {
|
|
436
|
+
const body = response?.body;
|
|
437
|
+
if (!body) return;
|
|
438
|
+
|
|
439
|
+
if (typeof body.getReader === "function") {
|
|
440
|
+
const reader = body.getReader();
|
|
441
|
+
try {
|
|
442
|
+
while (true) {
|
|
443
|
+
const { done, value } = await reader.read();
|
|
444
|
+
if (done) break;
|
|
445
|
+
if (value) yield value;
|
|
446
|
+
}
|
|
447
|
+
} finally {
|
|
448
|
+
try {
|
|
449
|
+
reader.releaseLock();
|
|
450
|
+
} catch {
|
|
451
|
+
// Best-effort cleanup only.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (typeof body[Symbol.asyncIterator] === "function") {
|
|
458
|
+
for await (const chunk of body) {
|
|
459
|
+
if (chunk) yield chunk;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function extractSseErrorReason(parsed) {
|
|
465
|
+
const error = parsed?.error && typeof parsed.error === "object" ? parsed.error : {};
|
|
466
|
+
return (
|
|
467
|
+
normalizeString(error.code) ||
|
|
468
|
+
normalizeString(error.message) ||
|
|
469
|
+
normalizeString(error.detail) ||
|
|
470
|
+
"session_stream_error"
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function processSseBlock(block, handlers) {
|
|
475
|
+
const normalizedBlock = normalizeString(block);
|
|
476
|
+
if (!normalizedBlock) return;
|
|
477
|
+
|
|
478
|
+
const dataLines = [];
|
|
479
|
+
let commentOnly = true;
|
|
480
|
+
for (const rawLine of String(block).split("\n")) {
|
|
481
|
+
const line = rawLine.trimEnd();
|
|
482
|
+
if (!line) continue;
|
|
483
|
+
if (line.startsWith(":")) continue;
|
|
484
|
+
commentOnly = false;
|
|
485
|
+
if (line.startsWith("data:")) {
|
|
486
|
+
dataLines.push(line.slice(5).trimStart());
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (dataLines.length === 0) {
|
|
491
|
+
if (commentOnly && typeof handlers.onHeartbeat === "function") {
|
|
492
|
+
await handlers.onHeartbeat();
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const rawData = dataLines.join("\n").trim();
|
|
498
|
+
if (!rawData) return;
|
|
499
|
+
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(rawData);
|
|
503
|
+
} catch {
|
|
504
|
+
await handlers.onError?.({ reason: "malformed_stream_event", cursor: handlers.cursor() });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (parsed?.type === "error") {
|
|
509
|
+
await handlers.onError?.({
|
|
510
|
+
reason: extractSseErrorReason(parsed),
|
|
511
|
+
cursor: handlers.cursor(),
|
|
512
|
+
error: parsed.error || null,
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await handlers.onEvent?.(parsed);
|
|
518
|
+
}
|
|
519
|
+
|
|
431
520
|
function sanitizeHumanMessage(rawMessage) {
|
|
432
521
|
const stripped = String(rawMessage || "")
|
|
433
522
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
@@ -1131,6 +1220,195 @@ export async function pollSessionEvents(
|
|
|
1131
1220
|
}
|
|
1132
1221
|
}
|
|
1133
1222
|
|
|
1223
|
+
/**
|
|
1224
|
+
* Consume the API's durable session SSE stream.
|
|
1225
|
+
*
|
|
1226
|
+
* This is the wakeup-first companion to `pollSessionEvents`: the stream uses
|
|
1227
|
+
* Redis wakeups server-side, while the listener can still fall back to durable
|
|
1228
|
+
* `/events` polling if the stream is unavailable or closes.
|
|
1229
|
+
*
|
|
1230
|
+
* @param {string} sessionId
|
|
1231
|
+
* @param {object} [options]
|
|
1232
|
+
* @param {string|null} [options.since] - durable cursor to resume after
|
|
1233
|
+
* @param {AbortSignal} [options.signal]
|
|
1234
|
+
* @param {(event: object) => Promise<void>|void} [options.onEvent]
|
|
1235
|
+
* @param {(payload: object) => Promise<void>|void} [options.onError]
|
|
1236
|
+
* @param {() => Promise<void>|void} [options.onHeartbeat]
|
|
1237
|
+
* @returns {Promise<{ok: boolean, reason: string, cursor: string|null, eventCount: number, errorCount: number, status?: number, aborted?: boolean}>}
|
|
1238
|
+
*/
|
|
1239
|
+
export async function streamSessionEvents(
|
|
1240
|
+
sessionId,
|
|
1241
|
+
{
|
|
1242
|
+
targetPath = process.cwd(),
|
|
1243
|
+
since = null,
|
|
1244
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1245
|
+
signal = undefined,
|
|
1246
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1247
|
+
fetchImpl = fetch,
|
|
1248
|
+
onEvent = async () => {},
|
|
1249
|
+
onError = async () => {},
|
|
1250
|
+
onHeartbeat = async () => {},
|
|
1251
|
+
} = {}
|
|
1252
|
+
) {
|
|
1253
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1254
|
+
const normalizedSince = normalizeString(since) || null;
|
|
1255
|
+
if (!normalizedSessionId) {
|
|
1256
|
+
return {
|
|
1257
|
+
ok: false,
|
|
1258
|
+
reason: "invalid_session_id",
|
|
1259
|
+
cursor: normalizedSince,
|
|
1260
|
+
eventCount: 0,
|
|
1261
|
+
errorCount: 0,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
let session = null;
|
|
1266
|
+
try {
|
|
1267
|
+
session = await resolveAuthSession({
|
|
1268
|
+
cwd: targetPath,
|
|
1269
|
+
env: process.env,
|
|
1270
|
+
autoRotate: false,
|
|
1271
|
+
});
|
|
1272
|
+
} catch {
|
|
1273
|
+
return {
|
|
1274
|
+
ok: false,
|
|
1275
|
+
reason: "no_session",
|
|
1276
|
+
cursor: normalizedSince,
|
|
1277
|
+
eventCount: 0,
|
|
1278
|
+
errorCount: 0,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
if (!session || !session.token) {
|
|
1282
|
+
return {
|
|
1283
|
+
ok: false,
|
|
1284
|
+
reason: "not_authenticated",
|
|
1285
|
+
cursor: normalizedSince,
|
|
1286
|
+
eventCount: 0,
|
|
1287
|
+
errorCount: 0,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1292
|
+
const query = new URLSearchParams();
|
|
1293
|
+
if (normalizedSince) {
|
|
1294
|
+
query.set("after", normalizedSince);
|
|
1295
|
+
}
|
|
1296
|
+
const suffix = query.toString() ? `?${query.toString()}` : "";
|
|
1297
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/stream${suffix}`;
|
|
1298
|
+
const controller = new AbortController();
|
|
1299
|
+
const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
1300
|
+
const timeoutHandle = setTimeout(() => controller.abort(), normalizedTimeoutMs);
|
|
1301
|
+
if (typeof timeoutHandle.unref === "function") {
|
|
1302
|
+
timeoutHandle.unref();
|
|
1303
|
+
}
|
|
1304
|
+
const forwardAbort = () => controller.abort(signal?.reason);
|
|
1305
|
+
if (signal) {
|
|
1306
|
+
if (signal.aborted) {
|
|
1307
|
+
controller.abort(signal.reason);
|
|
1308
|
+
} else {
|
|
1309
|
+
signal.addEventListener("abort", forwardAbort, { once: true });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
let response;
|
|
1314
|
+
try {
|
|
1315
|
+
response = await fetchImpl(
|
|
1316
|
+
endpoint,
|
|
1317
|
+
{
|
|
1318
|
+
method: "GET",
|
|
1319
|
+
headers: {
|
|
1320
|
+
Accept: "text/event-stream",
|
|
1321
|
+
Authorization: `Bearer ${session.token}`,
|
|
1322
|
+
},
|
|
1323
|
+
signal: controller.signal,
|
|
1324
|
+
},
|
|
1325
|
+
normalizedTimeoutMs
|
|
1326
|
+
);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
clearTimeout(timeoutHandle);
|
|
1329
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
1330
|
+
return {
|
|
1331
|
+
ok: false,
|
|
1332
|
+
reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
|
|
1333
|
+
cursor: normalizedSince,
|
|
1334
|
+
eventCount: 0,
|
|
1335
|
+
errorCount: 0,
|
|
1336
|
+
aborted: Boolean(signal?.aborted || isAbortLike(error)),
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
clearTimeout(timeoutHandle);
|
|
1340
|
+
|
|
1341
|
+
let cursor = normalizedSince;
|
|
1342
|
+
let eventCount = 0;
|
|
1343
|
+
let errorCount = 0;
|
|
1344
|
+
let lastErrorReason = "";
|
|
1345
|
+
const decoder = new TextDecoder();
|
|
1346
|
+
let buffer = "";
|
|
1347
|
+
|
|
1348
|
+
const handlers = {
|
|
1349
|
+
cursor: () => cursor,
|
|
1350
|
+
onHeartbeat,
|
|
1351
|
+
onError: async (payload) => {
|
|
1352
|
+
errorCount += 1;
|
|
1353
|
+
lastErrorReason = normalizeString(payload?.reason) || "session_stream_error";
|
|
1354
|
+
await onError(payload);
|
|
1355
|
+
},
|
|
1356
|
+
onEvent: async (event) => {
|
|
1357
|
+
const eventCursor = normalizeString(event?.cursor);
|
|
1358
|
+
if (eventCursor) cursor = eventCursor;
|
|
1359
|
+
eventCount += 1;
|
|
1360
|
+
await onEvent(event);
|
|
1361
|
+
},
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
try {
|
|
1365
|
+
if (!response || !response.ok || !response.body) {
|
|
1366
|
+
return {
|
|
1367
|
+
ok: false,
|
|
1368
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
1369
|
+
cursor,
|
|
1370
|
+
eventCount,
|
|
1371
|
+
errorCount,
|
|
1372
|
+
status: response?.status,
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
for await (const chunk of readResponseTextChunks(response)) {
|
|
1377
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1378
|
+
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1379
|
+
const blocks = buffer.split("\n\n");
|
|
1380
|
+
buffer = blocks.pop() || "";
|
|
1381
|
+
for (const block of blocks) {
|
|
1382
|
+
await processSseBlock(block, handlers);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
buffer += decoder.decode();
|
|
1386
|
+
if (normalizeString(buffer)) {
|
|
1387
|
+
await processSseBlock(buffer, handlers);
|
|
1388
|
+
}
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
return {
|
|
1391
|
+
ok: false,
|
|
1392
|
+
reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
|
|
1393
|
+
cursor,
|
|
1394
|
+
eventCount,
|
|
1395
|
+
errorCount,
|
|
1396
|
+
aborted: Boolean(signal?.aborted || isAbortLike(error)),
|
|
1397
|
+
};
|
|
1398
|
+
} finally {
|
|
1399
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return {
|
|
1403
|
+
ok: !lastErrorReason,
|
|
1404
|
+
reason: lastErrorReason,
|
|
1405
|
+
cursor,
|
|
1406
|
+
eventCount,
|
|
1407
|
+
errorCount,
|
|
1408
|
+
aborted: Boolean(signal?.aborted),
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1134
1412
|
/**
|
|
1135
1413
|
* Poll the latest durable session events page via the reverse-history endpoint.
|
|
1136
1414
|
*
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Adds at render time:
|
|
8
8
|
* - Per-agent active duration (first → last event with that agent id)
|
|
9
9
|
* - Total session live-for (createdAt → last event)
|
|
10
|
-
* - Token + cost roll-up
|
|
10
|
+
* - Token + cost roll-up from session_usage events through the
|
|
11
|
+
* pricing ledger, including idempotency dedupe
|
|
11
12
|
* - Avatar per speaker, picked from PERSONA_VISUALS / CLIENT_FAMILY_AVATARS,
|
|
12
13
|
* or a deterministic letter-tile fallback
|
|
13
14
|
* - Senti-orchestrator events tagged with the orchestrator avatar so
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { PERSONA_VISUALS, ORCHESTRATOR_VISUALS } from "../agents/persona-visuals.js";
|
|
22
|
+
import { buildSessionUsageLedger } from "./pricing-ledger.js";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Avatar map for client families (the OUTSIDE-the-persona-set agents
|
|
@@ -232,45 +234,39 @@ function eventBody(event) {
|
|
|
232
234
|
* Compute deterministic activity stats from the event log:
|
|
233
235
|
* - sessionLiveSeconds: created → last event
|
|
234
236
|
* - perAgent[agentId]: { firstSeen, lastSeen, eventCount, activeSeconds, family, displayName, model }
|
|
235
|
-
* - totals: { tokenTotal, costTotalUsd } summed
|
|
237
|
+
* - totals: { tokenTotal, costTotalUsd } summed through the pricing ledger
|
|
236
238
|
* - sentiActions: count of orchestrator events
|
|
237
239
|
*/
|
|
238
240
|
export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerProfiles = new Map() } = {}) {
|
|
239
241
|
const perAgent = new Map();
|
|
240
242
|
let firstEventTs = null;
|
|
241
243
|
let lastEventTs = null;
|
|
242
|
-
let tokenTotal = 0;
|
|
243
|
-
let costTotalUsd = 0;
|
|
244
244
|
let sentiActions = 0;
|
|
245
245
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (!
|
|
256
|
-
|
|
257
|
-
if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
|
|
258
|
-
|
|
259
|
-
if (!perAgent.has(agentId)) {
|
|
260
|
-
const profile = speakerProfiles.get(agentId) || null;
|
|
246
|
+
const ensureAgentRecord = ({
|
|
247
|
+
agentId,
|
|
248
|
+
agentModel = "",
|
|
249
|
+
epoch,
|
|
250
|
+
} = {}) => {
|
|
251
|
+
const normalizedAgentId = normalize(agentId);
|
|
252
|
+
if (!normalizedAgentId || !Number.isFinite(epoch)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (!perAgent.has(normalizedAgentId)) {
|
|
256
|
+
const profile = speakerProfiles.get(normalizedAgentId) || null;
|
|
261
257
|
const identity = resolveSpeakerIdentity({
|
|
262
|
-
agentId,
|
|
263
|
-
agentModel
|
|
258
|
+
agentId: normalizedAgentId,
|
|
259
|
+
agentModel,
|
|
264
260
|
profile,
|
|
265
261
|
});
|
|
266
|
-
perAgent.set(
|
|
267
|
-
agentId,
|
|
262
|
+
perAgent.set(normalizedAgentId, {
|
|
263
|
+
agentId: normalizedAgentId,
|
|
268
264
|
family: identity.family,
|
|
269
265
|
displayName: identity.displayName,
|
|
270
266
|
avatar: identity.avatar,
|
|
271
267
|
avatarUrl: identity.avatarUrl,
|
|
272
268
|
color: identity.color,
|
|
273
|
-
model:
|
|
269
|
+
model: agentModel,
|
|
274
270
|
firstSeenMs: epoch,
|
|
275
271
|
lastSeenMs: epoch,
|
|
276
272
|
eventCount: 0,
|
|
@@ -278,21 +274,59 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
|
|
|
278
274
|
costUsd: 0,
|
|
279
275
|
});
|
|
280
276
|
}
|
|
281
|
-
const record = perAgent.get(
|
|
277
|
+
const record = perAgent.get(normalizedAgentId);
|
|
278
|
+
if (!record.model && agentModel) {
|
|
279
|
+
record.model = agentModel;
|
|
280
|
+
}
|
|
281
|
+
return record;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
for (const event of events) {
|
|
285
|
+
const ts = eventTimestamp(event);
|
|
286
|
+
if (!ts) continue;
|
|
287
|
+
const epoch = Date.parse(ts);
|
|
288
|
+
if (!Number.isFinite(epoch)) continue;
|
|
289
|
+
if (firstEventTs == null || epoch < firstEventTs) firstEventTs = epoch;
|
|
290
|
+
if (lastEventTs == null || epoch > lastEventTs) lastEventTs = epoch;
|
|
291
|
+
|
|
292
|
+
const agentId = normalize(event.agent?.id || event.agentId);
|
|
293
|
+
if (!agentId) continue;
|
|
294
|
+
const lowerId = agentId.toLowerCase();
|
|
295
|
+
if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
|
|
296
|
+
|
|
297
|
+
const record = ensureAgentRecord({
|
|
298
|
+
agentId,
|
|
299
|
+
agentModel: event.agent?.model || event.agentModel || "",
|
|
300
|
+
epoch,
|
|
301
|
+
});
|
|
302
|
+
if (!record) continue;
|
|
282
303
|
record.eventCount += 1;
|
|
283
304
|
if (epoch < record.firstSeenMs) record.firstSeenMs = epoch;
|
|
284
305
|
if (epoch > record.lastSeenMs) record.lastSeenMs = epoch;
|
|
306
|
+
}
|
|
285
307
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
308
|
+
const usageLedger = buildSessionUsageLedger(events, {
|
|
309
|
+
sessionId: normalize(sessionMeta.sessionId),
|
|
310
|
+
});
|
|
311
|
+
const fallbackUsageEpoch =
|
|
312
|
+
lastEventTs ??
|
|
313
|
+
firstEventTs ??
|
|
314
|
+
(Number.isFinite(Date.parse(sessionMeta?.createdAt)) ? Date.parse(sessionMeta.createdAt) : 0);
|
|
315
|
+
for (const entry of usageLedger.entries) {
|
|
316
|
+
const entryEpoch = Number.isFinite(Date.parse(entry.timestamp))
|
|
317
|
+
? Date.parse(entry.timestamp)
|
|
318
|
+
: fallbackUsageEpoch;
|
|
319
|
+
const record = ensureAgentRecord({
|
|
320
|
+
agentId: entry.agentId,
|
|
321
|
+
agentModel: entry.model,
|
|
322
|
+
epoch: entryEpoch,
|
|
323
|
+
});
|
|
324
|
+
if (!record) continue;
|
|
325
|
+
if ((!record.model || record.model === "unknown") && entry.model && entry.model !== "unknown") {
|
|
326
|
+
record.model = entry.model;
|
|
295
327
|
}
|
|
328
|
+
record.tokens += entry.totalTokens;
|
|
329
|
+
record.costUsd = Math.round((record.costUsd + entry.providerCostUsd) * 1_000_000) / 1_000_000;
|
|
296
330
|
}
|
|
297
331
|
|
|
298
332
|
const createdAtMs = sessionMeta?.createdAt
|
|
@@ -329,7 +363,19 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
|
|
|
329
363
|
endedAt: lastEventTs ? new Date(lastEventTs).toISOString() : null,
|
|
330
364
|
sessionLiveSeconds,
|
|
331
365
|
agents,
|
|
332
|
-
totals: {
|
|
366
|
+
totals: {
|
|
367
|
+
tokenTotal: usageLedger.totals.totalTokens,
|
|
368
|
+
inputTokens: usageLedger.totals.inputTokens,
|
|
369
|
+
outputTokens: usageLedger.totals.outputTokens,
|
|
370
|
+
costTotalUsd: usageLedger.totals.providerCostUsd,
|
|
371
|
+
customerCostTotalUsd: usageLedger.totals.hasCustomerCost
|
|
372
|
+
? usageLedger.totals.customerCostUsd
|
|
373
|
+
: null,
|
|
374
|
+
usageEntries: usageLedger.entries.length,
|
|
375
|
+
duplicatesSkipped: usageLedger.duplicatesSkipped,
|
|
376
|
+
unpriced: usageLedger.totals.unpriced,
|
|
377
|
+
priceBookVersions: usageLedger.priceBookVersions,
|
|
378
|
+
},
|
|
333
379
|
sentiActions,
|
|
334
380
|
};
|
|
335
381
|
}
|
|
@@ -396,8 +442,12 @@ export function buildTranscriptMarkdown({
|
|
|
396
442
|
lines.push(`Live for: ${formatDuration(stats.sessionLiveSeconds)}`);
|
|
397
443
|
lines.push(`Senti actions: ${stats.sentiActions}`);
|
|
398
444
|
if (stats.totals.tokenTotal > 0 || stats.totals.costTotalUsd > 0) {
|
|
445
|
+
const billableText =
|
|
446
|
+
stats.totals.customerCostTotalUsd == null
|
|
447
|
+
? ""
|
|
448
|
+
: ` · Billable: $${stats.totals.customerCostTotalUsd.toFixed(4)}`;
|
|
399
449
|
lines.push(
|
|
400
|
-
`Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}`,
|
|
450
|
+
`Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}${billableText}`,
|
|
401
451
|
);
|
|
402
452
|
}
|
|
403
453
|
lines.push("");
|
package/src/session/usage.js
CHANGED
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
* }
|
|
40
40
|
*
|
|
41
41
|
* Design choice: emit BOTH the convenient flat fields AND a
|
|
42
|
-
* `payload.usage` block
|
|
43
|
-
*
|
|
44
|
-
* fields directly without re-parsing.
|
|
42
|
+
* `payload.usage` block. Transcript/download totals flow through
|
|
43
|
+
* pricing-ledger idempotency semantics, while web UIs can display the
|
|
44
|
+
* structured fields directly without re-parsing.
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
import process from "node:process";
|
|
@@ -191,8 +191,8 @@ export async function emitLLMInteraction(
|
|
|
191
191
|
chars: responseText.length,
|
|
192
192
|
text: responseText || undefined,
|
|
193
193
|
},
|
|
194
|
-
// Mirror into payload.usage
|
|
195
|
-
//
|
|
194
|
+
// Mirror into payload.usage for legacy readers and telemetry sync;
|
|
195
|
+
// transcript/download totals use the canonical pricing-ledger fields.
|
|
196
196
|
usage: {
|
|
197
197
|
totalTokens: totalT,
|
|
198
198
|
costUsd: cost,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Claude Code host wake adapter (Wake-Up & Notification Bus, L1).
|
|
2
|
+
//
|
|
3
|
+
// One of the per-host adapters the future `sentid` daemon (L2) drives through a
|
|
4
|
+
// single uniform interface: { hostName, installWakeHook(opts), wake(target) }.
|
|
5
|
+
// The daemon calls `adapter.wake(...)` without caring which CLI is behind it.
|
|
6
|
+
//
|
|
7
|
+
// Ground-truth this encodes (verified against Claude Code hook docs, 2026-05):
|
|
8
|
+
// * An external process CANNOT poke an idle/stopped Claude Code session in
|
|
9
|
+
// place. `asyncRewake` background hooks and `Stop`-hook `decision:"block"`
|
|
10
|
+
// are real, but they only act WITHIN an already-running session.
|
|
11
|
+
// * The only DETERMINISTIC external wake is for the daemon to own the agent
|
|
12
|
+
// lifecycle and (re)spawn `claude --resume <id> "<event>"` per message.
|
|
13
|
+
// So `wake()` is implemented as a daemon-owned resume, while the hook builders
|
|
14
|
+
// expose the in-session primitives for callers that keep a session parked.
|
|
15
|
+
//
|
|
16
|
+
// Borrowed by copy (no imports) from the reference agent-CLI wake patterns:
|
|
17
|
+
// the deferred-hook-result absorption shape and the channel-notification policy
|
|
18
|
+
// gate idea — adapted, not vendored.
|
|
19
|
+
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
export const hostName = "claude";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CLAUDE_BIN = "claude";
|
|
25
|
+
const DEFAULT_RESUME_TIMEOUT_MS = 120_000;
|
|
26
|
+
const DEFAULT_ASYNC_HOOK_TIMEOUT_S = 600;
|
|
27
|
+
// Claude Code overrides a Stop hook after it blocks this many times in a row
|
|
28
|
+
// without progress; our release helper mirrors that cap so a parked session
|
|
29
|
+
// can never wedge itself.
|
|
30
|
+
const STOP_BLOCK_CAP = 8;
|
|
31
|
+
const MAX_MESSAGE_CHARS = 16_000;
|
|
32
|
+
|
|
33
|
+
function requireNonEmptyString(value, label) {
|
|
34
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
35
|
+
throw new TypeError(`claude wake: ${label} must be a non-empty string`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeMessage(message) {
|
|
41
|
+
const text = requireNonEmptyString(message, "message");
|
|
42
|
+
// Cap length so a runaway event payload can't blow the argv / context.
|
|
43
|
+
return text.length > MAX_MESSAGE_CHARS ? text.slice(0, MAX_MESSAGE_CHARS) : text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the `claude` argv for a daemon-owned resume wake. Returns a plain
|
|
48
|
+
* argument array so callers invoke it via execFile (no shell), which is what
|
|
49
|
+
* keeps an untrusted event message from being interpreted as a command.
|
|
50
|
+
*
|
|
51
|
+
* @param {{ sessionId: string, message: string, print?: boolean, extraArgs?: string[] }} opts
|
|
52
|
+
* @returns {string[]}
|
|
53
|
+
*/
|
|
54
|
+
export function buildResumeArgs({ sessionId, message, print = true, extraArgs = [] } = {}) {
|
|
55
|
+
requireNonEmptyString(sessionId, "sessionId");
|
|
56
|
+
const text = normalizeMessage(message);
|
|
57
|
+
if (!Array.isArray(extraArgs) || extraArgs.some((a) => typeof a !== "string")) {
|
|
58
|
+
throw new TypeError("claude wake: extraArgs must be an array of strings");
|
|
59
|
+
}
|
|
60
|
+
const args = ["--resume", sessionId, ...extraArgs];
|
|
61
|
+
// `-p` runs headless/non-interactive, which is what a daemon-driven wake wants.
|
|
62
|
+
if (print) args.push("-p");
|
|
63
|
+
args.push(text);
|
|
64
|
+
return args;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build an `asyncRewake` background command-hook fragment. An agent installs
|
|
69
|
+
* this so a long-running background task can wake it: the task exits with code
|
|
70
|
+
* 2 and Claude surfaces its stderr (or stdout) as a system reminder. Implies
|
|
71
|
+
* `async: true`.
|
|
72
|
+
*
|
|
73
|
+
* @param {{ command: string, timeoutSeconds?: number }} opts
|
|
74
|
+
*/
|
|
75
|
+
export function buildAsyncRewakeHook({ command, timeoutSeconds = DEFAULT_ASYNC_HOOK_TIMEOUT_S } = {}) {
|
|
76
|
+
requireNonEmptyString(command, "command");
|
|
77
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
78
|
+
throw new TypeError("claude wake: timeoutSeconds must be a positive integer");
|
|
79
|
+
}
|
|
80
|
+
return { type: "command", command, asyncRewake: true, timeout: timeoutSeconds };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the JSON a `Stop` hook returns to keep a parked session alive: Claude
|
|
85
|
+
* cannot finish the turn and is fed `reason` as the next-turn context.
|
|
86
|
+
*
|
|
87
|
+
* @param {{ reason: string }} opts
|
|
88
|
+
*/
|
|
89
|
+
export function buildStopBlockDecision({ reason } = {}) {
|
|
90
|
+
return { decision: "block", reason: requireNonEmptyString(reason, "reason") };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A Stop hook must release (allow the session to stop) once Claude reports it
|
|
95
|
+
* has already been blocked `stop_hook_active` times, or it would wedge at the
|
|
96
|
+
* built-in cap. Returns true when the hook should let the session stop.
|
|
97
|
+
*
|
|
98
|
+
* @param {{ stop_hook_active?: boolean, stopHookActive?: boolean, blockCount?: number }} hookInput
|
|
99
|
+
*/
|
|
100
|
+
export function shouldReleaseStopBlock(hookInput = {}) {
|
|
101
|
+
if (hookInput.stop_hook_active === true || hookInput.stopHookActive === true) return true;
|
|
102
|
+
if (Number.isInteger(hookInput.blockCount) && hookInput.blockCount >= STOP_BLOCK_CAP) return true;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Shared-interface method: produce the settings fragment that installs the
|
|
108
|
+
* wake hook. Returns the fragment (caller decides where to merge it) rather
|
|
109
|
+
* than mutating a user's settings file, so installation stays non-destructive.
|
|
110
|
+
*
|
|
111
|
+
* @param {{ command: string, timeoutSeconds?: number, event?: string }} opts
|
|
112
|
+
*/
|
|
113
|
+
export function installWakeHook({ command, timeoutSeconds, event = "Stop" } = {}) {
|
|
114
|
+
const hook = buildAsyncRewakeHook({ command, timeoutSeconds });
|
|
115
|
+
return { hooks: { [event]: [{ hooks: [hook] }] } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Shared-interface method the L2 daemon calls. Deterministic external wake =
|
|
120
|
+
* daemon-owned resume: spawn `claude --resume <id> <message>` via execFile
|
|
121
|
+
* (argv array, no shell). Resolves to a structured result; never throws for a
|
|
122
|
+
* non-zero exit — the daemon inspects `ok` and decides whether to retry.
|
|
123
|
+
*
|
|
124
|
+
* @param {{ sessionId: string, message: string, print?: boolean, extraArgs?: string[] }} target
|
|
125
|
+
* @param {{ execFileImpl?: Function, claudeBin?: string, timeoutMs?: number, env?: object }} [deps]
|
|
126
|
+
* @returns {Promise<{ ok: boolean, hostName: string, sessionId: string, code: number|null, reason: string|null }>}
|
|
127
|
+
*/
|
|
128
|
+
export function wake(target = {}, deps = {}) {
|
|
129
|
+
const {
|
|
130
|
+
execFileImpl = execFile,
|
|
131
|
+
claudeBin = DEFAULT_CLAUDE_BIN,
|
|
132
|
+
timeoutMs = DEFAULT_RESUME_TIMEOUT_MS,
|
|
133
|
+
env = process.env,
|
|
134
|
+
} = deps;
|
|
135
|
+
|
|
136
|
+
// Build args first so validation errors reject the promise deterministically.
|
|
137
|
+
let args;
|
|
138
|
+
try {
|
|
139
|
+
args = buildResumeArgs(target);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return Promise.reject(error);
|
|
142
|
+
}
|
|
143
|
+
const sessionId = target.sessionId;
|
|
144
|
+
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
execFileImpl(
|
|
147
|
+
claudeBin,
|
|
148
|
+
args,
|
|
149
|
+
{ timeout: timeoutMs, env, windowsHide: true },
|
|
150
|
+
(error, _stdout, stderr) => {
|
|
151
|
+
if (!error) {
|
|
152
|
+
resolve({ ok: true, hostName, sessionId, code: 0, reason: null });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const code = typeof error.code === "number" ? error.code : null;
|
|
156
|
+
const reason = error.killed
|
|
157
|
+
? "resume_timeout"
|
|
158
|
+
: (typeof stderr === "string" && stderr.trim()) || error.message || "resume_failed";
|
|
159
|
+
resolve({ ok: false, hostName, sessionId, code, reason });
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const claudeWakeAdapter = {
|
|
166
|
+
hostName,
|
|
167
|
+
installWakeHook,
|
|
168
|
+
wake,
|
|
169
|
+
buildResumeArgs,
|
|
170
|
+
buildAsyncRewakeHook,
|
|
171
|
+
buildStopBlockDecision,
|
|
172
|
+
shouldReleaseStopBlock,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export default claudeWakeAdapter;
|