@chrysb/alphaclaw 0.4.0 → 0.4.1-beta.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.
Files changed (47) hide show
  1. package/lib/public/css/shell.css +21 -19
  2. package/lib/public/css/theme.css +17 -0
  3. package/lib/public/js/app.js +80 -5
  4. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  5. package/lib/public/js/components/file-viewer/index.js +3 -0
  6. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  7. package/lib/public/js/components/file-viewer/toolbar.js +13 -0
  8. package/lib/public/js/components/file-viewer/use-file-viewer.js +48 -13
  9. package/lib/public/js/components/google/account-row.js +34 -1
  10. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  11. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  12. package/lib/public/js/components/google/index.js +118 -4
  13. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  14. package/lib/public/js/components/scope-picker.js +1 -1
  15. package/lib/public/js/components/sidebar-git-panel.js +5 -6
  16. package/lib/public/js/components/sidebar.js +2 -0
  17. package/lib/public/js/components/toast.js +11 -7
  18. package/lib/public/js/components/usage-tab/constants.js +31 -0
  19. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  20. package/lib/public/js/components/usage-tab/index.js +72 -0
  21. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  22. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  23. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  24. package/lib/public/js/components/webhooks.js +182 -129
  25. package/lib/public/js/lib/api.js +106 -1
  26. package/lib/public/js/lib/format.js +71 -0
  27. package/lib/server/constants.js +28 -0
  28. package/lib/server/gmail-push.js +109 -0
  29. package/lib/server/gmail-serve.js +254 -0
  30. package/lib/server/gmail-watch.js +725 -0
  31. package/lib/server/google-state.js +130 -0
  32. package/lib/server/helpers.js +5 -7
  33. package/lib/server/internal-files-migration.js +31 -3
  34. package/lib/server/routes/gmail.js +128 -0
  35. package/lib/server/routes/google.js +19 -0
  36. package/lib/server/routes/system.js +107 -0
  37. package/lib/server/routes/usage.js +29 -2
  38. package/lib/server/routes/webhooks.js +52 -17
  39. package/lib/server/usage-db.js +283 -15
  40. package/lib/server/watchdog.js +66 -0
  41. package/lib/server/webhook-middleware.js +99 -1
  42. package/lib/server/webhooks.js +214 -65
  43. package/lib/server.js +27 -0
  44. package/lib/setup/gitignore +3 -0
  45. package/lib/setup/hourly-git-sync.sh +1 -1
  46. package/package.json +1 -1
  47. package/lib/public/js/components/usage-tab.js +0 -531
@@ -6,9 +6,12 @@ const {
6
6
  validateWebhookName,
7
7
  } = require("../webhooks");
8
8
 
9
- const isFiniteInteger = (value) => Number.isFinite(value) && Number.isInteger(value);
9
+ const isFiniteInteger = (value) =>
10
+ Number.isFinite(value) && Number.isInteger(value);
10
11
  const parseBooleanFlag = (value) => {
11
- const normalized = String(value == null ? "" : value).trim().toLowerCase();
12
+ const normalized = String(value == null ? "" : value)
13
+ .trim()
14
+ .toLowerCase();
12
15
  return ["1", "true", "yes", "on"].includes(normalized);
13
16
  };
14
17
 
@@ -40,20 +43,24 @@ const mergeWebhookAndSummary = ({ webhook, summary }) => {
40
43
  };
41
44
 
42
45
  const normalizeStatusFilter = (rawStatus) => {
43
- const status = String(rawStatus || "all").trim().toLowerCase();
46
+ const status = String(rawStatus || "all")
47
+ .trim()
48
+ .toLowerCase();
44
49
  if (["all", "success", "error"].includes(status)) return status;
45
50
  return "all";
46
51
  };
47
52
 
