@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.
- package/CHANGELOG.md +18 -0
- package/README.md +3 -1
- package/dist/chunks/{index-n1wwqxfe.js → index-m12xttw1.js} +3 -1
- package/dist/chunks/{index-9arv0b1j.js → index-qvcafabj.js} +63 -66
- package/dist/chunks/{index-nheq2nrz.js → index-zqsx461z.js} +1 -1
- package/dist/chunks/{index-ct3zdefz.js → index-zs5dba3t.js} +1 -1
- package/dist/chunks/{serve-ra46ms69.js → serve-ept6cfbe.js} +2 -2
- package/dist/chunks/{tui-a1tvcw2j.js → tui-4bh067a3.js} +3 -3
- package/dist/chunks/{watch-rvd3dn6r.js → watch-227kxmra.js} +3 -3
- package/dist/cli/index.js +10 -10
- package/dist/index.js +3 -1
- package/dist/ingest/claude.d.ts.map +1 -1
- package/dist/ingest/codex.d.ts.map +1 -1
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/mcp/index.js +65 -66
- package/dist/otel/index.js +34 -39
- package/dist/server/index.js +119 -71
- package/dist/server/legacy-preservation.d.ts.map +1 -1
- package/dist/server/service-worker.js +65 -66
- package/native/README.md +131 -0
- package/native/Tools/pair-qr.py +131 -0
- package/native/Tools/pair-qr.swift +223 -0
- package/package.json +3 -1
|
@@ -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
|
|
4292
|
-
import { join as
|
|
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
|
|
4920
|
-
|
|
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(
|
|
4959
|
+
walk(join5(dir, entry.name));
|
|
4931
4960
|
else if (entry.name.endsWith(".jsonl"))
|
|
4932
|
-
files.push(
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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"] ??
|
|
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"] ??
|
|
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"] ??
|
|
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"] ??
|
|
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
|
|
5384
|
-
var DEFAULT_GEMINI_TMP_DIR =
|
|
5385
|
-
var DEFAULT_GEMINI_HISTORY_DIR =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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) =>
|
|
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
|
|
5563
|
-
var 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 =
|
|
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 =
|
|
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
|
|
5784
|
-
var PI_SESSION_DIR = process.env["PI_CODING_AGENT_SESSION_DIR"] ??
|
|
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 =
|
|
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
|
|
5891
|
-
var HERMES_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
|
|
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 =
|
|
6029
|
-
return existsSync11(
|
|
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.
|
|
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",
|
package/native/README.md
ADDED
|
@@ -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())
|