@hasna/economy 0.5.2 → 0.5.4

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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +4 -2
  3. package/dist/chunks/{billing-bkpxy4kv.js → billing-22az60hy.js} +2 -2
  4. package/dist/chunks/{config-j9mv3ehk.js → config-hsp7ndhj.js} +2 -2
  5. package/dist/chunks/{index-npsjjv96.js → index-9arv0b1j.js} +162 -36
  6. package/dist/chunks/{index-hesxq90g.js → index-9wa1f2kv.js} +1 -1
  7. package/dist/chunks/{index-zpvea08j.js → index-c7h2rxx1.js} +38 -12
  8. package/dist/chunks/{index-9d2aky06.js → index-ct3zdefz.js} +3 -3
  9. package/dist/chunks/{index-k540hfww.js → index-dk5rq1rq.js} +1 -1
  10. package/dist/chunks/{index-ez9thsr7.js → index-f5jv6q5q.js} +38 -12
  11. package/dist/chunks/{index-dj3tctqh.js → index-n1wwqxfe.js} +14 -13
  12. package/dist/chunks/{index-kzwqw3ve.js → index-nheq2nrz.js} +2 -2
  13. package/dist/chunks/{index-2hwzmfgv.js → index-vajzfzgv.js} +38 -12
  14. package/dist/chunks/{open-projects-6z4pq6mx.js → open-projects-8qm46m3d.js} +1 -1
  15. package/dist/chunks/{pricing-vf2pycmh.js → pricing-462f8xkm.js} +1 -1
  16. package/dist/chunks/{pricing-f80p81gr.js → pricing-k9wsjwbz.js} +1 -1
  17. package/dist/chunks/{pricing-y21mcjsa.js → pricing-vxejj1qa.js} +1 -1
  18. package/dist/chunks/{serve-p2yarhm8.js → serve-ra46ms69.js} +18 -10
  19. package/dist/chunks/{sqlite-store-st99garx.js → sqlite-store-13nhr1z0.js} +2 -2
  20. package/dist/chunks/{sqlite-store-501f32ts.js → sqlite-store-gamn65xp.js} +2 -2
  21. package/dist/chunks/{sqlite-store-fq4natnf.js → sqlite-store-ht6frztj.js} +2 -2
  22. package/dist/chunks/{tui-x64hjfrh.js → tui-a1tvcw2j.js} +4 -4
  23. package/dist/chunks/{watch-bxstjpbx.js → watch-rvd3dn6r.js} +5 -5
  24. package/dist/chunks/{webhooks-k4fh2t9c.js → webhooks-nw4daw9y.js} +20 -9
  25. package/dist/cli/index.js +27 -27
  26. package/dist/db/database.d.ts +2 -2
  27. package/dist/db/database.d.ts.map +1 -1
  28. package/dist/db/sync-pg.d.ts.map +1 -1
  29. package/dist/index.js +15 -14
  30. package/dist/lib/accounts-store.d.ts +25 -1
  31. package/dist/lib/accounts-store.d.ts.map +1 -1
  32. package/dist/lib/accounts.d.ts.map +1 -1
  33. package/dist/lib/analytics.d.ts +1 -1
  34. package/dist/lib/analytics.d.ts.map +1 -1
  35. package/dist/lib/periods.d.ts +20 -1
  36. package/dist/lib/periods.d.ts.map +1 -1
  37. package/dist/lib/spikes.d.ts +3 -3
  38. package/dist/lib/spikes.d.ts.map +1 -1
  39. package/dist/lib/subscriptions-registry.d.ts +26 -0
  40. package/dist/lib/subscriptions-registry.d.ts.map +1 -0
  41. package/dist/lib/test-keychain-fixture.d.ts +3 -2
  42. package/dist/lib/test-keychain-fixture.d.ts.map +1 -1
  43. package/dist/mcp/index.js +245 -118
  44. package/dist/otel/index.js +56 -27
  45. package/dist/server/index.js +246 -77
  46. package/dist/server/service-operations.d.ts.map +1 -1
  47. package/dist/server/service-worker.js +237 -68
  48. package/package.json +1 -1
