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