sentinelayer-cli 0.18.2 → 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 +141 -4
- package/src/session/sync.js +296 -2
- 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
|
@@ -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;
|