@@ -1118,12 +1118,32 @@ function meteredBudgetAmounts(groups, limit, threshold) {
1118
1118
  function ymd(date2) {
1119
1119
  return date2.toISOString().substring(0, 10);
1120
1120
  }
1121
- function calendarPeriodBounds(period, now = new Date) {
1121
+ function resolveReportingInterval(interval, now = new Date) {
1122
1122
  if (!Number.isFinite(now.getTime()))
1123
1123
  throw new RangeError("Invalid reporting clock");
1124
- const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
1124
+ const start = new Date(now);
1125
+ start.setUTCHours(0, 0, 0, 0);
1125
1126
  const end = new Date(start);
1126
- switch (period) {
1127
+ if (interval.kind === "rolling") {
1128
+ if (!Number.isSafeInteger(interval.count) || interval.count < 1)
1129
+ throw new RangeError("Invalid reporting window");
1130
+ if (interval.unit === "hour") {
1131
+ start.setTime(now.getTime() - interval.count * 3600000);
1132
+ if (!Number.isFinite(start.getTime()))
1133
+ throw new RangeError("Invalid reporting window");
1134
+ return { time_zone: "UTC", start: start.toISOString(), end_exclusive: now.toISOString() };
1135
+ }
1136
+ if (interval.unit !== "day")
1137
+ throw new RangeError("Invalid reporting unit");
1138
+ start.setUTCDate(start.getUTCDate() - interval.count + 1);
1139
+ end.setUTCDate(end.getUTCDate() + 1);
1140
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()))
1141
+ throw new RangeError("Invalid reporting window");
1142
+ return { time_zone: "UTC", start: ymd(start), end_exclusive: ymd(end) };
1143
+ }
1144
+ if (interval.kind !== "calendar")
1145
+ throw new RangeError("Invalid reporting interval");
1146
+ switch (interval.period) {
1127
1147
  case "today":
1128
1148
  end.setUTCDate(end.getUTCDate() + 1);
1129
1149
  break;
@@ -1146,11 +1166,15 @@ function calendarPeriodBounds(period, now = new Date) {
1146
1166
  end.setUTCFullYear(end.getUTCFullYear() + 1);
1147
1167
  break;
1148
1168
  case "all":
1149
- return { time_zone: "UTC", start_date: null, end_date_exclusive: null };
1169
+ return { time_zone: "UTC", start: null, end_exclusive: null };
1150
1170
  default:
1151
1171
  throw new RangeError("Invalid reporting period");
1152
1172
  }
1153
- return { time_zone: "UTC", start_date: ymd(start), end_date_exclusive: ymd(end) };
1173
+ return { time_zone: "UTC", start: ymd(start), end_exclusive: ymd(end) };
1174
+ }
1175
+ function calendarPeriodBounds(period, now = new Date) {
1176
+ const bounds = resolveReportingInterval({ kind: "calendar", period }, now);
1177
+ return { time_zone: bounds.time_zone, start_date: bounds.start, end_date_exclusive: bounds.end_exclusive };
1154
1178
  }
1155
1179
  function calendarPeriodWhere(period, column, now = new Date) {
1156
1180
  if (!/^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/i.test(column)) {
@@ -2209,20 +2233,22 @@ function queryAccountBreakdownSince(db, since) {
2209
2233
  ORDER BY api_equivalent_usd DESC
2210
2234
  `).all(since, since);
2211
2235
  }
2212
- function queryDailyBreakdown(db, days = 30, machine) {
2236
+ function queryDailyBreakdown(db, days = 30, machine, now = new Date) {
2237
+ const bounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: days }, now);
2213
2238
  const machineClause = machine ? " AND machine_id = ?" : "";
2214
- const params = machine ? [`-${days}`, machine] : [`-${days}`];
2239
+ const params = machine ? [bounds.start, bounds.end_exclusive, machine] : [bounds.start, bounds.end_exclusive];
2215
2240
  return db.prepare(`
2216
2241
  SELECT DATE(timestamp) as date, agent, COALESCE(SUM(cost_usd), 0) as cost_usd
2217
2242
  FROM economy_effective_requests
2218
- WHERE timestamp >= DATE('now', ? || ' days')${machineClause}
2243
+ WHERE timestamp >= ? AND timestamp < ?${machineClause}
2219
2244
  GROUP BY DATE(timestamp), agent
2220
2245
  ORDER BY date ASC
2221
2246
  `).all(...params);
2222
2247
  }
2223
- function queryHourlyBreakdown(db, machine, hours) {
2224
- const clauses = hours == null ? [`DATE(timestamp) = DATE('now')`] : [`DATETIME(timestamp) >= DATETIME('now', ?)`];
2225
- const params = hours == null ? [] : [`-${hours} hours`];
2248
+ function queryHourlyBreakdown(db, machine, hours, now = new Date) {
2249
+ const bounds = resolveReportingInterval(hours == null ? { kind: "calendar", period: "today" } : { kind: "rolling", unit: "hour", count: hours }, now);
2250
+ const clauses = hours == null ? ["timestamp >= ? AND timestamp < ?"] : ["STRFTIME('%Y-%m-%dT%H:%M:%fZ', timestamp) >= ? AND STRFTIME('%Y-%m-%dT%H:%M:%fZ', timestamp) < ?"];
2251
+ const params = [bounds.start, bounds.end_exclusive];
2226
2252
  if (machine) {
2227
2253
  clauses.push("machine_id = ?");
2228
2254
  params.push(machine);
@@ -3143,15 +3169,16 @@ function translateSqliteDates(sql) {
3143
3169
  s = s.replace(/DATE\('now',\s*(\$\d+)\s*\|\|\s*' days'\)/gi, (_m, p) => isoDate(`now() + ((${p}) || ' days')::interval`));
3144
3170
  s = s.replace(/DATETIME\('now',\s*(\$\d+)\)/gi, (_m, p) => `(now() + (${p})::interval)`);
3145
3171
  s = s.replace(/DATETIME\('now',\s*'([^']+)'\)/gi, (_m, lit) => `(now() + interval '${lit}')`);
3172
+ s = s.replace(/STRFTIME\('%Y-%m-%dT%H:%M:%fZ',\s*([A-Za-z_][A-Za-z0-9_]*)\)/gi, (_m, col) => `to_char(CAST("${col}" AS timestamptz) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`);
3146
3173
  const strftimeMap = { "%H": "HH24", "%Y": "YYYY", "%m": "MM", "%d": "DD", "%M": "MI", "%S": "SS" };
3147
3174
  s = s.replace(/STRFTIME\('([^']+)',\s*([A-Za-z_][A-Za-z0-9_]*)\)/gi, (_m, fmt, col) => {
3148
3175
  const pgFmt = Object.entries(strftimeMap).reduce((acc, [k, v]) => acc.split(k).join(v), fmt);
3149
- return `to_char(CAST("${col}" AS timestamptz), '${pgFmt}')`;
3176
+ return `to_char(CAST("${col}" AS timestamptz) AT TIME ZONE 'UTC', '${pgFmt}')`;
3150
3177
  });
3151
3178
  s = s.replace(/\bDATE\('now'\)/gi, isoDate("now()"));
3152
3179
  s = s.replace(/\bDATETIME\('now'\)/gi, "now()");
3153
3180
  s = s.replace(/\bDATETIME\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gi, (_m, col) => `CAST("${col}" AS timestamptz)`);
3154
- s = s.replace(/\bDATE\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gi, (_m, col) => `substr("${col}", 1, 10)`);
3181
+ s = s.replace(/\bDATE\(\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s*\)/gi, (_m, col) => `substr(${col.split(".").map((part) => `"${part}"`).join(".")}, 1, 10)`);
3155
3182
  return s;
3156
3183
  }
3157
3184
  function resolveWorkerPath() {
@@ -4029,12 +4056,21 @@ var init_config = __esm(() => {
4029
4056
  function detectCostSpikes(dailyTotals, opts) {
4030
4057
  const windowDays = opts?.windowDays ?? 7;
4031
4058
  const multiplier = opts?.multiplier ?? 2;
4059
+ if (!Number.isSafeInteger(windowDays) || windowDays < 1)
4060
+ throw new RangeError("Invalid spike window");
4061
+ if (!Number.isFinite(multiplier) || multiplier <= 0)
4062
+ throw new RangeError("Invalid spike multiplier");
4032
4063
  const sorted = [...dailyTotals].sort((a, b) => a.date.localeCompare(b.date));
4033
4064
  const spikes = [];
4034
4065
  for (let i = windowDays;i < sorted.length; i++) {
4035
4066
  const window = sorted.slice(i - windowDays, i);
4036
- const avg = window.reduce((s, d) => s + d.cost_usd, 0) / window.length;
4037
4067
  const current = sorted[i];
4068
+ const previous = new Date(current.date + "T00:00:00.000Z");
4069
+ previous.setUTCDate(previous.getUTCDate() - 1);
4070
+ const bounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: windowDays }, previous);
4071
+ if (window[0].date !== bounds.start || window[window.length - 1].date !== previous.toISOString().slice(0, 10) || new Set(window.map((day) => day.date)).size !== windowDays)
4072
+ continue;
4073
+ const avg = window.reduce((s, d) => s + d.cost_usd, 0) / window.length;
4038
4074
  if (avg > 0 && current.cost_usd > avg * multiplier) {
4039
4075
  spikes.push({
4040
4076
  date: current.date,
@@ -4046,21 +4082,23 @@ function detectCostSpikes(dailyTotals, opts) {
4046
4082
  }
4047
4083
  return spikes;
4048
4084
  }
4049
- function queryRecentSpikes(db, days = 14) {
4085
+ function queryRecentSpikes(db, days = 14, now = new Date) {
4086
+ const bounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: days }, now);
4050
4087
  const rows = db.prepare(`
4051
4088
  SELECT DATE(timestamp) as date, COALESCE(SUM(cost_usd), 0) as cost_usd
4052
4089
  FROM economy_effective_requests
4053
- WHERE timestamp >= DATE('now', ? || ' days')
4090
+ WHERE timestamp >= ? AND timestamp < ?
4054
4091
  GROUP BY DATE(timestamp)
4055
4092
  ORDER BY date ASC
4056
- `).all(-days);
4093
+ `).all(bounds.start, bounds.end_exclusive);
4057
4094
  return detectCostSpikes(rows);
4058
4095
  }
4059
- function getTodaySpike(db) {
4060
- const today = new Date().toISOString().substring(0, 10);
4061
- const spikes = queryRecentSpikes(db, 14);
4096
+ function getTodaySpike(db, now = new Date) {
4097
+ const today = resolveReportingInterval({ kind: "calendar", period: "today" }, now).start;
4098
+ const spikes = queryRecentSpikes(db, 14, now);
4062
4099
  return spikes.find((s) => s.date === today) ?? null;
4063
4100
  }
4101
+ var init_spikes = () => {};
4064
4102
 
4065
4103
  // src/lib/webhooks.ts
4066
4104
  var exports_webhooks = {};
@@ -4127,6 +4165,7 @@ async function fireWebhook(url, payload) {
4127
4165
  var init_webhooks = __esm(() => {
4128
4166
  init_config();
4129
4167
  init_database();
4168
+ init_spikes();
4130
4169
  });
4131
4170
 
4132
4171
  // src/db/pg-migrate.ts
@@ -4581,7 +4620,7 @@ __export(exports_legacy_plan, {
4581
4620
  });
4582
4621
  import { createHash as createHash6 } from "crypto";
4583
4622
  import { closeSync, constants, fstatSync, openSync, readSync } from "fs";
4584
- import { isAbsolute } from "path";
4623
+ import { isAbsolute as isAbsolute2 } from "path";
4585
4624
  function object(value, fields, label) {
4586
4625
  if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== fields.sort().join(","))
4587
4626
  throw Error(`${label} has unsupported or missing fields`);
@@ -4634,7 +4673,7 @@ function parseLegacySelection(value) {
4634
4673
  return { schema: mode.selection, items };
4635
4674
  }
4636
4675
  function readLegacySelectionFile(path) {
4637
- if (!isAbsolute(path) || path.includes("\x00"))
4676
+ if (!isAbsolute2(path) || path.includes("\x00"))
4638
4677
  throw Error("--input must be an absolute path to a regular JSON file");
4639
4678
  let fd;
4640
4679
  try {
@@ -4669,7 +4708,7 @@ function readLegacySelectionFile(path) {
4669
4708
  }
4670
4709
  }
4671
4710
  function parseLegacyPlanOptions(args) {
4672
- if (args.length !== 2 || args[0] !== "--input" || !args[1] || !isAbsolute(args[1]) || args[1].includes("\x00"))
4711
+ if (args.length !== 2 || args[0] !== "--input" || !args[1] || !isAbsolute2(args[1]) || args[1].includes("\x00"))
4673
4712
  throw Error("legacy-plan accepts exactly --input <absolute JSON file>");
4674
4713
  return args[1];
4675
4714
  }
@@ -4798,7 +4837,7 @@ __export(exports_legacy_preservation, {
4798
4837
  });
4799
4838
  import { createHash as createHash7, randomUUID as randomUUID3 } from "crypto";
4800
4839
  import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, fsyncSync, lstatSync, openSync as openSync2, readSync as readSync2, realpathSync, writeSync } from "fs";
4801
- import { basename as basename4, dirname as dirname4, isAbsolute as isAbsolute2 } from "path";
4840
+ import { basename as basename4, dirname as dirname4, isAbsolute as isAbsolute3 } from "path";
4802
4841
  function parseLegacyPlan(value) {
4803
4842
  const p = record(value);
4804
4843
  const mode = legacyMode("plan", p.schema);
@@ -4949,7 +4988,7 @@ async function preserveLegacyOriginals(client, input) {
4949
4988
  });
4950
4989
  }
4951
4990
  function readLegacyBytes(path, maxBytes = MAX_BYTES) {
4952
- if (!isAbsolute2(path) || path.includes("\x00"))
4991
+ if (!isAbsolute3(path) || path.includes("\x00"))
4953
4992
  fail("Artifact path must be absolute");
4954
4993
  let fd;
4955
4994
  try {
@@ -4988,7 +5027,7 @@ function readLegacyArtifact(path, maxBytes = MAX_BYTES) {
4988
5027
  }
4989
5028
  function writeLegacyCapsule(path, input) {
4990
5029
  const capsule = parseLegacyCapsule(input);
4991
- if (!isAbsolute2(path) || path.includes("\x00") || realpathSync(dirname4(path)) !== dirname4(path))
5030
+ if (!isAbsolute3(path) || path.includes("\x00") || realpathSync(dirname4(path)) !== dirname4(path))
4992
5031
  fail("Output requires an absolute path with a real parent directory");
4993
5032
  const parent = lstatSync(dirname4(path));
4994
5033
  if (!parent.isDirectory() || parent.uid !== process.getuid?.() || (parent.mode & 63) !== 0)
@@ -5102,7 +5141,7 @@ async function rehearseLegacyRestore(client, input, scope = "transaction") {
5102
5141
  }
5103
5142
  function parseLegacyPreservationOptions(command, args) {
5104
5143
  const preserve = command === "legacy-preserve";
5105
- if (!preserve && command !== "legacy-rehearse" || args.length !== (preserve ? 4 : 2) || args[0] !== "--input" || !args[1] || !isAbsolute2(args[1]) || preserve && (args[2] !== "--output" || !args[3] || !isAbsolute2(args[3])) || args.some((x) => x.includes("\x00")))
5144
+ if (!preserve && command !== "legacy-rehearse" || args.length !== (preserve ? 4 : 2) || args[0] !== "--input" || !args[1] || !isAbsolute3(args[1]) || preserve && (args[2] !== "--output" || !args[3] || !isAbsolute3(args[3])) || args.some((x) => x.includes("\x00")))
5106
5145
  fail("Expected " + command + " --input <absolute JSON file>" + (preserve ? " --output <new file in an owner-only directory>" : ""));
5107
5146
  return { input: args[1], ...preserve ? { output: args[3] } : {} };
5108
5147
  }
@@ -5151,7 +5190,7 @@ __export(exports_legacy_operations, {
5151
5190
  LegacyOperationInputError: () => LegacyOperationInputError
5152
5191
  });
5153
5192
  import { createHash as createHash8 } from "crypto";
5154
- import { isAbsolute as isAbsolute3 } from "path";
5193
+ import { isAbsolute as isAbsolute4 } from "path";
5155
5194
  function parseLegacyIntent(action, value) {
5156
5195
  try {
5157
5196
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -5396,7 +5435,7 @@ function parseLegacyOperationOptions(command, args) {
5396
5435
  const apply = command === "legacy-apply";
5397
5436
  if (!apply && command !== "legacy-rollback")
5398
5437
  fail2("Unknown legacy operation or arguments");
5399
- if (args.length !== (apply ? 4 : 2) || args[0] !== "--input" || !args[1] || !isAbsolute3(args[1]) || apply && (args[2] !== "--capsule" || !args[3] || !isAbsolute3(args[3])) || args.some((s) => s.includes("\x00")))
5438
+ if (args.length !== (apply ? 4 : 2) || args[0] !== "--input" || !args[1] || !isAbsolute4(args[1]) || apply && (args[2] !== "--capsule" || !args[3] || !isAbsolute4(args[3])) || args.some((s) => s.includes("\x00")))
5400
5439
  fail2("Expected " + command + " --input <absolute intent JSON>" + (apply ? " --capsule <absolute preservation JSON>" : ""));
5401
5440
  return { input: args[1], ...apply ? { capsule: args[3] } : {} };
5402
5441
  }
@@ -5490,7 +5529,7 @@ function isAuthorizedRequest(req, path) {
5490
5529
  // package.json
5491
5530
  var package_default = {
5492
5531
  name: "@hasna/economy",
5493
- version: "0.5.2",
5532
+ version: "0.5.4",
5494
5533
  description: "AI coding cost tracker \u2014 CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
5495
5534
  repository: {
5496
5535
  type: "git",
@@ -8780,7 +8819,7 @@ import { join as join5, basename } from "path";
8780
8819
  import { existsSync as existsSync3, readFileSync } from "fs";
8781
8820
  import { homedir as homedir2 } from "os";
8782
8821
  import { join as join4 } from "path";
8783
- import { z } from "zod";
8822
+ import { z as z2 } from "zod";
8784
8823
  import { resolveClientTransport, resolveCredential } from "@hasna/contracts/client";
8785
8824
 
8786
8825
  // src/lib/cloud-storage.ts
@@ -8799,33 +8838,138 @@ import { createHasnaStorageClient } from "@hasna/contracts/client/storage";
8799
8838
  var LOCAL_STORAGE_OPT_IN_KEYS = ["HASNA_ECONOMY_LOCAL", "ECONOMY_LOCAL"];
8800
8839
  var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
8801
8840
 
8802
- // src/lib/accounts-store.ts
8803
- var ACCOUNTS_APP = "accounts";
8804
- var profileNameSchema = z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric/hyphen and start with a letter or digit");
8841
+ // src/lib/subscriptions-registry.ts
8842
+ import { execFile } from "child_process";
8843
+ import { promisify } from "util";
8844
+ import { isAbsolute } from "path";
8845
+ import { z } from "zod";
8846
+ var execute = promisify(execFile);
8847
+ var readSubscriptionsMetadata = async (args, env) => {
8848
+ let stdout;
8849
+ try {
8850
+ const result = await execute("subscriptions", args, {
8851
+ env,
8852
+ encoding: "utf8",
8853
+ timeout: 30000,
8854
+ maxBuffer: 16 * 1024 * 1024,
8855
+ windowsHide: true
8856
+ });
8857
+ stdout = result.stdout;
8858
+ } catch (error) {
8859
+ const code = error?.code;
8860
+ if (code === "ENOENT")
8861
+ throw new Error("subscriptions: CLI not found on PATH; install the Subscriptions CLI to resolve station account metadata");
8862
+ const status = typeof code === "number" && Number.isInteger(code) ? `exit ${code}` : "execution limit or transport failure";
8863
+ throw new Error(`subscriptions: read-only metadata command failed (${status}); verify the installed CLI and its hosted credentials`);
8864
+ }
8865
+ try {
8866
+ return JSON.parse(stdout);
8867
+ } catch {
8868
+ throw new Error("subscriptions: metadata command returned invalid JSON");
8869
+ }
8870
+ };
8871
+ var nonempty = z.string().min(1);
8805
8872
  var profileSchema = z.object({
8806
- name: z.string(),
8807
- tool: z.string(),
8873
+ name: nonempty,
8874
+ tool: nonempty,
8808
8875
  email: z.string().optional(),
8809
8876
  displayName: z.string().optional(),
8810
- dir: z.string(),
8877
+ dir: nonempty.refine(isAbsolute).refine((value) => !/[\0\r\n]/.test(value)),
8878
+ createdAt: nonempty,
8879
+ lastUsedAt: z.string().optional(),
8811
8880
  description: z.string().optional(),
8812
- createdAt: z.string(),
8813
- lastUsedAt: z.string().optional()
8881
+ active: z.boolean(),
8882
+ applied: z.boolean(),
8883
+ switchedAway: z.object({ profile: nonempty, email: z.string().optional() }).optional()
8884
+ });
8885
+ var toolsSchema = z.array(z.object({
8886
+ id: nonempty,
8887
+ label: nonempty,
8888
+ envVar: nonempty,
8889
+ defaultDir: nonempty,
8890
+ bin: nonempty
8891
+ }));
8892
+ function unique(rows, identity) {
8893
+ if (new Set(rows.map(identity)).size !== rows.length) {
8894
+ throw new Error("subscriptions: duplicate identity in metadata response");
8895
+ }
8896
+ return rows;
8897
+ }
8898
+
8899
+ class SubscriptionsRegistry {
8900
+ read;
8901
+ transport = "api";
8902
+ registry = "subscriptions";
8903
+ env;
8904
+ profiles;
8905
+ tools;
8906
+ constructor(baseUrl, env, read = readSubscriptionsMetadata) {
8907
+ this.read = read;
8908
+ this.env = { ...env, HASNA_SUBSCRIPTIONS_API_URL: baseUrl.replace(/\/v1\/?$/, "") };
8909
+ }
8910
+ allProfiles() {
8911
+ return this.profiles ??= this.read(["list", "--json"], this.env).then((raw) => {
8912
+ const parsed = z.array(profileSchema).safeParse(raw);
8913
+ if (!parsed.success)
8914
+ throw new Error("subscriptions: invalid profile metadata response");
8915
+ return unique(parsed.data, (row) => JSON.stringify([row.tool, row.name]));
8916
+ });
8917
+ }
8918
+ async listProfiles(tool) {
8919
+ const rows = await this.allProfiles();
8920
+ return rows.filter((row) => !tool || row.tool === tool);
8921
+ }
8922
+ async findProfile(name, tool) {
8923
+ const matches = (await this.listProfiles(tool)).filter((row) => row.name === name);
8924
+ return matches.length === 1 ? matches[0] : undefined;
8925
+ }
8926
+ async appliedProfile(tool) {
8927
+ const matches = (await this.allProfiles()).filter((row) => row.tool === tool && row.applied);
8928
+ if (matches.length > 1)
8929
+ throw new Error("subscriptions: ambiguous station applied-profile binding");
8930
+ return matches[0];
8931
+ }
8932
+ async currentProfile(_tool) {
8933
+ return;
8934
+ }
8935
+ async listTools() {
8936
+ return this.tools ??= this.read(["tools", "list", "--json"], this.env).then((raw) => {
8937
+ const parsed = toolsSchema.safeParse(raw);
8938
+ if (!parsed.success)
8939
+ throw new Error("subscriptions: invalid tool metadata response");
8940
+ return unique(parsed.data, (row) => row.id);
8941
+ });
8942
+ }
8943
+ }
8944
+
8945
+ // src/lib/accounts-store.ts
8946
+ var ACCOUNTS_APP = "accounts";
8947
+ var profileNameSchema = z2.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric/hyphen and start with a letter or digit");
8948
+ var profileSchema2 = z2.object({
8949
+ name: z2.string(),
8950
+ tool: z2.string(),
8951
+ email: z2.string().optional(),
8952
+ displayName: z2.string().optional(),
8953
+ dir: z2.string(),
8954
+ description: z2.string().optional(),
8955
+ createdAt: z2.string(),
8956
+ lastUsedAt: z2.string().optional(),
8957
+ switchedAway: z2.object({ profile: z2.string().min(1), email: z2.string().optional() }).optional()
8814
8958
  });
8815
- var toolDefSchema = z.object({
8816
- id: z.string(),
8817
- label: z.string(),
8818
- envVar: z.string(),
8819
- defaultDir: z.string(),
8820
- bin: z.string()
8959
+ var toolDefSchema = z2.object({
8960
+ id: z2.string(),
8961
+ label: z2.string(),
8962
+ envVar: z2.string(),
8963
+ defaultDir: z2.string(),
8964
+ bin: z2.string()
8821
8965
  });
8822
- var storeSchema = z.object({
8823
- version: z.literal(1),
8824
- current: z.record(z.string(), z.string()).default({}),
8825
- applied: z.record(z.string(), z.string()).default({}),
8826
- toolLocks: z.record(z.string(), z.string()).default({}),
8827
- profiles: z.array(profileSchema).default([]),
8828
- tools: z.array(toolDefSchema).default([])
8966
+ var storeSchema = z2.object({
8967
+ version: z2.literal(1),
8968
+ current: z2.record(z2.string(), z2.string()).default({}),
8969
+ applied: z2.record(z2.string(), z2.string()).default({}),
8970
+ toolLocks: z2.record(z2.string(), z2.string()).default({}),
8971
+ profiles: z2.array(profileSchema2).default([]),
8972
+ tools: z2.array(toolDefSchema).default([])
8829
8973
  });
8830
8974
  function localRegistryAllowed(env = process.env) {
8831
8975
  return LOCAL_STORAGE_OPT_IN_KEYS.some((key) => {
@@ -9107,25 +9251,34 @@ class ApiStore {
9107
9251
  }
9108
9252
  }
9109
9253
  function resolveStore(env = process.env, options = {}) {
9254
+ const legacy = resolveRegistryTransport(ACCOUNTS_APP, env, options.credentials);
9255
+ if (legacy)
9256
+ return new ApiStore(legacy.baseUrl, legacy.apiKey);
9257
+ const current = resolveRegistryTransport("subscriptions", env, options.subscriptionsCredentials);
9258
+ if (current)
9259
+ return new SubscriptionsRegistry(current.baseUrl, env, options.readSubscriptionsMetadata);
9260
+ return unconfiguredStore(env);
9261
+ }
9262
+ function resolveRegistryTransport(app, env, credentials) {
9110
9263
  const clientEnv = env;
9111
- const credential = resolveCredential(ACCOUNTS_APP, clientEnv, options.credentials);
9264
+ const credential = resolveCredential(app, clientEnv, credentials);
9112
9265
  if (credential === null) {
9113
9266
  try {
9114
- resolveClientTransport(ACCOUNTS_APP, clientEnv, options.credentials ? { credentials: options.credentials } : {});
9267
+ resolveClientTransport(app, clientEnv, credentials ? { credentials } : {});
9115
9268
  } catch (err) {
9116
9269
  if (isNoAccountsCredentialConfigurationError(err))
9117
- return unconfiguredStore(env);
9270
+ return null;
9118
9271
  throw err;
9119
9272
  }
9120
- return unconfiguredStore(env);
9273
+ return null;
9121
9274
  }
9122
9275
  if (credential.tier === "pointer") {
9123
- throw new Error(`accounts: ${credential.source} names a secrets-vault item, and the local accounts API store resolves ` + "credentials synchronously, so it cannot complete the pointer per request. It is a deliberate " + "selection and is not resolved around. Use a literal tier instead \u2014 the Keychain item " + "hasna.credentials.accounts.api-key, ~/.hasna/accounts/config/credentials, or HASNA_ACCOUNTS_API_KEY.");
9276
+ throw new Error(`${app}: ${credential.source} names a secrets-vault item, and the registry adapter resolves ` + "credentials synchronously, so it cannot complete the pointer per request. It is a deliberate " + "selection and is not resolved around. Use a literal tier instead \u2014 the Keychain item " + `hasna.credentials.${app}.api-key, ~/.hasna/${app}/config/credentials, or HASNA_${app.toUpperCase()}_API_KEY.`);
9124
9277
  }
9125
- const resolution = resolveClientTransport(ACCOUNTS_APP, clientEnv, {
9126
- credentials: { ...options.credentials, apiKey: credential.apiKey }
9278
+ const resolution = resolveClientTransport(app, clientEnv, {
9279
+ credentials: { ...credentials, apiKey: credential.apiKey }
9127
9280
  });
9128
- return new ApiStore(resolution.baseUrl, credential.apiKey);
9281
+ return { baseUrl: resolution.baseUrl, apiKey: credential.apiKey };
9129
9282
  }
9130
9283
  function isNoAccountsCredentialConfigurationError(error) {
9131
9284
  if (typeof error !== "object" || error === null)
@@ -9142,10 +9295,10 @@ function unconfiguredStore(env) {
9142
9295
  return new NoRegistryStore;
9143
9296
  }
9144
9297
  function accountsLocalModeNotice() {
9145
- return `accounts: LOCAL mode (${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1) \u2014 no Hasna Accounts credential resolved, so ` + "attribution reads the on-box JSON registry (~/.hasna/accounts/accounts.json) instead of accounts-serve. " + "To go hosted, put the fleet key in the Keychain item hasna.credentials.accounts.api-key, write " + "~/.hasna/accounts/config/credentials, or set HASNA_ACCOUNTS_API_KEY.";
9298
+ return `accounts: LOCAL mode (${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1) \u2014 neither Accounts nor Subscriptions credentials resolved, so ` + "attribution reads the on-box JSON registry (~/.hasna/accounts/accounts.json) instead of accounts-serve. " + "For hosted attribution, configure the Subscriptions CLI through its normal sign-in or credential resolver.";
9146
9299
  }
9147
9300
  function accountsRefusalNotice() {
9148
- return "accounts: no Hasna Accounts credential resolved (Keychain item hasna.credentials.accounts.api-key, " + "~/.hasna/accounts/config/credentials, HASNA_ACCOUNTS_API_KEY), so spend attribution is omitted. The " + `on-box registry (~/.hasna/accounts/accounts.json) is NOT read without ${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1 \u2014 ` + "economy fails closed instead of attributing hosted spend from an on-box file.";
9301
+ return "accounts: neither Accounts nor Subscriptions credentials resolved (legacy hasna.credentials.accounts.api-key, " + "~/.hasna/accounts/config/credentials, HASNA_ACCOUNTS_API_KEY; current hasna.credentials.subscriptions.api-key, " + "~/.hasna/subscriptions/config/credentials, HASNA_SUBSCRIPTIONS_API_KEY), so spend attribution is omitted. The " + `on-box registry (~/.hasna/accounts/accounts.json) is NOT read without ${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1 \u2014 ` + "economy fails closed instead of attributing hosted spend from an on-box file.";
9149
9302
  }
9150
9303
  var accountsLocalNoticePrinted = false;
9151
9304
  function announceAccountsLocalMode() {
@@ -9233,7 +9386,10 @@ async function profileForEnvDir(store, tool, env) {
9233
9386
  if (!configuredDir)
9234
9387
  return null;
9235
9388
  const normalized = normalizeDir(configuredDir);
9236
- return (await store.listProfiles(tool.id)).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
9389
+ const matches = (await store.listProfiles(tool.id)).filter((profile) => normalizeDir(profile.dir) === normalized);
9390
+ if (matches.length > 1)
9391
+ throw new Error("account attribution: multiple profiles match the configured directory");
9392
+ return matches[0] ?? null;
9237
9393
  }
9238
9394
  async function resolveAccountForAgent(agent, env = process.env, options = {}) {
9239
9395
  const override = envOverride(agent, env);
@@ -9247,8 +9403,15 @@ async function resolveAccountForAgent(agent, env = process.env, options = {}) {
9247
9403
  continue;
9248
9404
  const envProfile = await profileForEnvDir(store, tool, env);
9249
9405
  if (envProfile)
9250
- return fromProfile(envProfile, "env");
9251
- const appliedName = appliedProfileName(toolId);
9406
+ return envProfile.switchedAway ? null : fromProfile(envProfile, "env");
9407
+ if (store.registry === "subscriptions" && env[tool.envVar])
9408
+ return null;
9409
+ if (store.appliedProfile) {
9410
+ const applied = await store.appliedProfile(toolId);
9411
+ if (applied)
9412
+ return applied.switchedAway ? null : fromProfile(applied, "applied");
9413
+ }
9414
+ const appliedName = store.registry === "subscriptions" ? undefined : appliedProfileName(toolId);
9252
9415
  if (appliedName) {
9253
9416
  const applied = await store.findProfile(appliedName, toolId);
9254
9417
  if (applied)
@@ -11629,7 +11792,8 @@ function buildBrief(db, opts = {}) {
11629
11792
  }
11630
11793
 
11631
11794
  // src/lib/analytics.ts
11632
- function queryProjectDetail(db, nameOrPath) {
11795
+ function queryProjectDetail(db, nameOrPath, now = new Date) {
11796
+ const dailyBounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: 14 }, now);
11633
11797
  const like = `%${nameOrPath}%`;
11634
11798
  const sessions = db.prepare(`SELECT id, project_name, project_path, total_cost_usd, total_tokens, started_at
11635
11799
  FROM economy_effective_sessions
@@ -11643,8 +11807,8 @@ function queryProjectDetail(db, nameOrPath) {
11643
11807
  const daily = db.prepare(`SELECT DATE(r.timestamp) as date, COALESCE(SUM(r.cost_usd), 0) as cost_usd
11644
11808
  FROM economy_effective_requests r JOIN economy_effective_sessions s ON r.session_id = s.id
11645
11809
  WHERE (s.project_name LIKE ? OR s.project_path LIKE ?)
11646
- AND r.timestamp >= DATE('now', '-14 days')
11647
- GROUP BY date ORDER BY date ASC`).all(like, like);
11810
+ AND r.timestamp >= ? AND r.timestamp < ?
11811
+ GROUP BY date ORDER BY date ASC`).all(like, like, dailyBounds.start, dailyBounds.end_exclusive);
11648
11812
  const models = db.prepare(`SELECT r.model as model, COUNT(*) as requests, COALESCE(SUM(r.cost_usd), 0) as cost_usd
11649
11813
  FROM economy_effective_requests r JOIN economy_effective_sessions s ON r.session_id = s.id
11650
11814
  WHERE s.project_name LIKE ? OR s.project_path LIKE ?
@@ -11683,18 +11847,17 @@ function queryRangeStats(db, from, to) {
11683
11847
  return { cost: r.cost, requests: r.requests, tokens: r.tokens, sessions: s.sessions };
11684
11848
  }
11685
11849
  function queryForecast(db, now = new Date) {
11686
- const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
11687
- const dayOfMonth = now.getDate();
11688
- const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
11689
- const monthSoFar = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM economy_effective_requests WHERE DATE(timestamp) >= ?`).get(monthStart);
11850
+ const month = resolveReportingInterval({ kind: "calendar", period: "month" }, now);
11851
+ const recent = resolveReportingInterval({ kind: "rolling", unit: "day", count: 7 }, now);
11852
+ const daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate();
11853
+ const dayOfMonth = now.getUTCDate();
11854
+ const monthSoFar = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM economy_effective_requests WHERE timestamp >= ? AND timestamp < ?`).get(month.start, recent.end_exclusive);
11690
11855
  const dailyAvg = dayOfMonth > 0 ? monthSoFar.cost / dayOfMonth : 0;
11691
11856
  const projected = dailyAvg * daysInMonth;
11692
- const sevenDaysAgo = new Date(now);
11693
- sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
11694
- const last7 = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM economy_effective_requests WHERE DATE(timestamp) >= ?`).get(sevenDaysAgo.toISOString().substring(0, 10));
11857
+ const last7 = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM economy_effective_requests WHERE timestamp >= ? AND timestamp < ?`).get(recent.start, recent.end_exclusive);
11695
11858
  const last7DailyAvg = last7.cost / 7;
11696
11859
  const dailyCosts = db.prepare(`SELECT DATE(timestamp) as date, COALESCE(SUM(cost_usd),0) as cost_usd
11697
- FROM economy_effective_requests WHERE DATE(timestamp) >= ? GROUP BY date ORDER BY cost_usd ASC`).all(monthStart);
11860
+ FROM economy_effective_requests WHERE timestamp >= ? AND timestamp < ? GROUP BY date ORDER BY cost_usd ASC`).all(month.start, recent.end_exclusive);
11698
11861
  return {
11699
11862
  day_of_month: dayOfMonth,
11700
11863
  days_in_month: daysInMonth,
@@ -11871,7 +12034,13 @@ function createDataHandler(db, actorId) {
11871
12034
  if (path === "/api/daily" && method === "GET") {
11872
12035
  const days = Number(url.searchParams.get("days") ?? 30);
11873
12036
  const machine = url.searchParams.get("machine") ?? undefined;
11874
- return ok(queryDailyBreakdown(db, days, machine));
12037
+ const now = new Date;
12038
+ try {
12039
+ resolveReportingInterval({ kind: "rolling", unit: "day", count: days }, now);
12040
+ } catch {
12041
+ return err("days must be a positive integer within the supported UTC date range");
12042
+ }
12043
+ return ok(queryDailyBreakdown(db, days, machine, now));
11875
12044
  }
11876
12045
  if (path === "/api/hourly" && method === "GET") {
11877
12046
  const machine = url.searchParams.get("machine") ?? undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"service-operations.d.ts","sourceRoot":"","sources":["../../src/server/service-operations.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAuCxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AA6jB5E,gHAAgH;AAChH,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,aAAa,CAAC,CAe/G"}
1
+ {"version":3,"file":"service-operations.d.ts","sourceRoot":"","sources":["../../src/server/service-operations.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAuCxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAgkB5E,gHAAgH;AAChH,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,aAAa,CAAC,CAe/G"}