@syengup/friday-channel-next 0.0.45 → 0.1.1

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.
@@ -15,6 +15,7 @@ import { handleSessionsDelete } from "./handlers/sessions-delete.js";
15
15
  import { handleSessionsSettings } from "./handlers/sessions-settings.js";
16
16
  import { handleModelsList } from "./handlers/models-list.js";
17
17
  import { handleStatus } from "./handlers/status.js";
18
+ import { handleHealth } from "./handlers/health.js";
18
19
  import { applyCorsHeaders } from "./middleware/cors.js";
19
20
  import { resolveFridayNextConfig } from "../config.js";
20
21
  import { getHostOpenClawConfigSnapshot } from "../host-config.js";
@@ -67,6 +68,10 @@ async function handleFridayNextRoute(req, res) {
67
68
  if (req.method === "GET" && pathname === "/friday-next/status") {
68
69
  return await handleStatus(req, res);
69
70
  }
71
+ // Route: GET /friday-next/health?deviceId=...&nodeDeviceId=...&selfHeal=true
72
+ if (req.method === "GET" && pathname === "/friday-next/health") {
73
+ return await handleHealth(req, res);
74
+ }
70
75
  // Not found
71
76
  return false;
72
77
  }
@@ -5,11 +5,17 @@ type RunRoute = {
5
5
  };
6
6
  export type RunMetadata = {
7
7
  modelName?: string;
8
+ modelProvider?: string;
8
9
  totalTokens?: number;
9
10
  /** Tokens counted toward the model context window (prompt-side: input + cache read + cache write when present). */
10
11
  contextTokensUsed?: number;
11
12
  /** Resolved model context window limit when the runtime exposes it. */
12
13
  contextWindowMax?: number;
14
+ /** Detailed token breakdown captured from agent event usage (current run, not stale store read). */
15
+ inputTokens?: number;
16
+ outputTokens?: number;
17
+ cacheReadTokens?: number;
18
+ cacheWriteTokens?: number;
13
19
  };
14
20
  /** Vitest / harness: clears per-run metadata and final-delivered flags (not routes). */
15
21
  export declare function resetRunMetadataForTest(): void;
@@ -106,6 +106,11 @@ export function ingestAgentEventMetadata(runId, data) {
106
106
  undefined;
107
107
  if (modelName)
108
108
  next.modelName = modelName;
109
+ const modelProvider = (typeof data.modelProvider === "string" && data.modelProvider.trim()) ||
110
+ (typeof data.provider === "string" && data.provider.trim()) ||
111
+ undefined;
112
+ if (modelProvider)
113
+ next.modelProvider = modelProvider;
109
114
  const usage = recordValue(data.usage);
110
115
  const totalTokens = finiteNumber(data.totalTokens) ??
111
116
  finiteNumber(data.total_tokens) ??
@@ -115,6 +120,19 @@ export function ingestAgentEventMetadata(runId, data) {
115
120
  if (typeof totalTokens === "number" && totalTokens > 0) {
116
121
  next.totalTokens = Math.floor(totalTokens);
117
122
  }
123
+ const usageForTokens = usage ?? data;
124
+ const input = pickInputTokens(usageForTokens);
125
+ if (typeof input === "number" && input >= 0)
126
+ next.inputTokens = Math.floor(input);
127
+ const output = pickOutputTokens(usageForTokens);
128
+ if (typeof output === "number" && output >= 0)
129
+ next.outputTokens = Math.floor(output);
130
+ const cacheRead = pickCacheRead(usageForTokens);
131
+ if (typeof cacheRead === "number" && cacheRead >= 0)
132
+ next.cacheReadTokens = Math.floor(cacheRead);
133
+ const cacheWrite = pickCacheWrite(usageForTokens);
134
+ if (typeof cacheWrite === "number" && cacheWrite >= 0)
135
+ next.cacheWriteTokens = Math.floor(cacheWrite);
118
136
  const usageForContext = usage ?? data;
119
137
  const ctxUsed = contextTokensFromUsageRecord(usageForContext);
120
138
  if (typeof ctxUsed === "number" && ctxUsed > 0) {
@@ -131,9 +149,14 @@ export function ingestAgentEventMetadata(runId, data) {
131
149
  }
132
150
  }
133
151
  if (next.modelName ||
152
+ next.modelProvider ||
134
153
  typeof next.totalTokens === "number" ||
135
154
  typeof next.contextTokensUsed === "number" ||
136
- typeof next.contextWindowMax === "number") {
155
+ typeof next.contextWindowMax === "number" ||
156
+ typeof next.inputTokens === "number" ||
157
+ typeof next.outputTokens === "number" ||
158
+ typeof next.cacheReadTokens === "number" ||
159
+ typeof next.cacheWriteTokens === "number") {
137
160
  setRunMetadata(runId, next);
138
161
  }
139
162
  }
package/index.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./src/friday-session.js";
17
17
  import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
18
18
  import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
19
+ import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
19
20
 
20
21
  export { fridayNextChannelPlugin } from "./src/channel.js";
21
22
  export { setFridayNextRuntime } from "./src/runtime.js";
@@ -103,6 +104,21 @@ export default defineChannelPluginEntry({
103
104
  });
104
105
  });
