agentwrangler 0.1.0

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.
Files changed (157) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +116 -0
  3. package/dist/apply/jobs.js +429 -0
  4. package/dist/apply/open-terminal-child.mjs +98 -0
  5. package/dist/apply/open-terminal.js +221 -0
  6. package/dist/apply/settings-gen.js +35 -0
  7. package/dist/cli/agentwrangler.js +18 -0
  8. package/dist/daemon/config.js +51 -0
  9. package/dist/daemon/http.js +258 -0
  10. package/dist/daemon/index.js +372 -0
  11. package/dist/daemon/outcomes-pass.js +82 -0
  12. package/dist/daemon/readiness.js +15 -0
  13. package/dist/daemon/router.js +756 -0
  14. package/dist/daemon/static.js +146 -0
  15. package/dist/db/migrate.js +72 -0
  16. package/dist/db/migrations/001_observe.sql +196 -0
  17. package/dist/db/migrations/002_indexes.sql +6 -0
  18. package/dist/db/migrations/003_context_inventory_history.sql +20 -0
  19. package/dist/db/migrations/004_apply_jobs.sql +17 -0
  20. package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
  21. package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
  22. package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
  23. package/dist/db/migrations/008_thinking_tokens.sql +1 -0
  24. package/dist/db/migrations/009_user_turn_count.sql +1 -0
  25. package/dist/db/migrations/010_workspace_cwd.sql +1 -0
  26. package/dist/db/migrations/011_reports.sql +1 -0
  27. package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
  28. package/dist/db/migrations/013_friction_fields.sql +5 -0
  29. package/dist/db/migrations/014_session_churn.sql +11 -0
  30. package/dist/db/migrations/015_gap_aggregates.sql +6 -0
  31. package/dist/db/open.js +30 -0
  32. package/dist/detector/benchmark-anchors.js +36 -0
  33. package/dist/detector/calibration.js +302 -0
  34. package/dist/detector/context-history-retention.js +312 -0
  35. package/dist/detector/context-probe.js +574 -0
  36. package/dist/detector/d1-source-identity.js +25 -0
  37. package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
  38. package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
  39. package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
  40. package/dist/detector/detectors/d4_model_mismatch.js +258 -0
  41. package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
  42. package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
  43. package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
  44. package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
  45. package/dist/detector/detectors/d9_idle_background_session.js +101 -0
  46. package/dist/detector/engine.js +88 -0
  47. package/dist/detector/index.js +17 -0
  48. package/dist/detector/measurement.js +426 -0
  49. package/dist/detector/practice-registry.js +259 -0
  50. package/dist/detector/registry.js +32 -0
  51. package/dist/detector/savings.js +249 -0
  52. package/dist/detector/types.js +14 -0
  53. package/dist/evidence/common/approved-input.js +632 -0
  54. package/dist/evidence/common/boundary.js +84 -0
  55. package/dist/evidence/common/canonical.js +55 -0
  56. package/dist/evidence/common/redaction.js +321 -0
  57. package/dist/evidence/common/sqlite.js +25 -0
  58. package/dist/evidence/common/state.js +29 -0
  59. package/dist/evidence/cond1/cli.js +289 -0
  60. package/dist/evidence/cond1/packet.js +407 -0
  61. package/dist/evidence/cond1/prepare.js +295 -0
  62. package/dist/evidence/cond1/score.js +349 -0
  63. package/dist/evidence/cond1/types.js +1 -0
  64. package/dist/evidence/create-approval.js +365 -0
  65. package/dist/evidence/create-scratch.js +542 -0
  66. package/dist/evidence/d7/cli.js +113 -0
  67. package/dist/evidence/d7/measure.js +193 -0
  68. package/dist/evidence/d7/types.js +1 -0
  69. package/dist/evidence/discover-approval.js +492 -0
  70. package/dist/evidence/g2/adjudicate.js +20 -0
  71. package/dist/evidence/g2/cli.js +207 -0
  72. package/dist/evidence/g2/kappa.js +39 -0
  73. package/dist/evidence/g2/pipeline.js +92 -0
  74. package/dist/evidence/g2/store.js +14 -0
  75. package/dist/evidence/github/client.js +1 -0
  76. package/dist/evidence/github/gh-cli-client.js +301 -0
  77. package/dist/evidence/r3/cli.js +209 -0
  78. package/dist/evidence/r3/evaluate.js +417 -0
  79. package/dist/evidence/r3/packet.js +162 -0
  80. package/dist/evidence/r3/prepare.js +405 -0
  81. package/dist/evidence/r3/score.js +341 -0
  82. package/dist/evidence/r3/transcript.js +155 -0
  83. package/dist/evidence/r3/types.js +4 -0
  84. package/dist/hook/context-budget-hook.mjs +138 -0
  85. package/dist/hook/danger-guard-denylist.json +27 -0
  86. package/dist/hook/danger-guard-hook.mjs +167 -0
  87. package/dist/hook/install.js +0 -0
  88. package/dist/hook/limit-burn-hook.mjs +127 -0
  89. package/dist/hook/loop-guard-hook.mjs +104 -0
  90. package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
  91. package/dist/ingest/churn-collector.js +122 -0
  92. package/dist/ingest/detector-hook.js +52 -0
  93. package/dist/ingest/discovery.js +207 -0
  94. package/dist/ingest/health.js +43 -0
  95. package/dist/ingest/index.js +28 -0
  96. package/dist/ingest/ingestor.js +509 -0
  97. package/dist/ingest/parser.js +344 -0
  98. package/dist/ingest/pricing.js +153 -0
  99. package/dist/ingest/reconcile.js +52 -0
  100. package/dist/ingest/tail.js +152 -0
  101. package/dist/ingest/types.js +24 -0
  102. package/dist/ingest/workspace-mapping.js +114 -0
  103. package/dist/oauth/anthropic-api-key.js +88 -0
  104. package/dist/oauth/count-tokens.js +86 -0
  105. package/dist/oauth/credentials.js +171 -0
  106. package/dist/oauth/judge-g2-client.js +154 -0
  107. package/dist/oauth/usage.js +167 -0
  108. package/dist/outcomes/branch-key.js +49 -0
  109. package/dist/outcomes/conclusions.js +45 -0
  110. package/dist/outcomes/derive.js +94 -0
  111. package/dist/outcomes/finding-extractors.js +131 -0
  112. package/dist/outcomes/findings.js +237 -0
  113. package/dist/outcomes/github/client.js +367 -0
  114. package/dist/outcomes/github/credential.js +195 -0
  115. package/dist/outcomes/github/gh-cli-client.js +340 -0
  116. package/dist/outcomes/linker.js +486 -0
  117. package/dist/outcomes/pool.js +24 -0
  118. package/dist/outcomes/sync.js +276 -0
  119. package/dist/query/api/agents-liveness.js +182 -0
  120. package/dist/query/api/burn-status.js +50 -0
  121. package/dist/query/api/context-budget.js +114 -0
  122. package/dist/query/api/context-composition.js +67 -0
  123. package/dist/query/api/cost-per-success.js +104 -0
  124. package/dist/query/api/delivery.js +92 -0
  125. package/dist/query/api/effectiveness.js +254 -0
  126. package/dist/query/api/efficiency-headroom.js +74 -0
  127. package/dist/query/api/headroom-trend.js +105 -0
  128. package/dist/query/api/hook-config.js +75 -0
  129. package/dist/query/api/hook-install.js +8 -0
  130. package/dist/query/api/hot-sessions.js +17 -0
  131. package/dist/query/api/idle-sessions.js +52 -0
  132. package/dist/query/api/index.js +40 -0
  133. package/dist/query/api/loop-guard.js +90 -0
  134. package/dist/query/api/offload-share.js +41 -0
  135. package/dist/query/api/outcomes.js +218 -0
  136. package/dist/query/api/overview.js +535 -0
  137. package/dist/query/api/rec-prompt.js +138 -0
  138. package/dist/query/api/recommendations-ledger.js +111 -0
  139. package/dist/query/api/recommendations.js +514 -0
  140. package/dist/query/api/reports.js +78 -0
  141. package/dist/query/api/self-churn.js +77 -0
  142. package/dist/query/api/self-percentiles.js +109 -0
  143. package/dist/query/api/session-drivers.js +153 -0
  144. package/dist/query/api/settings.js +85 -0
  145. package/dist/query/api/spend-flavor.js +234 -0
  146. package/dist/query/api/trends.js +155 -0
  147. package/dist/query/cap-weighted.js +119 -0
  148. package/dist/query/db-context.js +42 -0
  149. package/dist/query/envelope.js +71 -0
  150. package/dist/query/forecast.js +191 -0
  151. package/dist/query/settings-store.js +441 -0
  152. package/dist/query/spend.js +171 -0
  153. package/dist/query/trends.js +194 -0
  154. package/dist/ui/assets/index-DnRKgc21.css +1 -0
  155. package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
  156. package/dist/ui/index.html +39 -0
  157. package/package.json +59 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * src/ingest/tail.ts — offset-persisting byte tailer.