48
53
  const buildWebhookUrls = ({ baseUrl, name }) => {
49
54
  const fullUrl = `${baseUrl}/hooks/${name}`;
50
- const token = String(process.env.WEBHOOK_TOKEN || "").trim();
55
+ const token = String(
56
+ process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "",
57
+ ).trim();
51
58
  const queryStringUrl = token
52
59
  ? `${fullUrl}?token=${encodeURIComponent(token)}`
53
- : `${fullUrl}?token=<WEBHOOK_TOKEN>`;
60
+ : `${fullUrl}?token=<OPENCLAW_HOOKS_TOKEN>`;
54
61
  const authHeaderValue = token
55
62
  ? `Authorization: Bearer ${token}`
56
- : "Authorization: Bearer <WEBHOOK_TOKEN>";
63
+ : "Authorization: Bearer <OPENCLAW_HOOKS_TOKEN>";
57
64
  return { fullUrl, queryStringUrl, authHeaderValue, hasRuntimeToken: !!token };
58
65
  };
59
66
 
@@ -71,7 +78,8 @@ const registerWebhookRoutes = ({
71
78
  getSnapshot: async () => ({ restartRequired: false }),
72
79
  };
73
80
  const resolvedRestartState = restartRequiredState || fallbackRestartState;
74
- const { markRequired: markRestartRequired, getSnapshot: getRestartSnapshot } = resolvedRestartState;
81
+ const { markRequired: markRestartRequired, getSnapshot: getRestartSnapshot } =
82
+ resolvedRestartState;
75
83
  const runWebhookGitSync = async (action, name) => {
76
84
  if (typeof shellCmd !== "function") return null;
77
85
  const safeName = String(name || "").trim();
@@ -95,7 +103,8 @@ const registerWebhookRoutes = ({
95
103
  mergeWebhookAndSummary({
96
104
  webhook,
97
105
  summary: summaryByHook.get(webhook.name),
98
- }));
106
+ }),
107
+ );
99
108
  res.json({ ok: true, webhooks });