105
106
 
107
+ api.on("llm_output", (event: any) => {
108
+ accumulateRunUsage(
109
+ event.runId,
110
+ {
111
+ input: event.usage?.input,
112
+ output: event.usage?.output,
113
+ cacheRead: event.usage?.cacheRead,
114
+ cacheWrite: event.usage?.cacheWrite,
115
+ total: event.usage?.total,
116
+ },
117
+ event.model,
118
+ event.provider,
119
+ );
120
+ });
121
+
106
122
  if (fridayNextToolHooksRegistered) {
107
123
  return;
108
124
  }
package/install.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
4
4
  import { homedir, networkInterfaces } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
@@ -102,6 +102,13 @@ try {
102
102
  if (out.trim()) console.log(out.trim());
103
103
  installed = true;
104
104
  log("Plugin registered with install record — auto-upgrade enabled.");
105
+
106
+ // Remove old manual install to avoid "duplicate plugin id" warning.
107
+ const legacyDir = join(USER_HOME, ".openclaw", "extensions", "friday-channel-next");
108
+ if (existsSync(legacyDir)) {
109
+ try { rmSync(legacyDir, { recursive: true, force: true }); log("Removed legacy manual install."); }
110
+ catch { /* non-critical */ }
111
+ }
105
112
  } catch (e) {
106
113
  const msg = (e.stderr || e.stdout || e.message || "").toString();
107
114
  warn("openclaw plugins install failed: " + msg.trim().split("\n").pop());
@@ -118,6 +125,11 @@ if (!installed) {
118
125
  }
119
126
  warn("Manual install complete, but auto-upgrade is NOT available.");
120
127
  warn("To enable auto-upgrade later, run: openclaw plugins install @syengup/friday-channel-next");
128
+ // Clean up legacy dir even in fallback to avoid duplicate warnings
129
+ if (existsSync(join(USER_HOME, ".openclaw", "extensions", "friday-channel-next"))) {
130
+ warn("Legacy install detected. Remove it to avoid duplicate warnings:");
131
+ warn(" rm -rf ~/.openclaw/extensions/friday-channel-next");
132
+ }
121
133
  }
122
134
 
123
135
  // --------------- configure OpenClaw ---------------
@@ -167,6 +179,10 @@ for (const id of ["friday-next", "canvas"]) {
167
179
  else if (!config.plugins.entries[id].enabled) { config.plugins.entries[id].enabled = true; configChanged = true; }
168
180
  }
169
181
 
182
+ // llm_output hook requires allowConversationAccess for non-bundled plugins.
183
+ if (!config.plugins.entries["friday-next"].hooks) { config.plugins.entries["friday-next"].hooks = {}; configChanged = true; }
184
+ if (!config.plugins.entries["friday-next"].hooks.allowConversationAccess) { config.plugins.entries["friday-next"].hooks.allowConversationAccess = true; configChanged = true; }
185
+
170
186
  // Channel
