@hasna/economy 0.5.5 → 0.5.6

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.6
4
+
5
+ ### Patch Changes
6
+
7
+ - Transfer preserved original rows and temporary restore rehearsals in bounded queries while retaining complete-session checks and exact PostgreSQL row bytes.
8
+ - Accept current Apple iPhone development profiles that list iOS alongside xrOS and visionOS, while retaining exact signing identity, bundle, device and expiry checks.
9
+ - 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.
10
+
11
+ 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.
12
+
13
+ - Pass the exact Apple team and bundle verification requirement as literal codesign source for native release checks.
14
+
3
15
  ## 0.5.5
4
16
 
5
17
  ### Patch Changes
@@ -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.6",
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-m12xttw1.js";
5
5
  import {
6
6
  assessCostFromDb,
7
7
  getIngestState,
@@ -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-zs5dba3t.js";
8
8
  import {
9
9
  formatReportedUsd,
10
10
  formatSummaryCoverage,
@@ -14,7 +14,7 @@ import {
14
14
  querySavingsSummary,
15
15
  requireCurrentBillingDiffSummary,
16
16
  requireCurrentSavingsReport
17
- } from "./index-pnnqdv3r.js";
17
+ } from "./index-m12xttw1.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-qvcafabj.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-m12xttw1.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-zqsx461z.js";
10
10
  import"./index-w3bvv0ns.js";
11
- import"./index-z9psed59.js";
12
- import"./index-pnnqdv3r.js";
11
+ import"./index-zs5dba3t.js";
12
+ import"./index-m12xttw1.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-qvcafabj.js";
6
6
  import {
7
7
  getStore,
8
8
  isCloudStore
9
- } from "./index-z9psed59.js";
10
- import"./index-pnnqdv3r.js";
9
+ } from "./index-zs5dba3t.js";
10
+ import"./index-m12xttw1.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-zqsx461z.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-qvcafabj.js";
17
17
  import {
18
18
  getStore,
19
19
  isCloudStore
20
- } from "../chunks/index-z9psed59.js";
20
+ } from "../chunks/index-zs5dba3t.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-m12xttw1.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-4bh067a3.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-4bh067a3.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-4bh067a3.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-4bh067a3.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-227kxmra.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-ept6cfbe.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.6",
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.6",
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.6",
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.6",
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.6",
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,131 @@
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
+ The existing Identities human login flow remains the bootstrap when no human
34
+ session exists; pairing does not create or assume an account.
35
+
36
+ The adapter verifies Economy's `/auth/session` subject, tenant, effective scopes
37
+ and integer expiry. Viewer sign-in never requests write access. Refresh and period
38
+ changes cannot orphan an active send-code/verify/workspace operation. Write-only users
39
+ remain signed in with a specific no-report-read-access state; native Save needs
40
+ both write permission and the readback used to confirm the rule. Refresh and
41
+ account/period changes fence pending responses. Sign-out clears local state first
42
+ and reports unconfirmed remote revocation. A stale logout cannot clear a new login.
43
+
44
+ **Hosted activation is off by default.** A reviewed delivery build passes
45
+ `auth-enabled`; the build receipt records the exact activation value, version and
46
+ build. An inactive artifact is not completed native delivery. Activation also
47
+ requires the reviewed human audience, independently verified store tenant and
48
+ principal allowlist, live grants, deployed server changes and owner-initiated
49
+ sign-in acceptance. No automated real OTP/email operation was performed here.
50
+
51
+ ## Reports and exact budget recovery
52
+
53
+ Reports use a captured session with generation/expiry checks before and after
54
+ network work. Responses are capped at 8 MiB with at most four concurrent reads.
55
+ Session and period changes cancel old work and clear the report. A failed refresh
56
+ retains the original acquisition time; a 401/403 clears private data. Report cache
57
+ is memory-only. Persistent offline reports are not implemented.
58
+
59
+ Subscriptions, account breakdowns and quota observations use current unpaginated
60
+ server arrays. Sessions traverse 100-row offset pages to a short final page, with
61
+ a 2,000-record bound and visible truncation. Duplicate pages fail closed. Offset
62
+ traversal is not a consistent snapshot; open records do not establish live work.
63
+ Totals cover the complete returned population independently of search/visible
64
+ rows. Known valuation subtotals distinguish missing prices/bases from affirmative
65
+ known zero; exact acquired UTC calendar bounds remain separate from local timestamp
66
+ display, including cached reports. Unknown fees and provider coverage remain explicit.
67
+ Quota selection uses source time before station recording time, expires at reset or
68
+ 15 minutes, and a native one-second view timeline reevaluates it without network
69
+ activity. Historical readings remain labeled. Budget scope labels include every
70
+ project, agent and cost-center restriction conjunctively. Station timestamps
71
+ are reported observations, not proof of a complete online fleet.
72
+
73
+ Copy prompt is the first budget control. Its prominent action uses an explicit
74
+ foreground/background pair in both appearances. Secondary Save requires current
75
+ read and write permission again before dispatch/retry, and creates one immutable
76
+ `metered_api_usd` rule through `/ingest`, then verifies its actor-bound receipt
77
+ and authoritative budget status. It excludes subscriptions, estimates, unknown
78
+ cost bases and provider billing. Calendar windows are UTC; weeks start Sunday.
79
+ Saved rules are alert thresholds, never spending enforcement or verified delivery.
80
+
81
+ Before POST, the complete command, stable UUID key and canonical body hash are
82
+ persisted and read back in account-bound Keychain recovery storage. After response
83
+ loss or restart, only explicit receipt checks and exact same-command retries are
84
+ offered. An absent receipt does not prove an earlier request cannot commit. A new
85
+ key never resolves an uncertain command. Retained recovery history is bounded to
86
+ 40 commands per account and fails closed at the bound. This is operational intent,
87
+ not a local authoritative report store. The typed canonical JSON writer has hash
88
+ parity vectors from the actual server, including Unicode and scalar edge cases.
89
+
90
+ ## Build and test
91
+
92
+ Use an exact committed source copy on an Xcode 26 Mac. The dependency-free runner
93
+ creates synthetic HOME/cache/DerivedData, kernel-enforces read-only station/source
94
+ files, denies network, runs positive/negative write controls and checks source
95
+ hashes. It invokes installed Apple `swiftc`, SDK and XCTest directly. Refused
96
+ Xcode/SwiftPM shared temporary writes remain in task evidence; no boundary was
97
+ relaxed to make those paths work.
98
+
99
+ ```sh
100
+ python3 apps/economy/native/Tools/build-isolated.py test /absolute/.hasna/scratchpad/scratch/session/test-1
101
+ python3 apps/economy/native/Tools/build-isolated.py mac /absolute/.hasna/scratchpad/scratch/session/mac-1 Release auth-disabled
102
+ python3 apps/economy/native/Tools/build-isolated.py ios /absolute/.hasna/scratchpad/scratch/session/ios-1 Debug
103
+ python3 apps/economy/native/Tools/build-isolated.py device /absolute/.hasna/scratchpad/scratch/session/device-1 Release auth-disabled
104
+ # Reviewed delivery preparation supplies both actual release metadata values:
105
+ # ... mac FRESH_PATH Release auth-enabled VERSION BUILD
106
+ ```
107
+
108
+ Omitted version/build use fixture-only `0.1.0` / `1`, clearly recorded in the
109
+ receipt. The existing macOS identifier is `com.hasna.economy-bar`, product
110
+ `HasnaEconomy.app`, display name `HasnaEconomy`, and LSUIElement is true. No new
111
+ identifier or signing registration is created. Signed iPhone identity, signing,
112
+ notarization, installation and distribution remain coordinator operations.
113
+
114
+ Debug apps accept `-demo -demoRoute usage|spend|unknown|account|budget|offline|empty|loading|error`.
115
+ The 125-subscription fixture uses distinct provider/account IDs and no files,
116
+ network or credential storage, even in an auth-enabled Debug build. Release
117
+ must exclude fixture markers and arguments, using Debug as the positive control.
118
+ The former localhost menu-bar Sources/Tests/Tools/Resources are retained but
119
+ excluded by the replacement Package.swift target.
120
+
121
+ `Tools/capture-ios.py` requires an explicit owned simulator/device set/name,
122
+ captures light/dark and largest Dynamic Type, then shuts down only that device.
123
+ The Mac helper captures only its own fresh fixture application's windows; its
124
+ station14 SSH window-discovery failure remains open. A fixture window is not
125
+ menu-bar interaction acceptance. Actual Mac interaction, keyboard/VoiceOver,
126
+ minimum-OS fallback and signed-device acceptance are separate from compiler and
127
+ fixture checks. Owner-facing screen captures go through Designs.
128
+
129
+ Apple references: [MenuBarExtra](https://developer.apple.com/documentation/swiftui/menubarextra),
130
+ [native design adoption](https://developer.apple.com/videos/play/wwdc2025/323/),
131
+ [application delegate adaptor](https://developer.apple.com/documentation/swiftui/nsapplicationdelegateadaptor).
@@ -0,0 +1,131 @@
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 json
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ import urllib.request
17
+
18
+
19
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
20
+ def redirect_request(self, *args, **kwargs):
21
+ return None
22
+
23
+
24
+ def owner_terminal():
25
+ """Refuse captured harness PTYs, including a CLI inside a Terminal window.
26
+
27
+ This prevents accidental transcript delivery; it is not a privilege boundary
28
+ against another process running as the owner. Never screen-capture the code.
29
+ """
30
+ if not sys.stdout.isatty() or not sys.stdin.isatty():
31
+ return False
32
+ if any(os.environ.get(key) for key in ['CODEX_THREAD_ID', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
33
+ 'CLAUDECODE', 'SUMI_SESSION_ID', 'OPENCODE_SESSION_ID']):
34
+ return False
35
+ pid = os.getppid()
36
+ for _ in range(12):
37
+ try:
38
+ result = subprocess.run(['/bin/ps', '-p', str(pid), '-o', 'ppid=', '-o', 'comm='],
39
+ capture_output=True, text=True, timeout=3)
40
+ except (OSError, subprocess.TimeoutExpired):
41
+ return False
42
+ if result.returncode:
43
+ return False
44
+ fields = result.stdout.strip().split(None, 1)
45
+ if len(fields) != 2 or not fields[0].isdigit():
46
+ return False
47
+ parent, executable = int(fields[0]), fields[1]
48
+ if executable == '/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal':
49
+ return True
50
+ if Path(executable).name not in ['Python', 'python3', 'bash', 'zsh', 'login', 'bun', 'secrets']:
51
+ return False
52
+ if parent <= 1 or parent == pid:
53
+ return False
54
+ pid = parent
55
+ return False
56
+
57
+
58
+ def launch_owner_terminal(reference, tenant):
59
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_./-]{0,254}', reference):
60
+ print('Invalid Secrets reference.', file=sys.stderr)
61
+ return 2
62
+ # AppleScript receives references and paths only. Its returned text never
63
+ # contains Terminal output. No QR or credential is returned to this process.
64
+ command = shlex.join(['secrets', 'exec', reference, '--as', 'ECONOMY_HUMAN_SESSION', '--',
65
+ '/usr/bin/python3', str(Path(__file__).resolve()), '--owner-terminal', '--tenant', tenant])
66
+ script = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(command) + '\nend tell\n'
67
+ try:
68
+ result = subprocess.run(['/usr/bin/osascript', '-'], input=script, text=True, capture_output=True, timeout=30)
69
+ except (OSError, subprocess.TimeoutExpired):
70
+ print('Could not confirm the owner Terminal window. Check Terminal before retrying.', file=sys.stderr)
71
+ return 1
72
+ if result.returncode:
73
+ print('Could not open the owner Terminal window. No code was displayed by this process.', file=sys.stderr)
74
+ return 1
75
+ print('Opened the Economy pairing window on this Mac. Scan it with Economy on your iPhone.')
76
+ return 0
77
+
78
+
79
+ def main():
80
+ parser = argparse.ArgumentParser(description=__doc__)
81
+ parser.add_argument('--tenant', help='Exact workspace ID, resolved through Identities')
82
+ parser.add_argument('--self-test', action='store_true')
83
+ parser.add_argument('--session-ref', help='Authorized human-session reference in Secrets; never a value')
84
+ parser.add_argument('--owner-terminal', action='store_true', help=argparse.SUPPRESS)
85
+ args = parser.parse_args()
86
+ human = os.environ.pop('ECONOMY_HUMAN_SESSION', None)
87
+ renderer = Path(__file__).with_name('pair-qr.swift')
88
+ if args.self_test:
89
+ return subprocess.run(['/usr/bin/swift', str(renderer), '--self-test']).returncode
90
+ if not args.tenant or not re.fullmatch(r'[A-Za-z0-9_-]{1,255}', args.tenant):
91
+ print('Provide the exact authorized workspace with --tenant.', file=sys.stderr)
92
+ return 2
93
+ if not args.owner_terminal:
94
+ if not args.session_ref:
95
+ print('Use --session-ref to open the owner Terminal. Credential values are not accepted here.', file=sys.stderr)
96
+ return 3
97
+ return launch_owner_terminal(args.session_ref, args.tenant)
98
+ if args.session_ref or not owner_terminal():
99
+ print('Refusing to show a connection code outside the direct owner Terminal path.', file=sys.stderr)
100
+ return 3
101
+ if not human or not re.fullmatch(r'hst_[A-Za-z0-9_-]{43}', human):
102
+ print('An existing human Identities session is required via ECONOMY_HUMAN_SESSION. Station keys are refused.', file=sys.stderr)
103
+ return 2
104
+ request = urllib.request.Request('https://api.hasna.com/identities/v1/auth/economy-pairing/issue',
105
+ data=json.dumps({'tenant_id': args.tenant, 'device_name': 'Your iPhone'}).encode(),
106
+ headers={'Authorization': 'Bearer ' + human, 'Content-Type': 'application/json',
107
+ 'Accept': 'application/json', 'User-Agent': 'Hasna-Economy-Pairing/1.0'}, method='POST')
108
+ try:
109
+ with urllib.request.build_opener(NoRedirect).open(request, timeout=15) as response:
110
+ raw = response.read(8193)
111
+ if response.status != 200 or len(raw) > 8192:
112
+ raise ValueError('response')
113
+ value = json.loads(raw)
114
+ if not re.fullmatch(r'hec_[A-Za-z0-9_-]{43}', value.get('code', '')) or not 1 <= value.get('expires_in', 0) <= 300:
115
+ raise ValueError('response')
116
+ except Exception:
117
+ print('Unable to issue the connection code. Check the human session, workspace and hosted pairing service.', file=sys.stderr)
118
+ return 1
119
+ human = None
120
+ # Only the temporary code goes to the renderer. The human session was
121
+ # removed from the environment before any child was started.
122
+ environment = {k: v for k, v in os.environ.items() if k in
123
+ ['PATH', 'HOME', 'TMPDIR', 'LANG', 'TERM', 'DEVELOPER_DIR', 'CLANG_MODULE_CACHE_PATH']}
124
+ environment['ECONOMY_PAIR_CODE'] = value['code']
125
+ environment['ECONOMY_PAIR_SERVER'] = 'https://api.hasna.com/economy'
126
+ environment['ECONOMY_PAIR_TTL'] = str(value['expires_in'])
127
+ return subprocess.run(['/usr/bin/swift', str(renderer)], env=environment).returncode
128
+
129
+
130
+ if __name__ == '__main__':
131
+ 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.6",
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",