@hasna/economy 0.5.4 → 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.
@@ -4288,8 +4288,8 @@ function sourceTimeMillis(value) {
4288
4288
  init_database();
4289
4289
  init_pricing();
4290
4290
  import { readdirSync as readdirSync2, readFileSync as readFileSync2, existsSync as existsSync5, statSync as statSync2 } from "fs";
4291
- import { homedir as homedir3 } from "os";
4292
- import { join as join4, basename } from "path";
4291
+ import { homedir as homedir4 } from "os";
4292
+ import { join as join5, basename } from "path";
4293
4293
 
4294
4294
  // src/lib/accounts-store.ts
4295
4295
  import { existsSync as existsSync4, readFileSync } from "fs";
@@ -4912,12 +4912,41 @@ function withAccount(record, account) {
4912
4912
  };
4913
4913
  }
4914
4914
 
4915
+ // src/lib/paths.ts
4916
+ import { homedir as homedir3 } from "os";
4917
+ import { join as join4 } from "path";
4918
+ function getHomeDir2() {
4919
+ return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir3();
4920
+ }
4921
+ function agentPaths() {
4922
+ const home = getHomeDir2();
4923
+ const claudeHome = process.env["CLAUDE_CONFIG_DIR"] || join4(home, ".claude");
4924
+ const codexHome = process.env["CODEX_HOME"] || join4(home, ".codex");
4925
+ return {
4926
+ claudeProjects: join4(claudeHome, "projects"),
4927
+ claudeCredentials: join4(claudeHome, ".credentials.json"),
4928
+ takumiProjects: join4(home, ".takumi", "projects"),
4929
+ codexDir: codexHome,
4930
+ codexDb: join4(codexHome, "state_5.sqlite"),
4931
+ codexAuth: join4(codexHome, "auth.json"),
4932
+ codexConfig: join4(codexHome, "config.toml"),
4933
+ geminiTmp: join4(home, ".gemini", "tmp"),
4934
+ geminiHistory: join4(home, ".gemini", "history"),
4935
+ opencodeMessages: join4(home, ".local", "share", "opencode", "storage", "message"),
4936
+ piSessions: join4(home, ".pi", "agent", "sessions"),
4937
+ hermesDir: join4(home, ".hermes"),
4938
+ hermesDb: join4(home, ".hermes", "state.db")
4939
+ };
4940
+ }
4941
+
4915
4942
  // src/ingest/claude.ts
4916
4943
  function autoDetectProject(cwd, projects) {
4917
4944
  return projects.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
4918
4945
  }