171
187
  if (!config.channels) config.channels = {};
172
188
  if (!config.channels["friday-next"]) { config.channels["friday-next"] = { enabled: true, transport: "http+sse" }; configChanged = true; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.0.45",
3
+ "version": "0.1.1",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "type": "module",
6
6
  "files": [
@@ -11,6 +11,15 @@
11
11
  "tsconfig.json",
12
12
  "openclaw.plugin.json"
13
13
  ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "prepublishOnly": "pnpm build",
17
+ "test": "npm run test:unit && npm run test:e2e",
18
+ "test:unit": "vitest run",
19
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
20
+ "test:smoke": "node scripts/e2e-smoke.mjs",
21
+ "test:msg-live": "node scripts/message-roundtrip-live.mjs"
22
+ },
14
23
  "bin": {
15
24
  "friday-channel-next": "install.js"
16
25
  },
@@ -56,13 +65,5 @@
56
65
  "typescript": "^6.0.3",
57
66
  "vitest": "^4.1.5",
58
67
  "zod": "^4.3.6"
59
- },
60
- "scripts": {
61
- "build": "tsc -p tsconfig.json",
62
- "test": "npm run test:unit && npm run test:e2e",
63
- "test:unit": "vitest run",
64
- "test:e2e": "vitest run --config vitest.e2e.config.ts",
65
- "test:smoke": "node scripts/e2e-smoke.mjs",
66
- "test:msg-live": "node scripts/message-roundtrip-live.mjs"
67
68
  }
68
- }
69
+ }
@@ -0,0 +1,70 @@
1
+ import type { FridaySessionUsagePayload } from "../session-usage-snapshot.js";
2
+
3
+ type UsageFields = {
4
+ input?: number;
5
+ output?: number;
6
+ cacheRead?: number;
7
+ cacheWrite?: number;
8
+ total?: number;
9
+ };
10
+
11
+ type AccumulatedUsage = {
12
+ input: number;
13
+ output: number;
14
+ cacheRead: number;
15
+ cacheWrite: number;
16
+ total: number;
17
+ model?: string;
18
+ provider?: string;
19
+ };
20
+
21
+ const usageByRunId = new Map<string, AccumulatedUsage>();
22
+
23
+ function ensure(runId: string): AccumulatedUsage {
24
+ let entry = usageByRunId.get(runId);
25
+ if (!entry) {
26
+ entry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
27
+ usageByRunId.set(runId, entry);
28
+ }
29
+ return entry;
30
+ }
31
+
32
+ export function accumulateRunUsage(
33
+ runId: string,
34
+ usage: UsageFields,
35
+ model?: string,
36
+ provider?: string,
37
+ ): void {
38
+ if (!runId.trim()) return;
39
+ const entry = ensure(runId);
40
+ if (typeof usage.input === "number" && usage.input > 0) entry.input += usage.input;
41
+ if (typeof usage.output === "number" && usage.output > 0) entry.output += usage.output;
42
+ if (typeof usage.cacheRead === "number" && usage.cacheRead > 0) entry.cacheRead += usage.cacheRead;
43
+ if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0) entry.cacheWrite += usage.cacheWrite;
44
+ if (typeof usage.total === "number" && usage.total > 0) entry.total += usage.total;
45
+ if (model && model.trim()) entry.model = model.trim();
46
+ if (provider && provider.trim()) entry.provider = provider.trim();
47
+ }
48
+
49
+ export function consumeRunUsage(runId: string): FridaySessionUsagePayload | undefined {
50
+ const entry = usageByRunId.get(runId);
51
+ if (!entry) return undefined;
52
+ usageByRunId.delete(runId);
53
+ const tokens: NonNullable<FridaySessionUsagePayload["tokens"]> = {};
54
+ if (entry.input > 0) tokens.input = entry.input;
55
+ if (entry.output > 0) tokens.output = entry.output;
56
+ if (entry.cacheRead > 0) tokens.cacheRead = entry.cacheRead;
57
+ if (entry.cacheWrite > 0) tokens.cacheWrite = entry.cacheWrite;
58
+ if (entry.total > 0) tokens.total = entry.total;
59
+ tokens.totalFresh = true;
60
+ if (Object.keys(tokens).length === 1) return undefined; // only totalFresh, no actual tokens
61
+ const payload: FridaySessionUsagePayload = { tokens };
62
+ if (entry.model) payload.modelId = entry.model;
63
+ if (entry.provider) payload.modelProvider = entry.provider;
64
+ return payload;
65
+ }
66
+
67
+ /** Vitest-only. */
68
+ export function resetRunUsageAccumulatorForTest(): void {
69
+ usageByRunId.clear();
70
+ }
@@ -10,6 +10,7 @@ import {
10
10
  resetThinkingStreamAccumStateForTest,
11
11
  } from "./friday-session.js";