100
109
  } catch (err) {
101
110
  res.status(500).json({ ok: false, error: err.message });
@@ -106,8 +115,11 @@ const registerWebhookRoutes = ({
106
115
  try {
107
116
  const name = validateWebhookName(req.params.name);
108
117
  const detail = getWebhookDetail({ fs, constants, name });
109
- if (!detail) return res.status(404).json({ ok: false, error: "Webhook not found" });
110
- const summary = webhooksDb.getHookSummaries().find((item) => item.hookName === name);
118
+ if (!detail)
119
+ return res.status(404).json({ ok: false, error: "Webhook not found" });
120
+ const summary = webhooksDb
121
+ .getHookSummaries()
122
+ .find((item) => item.hookName === name);
111
123
  const merged = mergeWebhookAndSummary({ webhook: detail, summary });
112
124
  const baseUrl = getBaseUrl(req);
113
125
  const urls = buildWebhookUrls({ baseUrl, name });
@@ -151,7 +163,9 @@ const registerWebhookRoutes = ({
151
163
  syncWarning,
152
164
  });
153
165
  } catch (err) {
154
- const status = String(err.message || "").includes("already exists") ? 409 : 400;
166
+ const status = String(err.message || "").includes("already exists")
167
+ ? 409
168
+ : 400;
155
169
  return res.status(status).json({ ok: false, error: err.message });
156
170
  }
157
171
  });
@@ -159,9 +173,23 @@ const registerWebhookRoutes = ({
159
173
  app.delete("/api/webhooks/:name", async (req, res) => {
160
174
  try {
161
175
  const name = validateWebhookName(req.params.name);
162
- const deleteTransformDir = parseBooleanFlag(req?.body?.deleteTransformDir);
163
- const deletion = deleteWebhook({ fs, constants, name, deleteTransformDir });
164
- if (!deletion?.removed) return res.status(404).json({ ok: false, error: "Webhook not found" });
176
+ const deleteTransformDir = parseBooleanFlag(
177
+ req?.body?.deleteTransformDir,
178
+ );
179
+ const deletion = deleteWebhook({
180
+ fs,
181
+ constants,
182
+ name,
183
+ deleteTransformDir,
184
+ });
185
+ if (deletion?.managed) {
186
+ return res.status(409).json({
187
+ ok: false,
188
+ error: `Webhook "${name}" is managed by system setup and cannot be deleted`,
189
+ });
190
+ }
191
+ if (!deletion?.removed)
192
+ return res.status(404).json({ ok: false, error: "Webhook not found" });
165
193
  const deletedRequestCount = webhooksDb.deleteRequestsByHook(name);
166
194
  const syncWarning = await runWebhookGitSync("delete", name);
167
195
  markRestartRequired("webhooks");
@@ -184,9 +212,15 @@ const registerWebhookRoutes = ({
184
212
  const limit = Number.parseInt(String(req.query.limit || 50), 10);
185
213
  const offset = Number.parseInt(String(req.query.offset || 0), 10);
186
214
  const status = normalizeStatusFilter(req.query.status);
187
- const hasBadPaging = !isFiniteInteger(limit) || limit <= 0 || !isFiniteInteger(offset) || offset < 0;
215
+ const hasBadPaging =
216
+ !isFiniteInteger(limit) ||
217
+ limit <= 0 ||
218
+ !isFiniteInteger(offset) ||
219
+ offset < 0;
188
220
  if (hasBadPaging) {
189
- return res.status(400).json({ ok: false, error: "Invalid limit/offset" });
221
+ return res
222
+ .status(400)
223
+ .json({ ok: false, error: "Invalid limit/offset" });
190
224
  }
191
225
  const requests = webhooksDb.getRequests(name, { limit, offset, status });
192
226
  return res.json({ ok: true, requests });
@@ -203,7 +237,8 @@ const registerWebhookRoutes = ({
203
237
  return res.status(400).json({ ok: false, error: "Invalid request id" });
204
238
  }
205
239
  const request = webhooksDb.getRequestById(name, requestId);
206
- if (!request) return res.status(404).json({ ok: false, error: "Request not found" });
240
+ if (!request)
241
+ return res.status(404).json({ ok: false, error: "Request not found" });
207
242
  return res.json({ ok: true, request });
208
243
  } catch (err) {
209
244
  return res.status(400).json({ ok: false, error: err.message });
@@ -7,7 +7,10 @@ const kMaxSessionLimit = 200;
7
7
  const kDefaultDays = 30;
8
8
  const kDefaultMaxPoints = 100;
9
9
  const kMaxMaxPoints = 1000;
10
+ const kDayMs = 24 * 60 * 60 * 1000;
10
11
  const kTokensPerMillion = 1_000_000;
12
+ const kUtcTimeZone = "UTC";
13
+ const kDayKeyFormatterCache = new Map();
11
14
  const kGlobalModelPricing = {
12
15
  "claude-opus-4-6": { input: 15.0, output: 75.0 },
13
16
  "claude-sonnet-4-6": { input: 3.0, output: 15.0 },
@@ -31,6 +34,39 @@ const coerceInt = (value, fallbackValue = 0) => {
31
34
  const clampInt = (value, minValue, maxValue, fallbackValue) =>
32
35
  Math.min(maxValue, Math.max(minValue, coerceInt(value, fallbackValue)));
33
36
 
37
+ const normalizeTimeZone = (value) => {
38
+ const raw = String(value || "").trim();
39
+ if (!raw) return kUtcTimeZone;
40
+ try {
41
+ new Intl.DateTimeFormat("en-US", { timeZone: raw });
42
+ return raw;
43
+ } catch {
44
+ return kUtcTimeZone;
45
+ }
46
+ };
47
+
48
+ const getDayKeyFormatter = (timeZone) => {
49
+ if (kDayKeyFormatterCache.has(timeZone)) {
50
+ return kDayKeyFormatterCache.get(timeZone);
51
+ }
52
+ const formatter = new Intl.DateTimeFormat("en-US", {
53
+ timeZone,
54
+ year: "numeric",
55
+ month: "2-digit",
56
+ day: "2-digit",
57
+ });
58
+ kDayKeyFormatterCache.set(timeZone, formatter);
59
+ return formatter;
60
+ };
61
+
62
+ const toTimeZoneDayKey = (timestampMs, timeZone) => {
63
+ const parts = getDayKeyFormatter(timeZone).formatToParts(new Date(timestampMs));
64
+ const year = parts.find((part) => part.type === "year")?.value || "0000";
65
+ const month = parts.find((part) => part.type === "month")?.value || "01";
66
+ const day = parts.find((part) => part.type === "day")?.value || "01";
67
+ return `${year}-${month}-${day}`;
68
+ };
69
+
34
70
  const resolvePricing = (model) => {
35
71
  const normalized = String(model || "").toLowerCase();
36
72
  if (!normalized) return null;
@@ -174,11 +210,15 @@ const initUsageDb = ({ rootDir }) => {
174
210
 
175
211
  const toDayKey = (timestampMs) => new Date(timestampMs).toISOString().slice(0, 10);
176
212
 
177
- const getPeriodRange = (days) => {
213
+ const getPeriodRange = (days, timeZone = kUtcTimeZone) => {
178
214
  const now = Date.now();
179
215
  const safeDays = clampInt(days, 1, 3650, kDefaultDays);
180
- const startMs = now - safeDays * 24 * 60 * 60 * 1000;
181
- return { now, safeDays, startDay: toDayKey(startMs) };
216
+ const startMs = now - safeDays * kDayMs;
217
+ const normalizedTimeZone = normalizeTimeZone(timeZone);
218
+ const startDay = normalizedTimeZone === kUtcTimeZone
219
+ ? toDayKey(startMs)
220
+ : toTimeZoneDayKey(startMs, normalizedTimeZone);
221
+ return { now, safeDays, startDay, timeZone: normalizedTimeZone };
182
222
  };
183
223
 
184
224
  const appendCostToRows = (rows) =>
@@ -208,27 +248,253 @@ const appendCostToRows = (rows) =>
208
248
  };
209
249
  });
210
250
 
211
- const getDailySummary = ({ days = kDefaultDays } = {}) => {
251
+ const parseAgentAndSourceFromSessionRef = (sessionRef) => {
252
+ const raw = String(sessionRef || "").trim();
253
+ if (!raw) {
254
+ return { agent: "unknown", source: "chat" };
255
+ }
256
+ const parts = raw.split(":");
257
+ const agent =
258
+ parts[0] === "agent" && String(parts[1] || "").trim()
259
+ ? String(parts[1] || "").trim()
260
+ : "unknown";
261
+ const source = parts.includes("hook")
262
+ ? "hooks"
263
+ : parts.includes("cron")
264
+ ? "cron"
265
+ : "chat";
266
+ return { agent, source };
267
+ };
268
+
269
+ const getAgentCostDistribution = ({
270
+ eventsRows = [],
271
+ startDay = "",
272
+ timeZone = kUtcTimeZone,
273
+ }) => {
274
+ const byAgent = new Map();
275
+ const ensureAgentBucket = (agent) => {
276
+ if (byAgent.has(agent)) return byAgent.get(agent);
277
+ const bucket = {
278
+ agent,
279
+ inputTokens: 0,
280
+ outputTokens: 0,
281
+ cacheReadTokens: 0,
282
+ cacheWriteTokens: 0,
283
+ totalTokens: 0,
284
+ totalCost: 0,
285
+ turnCount: 0,
286
+ sourceBreakdown: {
287
+ chat: {
288
+ source: "chat",
289
+ inputTokens: 0,
290
+ outputTokens: 0,
291
+ cacheReadTokens: 0,
292
+ cacheWriteTokens: 0,
293
+ totalTokens: 0,
294
+ totalCost: 0,
295
+ turnCount: 0,
296
+ },
297
+ hooks: {
298
+ source: "hooks",
299
+ inputTokens: 0,
300
+ outputTokens: 0,
301
+ cacheReadTokens: 0,
302
+ cacheWriteTokens: 0,
303
+ totalTokens: 0,
304
+ totalCost: 0,
305
+ turnCount: 0,
306
+ },
307
+ cron: {
308
+ source: "cron",
309
+ inputTokens: 0,
310
+ outputTokens: 0,
311
+ cacheReadTokens: 0,
312
+ cacheWriteTokens: 0,
313
+ totalTokens: 0,
314
+ totalCost: 0,
315
+ turnCount: 0,
316
+ },
317
+ },
318
+ };
319
+ byAgent.set(agent, bucket);
320
+ return bucket;
321
+ };
322
+
323
+ for (const eventRow of eventsRows) {
324
+ const timestamp = coerceInt(eventRow.timestamp);
325
+ const dayKey = timeZone === kUtcTimeZone
326
+ ? toDayKey(timestamp)
327
+ : toTimeZoneDayKey(timestamp, timeZone);
328
+ if (dayKey < startDay) continue;
329
+
330
+ const inputTokens = coerceInt(eventRow.input_tokens);
331
+ const outputTokens = coerceInt(eventRow.output_tokens);
332
+ const cacheReadTokens = coerceInt(eventRow.cache_read_tokens);
333
+ const cacheWriteTokens = coerceInt(eventRow.cache_write_tokens);
334
+ const totalTokens =
335
+ coerceInt(eventRow.total_tokens) ||
336
+ inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
337
+ const { totalCost } = deriveCostBreakdown({
338
+ inputTokens,
339
+ outputTokens,
340
+ cacheReadTokens,
341
+ cacheWriteTokens,
342
+ model: eventRow.model,
343
+ });
344
+ const sessionRef = String(eventRow.session_key || eventRow.session_id || "");
345
+ const { agent, source } = parseAgentAndSourceFromSessionRef(sessionRef);
346
+ const agentBucket = ensureAgentBucket(agent);
347
+ const sourceBucket = agentBucket.sourceBreakdown[source];
348
+
349
+ agentBucket.inputTokens += inputTokens;
350
+ agentBucket.outputTokens += outputTokens;
351
+ agentBucket.cacheReadTokens += cacheReadTokens;
352
+ agentBucket.cacheWriteTokens += cacheWriteTokens;
353
+ agentBucket.totalTokens += totalTokens;
354
+ agentBucket.totalCost += totalCost;
355
+ agentBucket.turnCount += 1;
356
+
357
+ sourceBucket.inputTokens += inputTokens;
358
+ sourceBucket.outputTokens += outputTokens;
359
+ sourceBucket.cacheReadTokens += cacheReadTokens;
360
+ sourceBucket.cacheWriteTokens += cacheWriteTokens;
361
+ sourceBucket.totalTokens += totalTokens;
362
+ sourceBucket.totalCost += totalCost;
363
+ sourceBucket.turnCount += 1;
364
+ }
365
+
366
+ const agents = Array.from(byAgent.values())
367
+ .map((bucket) => ({
368
+ agent: bucket.agent,
369
+ inputTokens: bucket.inputTokens,
370
+ outputTokens: bucket.outputTokens,
371
+ cacheReadTokens: bucket.cacheReadTokens,
372
+ cacheWriteTokens: bucket.cacheWriteTokens,
373
+ totalTokens: bucket.totalTokens,
374
+ totalCost: bucket.totalCost,
375
+ turnCount: bucket.turnCount,
376
+ sourceBreakdown: ["chat", "hooks", "cron"].map(
377
+ (source) => bucket.sourceBreakdown[source],
378
+ ),
379
+ }))
380
+ .sort((a, b) => b.totalCost - a.totalCost);
381
+
382
+ return {
383
+ agents,
384
+ totals: agents.reduce(
385
+ (acc, agentBucket) => {
386
+ acc.totalCost += Number(agentBucket.totalCost || 0);
387
+ acc.totalTokens += Number(agentBucket.totalTokens || 0);
388
+ acc.turnCount += Number(agentBucket.turnCount || 0);
389
+ return acc;
390
+ },
391
+ { totalCost: 0, totalTokens: 0, turnCount: 0 },
392
+ ),
393
+ };
394
+ };
395
+
396
+ const getDailySummary = ({ days = kDefaultDays, timeZone = kUtcTimeZone } = {}) => {
212
397
  const database = ensureDb();
213
- const { safeDays, startDay } = getPeriodRange(days);
214
- const rows = database
398
+ const { now, safeDays, startDay, timeZone: normalizedTimeZone } = getPeriodRange(
399
+ days,
400
+ timeZone,
401
+ );
402
+ let rows = [];
403
+ if (normalizedTimeZone === kUtcTimeZone) {
404
+ rows = database
405
+ .prepare(`
406
+ SELECT
407
+ date,
408
+ model,
409
+ provider,
410
+ input_tokens,
411
+ output_tokens,
412
+ cache_read_tokens,
413
+ cache_write_tokens,
414
+ total_tokens,
415
+ turn_count
416
+ FROM usage_daily
417
+ WHERE date >= $startDay
418
+ ORDER BY date ASC, total_tokens DESC
419
+ `)
420
+ .all({ $startDay: startDay });
421
+ } else {
422
+ const lookbackMs = now - (safeDays + 2) * kDayMs;
423
+ const eventRows = database
424
+ .prepare(`
425
+ SELECT
426
+ timestamp,
427
+ provider,
428
+ model,
429
+ input_tokens,
430
+ output_tokens,
431
+ cache_read_tokens,
432
+ cache_write_tokens,
433
+ total_tokens
434
+ FROM usage_events
435
+ WHERE timestamp >= $lookbackMs
436
+ ORDER BY timestamp ASC
437
+ `)
438
+ .all({ $lookbackMs: lookbackMs });
439
+ const byDateModel = new Map();
440
+ for (const eventRow of eventRows) {
441
+ const dayKey = toTimeZoneDayKey(coerceInt(eventRow.timestamp), normalizedTimeZone);
442
+ if (dayKey < startDay) continue;
443
+ const model = String(eventRow.model || "unknown");
444
+ const mapKey = `${dayKey}\u0000${model}`;
445
+ if (!byDateModel.has(mapKey)) {
446
+ byDateModel.set(mapKey, {
447
+ date: dayKey,
448
+ model,
449
+ provider: String(eventRow.provider || "unknown"),
450
+ input_tokens: 0,
451
+ output_tokens: 0,
452
+ cache_read_tokens: 0,
453
+ cache_write_tokens: 0,
454
+ total_tokens: 0,
455
+ turn_count: 0,
456
+ });
457
+ }
458
+ const aggregate = byDateModel.get(mapKey);
459
+ aggregate.input_tokens += coerceInt(eventRow.input_tokens);
460
+ aggregate.output_tokens += coerceInt(eventRow.output_tokens);
461
+ aggregate.cache_read_tokens += coerceInt(eventRow.cache_read_tokens);
462
+ aggregate.cache_write_tokens += coerceInt(eventRow.cache_write_tokens);
463
+ aggregate.total_tokens += coerceInt(eventRow.total_tokens);
464
+ aggregate.turn_count += 1;
465
+ if (!aggregate.provider && eventRow.provider) {
466
+ aggregate.provider = String(eventRow.provider || "unknown");
467
+ }
468
+ }
469
+ rows = Array.from(byDateModel.values()).sort((a, b) => {
470
+ if (a.date === b.date) return b.total_tokens - a.total_tokens;
471
+ return a.date.localeCompare(b.date);
472
+ });
473
+ }
474
+ const enriched = appendCostToRows(rows);
475
+ const lookbackMs = now - (safeDays + 2) * kDayMs;
476
+ const eventsRows = database
215
477
  .prepare(`
216
478
  SELECT
217
- date,
479
+ timestamp,
480
+ session_id,
481
+ session_key,
218
482
  model,
219
- provider,
220
483
  input_tokens,
221
484
  output_tokens,
222
485
  cache_read_tokens,
223
486
  cache_write_tokens,
224
- total_tokens,
225
- turn_count
226
- FROM usage_daily
227
- WHERE date >= $startDay
228
- ORDER BY date ASC, total_tokens DESC
487
+ total_tokens
488
+ FROM usage_events
489
+ WHERE timestamp >= $lookbackMs
490
+ ORDER BY timestamp ASC
229
491
  `)
230
- .all({ $startDay: startDay });
231
- const enriched = appendCostToRows(rows);
492
+ .all({ $lookbackMs: lookbackMs });
493
+ const costByAgent = getAgentCostDistribution({
494
+ eventsRows,
495
+ startDay,
496
+ timeZone: normalizedTimeZone,
497
+ });
232
498
  const byDate = new Map();
233
499
  for (const row of enriched) {
234
500
  if (!byDate.has(row.date)) byDate.set(row.date, []);
@@ -294,8 +560,10 @@ const getDailySummary = ({ days = kDefaultDays } = {}) => {
294
560
  return {
295
561
  updatedAt: Date.now(),
296
562
  days: safeDays,
563
+ timeZone: normalizedTimeZone,
297
564
  daily,
298
565
  totals,
566
+ costByAgent,
299
567
  };
300
568
  };
301
569
 
@@ -1,5 +1,7 @@
1
1
  const {
2
2
  kWatchdogCheckIntervalMs,
3
+ kWatchdogDegradedCheckIntervalMs,
4
+ kWatchdogStartupFailureThreshold,
3
5
  kWatchdogMaxRepairAttempts,
4
6
  kWatchdogCrashLoopWindowMs,
5
7
  kWatchdogCrashLoopThreshold,
@@ -64,9 +66,34 @@ const createWatchdog = ({
64
66
  expectedRestartInProgress: false,
65
67
  expectedRestartUntilMs: 0,
66
68
  pendingRecoveryNoticeSource: "",
69
+ startupConsecutiveHealthFailures: 0,
67
70
  };
68
71
  let healthTimer = null;
69
72
  let bootstrapHealthTimer = null;
73
+ let degradedHealthTimer = null;
74
+
75
+ const clearDegradedHealthCheckTimer = () => {
76
+ if (!degradedHealthTimer) return;
77
+ clearTimeout(degradedHealthTimer);
78
+ degradedHealthTimer = null;
79
+ };
80
+
81
+ const scheduleDegradedHealthCheck = () => {
82
+ if (degradedHealthTimer) return;
83
+ if (state.health !== "degraded" || state.lifecycle !== "running") return;
84
+ degradedHealthTimer = setTimeout(async () => {
85
+ degradedHealthTimer = null;
86
+ if (state.health !== "degraded" || state.lifecycle !== "running") return;
87
+ await runHealthCheck({
88
+ source: "degraded_retry",
89
+ allowAutoRepair: false,
90
+ });
91
+ if (state.health === "degraded" && state.lifecycle === "running") {
92
+ scheduleDegradedHealthCheck();
93
+ }
94
+ }, kWatchdogDegradedCheckIntervalMs);
95
+ if (typeof degradedHealthTimer.unref === "function") degradedHealthTimer.unref();
96
+ };
70
97
 
71
98
  const clearExpectedRestartWindow = () => {
72
99
  state.expectedRestartInProgress = false;
@@ -337,6 +364,8 @@ const createWatchdog = ({
337
364
  }
338
365
  if (parsed.ok) {
339
366
  const wasUnhealthy = state.health !== "healthy";
367
+ state.startupConsecutiveHealthFailures = 0;
368
+ clearDegradedHealthCheckTimer();
340
369
  clearExpectedRestartWindow();
341
370
  state.health = "healthy";
342
371
  if (state.lifecycle !== "crash_loop") state.lifecycle = "running";
@@ -359,6 +388,8 @@ const createWatchdog = ({
359
388
  return true;
360
389
  }
361
390
  if (restartWindowActive) {
391
+ state.startupConsecutiveHealthFailures = 0;
392
+ clearDegradedHealthCheckTimer();
362
393
  logEvent(
363
394
  "health_check",
364
395
  source,
@@ -381,6 +412,8 @@ const createWatchdog = ({
381
412
  state.lifecycle === "running" &&
382
413
  !state.crashRecoveryActive;
383
414
  if (withinStartupGrace) {
415
+ state.startupConsecutiveHealthFailures = 0;
416
+ clearDegradedHealthCheckTimer();
384
417
  logEvent(
385
418
  "health_check",
386
419
  source,
@@ -397,7 +430,31 @@ const createWatchdog = ({
397
430
  return false;
398
431
  }
399
432
 
433
+ if (state.health === "unknown" && state.lifecycle === "running") {
434
+ state.startupConsecutiveHealthFailures += 1;
435
+ if (state.startupConsecutiveHealthFailures < kWatchdogStartupFailureThreshold) {
436
+ logEvent(
437
+ "health_check",
438
+ source,
439
+ "ok",
440
+ {
441
+ reason: parsed.reason,
442
+ result,
443
+ skipped: true,
444
+ startupFailureRetryActive: true,
445
+ startupConsecutiveFailures: state.startupConsecutiveHealthFailures,
446
+ startupFailureThreshold: kWatchdogStartupFailureThreshold,
447
+ },
448
+ correlationId,
449
+ );
450
+ return false;
451
+ }
452
+ } else {
453
+ state.startupConsecutiveHealthFailures = 0;
454
+ }
455
+
400
456
  state.health = "degraded";
457
+ scheduleDegradedHealthCheck();
401
458
  logEvent(
402
459
  "health_check",
403
460
  source,
@@ -435,6 +492,7 @@ const createWatchdog = ({
435
492
 
436
493
  const onGatewayExit = ({ code, signal, expectedExit = false, stderrTail = [] } = {}) => {
437
494
  const correlationId = createCorrelationId();
495
+ clearDegradedHealthCheckTimer();
438
496
  if (expectedExit) {
439
497
  state.lifecycle = "restarting";
440
498
  state.health = "unknown";
@@ -504,8 +562,10 @@ const createWatchdog = ({
504
562
  };
505
563
 
506
564
  const onGatewayLaunch = ({ startedAt = Date.now(), pid = null } = {}) => {
565
+ clearDegradedHealthCheckTimer();
507
566
  state.lifecycle = "running";
508
567
  state.health = "unknown";
568
+ state.startupConsecutiveHealthFailures = 0;
509
569
  state.crashRecoveryActive = false;
510
570
  clearExpectedRestartWindow();
511
571
  state.uptimeStartedAt = startedAt;
@@ -515,8 +575,10 @@ const createWatchdog = ({
515
575
  };
516
576
 
517
577
  const onExpectedRestart = () => {
578
+ clearDegradedHealthCheckTimer();
518
579
  state.lifecycle = "restarting";
519
580
  state.health = "unknown";
581
+ state.startupConsecutiveHealthFailures = 0;
520
582
  state.crashRecoveryActive = false;
521
583
  markExpectedRestartWindow();
522
584
  startBootstrapHealthChecks();
@@ -533,14 +595,17 @@ const createWatchdog = ({
533
595
 
534
596
  const start = () => {
535
597
  if (healthTimer || bootstrapHealthTimer) return;
598
+ clearDegradedHealthCheckTimer();
536
599
  state.lifecycle = "running";
537
600
  state.health = "unknown";
601
+ state.startupConsecutiveHealthFailures = 0;
538
602
  state.uptimeStartedAt = Date.now();
539
603
  state.gatewayStartedAt = Date.now();
540
604
  startBootstrapHealthChecks();
541
605
  };
542
606
 
543
607
  const stop = () => {
608
+ clearDegradedHealthCheckTimer();
544
609
  if (bootstrapHealthTimer) {
545
610
  clearTimeout(bootstrapHealthTimer);
546
611
  bootstrapHealthTimer = null;
@@ -550,6 +615,7 @@ const createWatchdog = ({
550
615
  healthTimer = null;
551
616
  }
552
617
  state.lifecycle = "stopped";
618
+ state.startupConsecutiveHealthFailures = 0;
553
619
  };
554
620
 
555
621
  const getStatus = () => {