@hasna/economy 0.5.5 → 0.5.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.7
4
+
5
+ ### Patch Changes
6
+
7
+ - Support first-phone QR setup through private owner-Terminal login or explicitly confirmed signup. Accept signup HTTP 201 and attempt cleanup on catchable Terminal shutdown while preserving supplied sessions.
8
+
9
+ ## 0.5.6
10
+
11
+ ### Patch Changes
12
+
13
+ - Transfer preserved original rows and temporary restore rehearsals in bounded queries while retaining complete-session checks and exact PostgreSQL row bytes.
14
+ - Accept current Apple iPhone development profiles that list iOS alongside xrOS and visionOS, while retaining exact signing identity, bundle, device and expiry checks.
15
+ - Connect iPhone with QR scanning or secure Paste, and approve a Mac's expiring QR from the phone. Store independent Economy-only device sessions and retain workspace, scope and revocation checks.
16
+
17
+ Include the Terminal pairing launcher and renderer in the installed package so an agent can open the owner's QR window using a Secrets reference.
18
+
19
+ - Pass the exact Apple team and bundle verification requirement as literal codesign source for native release checks.
20
+
3
21
  ## 0.5.5
4
22
 
5
23
  ### Patch Changes
@@ -4,7 +4,7 @@ import {
4
4
  } from "./index-w3bvv0ns.js";
5
5
  import {
6
6
  getStore
7
- } from "./index-z9psed59.js";
7
+ } from "./index-z2n5y111.js";
8
8
  import {
9
9
  formatReportedUsd,
10
10
  formatSummaryCoverage,
@@ -20,7 +20,7 @@ import {
20
20
  // package.json
21
21
  var package_default = {
22
22
  name: "@hasna/economy",
23
- version: "0.5.5",
23
+ version: "0.5.7",
24
24
  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",
25
25
  repository: {
26
26
  type: "git",
@@ -53,6 +53,8 @@ var package_default = {
53
53
  files: [
54
54
  "dist",
55
55
  "docs",
56
+ "native/Tools/pair-qr.py",
57
+ "native/Tools/pair-qr.swift",
56
58
  "postinstall.js",
57
59
  "LICENSE",
58
60
  "README.md",
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
3
  LOCAL_STORAGE_OPT_IN_KEYS
4
- } from "./index-pnnqdv3r.js";
4
+ } from "./index-b6c649mq.js";
5
5
  import {
6
6
  assessCostFromDb,
7
7
  getIngestState,
@@ -14,7 +14,7 @@ import {
14
14
  querySavingsSummary,
15
15
  requireCurrentBillingDiffSummary,
16
16
  requireCurrentSavingsReport
17
- } from "./index-pnnqdv3r.js";
17
+ } from "./index-b6c649mq.js";
18
18
  import {
19
19
  deleteBudget,
20
20
  deleteGoal,
@@ -5,7 +5,7 @@ import {
5
5
  } from "./index-w3bvv0ns.js";
6
6
  import {
7
7
  syncAll
8
- } from "./index-61c0vz9p.js";
8
+ } from "./index-wq7c4wqs.js";
9
9
  import {
10
10
  buildBrief,
11
11
  packageMetadata,
@@ -16,7 +16,7 @@ import {
16
16
  queryProjectDetail,
17
17
  queryRangeStats,
18
18
  querySavingsSummary
19
- } from "./index-pnnqdv3r.js";
19
+ } from "./index-b6c649mq.js";
20
20
  import {
21
21
  AGENTS,
22
22
  INGEST_SCHEMA_SQL,
@@ -6,10 +6,10 @@ import {
6
6
  printStatusLine,
7
7
  printWaybarJson,
8
8
  runTui
9
- } from "./index-7k1xt8mj.js";
9
+ } from "./index-6yr4hb72.js";
10
10
  import"./index-w3bvv0ns.js";
11
- import"./index-z9psed59.js";
12
- import"./index-pnnqdv3r.js";
11
+ import"./index-z2n5y111.js";
12
+ import"./index-b6c649mq.js";
13
13
  import"./index-f5jv6q5q.js";
14
14
  import"./index-p35cyhc4.js";
15
15
  export {
@@ -2,12 +2,12 @@
2
2
  import {
3
3
  agentPaths,
4
4
  syncAll
5
- } from "./index-61c0vz9p.js";
5
+ } from "./index-wq7c4wqs.js";
6
6
  import {
7
7
  getStore,
8
8
  isCloudStore
9
- } from "./index-z9psed59.js";
10
- import"./index-pnnqdv3r.js";
9
+ } from "./index-z2n5y111.js";
10
+ import"./index-b6c649mq.js";
11
11
  import {
12
12
  formatSummaryValue
13
13
  } from "./index-f5jv6q5q.js";
package/dist/cli/index.js CHANGED
@@ -8,22 +8,22 @@ import {
8
8
  import {
9
9
  buildStatusLine,
10
10
  gatherStatusData
11
- } from "../chunks/index-7k1xt8mj.js";
11
+ } from "../chunks/index-6yr4hb72.js";
12
12
  import"../chunks/index-w3bvv0ns.js";
13
13
  import {
14
14
  agentPaths,
15
15
  syncAll
16
- } from "../chunks/index-61c0vz9p.js";
16
+ } from "../chunks/index-wq7c4wqs.js";
17
17
  import {
18
18
  getStore,
19
19
  isCloudStore
20
- } from "../chunks/index-z9psed59.js";
20
+ } from "../chunks/index-z2n5y111.js";
21
21
  import {
22
22
  billingDriftCheck,
23
23
  economyCloudStorage,
24
24
  economyTransportReport,
25
25
  packageMetadata
26
- } from "../chunks/index-pnnqdv3r.js";
26
+ } from "../chunks/index-b6c649mq.js";
27
27
  import {
28
28
  AGENTS,
29
29
  MAX_INGEST_BYTES,
@@ -1357,23 +1357,23 @@ function registerExtendedCommands(program) {
1357
1357
  console.log();
1358
1358
  });
1359
1359
  program.command("tui").description("Terminal status dashboard (Linux/SSH friendly)").option("--watch", "Live refresh").option("--interval <seconds>", "Refresh interval", "30").action(async (opts) => {
1360
- const { runTui } = await import("../chunks/tui-2yea642b.js");
1360
+ const { runTui } = await import("../chunks/tui-9t7340wd.js");
1361
1361
  await runTui({
1362
1362
  watch: Boolean(opts.watch),
1363
1363
  interval: Number(opts.interval ?? 30)
1364
1364
  });
1365
1365
  });
1366
1366
  program.command("waybar").description("Print waybar-compatible JSON status line").action(async () => {
1367
- const { printWaybarJson } = await import("../chunks/tui-2yea642b.js");
1367
+ const { printWaybarJson } = await import("../chunks/tui-9t7340wd.js");
1368
1368
  await printWaybarJson();
1369
1369
  });
1370
1370
  const barCmd = program.command("bar").description("Status bar helpers (Linux)");
1371
1371
  barCmd.command("tui").description("Alias for economy tui").option("--watch", "Live refresh").action(async (opts) => {
1372
- const { runTui } = await import("../chunks/tui-2yea642b.js");
1372
+ const { runTui } = await import("../chunks/tui-9t7340wd.js");
1373
1373
  await runTui({ watch: Boolean(opts.watch) });
1374
1374
  });
1375
1375
  barCmd.command("waybar").description("Alias for economy waybar").action(async () => {
1376
- const { printWaybarJson } = await import("../chunks/tui-2yea642b.js");
1376
+ const { printWaybarJson } = await import("../chunks/tui-9t7340wd.js");
1377
1377
  await printWaybarJson();
1378
1378
  });