12
12
  import { resetRunMetadataForTest } from "./run-metadata.js";
13
+ import { accumulateRunUsage, resetRunUsageAccumulatorForTest } from "./agent/run-usage-accumulator.js";
13
14
  import { sseEmitter } from "./sse/emitter.js";
14
15
  import { toSessionStoreKey } from "./session/session-manager.js";
15
16
 
@@ -24,6 +25,7 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
24
25
  resetOpenClawRunDeviceMappingForTest();
25
26
  resetFridayAgentForwardRuntimeForTest();
26
27
  resetRunMetadataForTest();
28
+ resetRunUsageAccumulatorForTest();
27
29
  registerFridaySessionDeviceMapping(sessionKey, deviceId);
28
30
  vi.spyOn(sseEmitter, "broadcastToRun").mockImplementation(() => {});
29
31
  });
@@ -198,38 +200,107 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
198
200
  expect("reasoningPrefixChars" in (payload.data as object)).toBe(false);
199
201
  });
200
202
 
201
- it("merges sessionUsage from session store on lifecycle end after persist (deferred)", async () => {
203
+ it("builds sessionUsage from llm_output accumulated usage on lifecycle end", () => {
204
+ // Simulate llm_output hook accumulating usage across API calls.
205
+ accumulateRunUsage(runId, { input: 100, output: 50, cacheRead: 10, total: 150 }, "my-model", "openai");
206
+ accumulateRunUsage(runId, { input: 30, output: 10, cacheRead: 0, total: 40 }, "my-model", "openai");
207
+
208
+ forwardAgentEventRaw({
209
+ runId,
210
+ seq: 1,
211
+ stream: "lifecycle",
212
+ sessionKey,
213
+ data: { phase: "end" },
214
+ });
215
+
216
+ // Lifecycle events are synchronous now (no file I/O wait).
217
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
218
+ const forwarded = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
219
+ expect(forwarded.stream).toBe("lifecycle");
220
+ const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<string, unknown>;
221
+ expect(sessionUsage).toBeDefined();
222
+ expect(sessionUsage.modelId).toBe("my-model");
223
+ expect(sessionUsage.modelProvider).toBe("openai");
224
+ // Accumulated totals across both API calls.
225
+ expect((sessionUsage.tokens as Record<string, unknown>).input).toBe(130);
226
+ expect((sessionUsage.tokens as Record<string, unknown>).output).toBe(60);
227
+ expect((sessionUsage.tokens as Record<string, unknown>).cacheRead).toBe(10);
228
+ expect((sessionUsage.tokens as Record<string, unknown>).total).toBe(190);
229
+ expect((sessionUsage.tokens as Record<string, unknown>).totalFresh).toBe(true);
230
+ });
231
+
232
+ it("merges llm_output usage with RunMetadata for sessionUsage on lifecycle end", () => {
233
+ // Simulate llm_output hook.
234
+ accumulateRunUsage(runId, { input: 500, output: 100, cacheRead: 200, cacheWrite: 0, total: 800 }, "llm-model", "llm-provider");
235
+
236
+ // Send an agent event that populates RunMetadata (model, context window).
237
+ forwardAgentEventRaw({
238
+ runId,
239
+ seq: 1,
240
+ stream: "assistant",
241
+ sessionKey,
242
+ data: {
243
+ model: "agent-model",
244
+ provider: "agent-provider",
245
+ usage: { input: 999, total: 999 },
246
+ contextWindow: 100000,
247
+ },
248
+ });
249
+
250
+ forwardAgentEventRaw({
251
+ runId,
252
+ seq: 2,
253
+ stream: "lifecycle",
254
+ sessionKey,
255
+ data: { phase: "end" },
256
+ });
257
+
258
+ // Assistant (1st) + lifecycle.end (2nd, synchronous).
259
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(2);
260
+ const lifecycleCall = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[1];
261
+ const lifecycleData = (lifecycleCall[1] as { data: { data: Record<string, unknown> } }).data.data;
262
+ const sessionUsage = lifecycleData.sessionUsage as Record<string, unknown> | undefined;
263
+ expect(sessionUsage).toBeDefined();
264
+ // llm_output tokens win (authoritative per-API-call data).
265
+ expect(sessionUsage!.modelId).toBe("llm-model");
266
+ expect(sessionUsage!.modelProvider).toBe("llm-provider");
267
+ expect((sessionUsage!.tokens as Record<string, unknown>).input).toBe(500);
268
+ expect((sessionUsage!.tokens as Record<string, unknown>).output).toBe(100);
269
+ expect((sessionUsage!.tokens as Record<string, unknown>).cacheRead).toBe(200);
270
+ expect((sessionUsage!.tokens as Record<string, unknown>).total).toBe(800);
271
+ // Context from RunMetadata (not available from llm_output).
272
+ expect((sessionUsage!.context as Record<string, unknown>).windowMax).toBe(100000);
273
+ });
274
+
275
+ it("falls back to store read when llm_output has no data (deferred)", async () => {
202
276
  const storeKey = toSessionStoreKey(sessionKey);
203
277
  const store: Record<string, Record<string, unknown>> = {
204
278
  [storeKey]: {
205
- model: "my-model",
206
- modelProvider: "openai",
207
- inputTokens: 100,
208
- outputTokens: 50,
209
- totalTokens: 9999,
279
+ model: "store-model",
280
+ modelProvider: "store-provider",
281
+ inputTokens: 200,
282
+ outputTokens: 80,
283
+ totalTokens: 5000,
210
284
  totalTokensFresh: true,
211
- contextTokens: 128000,
212
- estimatedCostUsd: 0.01,
213
- cacheRead: 10,
285
+ contextTokens: 64000,
286
+ estimatedCostUsd: 0.05,
287
+ cacheRead: 20,
214
288
  cacheWrite: 0,
215
289
  },
216
290
  };
217
- const loadSessionStore = vi.fn(() => store);
218
- const mockApi = {
291
+ setFridayAgentForwardRuntime({
219
292
  runtime: {
220
- config: {
221
- current: () => ({ session: {} }),
222
- },
293
+ config: { current: () => ({ session: {} }) },
223
294
  agent: {
224
295
  session: {
225
296
  resolveStorePath: () => "/tmp/sessions.json",
226
- loadSessionStore,
297
+ loadSessionStore: vi.fn(() => store),
227
298
  },
228
299
  },
229
300
  },
230
- };
231
- setFridayAgentForwardRuntime(mockApi as never);
301
+ } as never);
232
302
 
303
+ // No llm_output data accumulated — store is the only token source.
233
304
  forwardAgentEventRaw({
234
305
  runId,
235
306
  seq: 1,
@@ -237,28 +308,24 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
237
308
  sessionKey,
238
309
  data: { phase: "end" },
239
310
  });
311
+ // Deferred: not broadcast yet (setTimeout 100ms hasn't fired).
240
312
  expect(sseEmitter.broadcastToRun).not.toHaveBeenCalled();
241
313
 
242
- await new Promise<void>((resolve) => setImmediate(resolve));
314
+ await new Promise<void>((resolve) => setTimeout(resolve, 150));
243
315
 
244
316
  expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
245
317
  const forwarded = (sseEmitter.broadcastToRun as ReturnType<typeof vi.fn>).mock.calls[0][1].data;
246
318
  expect(forwarded.stream).toBe("lifecycle");
247
- expect((forwarded.data as Record<string, unknown>).sessionUsage).toEqual({
248
- modelId: "my-model",
249
- modelProvider: "openai",
250
- tokens: {
251
- input: 100,
252
- output: 50,
253
- cacheRead: 10,
254
- cacheWrite: 0,
255
- total: 9999,
256
- totalFresh: true,
257
- },
258
- context: { windowMax: 128000, used: 9999 },
259
- estimatedCostUsd: 0.01,
260
- });
261
- expect(loadSessionStore).toHaveBeenCalledWith("/tmp/sessions.json", { skipCache: true });
319
+ const sessionUsage = (forwarded.data as Record<string, unknown>).sessionUsage as Record<string, unknown>;
320
+ expect(sessionUsage).toBeDefined();
321
+ expect(sessionUsage.modelId).toBe("store-model");
322
+ expect(sessionUsage.modelProvider).toBe("store-provider");
323
+ expect((sessionUsage.tokens as Record<string, unknown>).input).toBe(200);
324
+ expect((sessionUsage.tokens as Record<string, unknown>).output).toBe(80);
325
+ expect((sessionUsage.tokens as Record<string, unknown>).total).toBe(5000);
326
+ expect((sessionUsage.tokens as Record<string, unknown>).totalFresh).toBe(true);
327
+ expect((sessionUsage.context as Record<string, unknown>).windowMax).toBe(64000);
328
+ expect(sessionUsage.estimatedCostUsd).toBe(0.05);
262
329
  });
263
330
  });