4919
- var CLAUDE_PROJECTS_DIR = process.env["HASNA_ECONOMY_CLAUDE_PROJECTS_DIR"] ?? join4(homedir3(), ".claude", "projects");
4920
- var TAKUMI_PROJECTS_DIR = process.env["HASNA_ECONOMY_TAKUMI_PROJECTS_DIR"] ?? join4(homedir3(), ".takumi", "projects");
4946
+ var TAKUMI_PROJECTS_DIR = process.env["HASNA_ECONOMY_TAKUMI_PROJECTS_DIR"] ?? join5(homedir4(), ".takumi", "projects");
4947
+ function claudeProjectsDir() {
4948
+ return process.env["HASNA_ECONOMY_CLAUDE_PROJECTS_DIR"] ?? agentPaths().claudeProjects;
4949
+ }
4921
4950
  function dirNameToPath(dirName) {
4922
4951
  return dirName.replace(/^-/, "/").replace(/-/g, "/").replace(/\/\//g, "/-");
4923
4952
  }
@@ -4927,9 +4956,9 @@ function collectJsonlFiles(projectDir) {
4927
4956
  try {
4928
4957
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
4929
4958
  if (entry.isDirectory())
4930
- walk(join4(dir, entry.name));
4959
+ walk(join5(dir, entry.name));
4931
4960
  else if (entry.name.endsWith(".jsonl"))
4932
- files.push(join4(dir, entry.name));
4961
+ files.push(join5(dir, entry.name));
4933
4962
  }
4934
4963
  } catch {
4935
4964
  collectionProblem("failed", "source_unreadable");
@@ -4938,7 +4967,7 @@ function collectJsonlFiles(projectDir) {
4938
4967
  walk(projectDir);
4939
4968
  return files;
4940
4969
  }
4941
- async function ingestClaude(db, verbose = false, projectsDir = CLAUDE_PROJECTS_DIR) {
4970
+ async function ingestClaude(db, verbose = false, projectsDir = claudeProjectsDir()) {
4942
4971
  return ingestJsonlProjects(db, projectsDir, "claude", verbose);
4943
4972
  }
4944
4973
  async function ingestTakumi(db, verbose = false, projectsDir = TAKUMI_PROJECTS_DIR) {
@@ -4960,7 +4989,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
4960
4989
  const ingestState = loadIngestState(db, agentName);
4961
4990
  const projectDirs = readdirSync2(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
4962
4991
  for (const projectDirEntry of projectDirs) {
4963
- const projectDirPath = join4(projectsDir, projectDirEntry.name);
4992
+ const projectDirPath = join5(projectsDir, projectDirEntry.name);
4964
4993
  const projectPath = dirNameToPath(projectDirEntry.name);
4965
4994
  const jsonlFiles = collectJsonlFiles(projectDirPath);
4966
4995
  for (const filePath of jsonlFiles) {
@@ -5111,24 +5140,19 @@ init_database();
5111
5140
  init_pricing();
5112
5141
  import { existsSync as existsSync6, readFileSync as readFileSync3, statSync as statSync3 } from "fs";
5113
5142
  import { createHash as createHash4 } from "crypto";
5114
- import { homedir as homedir4 } from "os";
5115
- import { join as join5, basename as basename2 } from "path";
5116
- var DEFAULT_CODEX_DB_PATH = join5(homedir4(), ".codex", "state_5.sqlite");
5117
- var DEFAULT_CODEX_CONFIG_PATH = join5(homedir4(), ".codex", "config.toml");
5118
- var DEFAULT_CODEWITH_DB_PATH = join5(homedir4(), ".codewith", "state_5.sqlite");
5119
- var DEFAULT_CODEWITH_CONFIG_PATH = join5(homedir4(), ".codewith", "config.toml");
5143
+ import { join as join6, basename as basename2 } from "path";
5120
5144
  var CODEX_INGEST_VERSION = "rollout-events-v4";
5121
5145
  function codexDbPath() {
5122
- return process.env["HASNA_ECONOMY_CODEX_DB_PATH"] ?? DEFAULT_CODEX_DB_PATH;
5146
+ return process.env["HASNA_ECONOMY_CODEX_DB_PATH"] ?? agentPaths().codexDb;
5123
5147
  }
5124
5148
  function codexConfigPath() {
5125
- return process.env["HASNA_ECONOMY_CODEX_CONFIG_PATH"] ?? DEFAULT_CODEX_CONFIG_PATH;
5149
+ return process.env["HASNA_ECONOMY_CODEX_CONFIG_PATH"] ?? agentPaths().codexConfig;
5126
5150
  }
5127
5151
  function codewithDbPath() {
5128
- return process.env["HASNA_ECONOMY_CODEWITH_DB_PATH"] ?? DEFAULT_CODEWITH_DB_PATH;
5152
+ return process.env["HASNA_ECONOMY_CODEWITH_DB_PATH"] ?? join6(getHomeDir2(), ".codewith", "state_5.sqlite");
5129
5153
  }
5130
5154
  function codewithConfigPath() {
5131
- return process.env["HASNA_ECONOMY_CODEWITH_CONFIG_PATH"] ?? DEFAULT_CODEWITH_CONFIG_PATH;
5155
+ return process.env["HASNA_ECONOMY_CODEWITH_CONFIG_PATH"] ?? join6(getHomeDir2(), ".codewith", "config.toml");
5132
5156
  }
5133
5157
  function codexSources() {
5134
5158
  const explicitCodexPath = process.env["HASNA_ECONOMY_CODEX_DB_PATH"];
@@ -5140,7 +5164,7 @@ function codexSources() {
5140
5164
  sessionPrefix: "codex",
5141
5165
  stateSource: "codex"
5142
5166
  }];
5143
- if (!explicitCodexPath || explicitCodewithPath) {
5167
+ if (!explicitCodexPath && !process.env["CODEX_HOME"] || explicitCodewithPath) {
5144
5168
  sources.push({
5145
5169
  label: "Codewith",
5146
5170
  dbPath: codewithDbPath(),
@@ -5380,9 +5404,9 @@ init_database();
5380
5404
  init_pricing();
5381
5405
  import { readdirSync as readdirSync3, readFileSync as readFileSync4, existsSync as existsSync7, statSync as statSync4 } from "fs";
5382
5406
  import { homedir as homedir5 } from "os";
5383
- import { join as join6, basename as basename3 } from "path";
5384
- var DEFAULT_GEMINI_TMP_DIR = join6(homedir5(), ".gemini", "tmp");
5385
- var DEFAULT_GEMINI_HISTORY_DIR = join6(homedir5(), ".gemini", "history");
5407
+ import { join as join7, basename as basename3 } from "path";
5408
+ var DEFAULT_GEMINI_TMP_DIR = join7(homedir5(), ".gemini", "tmp");
5409
+ var DEFAULT_GEMINI_HISTORY_DIR = join7(homedir5(), ".gemini", "history");
5386
5410
  function geminiTmpDir() {
5387
5411
  return process.env["HASNA_ECONOMY_GEMINI_TMP_DIR"] ?? DEFAULT_GEMINI_TMP_DIR;
5388
5412
  }
@@ -5404,7 +5428,7 @@ function listProjectDirs(...roots) {
5404
5428
  try {
5405
5429
  for (const entry of readdirSync3(root, { withFileTypes: true })) {
5406
5430
  if (entry.isDirectory())
5407
- dirs.add(join6(root, entry.name));
5431
+ dirs.add(join7(root, entry.name));
5408
5432
  }
5409
5433
  } catch {
5410
5434
  collectionProblem("failed", "source_unreadable");
@@ -5417,7 +5441,7 @@ function projectRoot(projectDir, chatData) {
5417
5441
  return chatData.projectPath;
5418
5442
  if (chatData.project_path)
5419
5443
  return chatData.project_path;
5420
- const rootFile = join6(projectDir, ".project_root");
5444
+ const rootFile = join7(projectDir, ".project_root");
5421
5445
  try {
5422
5446
  if (existsSync7(rootFile))
5423
5447
  return readFileSync4(rootFile, "utf-8").trim();
@@ -5442,12 +5466,12 @@ async function ingestGemini(db, verbose) {
5442
5466
  const account = await resolveAccountForAgent("gemini");
5443
5467
  const projectDirs = listProjectDirs(tmpDir, historyDir);
5444
5468
  for (const projectDir of projectDirs) {
5445
- const chatsDir = join6(projectDir, "chats");
5469
+ const chatsDir = join7(projectDir, "chats");
5446
5470
  if (!existsSync7(chatsDir))
5447
5471
  continue;
5448
5472
  let chatFiles = [];
5449
5473
  try {
5450
- chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join6(chatsDir, f));
5474
+ chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join7(chatsDir, f));
5451
5475
  } catch {
5452
5476
  collectionProblem("failed", "source_unreadable");
5453
5477
  continue;
@@ -5559,13 +5583,13 @@ init_database();
5559
5583
  init_pricing();
5560
5584
  import { existsSync as existsSync8, readFileSync as readFileSync5, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
5561
5585
  import { homedir as homedir6 } from "os";
5562
- import { join as join7 } from "path";
5563
- var OPENCODE_STORAGE = join7(homedir6(), ".local", "share", "opencode", "storage");
5586
+ import { join as join8 } from "path";
5587
+ var OPENCODE_STORAGE = join8(homedir6(), ".local", "share", "opencode", "storage");
5564
5588
  function walkJsonFiles(dir, acc = []) {
5565
5589
  if (!existsSync8(dir))
5566
5590
  return acc;
5567
5591
  for (const entry of readdirSync4(dir, { withFileTypes: true })) {
5568
- const p = join7(dir, entry.name);
5592
+ const p = join8(dir, entry.name);
5569
5593
  if (entry.isDirectory())
5570
5594
  walkJsonFiles(p, acc);
5571
5595
  else if (entry.name.endsWith(".json"))
@@ -5585,7 +5609,7 @@ function parseSessionIdFromPath(filePath) {
5585
5609
  return null;
5586
5610
  }
5587
5611
  async function ingestOpenCode(db, verbose = false, sourceDir = OPENCODE_STORAGE) {
5588
- const messageDir = join7(sourceDir, "message");
5612
+ const messageDir = join8(sourceDir, "message");
5589
5613
  if (!existsSync8(messageDir))
5590
5614
  collectionProblem("missing", "source_missing");
5591
5615
  const files = walkJsonFiles(messageDir);
@@ -5780,13 +5804,13 @@ async function ingestCursor(db, verbose = false) {
5780
5804
  init_database();
5781
5805
  import { existsSync as existsSync9, readFileSync as readFileSync6, readdirSync as readdirSync5, statSync as statSync6 } from "fs";
5782
5806
  import { homedir as homedir7 } from "os";
5783
- import { join as join8 } from "path";
5784
- var PI_SESSION_DIR = process.env["PI_CODING_AGENT_SESSION_DIR"] ?? join8(homedir7(), ".pi", "agent", "sessions");
5807
+ import { join as join9 } from "path";
5808
+ var PI_SESSION_DIR = process.env["PI_CODING_AGENT_SESSION_DIR"] ?? join9(homedir7(), ".pi", "agent", "sessions");
5785
5809
  function walkSessions(dir, acc = []) {
5786
5810
  if (!existsSync9(dir))
5787
5811
  return acc;
5788
5812
  for (const entry of readdirSync5(dir, { withFileTypes: true })) {
5789
- const p = join8(dir, entry.name);
5813
+ const p = join9(dir, entry.name);
5790
5814
  if (entry.isDirectory())
5791
5815
  walkSessions(p, acc);
5792
5816
  else if (entry.name.endsWith(".json"))
@@ -5887,8 +5911,8 @@ async function ingestPi(db, verbose = false, sourceDir = PI_SESSION_DIR) {
5887
5911
  init_database();
5888
5912
  import { existsSync as existsSync10, statSync as statSync7 } from "fs";
5889
5913
  import { homedir as homedir8 } from "os";
5890
- import { join as join9 } from "path";
5891
- var HERMES_DB = join9(homedir8(), ".hermes", "state.db");
5914
+ import { join as join10 } from "path";
5915
+ var HERMES_DB = join10(homedir8(), ".hermes", "state.db");
5892
5916
  async function ingestHermes(db, verbose = false, sourcePath = HERMES_DB) {
5893
5917
  if (!existsSync10(sourcePath)) {
5894
5918
  collectionProblem("missing", "source_missing");
@@ -5958,7 +5982,7 @@ init_database();
5958
5982
  init_pricing();
5959
5983
  import { existsSync as existsSync11 } from "fs";
5960
5984
  import { homedir as homedir9 } from "os";
5961
- import { join as join10 } from "path";
5985
+ import { join as join11 } from "path";
5962
5986
  import { homedir as pathsResolverHomedir2 } from "os";
5963
5987
  import { join as pathsResolverJoin2 } from "path";
5964
5988
  var PATHS_RESOLVER_KIND_ENV2 = {
@@ -6025,8 +6049,8 @@ function defaultLoopsDbPath() {
6025
6049
  app: "loops",
6026
6050
  home: process.env["HOME"] || process.env["USERPROFILE"] || undefined
6027
6051
  });
6028
- const legacy = join10(process.env["HOME"] || process.env["USERPROFILE"] || homedir9(), ".hasna", "loops", "loops.db");
6029
- return existsSync11(join10(resolved, "loops.db")) ? join10(resolved, "loops.db") : legacy;
6052
+ const legacy = join11(process.env["HOME"] || process.env["USERPROFILE"] || homedir9(), ".hasna", "loops", "loops.db");
6053
+ return existsSync11(join11(resolved, "loops.db")) ? join11(resolved, "loops.db") : legacy;
6030
6054
  }
6031
6055
  function loopsDbPath() {
6032
6056
  return process.env["HASNA_ECONOMY_LOOPS_DB_PATH"] ?? defaultLoopsDbPath();
@@ -6210,33 +6234,6 @@ async function ingestLoops(db, verbose = false) {
6210
6234
 
6211
6235
  // src/ingest/claude-quota.ts
6212
6236
  import { existsSync as existsSync12, readFileSync as readFileSync7 } from "fs";
6213
-
6214
- // src/lib/paths.ts
6215
- import { homedir as homedir10 } from "os";
6216
- import { join as join11 } from "path";
6217
- function getHomeDir2() {
6218
- return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir10();
6219
- }
6220
- function agentPaths() {
6221
- const home = getHomeDir2();
6222
- return {
6223
- claudeProjects: join11(home, ".claude", "projects"),
6224
- claudeCredentials: join11(home, ".claude", ".credentials.json"),
6225
- takumiProjects: join11(home, ".takumi", "projects"),
6226
- codexDir: join11(home, ".codex"),
6227
- codexDb: join11(home, ".codex", "state_5.sqlite"),
6228
- codexAuth: join11(home, ".codex", "auth.json"),
6229
- codexConfig: join11(home, ".codex", "config.toml"),
6230
- geminiTmp: join11(home, ".gemini", "tmp"),
6231
- geminiHistory: join11(home, ".gemini", "history"),
6232
- opencodeMessages: join11(home, ".local", "share", "opencode", "storage", "message"),
6233
- piSessions: join11(home, ".pi", "agent", "sessions"),
6234
- hermesDir: join11(home, ".hermes"),
6235
- hermesDb: join11(home, ".hermes", "state.db")
6236
- };
6237
- }
6238
-
6239
- // src/ingest/claude-quota.ts
6240
6237
  var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
6241
6238
  var OAUTH_BETA = "oauth-2025-04-20";
6242
6239
  function readClaudeToken() {
@@ -7429,7 +7426,7 @@ import { randomUUID } from "crypto";
7429
7426
  // package.json
7430
7427
  var package_default = {
7431
7428
  name: "@hasna/economy",
7432
- version: "0.5.4",
7429
+ version: "0.5.6",
7433
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",
7434
7431
  repository: {
7435
7432
  type: "git",
@@ -7462,6 +7459,8 @@ var package_default = {
7462
7459
  files: [
7463
7460
  "dist",
7464
7461
  "docs",
7462
+ "native/Tools/pair-qr.py",
7463
+ "native/Tools/pair-qr.swift",
7465
7464
  "postinstall.js",
7466
7465
  "LICENSE",
7467
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())