1379
1379
  program.command("estimate").description("Estimate cost for token counts").requiredOption("--model <model>", "Model name").option("--input <n>", "Input tokens", "0").option("--output <n>", "Output tokens", "0").action(async (opts) => {
@@ -3198,7 +3198,7 @@ program.command("accounts [period]").description("List account usage by email ad
3198
3198
  console.log();
3199
3199
  });
3200
3200
  program.command("watch").description("Live stream of incoming costs").option("--interval <seconds>", "Poll interval in seconds", "10").option("--daemon", "Watch agent data directories and sync on change").option("--agent <agent>", `Filter by agent (${AGENTS.join("|")})`).option("--notify <amount>", "Fire macOS notification when cumulative cost crosses this USD threshold").action(async (opts) => {
3201
- const { watchCosts } = await import("../chunks/watch-811vncjz.js");
3201
+ const { watchCosts } = await import("../chunks/watch-t6ssnx30.js");
3202
3202
  await watchCosts({
3203
3203
  interval: parsePositiveCliInteger(opts.interval ?? "10", "--interval"),
3204
3204
  daemon: Boolean(opts.daemon),
@@ -3434,7 +3434,7 @@ pricingCmd.command("remove <model>").description("Remove pricing for a model").a
3434
3434
  });
3435
3435
  program.command("serve").description("Start the REST API server").option("-p, --port <port>", "Port", "3456").action(async (opts) => {
3436
3436
  const port = parseCliPort(opts.port ?? "3456", "--port");
3437
- const { startServer } = await import("../chunks/serve-zp5a3qzw.js");
3437
+ const { startServer } = await import("../chunks/serve-ms8dvf7e.js");
3438
3438
  startServer(port);
3439
3439
  });
3440
3440
  program.command("mcp").description("Show MCP server install commands").option("--claude", "Install into Claude Code").option("--codex", "Install into Codex").option("--gemini", "Install into Gemini CLI").option("--all", "Install into all agents").action(async (opts) => {
package/dist/index.js CHANGED
@@ -62,7 +62,7 @@ import { randomUUID } from "crypto";
62
62
  // package.json
63
63
  var package_default = {
64
64
  name: "@hasna/economy",
65
- version: "0.5.5",
65
+ version: "0.5.7",
66
66
  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",
67
67
  repository: {
68
68
  type: "git",
@@ -95,6 +95,8 @@ var package_default = {
95
95
  files: [
96
96
  "dist",
97
97
  "docs",
98
+ "native/Tools/pair-qr.py",
99
+ "native/Tools/pair-qr.swift",
98
100
  "postinstall.js",
99
101
  "LICENSE",
100
102
  "README.md",
package/dist/mcp/index.js CHANGED
@@ -85,7 +85,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
85
85
  // package.json
86
86
  var package_default = {
87
87
  name: "@hasna/economy",
88
- version: "0.5.5",
88
+ version: "0.5.7",
89
89
  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",
90
90
  repository: {
91
91
  type: "git",
@@ -118,6 +118,8 @@ var package_default = {
118
118
  files: [
119
119
  "dist",
120
120
  "docs",
121
+ "native/Tools/pair-qr.py",
122
+ "native/Tools/pair-qr.swift",
121
123
  "postinstall.js",
122
124
  "LICENSE",
123
125
  "README.md",
@@ -3480,7 +3480,7 @@ async function pushIngestRows(cloud, scratch) {
3480
3480
  // package.json
3481
3481
  var package_default = {
3482
3482
  name: "@hasna/economy",
3483
- version: "0.5.5",
3483
+ version: "0.5.7",
3484
3484
  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",
3485
3485
  repository: {
3486
3486
  type: "git",
@@ -3513,6 +3513,8 @@ var package_default = {
3513
3513
  files: [
3514
3514
  "dist",
3515
3515
  "docs",
3516
+ "native/Tools/pair-qr.py",
3517
+ "native/Tools/pair-qr.swift",
3516
3518
  "postinstall.js",
3517
3519
  "LICENSE",
3518
3520
  "README.md",
@@ -4909,6 +4909,48 @@ function assertLegacyPlanCurrent(expected, current) {
4909
4909
  if (!same(state(expected), state(current)))
4910
4910
  fail("Legacy plan is stale or belongs to a different store or schema");
4911
4911
  }
4912
+ async function originalPages(load, count3) {
4913
+ const originals = [];
4914
+ let after = null;
4915
+ for (;; ) {
4916
+ const page = await load(after);
4917
+ if (!Array.isArray(page) || page.length > ORIGINAL_PAGE_ROWS)
4918
+ fail("Invalid original transfer page");
4919
+ for (const value of page) {
4920
+ const row = record(value);
4921
+ fields(row, ["id", "original"]);
4922
+ if (typeof row.original !== "string")
4923
+ fail("Invalid original transfer row");
4924
+ const id = identifier3(row.id);
4925
+ if (after !== null && cOrder(after, id) >= 0)
4926
+ fail("Original transfer cursor did not advance");
4927
+ after = id;
4928
+ originals.push({ id, original: row.original });
4929
+ if (originals.length > count3)
4930
+ fail("Original transfer exceeds the complete population");
4931
+ }
4932
+ if (page.length < ORIGINAL_PAGE_ROWS) {
4933
+ if (originals.length !== count3)
4934
+ fail("Original transfer omitted part of the complete population");
4935
+ return originals;
4936
+ }
4937
+ }
4938
+ }
4939
+ function* restoreBatches(originals) {
4940
+ let rows = [], bytes = 2;
4941
+ for (const row of originals) {
4942
+ const size = Buffer.byteLength(row.original);
4943
+ if (rows.length && (rows.length === ORIGINAL_PAGE_ROWS || bytes + 1 + size > RESTORE_BATCH_BYTES)) {
4944
+ yield "[" + rows.join(",") + "]";
4945
+ rows = [];
4946
+ bytes = 2;
4947
+ }
4948
+ bytes += size + (rows.length ? 1 : 0);
4949
+ rows.push(row.original);
4950
+ }
4951
+ if (rows.length)
4952
+ yield "[" + rows.join(",") + "]";
4953
+ }
4912
4954
  function parseLegacyCapsule(value) {
4913
4955
  const c = record(value);
4914
4956
  const mode = legacyMode("capsule", c.schema);
@@ -4981,7 +5023,12 @@ async function preserveLegacyOriginals(client, input) {
4981
5023
  (SELECT COALESCE(SUM(octet_length(row_to_json(sessions)::text)),0) FROM sessions WHERE id=ANY($1::text[]))::text AS session_bytes`, [ids])).rows[0];
4982
5024
  if (!sizes || !/^[0-9]+$/.test(sizes.request_bytes) || !/^[0-9]+$/.test(sizes.session_bytes) || BigInt(sizes.request_bytes) + BigInt(sizes.session_bytes) > BigInt(mode.originalBytes))
4983
5025
  fail(`Preservation exceeds ${mode.originalBytes / 1024 / 1024} MiB of original row text; complete sessions cannot be truncated`);
4984
- const requests = (await client.query(`SELECT id,row_to_json(requests)::text AS original FROM requests WHERE session_id=ANY($1::text[]) ORDER BY session_id COLLATE "C",id COLLATE "C" LIMIT ${mode.members + 1}`, [ids])).rows;
5026
+ const requests = [];
5027
+ for (const session of current.sessions) {
5028
+ requests.push(...await originalPages(async (after) => (await client.query(`SELECT id,row_to_json(requests)::text AS original FROM requests
5029
+ WHERE session_id=$1::text AND ($2::text IS NULL OR id COLLATE "C" > $2::text COLLATE "C")
5030
+ ORDER BY id COLLATE "C" LIMIT ${ORIGINAL_PAGE_ROWS}`, [session.id, after])).rows, session.request_count));
5031
+ }
4985
5032
  const sessions = (await client.query(`SELECT id,row_to_json(sessions)::text AS original FROM sessions WHERE id=ANY($1::text[]) ORDER BY id COLLATE "C" LIMIT ${mode.sessions + 1}`, [ids])).rows;
4986
5033
  const body = { schema: mode.capsule, plan: expected, captured_at: current.snapshot.at, requests, sessions };
4987
5034
  return parseLegacyCapsule({ ...body, capsule_sha256: sha2(JSON.stringify(body)) });
@@ -5114,9 +5161,10 @@ async function rehearseLegacyRestore(client, input, scope = "transaction") {
5114
5161
  if ((await client.query("SELECT current_setting('transaction_read_only') AS read_only")).rows[0]?.read_only !== "on")
5115
5162
  fail("Restore reconstruction requires a read-only transaction");
5116
5163
  for (const { source, temp, originals } of restoreTables) {
5117
- const payload = "[" + originals.map((x) => x.original).join(",") + "]";
5118
- await client.query("INSERT INTO pg_temp." + temp + " SELECT * FROM json_populate_recordset(NULL::" + source + ",$1::json)", [payload]);
5119
- const restored = (await client.query("SELECT id,row_to_json(t)::text AS original FROM pg_temp." + temp + ' t ORDER BY id COLLATE "C"')).rows;
5164
+ for (const payload of restoreBatches(originals)) {
5165
+ await client.query("INSERT INTO pg_temp." + temp + " SELECT * FROM json_populate_recordset(NULL::" + source + ",$1::json)", [payload]);
5166
+ }
5167
+ const restored = await originalPages(async (after) => (await client.query("SELECT id,row_to_json(t)::text AS original FROM pg_temp." + temp + ' t WHERE ($1::text IS NULL OR id COLLATE "C" > $1::text COLLATE "C") ORDER BY id COLLATE "C" LIMIT ' + ORIGINAL_PAGE_ROWS, [after])).rows, originals.length);
5120
5168
  const expectedRows = [...originals].sort((a, b) => cOrder(a.id, b.id));
5121
5169
  if (!same(restored, expectedRows))
5122
5170
  fail("Restored original bytes differ from preservation");
@@ -5170,11 +5218,12 @@ var MAX_BYTES, sha2 = (s) => createHash7("sha256").update(s).digest("hex"), fail
5170
5218
  }, record = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : fail("Expected a JSON object"), hash = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v) ? v : fail("Expected a lowercase SHA-256 digest"), identifier3 = (v) => typeof v === "string" && v.length > 0 && v.length <= 1024 && !v.includes("\x00") ? v : fail("Expected a nonempty identifier of at most 1024 characters without NUL"), fields = (o, names) => {
5171
5219
  if (Object.keys(o).sort().join(",") !== [...names].sort().join(","))
5172
5220
  fail("Unexpected or missing preservation fields");
5173
- }, cOrder = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)), same = (a, b) => JSON.stringify(a) === JSON.stringify(b), quote3 = (s) => '"' + s.replaceAll('"', '""') + '"';
5221
+ }, cOrder = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)), same = (a, b) => JSON.stringify(a) === JSON.stringify(b), quote3 = (s) => '"' + s.replaceAll('"', '""') + '"', ORIGINAL_PAGE_ROWS = 1000, RESTORE_BATCH_BYTES;
5174
5222
  var init_legacy_preservation = __esm(() => {
5175
5223
  init_legacy_plan();
5176
5224
  init_legacy_limits();
5177
5225
  MAX_BYTES = LEGACY_SINGLE_SESSION.capsuleBytes;
5226
+ RESTORE_BATCH_BYTES = 1024 * 1024;
5178
5227
  });
5179
5228
 
5180
5229
  // src/server/legacy-operations.ts
@@ -5529,7 +5578,7 @@ function isAuthorizedRequest(req, path) {
5529
5578
  // package.json
5530
5579
  var package_default = {
5531
5580
  name: "@hasna/economy",
5532
- version: "0.5.5",
5581
+ version: "0.5.7",
5533
5582
  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",
5534
5583
  repository: {
5535
5584
  type: "git",
@@ -5562,6 +5611,8 @@ var package_default = {
5562
5611
  files: [
5563
5612
  "dist",
5564
5613
  "docs",
5614
+ "native/Tools/pair-qr.py",
5615
+ "native/Tools/pair-qr.swift",
5565
5616
  "postinstall.js",
5566
5617
  "LICENSE",
5567
5618
  "README.md",
@@ -1 +1 @@
1
- {"version":3,"file":"legacy-preservation.d.ts","sourceRoot":"","sources":["../../src/server/legacy-preservation.ts"],"names":[],"mappings":"AAGA,OAAO,EAAgD,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAA;AACnH,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAc,MAAM,oBAAoB,CAAA;AAevF,2FAA2F;AAC3F,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CA8C1D;AAOD,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,UAAU,GAAG,IAAI,CAEvF;AAED,UAAU,QAAQ;IAAG,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;AACnD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,OAAO,eAAe,CAAC,OAAO,GAAG,OAAO,qBAAqB,CAAC,OAAO,CAAA;IAC7E,IAAI,EAAE,UAAU,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,QAAQ,EAAE,CAAA;IACpB,QAAQ,EAAE,QAAQ,EAAE,CAAA;IACpB,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,wFAAwF;AACxF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,CA4ChE;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAkB1G;AAmBD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAU,GAAG,OAAO,CAS5E;AAED;sFACsF;AACtF,wBAAgB,kBAAkB,CAAC,IAAI,EAAC,MAAM,EAAC,KAAK,EAAC,OAAO;;;;;;;;;;;EAwB3D;AAED;;kFAEkF;AAClF,wBAAsB,qBAAqB,CAAC,MAAM,EAAC,YAAY,EAAC,KAAK,EAAC,OAAO,EAAC,KAAK,GAAC,aAAa,GAAC,WAAyB;;;;;;;;;;GAwD1H;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAC,MAAM,EAAC,IAAI,EAAC,MAAM,EAAE,GAAG;IAAC,KAAK,EAAC,MAAM,CAAC;IAAA,MAAM,CAAC,EAAC,MAAM,CAAA;CAAC,CAM1G;AAED,wBAAsB,qBAAqB,CAAC,OAAO,EAAC,MAAM,EAAC,OAAO,EAAC;IAAC,KAAK,EAAC,MAAM,CAAC;IAAA,MAAM,CAAC,EAAC,MAAM,CAAA;CAAC,iBAgB/F"}
1
+ {"version":3,"file":"legacy-preservation.d.ts","sourceRoot":"","sources":["../../src/server/legacy-preservation.ts"],"names":[],"mappings":"AAGA,OAAO,EAAgD,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAA;AACnH,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAc,MAAM,oBAAoB,CAAA;AAevF,2FAA2F;AAC3F,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,UAAU,CA8C1D;AAOD,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,UAAU,GAAG,IAAI,CAEvF;AAED,UAAU,QAAQ;IAAG,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;AA6CnD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,OAAO,eAAe,CAAC,OAAO,GAAG,OAAO,qBAAqB,CAAC,OAAO,CAAA;IAC7E,IAAI,EAAE,UAAU,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,QAAQ,EAAE,CAAA;IACpB,QAAQ,EAAE,QAAQ,EAAE,CAAA;IACpB,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,wFAAwF;AACxF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,CA4ChE;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAwB1G;AAmBD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,SAAU,GAAG,OAAO,CAS5E;AAED;sFACsF;AACtF,wBAAgB,kBAAkB,CAAC,IAAI,EAAC,MAAM,EAAC,KAAK,EAAC,OAAO;;;;;;;;;;;EAwB3D;AAED;;kFAEkF;AAClF,wBAAsB,qBAAqB,CAAC,MAAM,EAAC,YAAY,EAAC,KAAK,EAAC,OAAO,EAAC,KAAK,GAAC,aAAa,GAAC,WAAyB;;;;;;;;;;GA2D1H;AAED,wBAAgB,8BAA8B,CAAC,OAAO,EAAC,MAAM,EAAC,IAAI,EAAC,MAAM,EAAE,GAAG;IAAC,KAAK,EAAC,MAAM,CAAC;IAAA,MAAM,CAAC,EAAC,MAAM,CAAA;CAAC,CAM1G;AAED,wBAAsB,qBAAqB,CAAC,OAAO,EAAC,MAAM,EAAC,OAAO,EAAC;IAAC,KAAK,EAAC,MAAM,CAAC;IAAA,MAAM,CAAC,EAAC,MAAM,CAAA;CAAC,iBAgB/F"}
@@ -7426,7 +7426,7 @@ import { randomUUID } from "crypto";
7426
7426
  // package.json
7427
7427
  var package_default = {
7428
7428
  name: "@hasna/economy",
7429
- version: "0.5.5",
7429
+ version: "0.5.7",
7430
7430
  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",
7431
7431
  repository: {
7432
7432
  type: "git",
@@ -7459,6 +7459,8 @@ var package_default = {
7459
7459
  files: [
7460
7460
  "dist",
7461
7461
  "docs",
7462
+ "native/Tools/pair-qr.py",
7463
+ "native/Tools/pair-qr.swift",
7462
7464
  "postinstall.js",
7463
7465
  "LICENSE",
7464
7466
  "README.md",
@@ -0,0 +1,151 @@
1
+ # Economy native applications
2
+
3
+ Shared Swift models, canonical HTTPS clients and SwiftUI reports for the Mac
4
+ menu bar (`../menubar/App`) and iPhone (`../ios`). Minimum macOS 14 / iOS 17;
5
+ system glass controls on OS 26. Opaque data cards/lists, semantic fonts, Dynamic
6
+ Type and native navigation retain the approved Usage / Spend direction.
7
+
8
+ ## Authentication and activation
9
+
10
+ iPhone sign-in uses Scan pairing code, with a secure Paste fallback. The Mac
11
+ shows a five-minute QR code; the signed-in iPhone inspects the computer and
12
+ workspace and explicitly approves read-only access. Each device gets its own
13
+ Economy-only Identities session. Scanning never approves a Mac automatically.
14
+
15
+ The native adapter calls the pinned `economy-pairing` routes at
16
+ `https://api.hasna.com/identities/v1`, selects the returned workspace, exchanges
17
+ short-lived Economy tokens, refreshes and revokes the exact session on logout.
18
+ The session stays in device-only Keychain storage after first unlock. The
19
+ Economy access token stays in memory and goes only to
20
+ `https://api.hasna.com/economy/v1`. Redirects, foreign QR authorities and responses
21
+ with another app or tenant are refused. No station key importer, local server
22
+ fallback or connect URL scheme is registered.
23
+
24
+ An already authorized human session bootstraps the iPhone through
25
+ `python3 native/Tools/pair-qr.py --tenant <workspace-id> --session-ref <human-session-reference>`.
26
+ The launcher opens the owner's Terminal and projects the named Secrets reference
27
+ there as `ECONOMY_HUMAN_SESSION`; it returns no Terminal output. Both issuer and
28
+ renderer verify the direct Terminal ancestry and refuse captured harness PTYs.
29
+ Never run real codes in a captured agent terminal. The helper obtains a
30
+ single-use code, hands only that temporary code to the CoreImage renderer and
31
+ clears the display within five minutes. `--self-test` decodes a synthetic QR;
32
+ `test_pair_qr.py` proves transcript/agent-key refusal and credential confinement.
33
+ Without a saved human session, use
34
+ `python3 native/Tools/pair-qr.py --tenant <workspace-id> --login-email <human-email>`.
35
+ The helper opens the owner's Terminal, asks for confirmation before requesting
36
+ one sign-in email, and reads the verification code there with echo disabled.
37
+ It verifies a human principal and the selected workspace before displaying the
38
+ one-use QR. The new setup session stays in memory. On renderer exit or catchable
39
+ Terminal shutdown (HUP, TERM or Ctrl-C), setup makes one timeout-limited logout
40
+ attempt and reports an unconfirmed result as a failure. Force-kill, a reboot,
41
+ network failure or an interrupted verification response can prevent confirmation;
42
+ check the account's sessions before retrying in those cases. An existing session
43
+ supplied with `--session-ref` is retained.
44
+ Only the temporary QR code reaches the renderer, and no response or verification
45
+ code is returned to the agent terminal.
46
+
47
+ For a first account, `--create-account` offers signup through the existing
48
+ Identities flow instead of login. It requires direct confirmation in the owner
49
+ Terminal before account creation or email delivery. It does not create an
50
+ organization, grant an owner role, alter memberships or configure Economy's
51
+ store binding. An existing-account refusal is not retried as login, and failed
52
+ delivery/verification is not retried automatically. Never type the owner's code
53
+ for them or copy it into an agent session. A source/fixture test does not prove
54
+ real email delivery, account authority or a successful phone connection.
55
+
56
+ The adapter verifies Economy's `/auth/session` subject, tenant, effective scopes
57
+ and integer expiry. Viewer sign-in never requests write access. Refresh and period
58
+ changes cannot orphan an active send-code/verify/workspace operation. Write-only users
59
+ remain signed in with a specific no-report-read-access state; native Save needs
60
+ both write permission and the readback used to confirm the rule. Refresh and
61
+ account/period changes fence pending responses. Sign-out clears local state first
62
+ and reports unconfirmed remote revocation. A stale logout cannot clear a new login.
63
+
64
+ **Hosted activation is off by default.** A reviewed delivery build passes
65
+ `auth-enabled`; the build receipt records the exact activation value, version and
66
+ build. An inactive artifact is not completed native delivery. Activation also
67
+ requires the reviewed human audience, independently verified store tenant and
68
+ principal allowlist, live grants, deployed server changes and owner-initiated
69
+ sign-in acceptance. No automated real OTP/email operation was performed here.
70
+
71
+ ## Reports and exact budget recovery
72
+
73
+ Reports use a captured session with generation/expiry checks before and after
74
+ network work. Responses are capped at 8 MiB with at most four concurrent reads.
75
+ Session and period changes cancel old work and clear the report. A failed refresh
76
+ retains the original acquisition time; a 401/403 clears private data. Report cache
77
+ is memory-only. Persistent offline reports are not implemented.
78
+
79
+ Subscriptions, account breakdowns and quota observations use current unpaginated
80
+ server arrays. Sessions traverse 100-row offset pages to a short final page, with
81
+ a 2,000-record bound and visible truncation. Duplicate pages fail closed. Offset
82
+ traversal is not a consistent snapshot; open records do not establish live work.
83
+ Totals cover the complete returned population independently of search/visible
84
+ rows. Known valuation subtotals distinguish missing prices/bases from affirmative
85
+ known zero; exact acquired UTC calendar bounds remain separate from local timestamp
86
+ display, including cached reports. Unknown fees and provider coverage remain explicit.
87
+ Quota selection uses source time before station recording time, expires at reset or
88
+ 15 minutes, and a native one-second view timeline reevaluates it without network
89
+ activity. Historical readings remain labeled. Budget scope labels include every
90
+ project, agent and cost-center restriction conjunctively. Station timestamps
91
+ are reported observations, not proof of a complete online fleet.
92
+
93
+ Copy prompt is the first budget control. Its prominent action uses an explicit
94
+ foreground/background pair in both appearances. Secondary Save requires current
95
+ read and write permission again before dispatch/retry, and creates one immutable
96
+ `metered_api_usd` rule through `/ingest`, then verifies its actor-bound receipt
97
+ and authoritative budget status. It excludes subscriptions, estimates, unknown
98
+ cost bases and provider billing. Calendar windows are UTC; weeks start Sunday.
99
+ Saved rules are alert thresholds, never spending enforcement or verified delivery.
100
+
101
+ Before POST, the complete command, stable UUID key and canonical body hash are
102
+ persisted and read back in account-bound Keychain recovery storage. After response
103
+ loss or restart, only explicit receipt checks and exact same-command retries are
104
+ offered. An absent receipt does not prove an earlier request cannot commit. A new
105
+ key never resolves an uncertain command. Retained recovery history is bounded to
106
+ 40 commands per account and fails closed at the bound. This is operational intent,
107
+ not a local authoritative report store. The typed canonical JSON writer has hash
108
+ parity vectors from the actual server, including Unicode and scalar edge cases.
109
+
110
+ ## Build and test
111
+
112
+ Use an exact committed source copy on an Xcode 26 Mac. The dependency-free runner
113
+ creates synthetic HOME/cache/DerivedData, kernel-enforces read-only station/source
114
+ files, denies network, runs positive/negative write controls and checks source
115
+ hashes. It invokes installed Apple `swiftc`, SDK and XCTest directly. Refused
116
+ Xcode/SwiftPM shared temporary writes remain in task evidence; no boundary was
117
+ relaxed to make those paths work.
118
+
119
+ ```sh
120
+ python3 apps/economy/native/Tools/build-isolated.py test /absolute/.hasna/scratchpad/scratch/session/test-1
121
+ python3 apps/economy/native/Tools/build-isolated.py mac /absolute/.hasna/scratchpad/scratch/session/mac-1 Release auth-disabled
122
+ python3 apps/economy/native/Tools/build-isolated.py ios /absolute/.hasna/scratchpad/scratch/session/ios-1 Debug
123
+ python3 apps/economy/native/Tools/build-isolated.py device /absolute/.hasna/scratchpad/scratch/session/device-1 Release auth-disabled
124
+ # Reviewed delivery preparation supplies both actual release metadata values:
125
+ # ... mac FRESH_PATH Release auth-enabled VERSION BUILD
126
+ ```
127
+
128
+ Omitted version/build use fixture-only `0.1.0` / `1`, clearly recorded in the
129
+ receipt. The existing macOS identifier is `com.hasna.economy-bar`, product
130
+ `HasnaEconomy.app`, display name `HasnaEconomy`, and LSUIElement is true. No new
131
+ identifier or signing registration is created. Signed iPhone identity, signing,
132
+ notarization, installation and distribution remain coordinator operations.
133
+
134
+ Debug apps accept `-demo -demoRoute usage|spend|unknown|account|budget|offline|empty|loading|error`.
135
+ The 125-subscription fixture uses distinct provider/account IDs and no files,
136
+ network or credential storage, even in an auth-enabled Debug build. Release
137
+ must exclude fixture markers and arguments, using Debug as the positive control.
138
+ The former localhost menu-bar Sources/Tests/Tools/Resources are retained but
139
+ excluded by the replacement Package.swift target.
140
+
141
+ `Tools/capture-ios.py` requires an explicit owned simulator/device set/name,
142
+ captures light/dark and largest Dynamic Type, then shuts down only that device.
143
+ The Mac helper captures only its own fresh fixture application's windows; its
144
+ station14 SSH window-discovery failure remains open. A fixture window is not
145
+ menu-bar interaction acceptance. Actual Mac interaction, keyboard/VoiceOver,
146
+ minimum-OS fallback and signed-device acceptance are separate from compiler and
147
+ fixture checks. Owner-facing screen captures go through Designs.
148
+
149
+ Apple references: [MenuBarExtra](https://developer.apple.com/documentation/swiftui/menubarextra),
150
+ [native design adoption](https://developer.apple.com/videos/play/wwdc2025/323/),
151
+ [application delegate adaptor](https://developer.apple.com/documentation/swiftui/nsapplicationdelegateadaptor).
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env python3
2
+ """Show a one-use Economy connection code only in the owner's Terminal.
3
+
4
+ Pass an existing authorized human Identities session's Secrets reference using
5
+ --session-ref. The launcher opens Terminal and projects it there. The server
6
+ verifies the session and selected tenant; no station agent key works.
7
+ """
8
+ import argparse
9
+ import getpass
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+ import shlex
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import urllib.request
19
+ import warnings
20
+
21
+
22
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
23
+ def redirect_request(self, *args, **kwargs):
24
+ return None
25
+
26
+
27
+ def owner_terminal():
28
+ """Refuse captured harness PTYs, including a CLI inside a Terminal window.
29
+
30
+ This prevents accidental transcript delivery; it is not a privilege boundary
31
+ against another process running as the owner. Never screen-capture the code.
32
+ """
33
+ if not sys.stdout.isatty() or not sys.stdin.isatty():
34
+ return False
35
+ if any(os.environ.get(key) for key in ['CODEX_THREAD_ID', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
36
+ 'CLAUDECODE', 'SUMI_SESSION_ID', 'OPENCODE_SESSION_ID']):
37
+ return False
38
+ pid = os.getppid()
39
+ for _ in range(12):
40
+ try:
41
+ result = subprocess.run(['/bin/ps', '-p', str(pid), '-o', 'ppid=', '-o', 'comm='],
42
+ capture_output=True, text=True, timeout=3)
43
+ except (OSError, subprocess.TimeoutExpired):
44
+ return False
45
+ if result.returncode:
46
+ return False
47
+ fields = result.stdout.strip().split(None, 1)
48
+ if len(fields) != 2 or not fields[0].isdigit():
49
+ return False
50
+ parent, executable = int(fields[0]), fields[1]
51
+ if executable == '/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal':
52
+ return True
53
+ if Path(executable).name not in ['Python', 'python3', 'bash', 'zsh', 'login', 'bun', 'secrets']:
54
+ return False
55
+ if parent <= 1 or parent == pid:
56
+ return False
57
+ pid = parent
58
+ return False
59
+
60
+
61
+ def launch_owner_terminal(reference, tenant, email=None, create_account=False):
62
+ if reference is not None and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_./-]{0,254}', reference):
63
+ print('Invalid Secrets reference.', file=sys.stderr)
64
+ return 2
65
+ # AppleScript receives references and paths only. Its returned text never
66
+ # contains Terminal output. No QR or credential is returned to this process.
67
+ arguments = ['/usr/bin/python3', str(Path(__file__).resolve()), '--owner-terminal', '--tenant', tenant]
68
+ if email is not None:
69
+ arguments += ['--login-email', email]
70
+ if create_account:
71
+ arguments.append('--create-account')
72
+ else:
73
+ arguments = ['secrets', 'exec', reference, '--as', 'ECONOMY_HUMAN_SESSION', '--', *arguments]
74
+ command = shlex.join(arguments)
75
+ script = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(command) + '\nend tell\n'
76
+ try:
77
+ result = subprocess.run(['/usr/bin/osascript', '-'], input=script, text=True, capture_output=True, timeout=30)
78
+ except (OSError, subprocess.TimeoutExpired):
79
+ print('Could not confirm the owner Terminal window. Check Terminal before retrying.', file=sys.stderr)
80
+ return 1
81
+ if result.returncode:
82
+ print('Could not open the owner Terminal window. No code was displayed by this process.', file=sys.stderr)
83
+ return 1
84
+ print('Opened Economy setup in Terminal on this Mac. Complete the steps there, then scan its QR code.')
85
+ return 0
86
+
87
+
88
+ def identity_request(action, body, bearer=None):
89
+ if action not in ['signup', 'login', 'verify', 'logout', 'economy-pairing/issue']:
90
+ raise ValueError('Unsupported identity operation')
91
+ headers = {'Content-Type': 'application/json', 'Accept': 'application/json',
92
+ 'User-Agent': 'Hasna-Economy-Pairing/1.0'}
93
+ if bearer is not None:
94
+ headers['Authorization'] = 'Bearer ' + bearer
95
+ request = urllib.request.Request('https://api.hasna.com/identities/v1/auth/' + action,
96
+ data=json.dumps(body).encode(), headers=headers, method='POST')
97
+ with urllib.request.build_opener(NoRedirect).open(request, timeout=15) as response:
98
+ raw = response.read(16385)
99
+ if response.status != (201 if action == 'signup' else 200) or len(raw) > 16384:
100
+ raise ValueError('Invalid identity response')
101
+ value = json.loads(raw)
102
+ if not isinstance(value, dict):
103
+ raise ValueError('Invalid identity response')
104
+ return value
105
+
106
+
107
+ def login_in_owner_terminal(email, create_account):
108
+ action = 'Create your Hasna sign-in and send one verification email to' if create_account else 'Send one sign-in email to'
109
+ if input(action + ' ' + email + '? Type yes to continue: ').strip().lower() != 'yes':
110
+ print('Cancelled. No email was requested.')
111
+ return None
112
+ challenge = identity_request('signup' if create_account else 'login', {'email': email})
113
+ if challenge.get('challenge') is not True or challenge.get('email_sent') is False:
114
+ raise ValueError('Email challenge unavailable')
115
+ with warnings.catch_warnings():
116
+ warnings.simplefilter('error', getpass.GetPassWarning)
117
+ code = getpass.getpass('Verification code (hidden; enter only in this Terminal): ')
118
+ if not re.fullmatch(r'[0-9]{6}', code):
119
+ raise ValueError('Invalid verification code')
120
+ try:
121
+ return identity_request('verify', {'email': email, 'code': code})
122
+ finally:
123
+ code = None
124
+
125
+
126
+ def render_pairing_code(human, tenant, renderer):
127
+ value = identity_request('economy-pairing/issue', {'tenant_id': tenant, 'device_name': 'Your iPhone'}, human)
128
+ if not re.fullmatch(r'hec_[A-Za-z0-9_-]{43}', value.get('code', '')) or not 1 <= value.get('expires_in', 0) <= 300:
129
+ raise ValueError('Invalid pairing response')
130
+ environment = {k: v for k, v in os.environ.items() if k in
131
+ ['PATH', 'HOME', 'TMPDIR', 'LANG', 'TERM', 'DEVELOPER_DIR', 'CLANG_MODULE_CACHE_PATH']}
132
+ environment['ECONOMY_PAIR_CODE'] = value['code']
133
+ environment['ECONOMY_PAIR_SERVER'] = 'https://api.hasna.com/economy'
134
+ environment['ECONOMY_PAIR_TTL'] = str(value['expires_in'])
135
+ return subprocess.run(['/usr/bin/swift', str(renderer)], env=environment).returncode
136
+
137
+
138
+ def main():
139
+ parser = argparse.ArgumentParser(description=__doc__)
140
+ parser.add_argument('--tenant', help='Exact workspace ID, resolved through Identities')
141
+ parser.add_argument('--self-test', action='store_true')
142
+ source = parser.add_mutually_exclusive_group()
143
+ source.add_argument('--session-ref', help='Authorized human-session reference in Secrets; never a value')
144
+ source.add_argument('--login-email', help='Sign in privately in the owner Terminal before displaying the QR code')
145
+ parser.add_argument('--create-account', action='store_true', help='Offer first-account signup in the owner Terminal; requires --login-email and direct confirmation')
146
+ parser.add_argument('--owner-terminal', action='store_true', help=argparse.SUPPRESS)
147
+ args = parser.parse_args()
148
+ human = os.environ.pop('ECONOMY_HUMAN_SESSION', None)
149
+ renderer = Path(__file__).with_name('pair-qr.swift')
150
+ if args.self_test:
151
+ return subprocess.run(['/usr/bin/swift', str(renderer), '--self-test']).returncode
152
+ if not args.tenant or not re.fullmatch(r'[A-Za-z0-9_-]{1,255}', args.tenant):
153
+ print('Provide the exact authorized workspace with --tenant.', file=sys.stderr)
154
+ return 2
155
+ if args.create_account and not args.login_email:
156
+ print('--create-account requires --login-email.', file=sys.stderr)
157
+ return 2
158
+ if args.login_email is not None and (len(args.login_email) > 320 or not re.fullmatch(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+', args.login_email)):
159
+ print('Provide a valid sign-in email address.', file=sys.stderr)
160
+ return 2
161
+ if not args.owner_terminal:
162
+ if not args.session_ref and not args.login_email:
163
+ print('Use --session-ref or --login-email to open the owner Terminal. Credential values are not accepted here.', file=sys.stderr)
164
+ return 3
165
+ return launch_owner_terminal(args.session_ref, args.tenant, args.login_email, args.create_account)
166
+ if args.session_ref or not owner_terminal():
167
+ print('Refusing to show a connection code outside the direct owner Terminal path.', file=sys.stderr)
168
+ return 3
169
+ if args.login_email and human:
170
+ print('Choose either a saved session or interactive sign-in.', file=sys.stderr)
171
+ return 2
172
+ if not args.login_email and (not human or not re.fullmatch(r'hst_[A-Za-z0-9_-]{43}', human)):
173
+ print('An existing human Identities session is required via ECONOMY_HUMAN_SESSION. Station keys are refused.', file=sys.stderr)
174
+ return 2
175
+ created_session = None
176
+ result = 1
177
+ previous_handlers = {}
178
+ cleaning_up = False
179
+ def shutdown(signum, frame):
180
+ if not cleaning_up:
181
+ raise KeyboardInterrupt
182
+ try:
183
+ for sig in [signal.SIGHUP, signal.SIGTERM, signal.SIGINT]:
184
+ previous_handlers[sig] = signal.signal(sig, shutdown)
185
+ if args.login_email:
186
+ identity = login_in_owner_terminal(args.login_email, args.create_account)
187
+ if identity is None:
188
+ return 0
189
+ human = identity.get('session')
190
+ if not isinstance(human, str) or not re.fullmatch(r'hst_[A-Za-z0-9_-]{43}', human):
191
+ raise ValueError('Invalid human session')
192
+ created_session = human
193
+ if identity.get('principal', {}).get('kind') != 'human' or not any(
194
+ isinstance(row, dict) and row.get('tenant_id') == args.tenant for row in identity.get('tenants', [])):
195
+ raise ValueError('Human or workspace mismatch')
196
+ identity = None
197
+ print('Scan this code before closing its window. Setup will then try to sign out its temporary session.')
198
+ result = render_pairing_code(human, args.tenant, renderer)
199
+ except (Exception, KeyboardInterrupt):
200
+ print('Unable to complete setup. No automatic email or sign-in retry was made. Check your account and workspace before trying again.', file=sys.stderr)
201
+ finally:
202
+ # Catchable Terminal shutdown unwinds into cleanup. Further catchable
203
+ # signals cannot interrupt the single timeout-limited logout attempt.
204
+ cleaning_up = True
205
+ try:
206
+ if created_session:
207
+ try:
208
+ if identity_request('logout', {}, created_session).get('signed_out') is not True:
209
+ raise ValueError('Sign-out unconfirmed')
210
+ except Exception:
211
+ print('Setup session sign-out could not be confirmed. Check your account sessions before retrying.', file=sys.stderr)
212
+ result = 1
213
+ finally:
214
+ human = None
215
+ created_session = None
216
+ for sig, handler in previous_handlers.items():
217
+ signal.signal(sig, handler)
218
+ return result
219
+
220
+
221
+ if __name__ == '__main__':
222
+ raise SystemExit(main())
@@ -0,0 +1,223 @@
1
+ // Shows the Economy pairing code as a QR code in the terminal, for the iPhone app's Connect → Scan code.
2
+ // Run it through native/Tools/pair-qr.py. The one-use code is issued inside the owner's Terminal:
3
+ // it is never an argument, never written to a file, and the screen and its
4
+ // scrollback are cleared once the code has been scanned.
5
+ //
6
+ // QR codes come from CoreImage's built-in CIQRCodeGenerator, so no extra tool is needed.
7
+ // `--self-test` renders a synthetic code the same way and decodes it with CoreImage's detector.
8
+ import CoreImage
9
+ import Foundation
10
+
11
+ /// The same text as Pairing.code(server:key:) in the app.
12
+ func pairingCode(server: String, key: String) -> String {
13
+ let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
14
+ func escaped(_ text: String) -> String { text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? "" }
15
+ return "hasna-economy://connect?server=\(escaped(server))&key=\(escaped(key))"
16
+ }
17
+
18
+ /// The QR modules, row by row from the top, true for dark. One pixel per module from CoreImage.
19
+ func qrModules(_ text: String) -> [[Bool]]? {
20
+ guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
21
+ filter.setValue(Data(text.utf8), forKey: "inputMessage")
22
+ filter.setValue("M", forKey: "inputCorrectionLevel")
23
+ guard let image = filter.outputImage else { return nil }
24
+ let extent = image.extent.integral
25
+ let width = Int(extent.width)
26
+ let height = Int(extent.height)
27
+ var pixels = [UInt8](repeating: 0, count: width * height * 4)
28
+ CIContext().render(image, toBitmap: &pixels, rowBytes: width * 4, bounds: extent, format: .RGBA8,
29
+ colorSpace: CGColorSpaceCreateDeviceRGB())
30
+ return (0..<height).map { y in (0..<width).map { x in pixels[(y * width + x) * 4] < 128 } }
31
+ }
32
+
33
+ /// Adds a light margin of `quiet` modules on every side.
34
+ func withQuietZone(_ modules: [[Bool]], _ quiet: Int = 4) -> [[Bool]] {
35
+ let width = (modules.first?.count ?? 0) + quiet * 2
36
+ let blank = [Bool](repeating: false, count: width)
37
+ let pad = [Bool](repeating: false, count: quiet)
38
+ return Array(repeating: blank, count: quiet) + modules.map { pad + $0 + pad } + Array(repeating: blank, count: quiet)
39
+ }
40
+
41
+ /// Two module rows per text line with half blocks, black on white whatever the terminal theme.
42
+ func terminalLines(_ modules: [[Bool]]) -> [String] {
43
+ var rows = modules
44
+ if rows.count % 2 == 1 { rows.append([Bool](repeating: false, count: rows.first?.count ?? 0)) }
45
+ return stride(from: 0, to: rows.count, by: 2).map { y in
46
+ let line = zip(rows[y], rows[y + 1]).map { top, bottom -> String in
47
+ switch (top, bottom) {
48
+ case (true, true): "█"
49
+ case (true, false): "▀"
50
+ case (false, true): "▄"
51
+ case (false, false): " "
52
+ }
53
+ }.joined()
54
+ return "\u{1B}[30;107m" + line + "\u{1B}[0m"
55
+ }
56
+ }
57
+
58
+ /// Reads the modules back from the terminal lines, to prove they encode exactly the code.
59
+ func modulesFromLines(_ lines: [String]) -> [[Bool]] {
60
+ lines.flatMap { line -> [[Bool]] in
61
+ let body = line.replacingOccurrences(of: "\u{1B}[30;107m", with: "").replacingOccurrences(of: "\u{1B}[0m", with: "")
62
+ let top = body.map { $0 == "█" || $0 == "▀" }
63
+ let bottom = body.map { $0 == "█" || $0 == "▄" }
64
+ return [top, bottom]
65
+ }
66
+ }
67
+
68
+ /// Decodes a module grid with CoreImage's QR detector, drawn as the terminal shows it.
69
+ func decode(_ modules: [[Bool]]) -> String? {
70
+ let scale = 8
71
+ let height = modules.count
72
+ let width = modules.first?.count ?? 0
73
+ var pixels = [UInt8](repeating: 255, count: width * scale * height * scale)
74
+ for (y, row) in modules.enumerated() {
75
+ for (x, dark) in row.enumerated() where dark {
76
+ for dy in 0..<scale {
77
+ for dx in 0..<scale { pixels[(y * scale + dy) * width * scale + x * scale + dx] = 0 }
78
+ }
79
+ }
80
+ }
81
+ guard let provider = CGDataProvider(data: Data(pixels) as CFData),
82
+ let cgImage = CGImage(width: width * scale, height: height * scale, bitsPerComponent: 8, bitsPerPixel: 8,
83
+ bytesPerRow: width * scale, space: CGColorSpaceCreateDeviceGray(),
84
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
85
+ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent),
86
+ let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
87
+ else { return nil }
88
+ return (detector.features(in: CIImage(cgImage: cgImage)).first as? CIQRCodeFeature)?.messageString
89
+ }
90
+
91
+ /// A QR finder pattern sits in the top-left, top-right and bottom-left corners, never bottom-right.
92
+ func isUpright(_ modules: [[Bool]]) -> Bool {
93
+ let size = modules.count
94
+ func finder(_ x: Int, _ y: Int) -> Bool {
95
+ (0..<7).allSatisfy { i in modules[y][x + i] && modules[y + 6][x + i] && modules[y + i][x] && modules[y + i][x + 6] }
96
+ && modules[y + 3][x + 3]
97
+ }
98
+ guard size >= 21, modules.allSatisfy({ $0.count == size }) else { return false }
99
+ return finder(0, 0) && finder(size - 7, 0) && finder(0, size - 7) && !finder(size - 7, size - 7)
100
+ }
101
+
102
+ func fail(_ message: String, _ code: Int32) -> Never {
103
+ FileHandle.standardError.write(Data((message + "\n").utf8))
104
+ exit(code)
105
+ }
106
+
107
+ /// Check the real ancestry as well as the TTY: a captured harness PTY is still a TTY.
108
+ /// This prevents accidental transcript output, not access by other owner processes.
109
+ func ownerTerminal(environment: [String: String], tty: Bool, parent: Int32,
110
+ lookup: (Int32) -> (Int32, String)?) -> Bool {
111
+ guard tty else { return false }
112
+ for key in ["CODEX_THREAD_ID", "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "CLAUDECODE", "SUMI_SESSION_ID", "OPENCODE_SESSION_ID"] {
113
+ if let value = environment[key], !value.isEmpty { return false }
114
+ }
115
+ var pid = parent
116
+ for _ in 0..<12 {
117
+ guard let (next, executable) = lookup(pid) else { return false }
118
+ if executable == "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal" { return true }
119
+ guard ["Python", "python3", "bash", "zsh", "login", "bun", "secrets", "swift", "swift-frontend"]
120
+ .contains(URL(fileURLWithPath: executable).lastPathComponent), next > 1, next != pid else { return false }
121
+ pid = next
122
+ }
123
+ return false
124
+ }
125
+
126
+ func parentProcess(_ pid: Int32) -> (Int32, String)? {
127
+ let process = Process()
128
+ let output = Pipe()
129
+ process.executableURL = URL(fileURLWithPath: "/bin/ps")
130
+ process.arguments = ["-p", String(pid), "-o", "ppid=", "-o", "comm="]
131
+ process.standardOutput = output
132
+ process.standardError = FileHandle.nullDevice
133
+ do { try process.run() } catch { return nil }
134
+ let data = output.fileHandleForReading.readDataToEndOfFile()
135
+ process.waitUntilExit()
136
+ guard process.terminationStatus == 0, data.count <= 4096,
137
+ let text = String(data: data, encoding: .utf8) else { return nil }
138
+ let fields = text.trimmingCharacters(in: .whitespacesAndNewlines).split(maxSplits: 1, whereSeparator: { $0.isWhitespace })
139
+ guard fields.count == 2, let next = Int32(fields[0]) else { return nil }
140
+ return (next, String(fields[1]))
141
+ }
142
+
143
+ func selfTest() {
144
+ let terminal = "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal"
145
+ guard !ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { _ in (200, "/runtime/codex") }),
146
+ !ownerTerminal(environment: [:], tty: false, parent: 100, lookup: { _ in (200, terminal) }),
147
+ !ownerTerminal(environment: ["CODEX_THREAD_ID": "fixture"], tty: true, parent: 100, lookup: { _ in (200, terminal) }),
148
+ !ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { _ in nil }),
149
+ ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { pid in
150
+ pid == 100 ? (200, "/usr/bin/python3") : (300, terminal)
151
+ }) else { fail("pair-qr self-test: FAIL (owner Terminal boundary)", 1) }
152
+ // A synthetic key with characters that need escaping; not a credential.
153
+ let key = "selftest-" + UUID().uuidString + "+/=&"
154
+ let code = pairingCode(server: "https://api.hasna.com/economy", key: key)
155
+ guard let modules = qrModules(code) else { fail("pair-qr self-test: FAIL (CoreImage made no QR code)", 1) }
156
+ let trimmed = trimQuietZone(modules)
157
+ guard isUpright(trimmed) else { fail("pair-qr self-test: FAIL (the code is mirrored or rotated)", 1) }
158
+ let lines = terminalLines(withQuietZone(trimmed))
159
+ guard decode(modulesFromLines(lines)) == code else { fail("pair-qr self-test: FAIL (the terminal rendering does not decode to the code)", 1) }
160
+ print("pair-qr self-test: PASS (\(trimmed.count)x\(trimmed.count) modules, \(lines.count) terminal lines, decoded to the exact pairing code)")
161
+ }
162
+
163
+ /// CoreImage adds its own margin; drop it so the finder patterns sit at the edges.
164
+ func trimQuietZone(_ modules: [[Bool]]) -> [[Bool]] {
165
+ let rows = modules.indices.filter { modules[$0].contains(true) }
166
+ let columns = (0..<(modules.first?.count ?? 0)).filter { x in modules.contains { $0[x] } }
167
+ guard let top = rows.first, let bottom = rows.last, let left = columns.first, let right = columns.last else { return modules }
168
+ return modules[top...bottom].map { Array($0[left...right]) }
169
+ }
170
+
171
+ /// Clears the screen and the terminal's scrollback.
172
+ let clearSequence = "\u{1B}[2J\u{1B}[3J\u{1B}[H"
173
+
174
+ /// Ctrl-C, a hang-up or a kill also clear the code before exiting. Kept alive for the whole run.
175
+ var signalSources: [DispatchSourceSignal] = []
176
+
177
+ func clearOnSignals() {
178
+ for number in [SIGINT, SIGTERM, SIGHUP] {
179
+ signal(number, SIG_IGN)
180
+ let source = DispatchSource.makeSignalSource(signal: number, queue: .global())
181
+ source.setEventHandler {
182
+ FileHandle.standardOutput.write(Data(clearSequence.utf8))
183
+ exit(128 + number)
184
+ }
185
+ source.resume()
186
+ signalSources.append(source)
187
+ }
188
+ }
189
+
190
+ func show() {
191
+ let environment = ProcessInfo.processInfo.environment
192
+ guard let key = environment["ECONOMY_PAIR_CODE"], !key.isEmpty else { fail("ECONOMY_PAIR_CODE is not set", 2) }
193
+ unsetenv("ECONOMY_PAIR_CODE")
194
+ let server = environment["ECONOMY_PAIR_SERVER"] ?? "https://api.hasna.com/economy"
195
+ guard ownerTerminal(environment: environment, tty: isatty(STDOUT_FILENO) != 0 && isatty(STDIN_FILENO) != 0,
196
+ parent: getppid(), lookup: parentProcess) else {
197
+ fail("Refusing to show a connection code outside the direct owner Terminal path.", 3)
198
+ }
199
+ guard let modules = qrModules(pairingCode(server: server, key: key)) else { fail("CoreImage made no QR code", 1) }
200
+ let lines = terminalLines(withQuietZone(trimQuietZone(modules)))
201
+ guard key.range(of: "^hec_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil,
202
+ server == "https://api.hasna.com/economy" else { fail("Invalid Economy pairing request", 2) }
203
+ clearOnSignals()
204
+ let lifetime = max(1, min(290, Int(environment["ECONOMY_PAIR_TTL"] ?? "290") ?? 290))
205
+ DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(lifetime)) {
206
+ FileHandle.standardOutput.write(Data(clearSequence.utf8))
207
+ exit(0)
208
+ }
209
+ print("")
210
+ lines.forEach { print(" " + $0) }
211
+ print("\n Economy → Connect → Scan code. Server: \(server)")
212
+ print(" Press Return when the iPhone has connected; the screen and scrollback are then cleared (Ctrl-C clears too). This screen closes within five minutes.")
213
+ if let tty = FileHandle(forReadingAtPath: "/dev/tty") {
214
+ _ = tty.availableData
215
+ }
216
+ print(clearSequence, terminator: "")
217
+ }
218
+
219
+ if CommandLine.arguments.contains("--self-test") {
220
+ selfTest()
221
+ } else {
222
+ show()
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,6 +33,8 @@
33
33
  "files": [
34
34
  "dist",
35
35
  "docs",
36
+ "native/Tools/pair-qr.py",
37
+ "native/Tools/pair-qr.swift",
36
38
  "postinstall.js",
37
39
  "LICENSE",
38
40
  "README.md",