264
331
 
@@ -4,7 +4,9 @@ import { toSessionStoreKey } from "./session/session-manager.js";
4
4
  import { getOpenClawAgentRunContext } from "./agent-run-context-bridge.js";
5
5
  import { observeAgentEventForActiveRuns } from "./agent/active-runs.js";
6
6
  import { getRunMetadata, ingestAgentEventMetadata } from "./run-metadata.js";
7
+ import { consumeRunUsage } from "./agent/run-usage-accumulator.js";
7
8
  import { buildSessionUsageSnapshot } from "./session-usage-snapshot.js";
9
+ import type { FridaySessionUsagePayload } from "./session-usage-snapshot.js";
8
10
  import {
9
11
  lookupByRunId,
10
12
  registerSessionKeyForRun,
@@ -178,7 +180,7 @@ function mergeRunMetadataIntoLifecycleEnd(
178
180
  return { ...base, ...extra };
179
181
  }
180
182
 
181
- function tryReadSessionUsageFromStore(sessionKeyForStore: string): ReturnType<typeof buildSessionUsageSnapshot> {
183
+ function tryReadSessionUsageFromStore(sessionKeyForStore: string): FridaySessionUsagePayload | undefined {
182
184
  const access = getFridayAgentForwardRuntime();
183
185
  if (!access) return undefined;
184
186
  try {
@@ -195,6 +197,51 @@ function tryReadSessionUsageFromStore(sessionKeyForStore: string): ReturnType<ty
195
197
  }
196
198
  }
197
199
 
200
+ function buildSessionUsageFromRunMetadata(runId: string): FridaySessionUsagePayload | undefined {
201
+ const meta = getRunMetadata(runId);
202
+ if (!meta) return undefined;
203
+ const payload: FridaySessionUsagePayload = {};
204
+ if (typeof meta.modelName === "string" && meta.modelName.trim()) {
205
+ payload.modelId = meta.modelName.trim();
206
+ }
207
+ if (typeof meta.modelProvider === "string" && meta.modelProvider.trim()) {
208
+ payload.modelProvider = meta.modelProvider.trim();
209
+ }
210
+ const tokens: NonNullable<typeof payload.tokens> = {};
211
+ if (typeof meta.inputTokens === "number") tokens.input = meta.inputTokens;
212
+ if (typeof meta.outputTokens === "number") tokens.output = meta.outputTokens;
213
+ if (typeof meta.cacheReadTokens === "number") tokens.cacheRead = meta.cacheReadTokens;
214
+ if (typeof meta.cacheWriteTokens === "number") tokens.cacheWrite = meta.cacheWriteTokens;
215
+ if (typeof meta.totalTokens === "number") tokens.total = meta.totalTokens;
216
+ if (Object.keys(tokens).length > 0) payload.tokens = tokens;
217
+ const context: NonNullable<typeof payload.context> = {};
218
+ if (typeof meta.contextWindowMax === "number") context.windowMax = meta.contextWindowMax;
219
+ if (typeof meta.totalTokens === "number") context.used = meta.totalTokens;
220
+ if (Object.keys(context).length > 0) payload.context = context;
221
+ if (!payload.modelId && !payload.modelProvider && !payload.tokens && !payload.context) {
222
+ return undefined;
223
+ }
224
+ return payload;
225
+ }
226
+
227
+ function mergeUsage(
228
+ llmUsage: FridaySessionUsagePayload | undefined,
229
+ memUsage: FridaySessionUsagePayload | undefined,
230
+ ): FridaySessionUsagePayload | undefined {
231
+ if (!llmUsage && !memUsage) return undefined;
232
+ if (!llmUsage) return memUsage;
233
+ if (!memUsage) return llmUsage;
234
+ // llm_output tokens are authoritative (per API call, no race);
235
+ // RunMetadata fills context window gaps.
236
+ return {
237
+ modelId: llmUsage.modelId ?? memUsage.modelId,
238
+ modelProvider: llmUsage.modelProvider ?? memUsage.modelProvider,
239
+ tokens: llmUsage.tokens,
240
+ context: memUsage.context ?? llmUsage.context,
241
+ estimatedCostUsd: llmUsage.estimatedCostUsd ?? memUsage.estimatedCostUsd,
242
+ };
243
+ }
244
+
198
245
  function completeAgentEventForward(params: {
199
246
  evt: ForwardAgentEventArgs;
200
247
  sk: string;
@@ -436,23 +483,38 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
436
483
  }
437
484
  }
438
485
 
439
- if (isTerminalLifecycle && getFridayAgentForwardRuntime()) {
440
- setImmediate(() => {
441
- let data = outgoingData;
442
- const usage = tryReadSessionUsageFromStore(sk);
486
+ // Build sessionUsage: llm_output hook (primary, no race) → store read (fallback).
487
+ if (isTerminalLifecycle) {
488
+ const llmUsage = consumeRunUsage(evt.runId);
489
+ const memUsage = buildSessionUsageFromRunMetadata(evt.runId);
490
+ const hasRealTokens = llmUsage?.tokens && Object.keys(llmUsage.tokens).length > 1;
491
+
492
+ if (hasRealTokens) {
493
+ const usage = mergeUsage(llmUsage, memUsage);
443
494
  if (usage) {
444
- data = { ...outgoingData, sessionUsage: usage };
495
+ outgoingData = { ...outgoingData, sessionUsage: usage };
445
496
  }
446
- completeAgentEventForward({
447
- evt,
448
- sk,
449
- deviceIdRaw,
450
- outgoingData: data,
451
- isTerminalLifecycle: true,
452
- subagentMeta,
453
- });
454
- });
455
- return;
497
+ } else if (getFridayAgentForwardRuntime()) {
498
+ // llm_output hook fires async ~20ms after lifecycle.end.
499
+ // Wait 100ms then re-check before falling back to store read.
500
+ setTimeout(() => {
501
+ let data = outgoingData;
502
+ const retryLlm = consumeRunUsage(evt.runId);
503
+ const usage = mergeUsage(retryLlm, memUsage) ?? tryReadSessionUsageFromStore(sk);
504
+ if (usage) {
505
+ data = { ...outgoingData, sessionUsage: usage };
506
+ }
507
+ completeAgentEventForward({
508
+ evt,
509
+ sk,
510
+ deviceIdRaw,
511
+ outgoingData: data,
512
+ isTerminalLifecycle: true,
513
+ subagentMeta,
514
+ });
515
+ }, 100);
516
+ return;
517
+ }
456
518
  }
457
519
 
458
520
  completeAgentEventForward({