3
+ *
4
+ * Promoted from the proven spike (spikes/s2-live-tail/s2-core.mjs). Pure file
5
+ * logic; offsets persist in the `ingest_offsets` SQLite table.
6
+ *
7
+ * Invariants:
8
+ * - only complete lines (terminated by \n) are emitted; a partial trailing line
9
+ * waits for the next pass (offset is not advanced past it);
10
+ * - truncation (fileSize < storedOffset) ⇒ reset to 0 + re-scan;
11
+ * - rotation (head-hash mismatch) ⇒ reset to 0 + re-scan;
12
+ * - re-scans are made safe by message_id dedupe downstream (idempotent).
13
+ */
14
+ import * as crypto from "node:crypto";
15
+ import * as fs from "node:fs";
16
+ const HEAD_BYTES = 256; // bytes hashed for rotation detection
17
+ /** SHA-256 of the first n bytes of a file (or fewer if the file is shorter). */
18
+ function headHashN(filePath, n) {
19
+ let fd;
20
+ try {
21
+ fd = fs.openSync(filePath, "r");
22
+ const buf = Buffer.alloc(n);
23
+ const bytesRead = fs.readSync(fd, buf, 0, n, 0);
24
+ return crypto.createHash("sha256").update(buf.subarray(0, bytesRead)).digest("hex");
25
+ }
26
+ finally {
27
+ if (fd !== undefined)
28
+ fs.closeSync(fd);
29
+ }
30
+ }
31
+ /**
32
+ * SHA-256 of the first HEAD_BYTES bytes (or fewer if the file is shorter).
33
+ * Returns "<bytesRead>:<hexHash>" so comparisons can re-use the same byte count
34
+ * and avoid false rotation signals when a small file grows.
35
+ */
36
+ export function headHash(filePath) {
37
+ let fd;
38
+ try {
39
+ fd = fs.openSync(filePath, "r");
40
+ const buf = Buffer.alloc(HEAD_BYTES);
41
+ const n = fs.readSync(fd, buf, 0, HEAD_BYTES, 0);
42
+ const hex = crypto.createHash("sha256").update(buf.subarray(0, n)).digest("hex");
43
+ return `${n}:${hex}`;
44
+ }
45
+ finally {
46
+ if (fd !== undefined)
47
+ fs.closeSync(fd);
48
+ }
49
+ }
50
+ /**
51
+ * Tail one file from its stored offset. Returns complete lines only.
52
+ * See module header for the rotation/truncation contract.
53
+ */
54
+ export function tailFile(filePath, stored) {
55
+ let storedOffset = stored?.offset ?? 0;
56
+ let storedHead = stored?.headHash ?? null;
57
+ let stat;
58
+ try {
59
+ stat = fs.statSync(filePath);
60
+ }
61
+ catch {
62
+ return {
63
+ lines: [],
64
+ newOffset: storedOffset,
65
+ newHeadHash: storedHead ?? "",
66
+ event: null,
67
+ wasReset: false,
68
+ };
69
+ }
70
+ const fileSize = stat.size;
71
+ let event = null;
72
+ let wasReset = false;
73
+ const curHead = fileSize > 0 ? headHash(filePath) : "";
74
+ // Re-hash using the stored byte count so a growing file doesn't appear to
75
+ // rotate. headHash always width-prefixes ("<n>:<hex>"), so storedHead carries
76
+ // the byte count we hashed originally.
77
+ let headChanged = false;
78
+ if (storedHead !== null && storedHead !== "") {
79
+ const colonIdx = storedHead.indexOf(":");
80
+ const storedN = Number.parseInt(storedHead.slice(0, colonIdx), 10);
81
+ const storedHex = storedHead.slice(colonIdx + 1);
82
+ if (fileSize >= storedN) {
83
+ headChanged = headHashN(filePath, storedN) !== storedHex;
84
+ }
85
+ // fileSize < storedN: size regression — headChanged stays false; handled below.
86
+ }
87
+ if (storedOffset > fileSize) {
88
+ // Size regression always means truncation, regardless of head change.
89
+ event = "TRUNCATION";
90
+ storedOffset = 0;
91
+ wasReset = true;
92
+ storedHead = curHead;
93
+ }
94
+ else if (headChanged) {
95
+ event = "ROTATION";
96
+ storedOffset = 0;
97
+ wasReset = true;
98
+ storedHead = curHead;
99
+ }
100
+ else if (storedHead === null) {
101
+ storedHead = curHead;
102
+ }
103
+ if (fileSize === storedOffset) {
104
+ return { lines: [], newOffset: storedOffset, newHeadHash: curHead, event, wasReset };
105
+ }
106
+ const toRead = fileSize - storedOffset;
107
+ const buf = Buffer.alloc(toRead);
108
+ let bytesRead;
109
+ let fd;
110
+ try {
111
+ fd = fs.openSync(filePath, "r");
112
+ bytesRead = fs.readSync(fd, buf, 0, toRead, storedOffset);
113
+ }
114
+ finally {
115
+ if (fd !== undefined)
116
+ fs.closeSync(fd);
117
+ }
118
+ // Find last newline (0x0A is a single byte in UTF-8, so this is safe).
119
+ let lastNl = -1;
120
+ for (let i = bytesRead - 1; i >= 0; i--) {
121
+ if (buf[i] === 0x0a) {
122
+ lastNl = i;
123
+ break;
124
+ }
125
+ }
126
+ if (lastNl === -1) {
127
+ // Entire read is a partial line — hold it, do not advance.
128
+ return { lines: [], newOffset: storedOffset, newHeadHash: curHead, event, wasReset };
129
+ }
130
+ const completeStr = buf.subarray(0, lastNl).toString("utf8");
131
+ const newOffset = storedOffset + lastNl + 1;
132
+ const lines = completeStr.split("\n").filter((l) => l.length > 0);
133
+ return { lines, newOffset, newHeadHash: curHead, event, wasReset };
134
+ }
135
+ /** Read the persisted offset for a file, or null if never tailed. */
136
+ export function loadOffset(db, filePath) {
137
+ const row = db
138
+ .prepare("SELECT byte_offset, file_hash_head FROM ingest_offsets WHERE file_path = ?")
139
+ .get(filePath);
140
+ if (row === undefined)
141
+ return null;
142
+ return { offset: row.byte_offset, headHash: row.file_hash_head };
143
+ }
144
+ /** Upsert the persisted offset for a file. */
145
+ export function saveOffset(db, filePath, offset, headHash) {
146
+ db.prepare(`INSERT INTO ingest_offsets (file_path, byte_offset, file_hash_head, updated_at)
147
+ VALUES (?,?,?,?)
148
+ ON CONFLICT(file_path) DO UPDATE SET
149
+ byte_offset = excluded.byte_offset,
150
+ file_hash_head = excluded.file_hash_head,
151
+ updated_at = excluded.updated_at`).run(filePath, offset, headHash, new Date().toISOString());
152
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * src/ingest/types.ts — shared types for the ingestion pipeline (WP1).
3
+ *
4
+ * SEC-101: none of these projections carry transcript content. Content is read
5
+ * in-process for size/SHA computation and then discarded; only tokens, ids,
6
+ * sizes, model names, and structural markers survive into these shapes.
7
+ */
8
+ /** Parser projection version, stamped on every turn row (NFR-106 drift signal). */
9
+ export const PARSER_VERSION = "ingest-2";
10
+ /** EF3 long-gap threshold in seconds; UI declares it in its tooltip. */
11
+ export const LONG_GAP_THRESHOLD_S = 300;
12
+ /** Create a zeroed health-counter block. */
13
+ export function newHealthCounters() {
14
+ return {
15
+ filesSeen: 0,
16
+ filesParsed: 0,
17
+ linesQuarantined: 0,
18
+ syntheticExcluded: 0,
19
+ duplicateDrops: 0,
20
+ turnsIngested: 0,
21
+ unknownFieldKinds: {},
22
+ parserVersionMix: {},
23
+ };
24
+ }
@@ -0,0 +1,114 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ /** Parse a GitHub SSH or HTTPS remote into its canonical owner/repo pair. */
4
+ export function parseRemoteUrl(url) {
5
+ const match = /^(?:git@github\.com:|https:\/\/github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/i.exec(url.trim());
6
+ if (match === null)
7
+ return null;
8
+ const owner = match[1];
9
+ const repo = match[2]?.replace(/\.git$/i, "");
10
+ if (owner === undefined || repo === undefined || repo === "")
11
+ return null;
12
+ return { owner, repo };
13
+ }
14
+ /** Read the origin URL from a local checkout without exposing git output. */
15
+ export function defaultReadRemote(repoPath) {
16
+ try {
17
+ const result = spawnSync("git", ["-C", repoPath, "remote", "get-url", "origin"], {
18
+ encoding: "utf8",
19
+ windowsHide: true,
20
+ });
21
+ if (result.error !== undefined || result.status !== 0)
22
+ return null;
23
+ return result.stdout.trim();
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /**
30
+ * Read the recorded `cwd` (a top-level field, not transcript content) from the first line that
31
+ * carries one, scanning only the head of the file so a large transcript is never fully read.
32
+ */
33
+ export function defaultReadCwd(filePath) {
34
+ let fd;
35
+ try {
36
+ fd = fs.openSync(filePath, "r");
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ try {
42
+ const buf = Buffer.alloc(65536);
43
+ const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
44
+ for (const line of buf.toString("utf8", 0, bytes).split("\n")) {
45
+ const trimmed = line.trim();
46
+ if (trimmed === "")
47
+ continue;
48
+ try {
49
+ const rec = JSON.parse(trimmed);
50
+ if (typeof rec.cwd === "string" && rec.cwd !== "")
51
+ return rec.cwd;
52
+ }
53
+ catch {
54
+ // A truncated trailing line (or a malformed one) is skipped; cwd is in the head anyway.
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ finally {
60
+ fs.closeSync(fd);
61
+ }
62
+ }
63
+ /**
64
+ * Backfill `discovered_cwd` for workspaces that have none yet — needed on existing installs where
65
+ * the boot back-scan skips already-ingested files (so ingestor.ts never re-captures their cwd).
66
+ * Reads at most one cwd-bearing transcript per unmapped workspace; self-limiting once all are filled.
67
+ */
68
+ export function backfillDiscoveredCwd(db, files, readCwd) {
69
+ const needed = new Set(db
70
+ .prepare("SELECT project_slug FROM workspaces WHERE discovered_cwd IS NULL")
71
+ .all().map((r) => r.project_slug));
72
+ if (needed.size === 0)
73
+ return;
74
+ const setCwd = db.prepare("UPDATE workspaces SET discovered_cwd=? WHERE project_slug=? AND discovered_cwd IS NULL");
75
+ const done = new Set();
76
+ for (const f of files) {
77
+ if (!needed.has(f.projectSlug) || done.has(f.projectSlug))
78
+ continue;
79
+ const cwd = readCwd(f.filePath);
80
+ if (cwd === null)
81
+ continue;
82
+ setCwd.run(cwd, f.projectSlug);
83
+ done.add(f.projectSlug);
84
+ }
85
+ }
86
+ export function resolveWorkspaceMappings(db, opts) {
87
+ db.prepare("UPDATE workspaces SET repo_path=discovered_cwd WHERE repo_path IS NULL AND discovered_cwd IS NOT NULL").run();
88
+ const workspaces = db
89
+ .prepare("SELECT workspace_id, repo_path FROM workspaces WHERE repo_path IS NOT NULL AND repo_owner IS NULL")
90
+ .all();
91
+ const setCanonical = db.prepare("UPDATE workspaces SET repo_owner=?, repo_name=? WHERE workspace_id=? AND repo_owner IS NULL AND repo_name IS NULL");
92
+ let newlyMapped = 0;
93
+ for (const workspace of workspaces) {
94
+ // Skip known failures for the daemon lifetime. A GitHub remote added to a previously
95
+ // remote-less repo while it runs is not picked up until the next daemon restart; this is an
96
+ // acceptable tradeoff for this non-blocking perf fix.
97
+ if (opts.unresolved?.has(workspace.workspace_id))
98
+ continue;
99
+ const remote = opts.readRemote(workspace.repo_path);
100
+ if (remote === null) {
101
+ opts.unresolved?.add(workspace.workspace_id);
102
+ continue;
103
+ }
104
+ const mapping = parseRemoteUrl(remote);
105
+ if (mapping === null) {
106
+ opts.unresolved?.add(workspace.workspace_id);
107
+ continue;
108
+ }
109
+ const result = setCanonical.run(mapping.owner, mapping.repo, workspace.workspace_id);
110
+ if (result.changes === 1)
111
+ newlyMapped += 1;
112
+ }
113
+ return newlyMapped;
114
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * src/oauth/anthropic-api-key.ts — Anthropic API-key reader for the G2 judge.
3
+ *
4
+ * Resolves a raw Anthropic Console API key so the judge can authenticate with
5
+ * `x-api-key` (a separate rate-limit pool) instead of the Claude Code
6
+ * subscription OAuth token, which it shares with live Claude Code generation.
7
+ *
8
+ * Sources, first match wins:
9
+ * - ANTHROPIC_API_KEY env var → {ok:true, source:'env'}
10
+ * - Windows Credential Manager Generic cred "AgentWrangler-AnthropicKey"
11
+ * → {ok:true, source:'credential-manager'}
12
+ *
13
+ * SEC-101: the key VALUE is never logged; callers report only `source`.
14
+ * Never throws.
15
+ */
16
+ import * as os from "node:os";
17
+ import { runPowerShell } from "../outcomes/github/credential.js";
18
+ const CREDENTIAL_TARGET = "AgentWrangler-AnthropicKey";
19
+ /** CredRead P/Invoke for the Anthropic key blob (mirrors the GitHub reader). */
20
+ const PS_SCRIPT = `
21
+ $ErrorActionPreference = 'Stop'
22
+ Add-Type @"
23
+ using System;
24
+ using System.Runtime.InteropServices;
25
+ using System.Text;
26
+ public class AnthKeyCredMgr {
27
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
28
+ public struct CREDENTIAL {
29
+ public int Flags;
30
+ public int Type;
31
+ public string TargetName;
32
+ public string Comment;
33
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
34
+ public int CredentialBlobSize;
35
+ public IntPtr CredentialBlob;
36
+ public int Persist;
37
+ public int AttributeCount;
38
+ public IntPtr Attributes;
39
+ public string TargetAlias;
40
+ public string UserName;
41
+ }
42
+ [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
43
+ public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
44
+ [DllImport("advapi32.dll", SetLastError=true)]
45
+ public static extern void CredFree([In] IntPtr buffer);
46
+ }
47
+ "@
48
+ $ptr = [IntPtr]::Zero
49
+ if ([AnthKeyCredMgr]::CredRead("${CREDENTIAL_TARGET}", 1, 0, [ref]$ptr)) {
50
+ $cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][AnthKeyCredMgr+CREDENTIAL])
51
+ if ($cred.CredentialBlobSize -gt 0) {
52
+ $bytes = New-Object byte[] $cred.CredentialBlobSize
53
+ [System.Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
54
+ [AnthKeyCredMgr]::CredFree($ptr)
55
+ $key = [System.Text.Encoding]::Unicode.GetString($bytes).Trim()
56
+ if ($key.Length -gt 0) { Write-Output $key }
57
+ } else {
58
+ [AnthKeyCredMgr]::CredFree($ptr)
59
+ }
60
+ }
61
+ `.trim();
62
+ /**
63
+ * Read the Anthropic API key from the env or Windows Credential Manager.
64
+ * `raw` is injectable for tests. Never throws.
65
+ */
66
+ export async function readAnthropicApiKey(raw) {
67
+ const envKey = process.env.ANTHROPIC_API_KEY;
68
+ if (typeof envKey === "string" && envKey.trim().length > 0) {
69
+ return { ok: true, data: envKey.trim(), source: "env" };
70
+ }
71
+ if (raw === undefined && os.platform() !== "win32") {
72
+ return { ok: false, reason: "non-windows-platform" };
73
+ }
74
+ try {
75
+ const key = await runPowerShell(PS_SCRIPT, raw);
76
+ if (key.length === 0) {
77
+ return { ok: false, reason: "anthropic-key-not-found" };
78
+ }
79
+ return { ok: true, data: key, source: "credential-manager" };
80
+ }
81
+ catch (e) {
82
+ const msg = e instanceof Error ? e.message : String(e);
83
+ if (msg.includes("ENOENT") || msg.includes("not found") || msg.includes("spawn")) {
84
+ return { ok: false, reason: "powershell-unavailable" };
85
+ }
86
+ return { ok: false, reason: `credential-read-failed: ${msg}` };
87
+ }
88
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * src/oauth/count-tokens.ts — Anthropic count_tokens API client.
3
+ *
4
+ * Calls POST https://api.anthropic.com/v1/messages/count_tokens using the local
5
+ * Claude Code OAuth token. The endpoint is free (no generation) and returns
6
+ * {input_tokens: N} for a given model + message text.
7
+ *
8
+ * Used by the bytes→token calibration path (R12). Text passes through this
9
+ * call only; nothing is persisted or logged (SEC-101).
10
+ *
11
+ * Mirrors the structure of src/oauth/usage.ts.
12
+ */
13
+ import { fileCredentialSource } from "./credentials.js";
14
+ const COUNT_TOKENS_URL = "https://api.anthropic.com/v1/messages/count_tokens";
15
+ /**
16
+ * Count the tokens in `text` for the given `model` using Anthropic's free
17
+ * count_tokens endpoint, authenticated via the local OAuth credential.
18
+ *
19
+ * Returns ok:false (no network call) when credentials are missing or expired.
20
+ * Treats any non-200 (including 404 from an old model id) as ok:false.
21
+ * Never throws.
22
+ *
23
+ * @param text The text to count tokens for (tool-result sample).
24
+ * @param model A current model id (e.g. "claude-sonnet-4-6").
25
+ * @param credSource Credential source — injectable for tests.
26
+ */
27
+ export async function countTokens(text, model, credSource = fileCredentialSource) {
28
+ // 1. Read credential; fail closed if unavailable.
29
+ const credResult = credSource.read();
30
+ if (!credResult.ok) {
31
+ return { ok: false, reason: credResult.reason };
32
+ }
33
+ const { accessToken } = credResult.credential;
34
+ // 2. Call count_tokens (text lives in memory only for this call — SEC-101).
35
+ try {
36
+ const res = await fetch(COUNT_TOKENS_URL, {
37
+ method: "POST",
38
+ headers: {
39
+ Authorization: `Bearer ${accessToken}`,
40
+ "anthropic-beta": "oauth-2025-04-20",
41
+ "anthropic-version": "2023-06-01",
42
+ "content-type": "application/json",
43
+ },
44
+ body: JSON.stringify({
45
+ model,
46
+ messages: [{ role: "user", content: text }],
47
+ }),
48
+ });
49
+ if (!res.ok) {
50
+ if (res.status === 401) {
51
+ return {
52
+ ok: false,
53
+ reason: "OAuth token rejected (401) — re-login to Claude Code.",
54
+ status: 401,
55
+ };
56
+ }
57
+ if (res.status === 429) {
58
+ return {
59
+ ok: false,
60
+ reason: "Rate limited (429) — try again later.",
61
+ status: 429,
62
+ };
63
+ }
64
+ return {
65
+ ok: false,
66
+ reason: `HTTP ${res.status} from count_tokens`,
67
+ status: res.status,
68
+ };
69
+ }
70
+ const raw = await res.json();
71
+ if (typeof raw !== "object" || raw === null) {
72
+ return { ok: false, reason: "count_tokens returned an invalid response shape." };
73
+ }
74
+ const candidate = raw;
75
+ if (typeof candidate.input_tokens !== "number") {
76
+ return { ok: false, reason: "count_tokens response missing input_tokens." };
77
+ }
78
+ return { ok: true, input_tokens: candidate.input_tokens };
79
+ }
80
+ catch (e) {
81
+ return {
82
+ ok: false,
83
+ reason: `Could not reach ${COUNT_TOKENS_URL}: ${e instanceof Error ? e.message : String(e)}`,
84
+ };
85
+ }
86
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * src/oauth/credentials.ts — OAuth credential source for the usage reader.
3
+ *
4
+ * Defines a CredentialSource interface and the v1 file-based implementation
5
+ * that reads ~/.claude/.credentials.json → claudeAiOauth.
6
+ *
7
+ * NOTE: macOS Keychain source ("Claude Code-credentials") is a documented
8
+ * follow-on. Implement a KeychainCredentialSource behind this same interface
9
+ * using the Security framework via a native addon or `security` CLI.
10
+ *
11
+ * SEC-101: token VALUES are NEVER logged; only key names when tracing.
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ /**
18
+ * Derive the public auth status from a credential source without exposing the token.
19
+ * Safe to send to the browser.
20
+ */
21
+ export function getOAuthStatus(source = fileCredentialSource) {
22
+ const result = source.read();
23
+ if (!result.ok) {
24
+ return { authenticated: false, tier: null, reason: result.reason };
25
+ }
26
+ return { authenticated: true, tier: result.credential.rateLimitTier };
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // File-based source (~/.claude/.credentials.json)
30
+ // ---------------------------------------------------------------------------
31
+ /** Default credentials file path used by Claude Code. */
32
+ export const DEFAULT_CREDENTIALS_PATH = path.join(os.homedir(), ".claude", ".credentials.json");
33
+ /**
34
+ * Parse a raw `{claudeAiOauth:{…}}` JSON blob (the shape Claude Code stores in
35
+ * both the credentials file and the macOS Keychain) into a CredentialResult.
36
+ * `noun` names the source in failure reasons so each source stays honest about
37
+ * where the gap is; the file source passes "Credentials file" to preserve its
38
+ * exact historical strings (Windows byte-identical guarantee).
39
+ */
40
+ function parseOAuthBlob(raw, noun) {
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ }
45
+ catch {
46
+ return { ok: false, reason: `${noun} is not valid JSON — re-login to Claude Code.` };
47
+ }
48
+ if (typeof parsed !== "object" || parsed === null) {
49
+ return { ok: false, reason: `${noun} has unexpected shape — re-login to Claude Code.` };
50
+ }
51
+ const top = parsed;
52
+ const oauth = top.claudeAiOauth;
53
+ if (typeof oauth !== "object" || oauth === null || oauth === undefined) {
54
+ return { ok: false, reason: `${noun} is missing claudeAiOauth — re-login to Claude Code.` };
55
+ }
56
+ const { accessToken, expiresAt, subscriptionType, rateLimitTier } = oauth;
57
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
58
+ return { ok: false, reason: `${noun} has no accessToken — re-login to Claude Code.` };
59
+ }
60
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
61
+ return { ok: false, reason: `${noun} has invalid expiresAt — re-login to Claude Code.` };
62
+ }
63
+ if (Date.now() >= expiresAt) {
64
+ return { ok: false, reason: "OAuth token has expired — re-login to Claude Code." };
65
+ }
66
+ return {
67
+ ok: true,
68
+ credential: {
69
+ accessToken,
70
+ expiresAt,
71
+ subscriptionType: typeof subscriptionType === "string" ? subscriptionType : null,
72
+ rateLimitTier: typeof rateLimitTier === "string" ? rateLimitTier : null,
73
+ },
74
+ };
75
+ }
76
+ export class FileCredentialSource {
77
+ filePath;
78
+ constructor(filePath = DEFAULT_CREDENTIALS_PATH) {
79
+ this.filePath = filePath;
80
+ }
81
+ read() {
82
+ let raw;
83
+ try {
84
+ raw = fs.readFileSync(this.filePath, "utf-8");
85
+ }
86
+ catch {
87
+ return {
88
+ ok: false,
89
+ reason: "Credentials file not found — re-login to Claude Code.",
90
+ };
91
+ }
92
+ return parseOAuthBlob(raw, "Credentials file");
93
+ }
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // macOS Keychain source (NU6) — darwin-only, ordered AFTER the file source
97
+ // ---------------------------------------------------------------------------
98
+ /** The Keychain service Claude Code stores its OAuth credential under (macOS). */
99
+ export const KEYCHAIN_SERVICE = "Claude Code-credentials";
100
+ /**
101
+ * Read the credential blob from the login Keychain via the `security` CLI.
102
+ * `-w` prints only the password (the JSON blob) on stdout; a missing entry
103
+ * exits non-zero. Never throws; never logs the secret (SEC-101).
104
+ */
105
+ function defaultSecurityExec() {
106
+ try {
107
+ const result = spawnSync("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], {
108
+ encoding: "utf8",
109
+ windowsHide: true,
110
+ });
111
+ if (result.error !== undefined || result.status !== 0)
112
+ return null;
113
+ const out = result.stdout.trim();
114
+ return out.length > 0 ? out : null;
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ export class KeychainCredentialSource {
121
+ exec;
122
+ platform;
123
+ constructor(exec = defaultSecurityExec, platform = os.platform()) {
124
+ this.exec = exec;
125
+ this.platform = platform;
126
+ }
127
+ read() {
128
+ if (this.platform !== "darwin") {
129
+ return { ok: false, reason: "macOS Keychain source only applies on darwin." };
130
+ }
131
+ const raw = this.exec();
132
+ if (raw === null) {
133
+ return { ok: false, reason: "Keychain lookup failed — re-login to Claude Code." };
134
+ }
135
+ return parseOAuthBlob(raw, "Keychain credential");
136
+ }
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // Chained source — first ok wins; on total failure the reason names every gap
140
+ // ---------------------------------------------------------------------------
141
+ export class ChainedCredentialSource {
142
+ sources;
143
+ constructor(sources) {
144
+ this.sources = sources;
145
+ }
146
+ read() {
147
+ const reasons = [];
148
+ for (const source of this.sources) {
149
+ const result = source.read();
150
+ if (result.ok)
151
+ return result;
152
+ reasons.push(result.reason);
153
+ }
154
+ return {
155
+ ok: false,
156
+ reason: reasons.join(" ") || "No credential source available.",
157
+ };
158
+ }
159
+ }
160
+ /**
161
+ * Production credential source (singleton) used as the default by every
162
+ * consumer (oauth/usage.ts, oauth/count-tokens.ts, oauth/judge-g2-client.ts,
163
+ * getOAuthStatus). On macOS it chains the file source then the Keychain so a
164
+ * Keychain-only login (no ~/.claude/.credentials.json) still authenticates;
165
+ * elsewhere it is exactly a FileCredentialSource, so Windows/Linux behavior is
166
+ * byte-identical to before NU6. The `fileCredentialSource` name is retained
167
+ * because the RI-Track-1-owned usage.ts imports it verbatim.
168
+ */
169
+ export const fileCredentialSource = os.platform() === "darwin"
170
+ ? new ChainedCredentialSource([new FileCredentialSource(), new KeychainCredentialSource()])
171
+ : new FileCredentialSource();