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,276 @@
1
+ /**
2
+ * src/outcomes/sync.ts — GitHub → work_items sync.
3
+ *
4
+ * Fetches PRs from GitHub and upserts into the work_items table.
5
+ * Watermark in user_config (key: "gh_watermark:<owner>/<repo>") prevents
6
+ * re-fetching already-processed PRs.
7
+ *
8
+ * Body and diff are parsed in-memory only — never stored (SEC-101).
9
+ * Skip workspaces without repo_owner/repo_name.
10
+ */
11
+ import { DEFAULT_CONCURRENCY, mapWithConcurrency } from "./pool.js";
12
+ /**
13
+ * Sync PRs for one workspace into work_items.
14
+ * Idempotent — ON CONFLICT DO UPDATE safe to call repeatedly.
15
+ * Skips workspace silently if client is disabled.
16
+ */
17
+ export async function syncWorkItems(db, client, workspace) {
18
+ if (!client.enabled)
19
+ return;
20
+ const { workspace_id, repo_owner, repo_name } = workspace;
21
+ const watermarkKey = `gh_watermark:${repo_owner}/${repo_name}`;
22
+ // Read watermark
23
+ const wmRow = db.prepare("SELECT value FROM user_config WHERE key = ?").get(watermarkKey);
24
+ const since = wmRow?.value ?? undefined;
25
+ const listResult = await client.listPRs(repo_owner, repo_name, since);
26
+ if (!listResult.ok) {
27
+ console.warn(`syncWorkItems: listPRs failed — ${listResult.reason}`);
28
+ return;
29
+ }
30
+ const prs = listResult.data;
31
+ if (prs.length === 0)
32
+ return;
33
+ const upsert = db.prepare(`
34
+ INSERT INTO work_items
35
+ (work_item_id, workspace_id, number, state, final_commit,
36
+ checks_conclusion, opened_at, merged_at, closed_at, synced_at)
37
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
38
+ ON CONFLICT(work_item_id) DO UPDATE SET
39
+ state = excluded.state,
40
+ final_commit = excluded.final_commit,
41
+ checks_conclusion = COALESCE(excluded.checks_conclusion, work_items.checks_conclusion),
42
+ merged_at = excluded.merged_at,
43
+ closed_at = excluded.closed_at,
44
+ synced_at = excluded.synced_at
45
+ `);
46
+ const ensureConfig = db.prepare(`
47
+ INSERT OR IGNORE INTO user_config (key, value, updated_at) VALUES (?, ?, ?)
48
+ `);
49
+ const updateConfig = db.prepare("UPDATE user_config SET value = ?, updated_at = ? WHERE key = ?");
50
+ const upsertBranchKey = db.prepare(`
51
+ INSERT INTO work_item_branch_keys
52
+ (work_item_id, head_ref_key, normalization_version, synced_at)
53
+ VALUES (?, ?, 'branch-v1', ?)
54
+ ON CONFLICT(work_item_id) DO UPDATE SET
55
+ head_ref_key = excluded.head_ref_key,
56
+ normalization_version = excluded.normalization_version,
57
+ synced_at = excluded.synced_at
58
+ `);
59
+ const deleteBranchKey = db.prepare("DELETE FROM work_item_branch_keys WHERE work_item_id = ?");
60
+ const now = new Date().toISOString();
61
+ let newestUpdated = since ?? "1970-01-01T00:00:00Z";
62
+ // Compute per-PR row fields synchronously, then fetch check conclusions with
63
+ // bounded concurrency (~0.77s per call; serially that was ~97s across a
64
+ // 126-PR page — pooled at DEFAULT_CONCURRENCY it drops proportionally).
65
+ const rows = prs.map((pr) => {
66
+ const workItemId = `gh:${repo_owner}/${repo_name}#${pr.number}`;
67
+ const state = pr.merged_at !== null ? "MERGED" : pr.state === "closed" ? "CLOSED" : "OPEN";
68
+ const finalCommit = pr.merge_commit_sha ?? pr.head.sha;
69
+ return { pr, workItemId, state, finalCommit };
70
+ });
71
+ const conclusions = await mapWithConcurrency(rows, DEFAULT_CONCURRENCY, (row) => client.getCheckConclusion(repo_owner, repo_name, row.pr.head.sha));
72
+ const writePR = db.transaction((row, checksConclusion, syncedAt) => {
73
+ upsert.run(row.workItemId, workspace_id, row.pr.number, row.state, row.finalCommit, checksConclusion, row.pr.created_at, row.pr.merged_at, row.pr.closed_at, syncedAt);
74
+ if (row.pr.head.refKey === null) {
75
+ deleteBranchKey.run(row.workItemId);
76
+ }
77
+ else {
78
+ upsertBranchKey.run(row.workItemId, row.pr.head.refKey, syncedAt);
79
+ }
80
+ });
81
+ for (let i = 0; i < rows.length; i++) {
82
+ const row = rows[i];
83
+ // Get check conclusion for the HEAD SHA.
84
+ // If the call fails (rate-limit, truncation, transient error), pass null so the
85
+ // COALESCE in the upsert preserves any previously-correct value rather than
86
+ // overwriting it — preventing a false OBSERVED_SUCCESS from a transient failure.
87
+ const checkResult = conclusions[i];
88
+ const checksConclusion = checkResult?.ok === true ? checkResult.data : null;
89
+ // The work-item projection and privacy-safe branch key are one unit: a
90
+ // changed head SHA can never commit alongside a stale branch key.
91
+ writePR(row, checksConclusion, now);
92
+ // Track newest updated_at for watermark
93
+ const updatedAt = row.pr.merged_at ?? row.pr.closed_at ?? row.pr.created_at;
94
+ if (updatedAt > newestUpdated)
95
+ newestUpdated = updatedAt;
96
+ }
97
+ // Advance watermark
98
+ ensureConfig.run(watermarkKey, newestUpdated, now);
99
+ updateConfig.run(newestUpdated, now, watermarkKey);
100
+ }
101
+ const STRICT_UTC_RFC3339 = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/u;
102
+ const EVIDENCE_SCAN_CHUNK = 256;
103
+ function parseStrictUtcTimestamp(value, code) {
104
+ const match = STRICT_UTC_RFC3339.exec(value);
105
+ if (match === null)
106
+ throw new Error(code);
107
+ const milliseconds = (match[2] ?? "").padEnd(3, "0");
108
+ const canonical = `${match[1]}.${milliseconds}Z`;
109
+ const epoch = Date.parse(canonical);
110
+ if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== canonical)
111
+ throw new Error(code);
112
+ return epoch;
113
+ }
114
+ function assertEvidenceRepositories(db, evidence) {
115
+ if (evidence.repositories.length === 0 ||
116
+ evidence.asOf.length === 0 ||
117
+ evidence.syncedAt.length === 0 ||
118
+ !Number.isFinite(parseStrictUtcTimestamp(evidence.asOf, "branch_backfill_as_of_invalid")) ||
119
+ !Number.isFinite(parseStrictUtcTimestamp(evidence.syncedAt, "branch_backfill_synced_at_invalid"))) {
120
+ throw new Error("branch_backfill_evidence_options_invalid");
121
+ }
122
+ const seen = new Set();
123
+ const findWorkspace = db.prepare("SELECT repo_owner, repo_name FROM workspaces WHERE workspace_id = ?");
124
+ for (const repository of evidence.repositories) {
125
+ if (seen.has(repository.workspaceId))
126
+ throw new Error("branch_backfill_allowlist_duplicate");
127
+ seen.add(repository.workspaceId);
128
+ const row = findWorkspace.get(repository.workspaceId);
129
+ if (row === undefined ||
130
+ row.repo_owner !== repository.owner ||
131
+ row.repo_name !== repository.repo) {
132
+ throw new Error("branch_backfill_allowlist_mismatch");
133
+ }
134
+ }
135
+ }
136
+ /**
137
+ * Validation-only, bounded backfill for historical work items missed by the
138
+ * 100-row incremental listing. It is deliberately not wired into the daemon.
139
+ * Existing keys are never fetched or overwritten, and failed/ineligible rows
140
+ * remain missing so a later traversal from the start can retry them.
141
+ */
142
+ export async function backfillMissingWorkItemBranchKeys(db, client, options = {}) {
143
+ const requestedLimit = options.limit ?? 100;
144
+ const limit = Math.max(1, Math.min(1000, Math.trunc(requestedLimit)));
145
+ const requestedConcurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
146
+ const concurrency = Math.max(1, Math.min(32, Math.trunc(requestedConcurrency)));
147
+ const evidence = options.evidence;
148
+ if (evidence !== undefined)
149
+ assertEvidenceRepositories(db, evidence);
150
+ const cursor = evidence?.resumeFromStart === true ? "" : (options.cursor ?? "");
151
+ const allowlistClause = evidence === undefined
152
+ ? ""
153
+ : ` AND (${evidence.repositories
154
+ .map(() => "(wi.workspace_id = ? AND ws.repo_owner = ? AND ws.repo_name = ?)")
155
+ .join(" OR ")})`;
156
+ const queryArguments = [cursor];
157
+ if (evidence !== undefined) {
158
+ for (const repository of evidence.repositories) {
159
+ queryArguments.push(repository.workspaceId, repository.owner, repository.repo);
160
+ }
161
+ }
162
+ queryArguments.push(evidence === undefined ? limit + 1 : EVIDENCE_SCAN_CHUNK);
163
+ const queriedCandidates = db
164
+ .prepare(`SELECT wi.work_item_id, wi.workspace_id, ws.repo_owner, ws.repo_name, wi.number,
165
+ wi.synced_at
166
+ FROM work_items wi
167
+ JOIN workspaces ws ON ws.workspace_id = wi.workspace_id
168
+ LEFT JOIN work_item_branch_keys bk ON bk.work_item_id = wi.work_item_id
169
+ WHERE bk.work_item_id IS NULL
170
+ AND ws.repo_owner IS NOT NULL
171
+ AND ws.repo_name IS NOT NULL
172
+ AND wi.work_item_id =
173
+ 'gh:' || ws.repo_owner || '/' || ws.repo_name || '#' || wi.number
174
+ AND wi.work_item_id > ?
175
+ ${allowlistClause}
176
+ ORDER BY wi.work_item_id
177
+ LIMIT ?`)
178
+ .all(...queryArguments);
179
+ const candidates = evidence === undefined
180
+ ? queriedCandidates
181
+ : queriedCandidates
182
+ .filter((row) => parseStrictUtcTimestamp(row.synced_at, "branch_backfill_work_item_timestamp_invalid") <= parseStrictUtcTimestamp(evidence.asOf, "branch_backfill_as_of_invalid"))
183
+ .slice(0, limit + 1);
184
+ const eligibleOverflow = candidates.length > limit;
185
+ const page = eligibleOverflow ? candidates.slice(0, limit) : candidates;
186
+ const results = await mapWithConcurrency(page, concurrency, async (row) => {
187
+ try {
188
+ const result = await client.getPRHeadKey(row.repo_owner, row.repo_name, row.number);
189
+ return result.ok
190
+ ? result
191
+ : { ok: false, failureReason: "GITHUB_READ_FAILED" };
192
+ }
193
+ catch {
194
+ return { ok: false, failureReason: "GITHUB_READ_THREW" };
195
+ }
196
+ });
197
+ const insertKey = db.prepare(`
198
+ INSERT OR IGNORE INTO work_item_branch_keys
199
+ (work_item_id, head_ref_key, normalization_version, synced_at)
200
+ VALUES (?, ?, 'branch-v1', ?)
201
+ `);
202
+ let keyed = 0;
203
+ let ineligible = 0;
204
+ let failed = 0;
205
+ const classifications = {
206
+ KEYED: 0,
207
+ INELIGIBLE: 0,
208
+ FETCH_FAILED: 0,
209
+ };
210
+ const failureReasonCounts = {
211
+ GITHUB_READ_FAILED: 0,
212
+ GITHUB_READ_THREW: 0,
213
+ };
214
+ const commitClassifications = db.transaction(() => {
215
+ for (let i = 0; i < page.length; i++) {
216
+ const result = results[i];
217
+ if (result?.ok !== true) {
218
+ failed += 1;
219
+ classifications.FETCH_FAILED += 1;
220
+ const reason = result?.failureReason ?? "GITHUB_READ_THREW";
221
+ failureReasonCounts[reason] = (failureReasonCounts[reason] ?? 0) + 1;
222
+ continue;
223
+ }
224
+ if (result.data === null) {
225
+ ineligible += 1;
226
+ classifications.INELIGIBLE += 1;
227
+ continue;
228
+ }
229
+ const info = insertKey.run(page[i]?.work_item_id, result.data, evidence?.syncedAt ?? new Date().toISOString());
230
+ if (info.changes === 1) {
231
+ keyed += 1;
232
+ classifications.KEYED += 1;
233
+ }
234
+ }
235
+ });
236
+ commitClassifications();
237
+ const scanMayContinue = evidence !== undefined && queriedCandidates.length === EVIDENCE_SCAN_CHUNK;
238
+ const checkpointCursor = eligibleOverflow
239
+ ? (page.at(-1)?.work_item_id ?? null)
240
+ : (queriedCandidates.at(-1)?.work_item_id ?? null);
241
+ const nextCursor = eligibleOverflow || scanMayContinue ? checkpointCursor : null;
242
+ if (evidence?.onPageCheckpoint !== undefined) {
243
+ await evidence.onPageCheckpoint({
244
+ afterWorkItemId: checkpointCursor,
245
+ scanned: queriedCandidates.length,
246
+ selected: page.length,
247
+ keyed,
248
+ ineligible,
249
+ failed,
250
+ });
251
+ }
252
+ return {
253
+ selected: page.length,
254
+ keyed,
255
+ ineligible,
256
+ failed,
257
+ missing: ineligible + failed,
258
+ nextCursor,
259
+ ...(evidence === undefined
260
+ ? {}
261
+ : { scanned: queriedCandidates.length, classifications, failureReasonCounts }),
262
+ };
263
+ }
264
+ /**
265
+ * Sync all mapped workspaces (those with repo_owner and repo_name set).
266
+ */
267
+ export async function syncAllWorkspaces(db, client) {
268
+ if (!client.enabled)
269
+ return;
270
+ const workspaces = db
271
+ .prepare("SELECT workspace_id, repo_owner, repo_name FROM workspaces WHERE repo_owner IS NOT NULL AND repo_name IS NOT NULL")
272
+ .all();
273
+ for (const ws of workspaces) {
274
+ await syncWorkItems(db, client, ws);
275
+ }
276
+ }
@@ -0,0 +1,182 @@
1
+ /** Live Claude Code agent measurements and confirm-gated session termination. */
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { capWeightExprSql, resolveCapReadCoeff } from "../cap-weighted.js";
4
+ import { getQueryDb } from "../db-context.js";
5
+ import { buildResponse } from "../envelope.js";
6
+ const LIVENESS_UNAVAILABLE = "liveness unknown: Claude Code CLI not found or too old";
7
+ const CACHE_MS = 30_000;
8
+ let cached = null;
9
+ function stringValue(value) {
10
+ return typeof value === "string" ? value : "";
11
+ }
12
+ function unavailable() {
13
+ const result = {
14
+ available: false,
15
+ reason: LIVENESS_UNAVAILABLE,
16
+ agents: [],
17
+ };
18
+ return buildResponse(result, { claim_kind: "OBS_PROXY", n: result.agents.length });
19
+ }
20
+ function runAgentsCommand() {
21
+ const childEnv = { ...process.env };
22
+ // biome-ignore lint/performance/noDelete: the child must not inherit Claude's nesting guard.
23
+ delete childEnv.CLAUDECODE;
24
+ for (const key of Object.keys(childEnv)) {
25
+ if (key.startsWith("CLAUDE_CODE_"))
26
+ delete childEnv[key];
27
+ }
28
+ let child;
29
+ try {
30
+ child = spawn("claude", ["agents", "--json"], {
31
+ env: childEnv,
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ });
34
+ }
35
+ catch {
36
+ return Promise.resolve(null);
37
+ }
38
+ const stdoutStream = child.stdout;
39
+ if (stdoutStream === null)
40
+ return Promise.resolve(null);
41
+ return new Promise((resolve) => {
42
+ let stdout = "";
43
+ let settled = false;
44
+ const settle = (value) => {
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ clearTimeout(timeout);
49
+ resolve(value);
50
+ };
51
+ const timeout = setTimeout(() => {
52
+ child.kill();
53
+ settle(null);
54
+ }, 5_000);
55
+ stdoutStream.on("data", (chunk) => {
56
+ stdout += chunk.toString("utf8");
57
+ });
58
+ child.once("error", () => settle(null));
59
+ child.once("close", (code) => settle(code === 0 ? stdout : null));
60
+ });
61
+ }
62
+ function isAlive(pid) {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ }
66
+ function isProcessNotFound(error) {
67
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH";
68
+ }
69
+ function pause(milliseconds) {
70
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
71
+ }
72
+ /** End an agent process only after an explicit user confirmation. */
73
+ export function endSession(pid, confirm) {
74
+ if (confirm !== true)
75
+ return { ok: false, reason: "confirmation required", status: 400 };
76
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
77
+ return { ok: false, reason: "invalid pid", status: 400 };
78
+ }
79
+ try {
80
+ isAlive(pid);
81
+ }
82
+ catch (error) {
83
+ if (isProcessNotFound(error))
84
+ return { ok: false, reason: "process not found", status: 404 };
85
+ return {
86
+ ok: false,
87
+ reason: error instanceof Error ? error.message : "unable to probe process",
88
+ status: 500,
89
+ };
90
+ }
91
+ try {
92
+ if (process.platform === "win32") {
93
+ const result = spawnSync("taskkill", ["/pid", String(pid), "/t", "/f"], {
94
+ stdio: "ignore",
95
+ });
96
+ if (result.error)
97
+ throw result.error;
98
+ if (result.status !== 0)
99
+ throw new Error("taskkill failed");
100
+ }
101
+ else {
102
+ process.kill(pid, "SIGTERM");
103
+ pause(100);
104
+ try {
105
+ isAlive(pid);
106
+ }
107
+ catch (error) {
108
+ if (isProcessNotFound(error))
109
+ return { ok: true, ended: pid, status: 200 };
110
+ throw error;
111
+ }
112
+ process.kill(pid, "SIGKILL");
113
+ }
114
+ return { ok: true, ended: pid, status: 200 };
115
+ }
116
+ catch (error) {
117
+ return {
118
+ ok: false,
119
+ reason: error instanceof Error ? error.message : "failed to end process",
120
+ status: 500,
121
+ };
122
+ }
123
+ }
124
+ /** Return active Claude Code agents enriched with local, read-only usage measurements. */
125
+ export async function getAgentsLiveness() {
126
+ if (cached !== null && Date.now() - cached.at < CACHE_MS)
127
+ return cached.result;
128
+ const stdout = await runAgentsCommand();
129
+ if (stdout === null)
130
+ return unavailable();
131
+ let rawAgents;
132
+ try {
133
+ rawAgents = JSON.parse(stdout);
134
+ }
135
+ catch {
136
+ return unavailable();
137
+ }
138
+ if (!Array.isArray(rawAgents))
139
+ return unavailable();
140
+ try {
141
+ const db = getQueryDb();
142
+ const expr = capWeightExprSql("turns", resolveCapReadCoeff(db));
143
+ const usage = db.prepare(`SELECT MAX(workspace_id) AS workspace_id,
144
+ MAX(ts) AS last_activity_ts,
145
+ COALESCE(SUM(${expr}), 0) AS cap_weighted_raw
146
+ FROM turns
147
+ WHERE session_id = ?`);
148
+ const now = Date.now();
149
+ const agents = [];
150
+ for (const rawAgent of rawAgents) {
151
+ if (typeof rawAgent !== "object" || rawAgent === null)
152
+ continue;
153
+ const agent = rawAgent;
154
+ const sessionId = stringValue(agent.sessionId);
155
+ if (sessionId.length === 0)
156
+ continue;
157
+ const usageRow = usage.get(sessionId);
158
+ const lastActivityMs = Date.parse(usageRow.last_activity_ts ?? "");
159
+ agents.push({
160
+ session_id: sessionId,
161
+ pid: typeof agent.pid === "number" && Number.isInteger(agent.pid) ? agent.pid : null,
162
+ workspace_id: usageRow.workspace_id,
163
+ cwd: stringValue(agent.cwd),
164
+ kind: stringValue(agent.kind),
165
+ status: stringValue(agent.status) || stringValue(agent.state),
166
+ name: stringValue(agent.name),
167
+ started_at: stringValue(agent.startedAt),
168
+ idle_seconds: Number.isFinite(lastActivityMs)
169
+ ? Math.floor(Math.max(0, now - lastActivityMs) / 1_000)
170
+ : 0,
171
+ cap_weighted_context_held: Math.round(usageRow.cap_weighted_raw ?? 0),
172
+ });
173
+ }
174
+ const result = { available: true, reason: null, agents };
175
+ const response = buildResponse(result, { claim_kind: "OBS_PROXY", n: result.agents.length });
176
+ cached = { at: now, result: response };
177
+ return response;
178
+ }
179
+ catch {
180
+ return unavailable();
181
+ }
182
+ }
@@ -0,0 +1,50 @@
1
+ /** OAuth-backed Claude Code burn status for the local dashboard. */
2
+ import { fetchOAuthUsage } from "../../oauth/usage.js";
3
+ import { getQueryDb } from "../db-context.js";
4
+ import { buildResponse } from "../envelope.js";
5
+ /**
6
+ * Persist the latest per-model utilization snapshot to user_config so the D4
7
+ * detector can read it synchronously (async→sync bridge, R12 calibration
8
+ * pattern). The dashboard's recurring GET /api/burn-status poll IS the recurring
9
+ * write. Never throws — a persist failure must not fail the burn-status response,
10
+ * and we only overwrite when live per-model data is present (absence leaves the
11
+ * last-known snapshot in place; D4's 24h staleness bound handles the rest).
12
+ */
13
+ function persistPerModelSnapshot(five_hour, seven_day, per_model) {
14
+ try {
15
+ const snapshot = JSON.stringify({
16
+ captured_at: new Date().toISOString(),
17
+ seven_day_util: seven_day.utilization,
18
+ five_hour_util: five_hour.utilization,
19
+ per_model,
20
+ });
21
+ getQueryDb()
22
+ .prepare(`INSERT INTO user_config (key, value, updated_at) VALUES ('per_model_snapshot', ?, ?)
23
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
24
+ .run(snapshot, new Date().toISOString());
25
+ }
26
+ catch {
27
+ // Best-effort: a persist failure never fails the response.
28
+ }
29
+ }
30
+ export async function getBurnStatus(reader = fetchOAuthUsage) {
31
+ try {
32
+ const result = await reader();
33
+ if (!result.ok) {
34
+ return buildResponse({ available: false, reason: result.reason }, { claim_kind: "OBS_PROXY", n: 0 });
35
+ }
36
+ const { five_hour, seven_day, per_model } = result.data;
37
+ if (per_model !== undefined && per_model.length > 0) {
38
+ persistPerModelSnapshot(five_hour, seven_day, per_model);
39
+ }
40
+ return buildResponse({
41
+ available: true,
42
+ five_hour,
43
+ seven_day,
44
+ ...(per_model !== undefined && per_model.length > 0 ? { per_model } : {}),
45
+ }, { claim_kind: "OBS_PROXY", n: 1 });
46
+ }
47
+ catch {
48
+ return buildResponse({ available: false, reason: "OAuth usage is unavailable." }, { claim_kind: "OBS_PROXY", n: 0 });
49
+ }
50
+ }
@@ -0,0 +1,114 @@
1
+ /** Fail-open context-budget measurement for the local hook. */
2
+ import { getQueryDb } from "../db-context.js";
3
+ import { buildResponse } from "../envelope.js";
4
+ import { DEFAULT_HOOK_CONFIG, readHookConfig } from "./hook-config.js";
5
+ const STANDARD_WINDOW = 200_000;
6
+ const LARGE_WINDOW = 1_000_000;
7
+ /**
8
+ * The transcript records only a base model id (e.g. `claude-opus-4-8`), never the
9
+ * context-window variant, so the true window is unknowable from the data alone. We take
10
+ * the user-declared window and floor it to a tier at least as large as the biggest context
11
+ * the session has actually reached — a 200k-window model can never exceed 200k tokens, so a
12
+ * session that has is provably a larger-window variant. This prevents false "near the limit"
13
+ * warnings when the declared window is left at the standard default on a large-window model.
14
+ */
15
+ function effectiveWindow(declaredWindow, maxObserved) {
16
+ let floor;
17
+ if (maxObserved > LARGE_WINDOW)
18
+ floor = maxObserved;
19
+ else if (maxObserved > STANDARD_WINDOW)
20
+ floor = LARGE_WINDOW;
21
+ else
22
+ floor = STANDARD_WINDOW;
23
+ return Math.max(declaredWindow, floor);
24
+ }
25
+ function okBudget(sessionId, reason, config) {
26
+ const window = config.context_window;
27
+ return {
28
+ stage: "ok",
29
+ context_tokens: 0,
30
+ soft_at: Math.round(config.soft_pct * window),
31
+ hard_at: Math.round(config.hard_pct * window),
32
+ window,
33
+ usage_pct: 0,
34
+ model: null,
35
+ recommended_action: "compact",
36
+ reason,
37
+ ts: null,
38
+ stale_s: null,
39
+ session_id: sessionId,
40
+ };
41
+ }
42
+ function response(data) {
43
+ return buildResponse(data, {
44
+ claim_kind: "OBS_PROXY",
45
+ n: data.ts === null ? 0 : 1,
46
+ drilldown_ids: { session_id: data.session_id },
47
+ });
48
+ }
49
+ /**
50
+ * Return a strictly fail-open budget result. This endpoint exposes only session
51
+ * identifiers and measured numbers; it never reads or returns transcript text.
52
+ */
53
+ export function getContextBudget(sessionId) {
54
+ try {
55
+ const db = getQueryDb();
56
+ const config = readHookConfig(db);
57
+ const latest = db
58
+ .prepare(`SELECT ts, model, context_tokens
59
+ FROM turns
60
+ WHERE session_id = ?
61
+ ORDER BY ts DESC, rowid DESC
62
+ LIMIT 1`)
63
+ .get(sessionId);
64
+ if (latest === undefined) {
65
+ const session = db
66
+ .prepare("SELECT 1 AS present FROM sessions WHERE session_id = ? LIMIT 1")
67
+ .get(sessionId);
68
+ return response(okBudget(sessionId, session === undefined ? "unknown_session" : "no_turns", config));
69
+ }
70
+ const timestamp = Date.parse(latest.ts);
71
+ if (!Number.isFinite(timestamp)) {
72
+ return response(okBudget(sessionId, "invalid_timestamp", config));
73
+ }
74
+ const staleSeconds = Math.max(0, (Date.now() - timestamp) / 1_000);
75
+ const maxRow = db
76
+ .prepare(`SELECT COALESCE(MAX(context_tokens), 0) AS max_ctx
77
+ FROM turns
78
+ WHERE session_id = ?`)
79
+ .get(sessionId);
80
+ const maxObserved = Math.max(0, maxRow.max_ctx ?? 0);
81
+ const window = effectiveWindow(config.context_window, maxObserved);
82
+ const softAt = Math.round(config.soft_pct * window);
83
+ const hardAt = Math.round(config.hard_pct * window);
84
+ if (staleSeconds > config.stale_s) {
85
+ const stale = okBudget(sessionId, "stale", config);
86
+ stale.soft_at = softAt;
87
+ stale.hard_at = hardAt;
88
+ stale.window = window;
89
+ stale.ts = latest.ts;
90
+ stale.model = latest.model;
91
+ stale.stale_s = staleSeconds;
92
+ return response(stale);
93
+ }
94
+ const contextTokens = Math.max(0, latest.context_tokens ?? 0);
95
+ const stage = contextTokens >= hardAt ? "hard" : contextTokens >= softAt ? "soft" : "ok";
96
+ return response({
97
+ stage,
98
+ context_tokens: contextTokens,
99
+ soft_at: softAt,
100
+ hard_at: hardAt,
101
+ window,
102
+ usage_pct: contextTokens / window,
103
+ model: latest.model,
104
+ recommended_action: stage === "hard" ? "clear" : "compact",
105
+ reason: stage,
106
+ ts: latest.ts,
107
+ stale_s: staleSeconds,
108
+ session_id: sessionId,
109
+ });
110
+ }
111
+ catch {
112
+ return response(okBudget(sessionId, "unavailable", DEFAULT_HOOK_CONFIG));
113
+ }
114
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Workspace context composition. v1 intentionally attributes only the
3
+ * always-loaded local files we can measure; tool output stays in the residual.
4
+ */
5
+ import { getQueryDb } from "../db-context.js";
6
+ import { buildResponse } from "../envelope.js";
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ /**
9
+ * Compare current CLAUDE.md/MEMORY inventory with the inclusive provisional
10
+ * seven-day context average. This deliberately never reads tool_result_bytes:
11
+ * it is byte-sized and v1 leaves tool output lumped in the residual.
12
+ */
13
+ export function getContextComposition(workspaceId) {
14
+ const db = getQueryDb();
15
+ const to = new Date();
16
+ const from = new Date(to.getTime() - 7 * DAY_MS);
17
+ const fromIso = from.toISOString();
18
+ const toIso = to.toISOString();
19
+ const inventory = db
20
+ .prepare(`SELECT COUNT(*) AS inventory_rows, COALESCE(SUM(tokens), 0) AS always_loaded
21
+ FROM context_inventory
22
+ WHERE workspace_id IN (?, '__global__')
23
+ AND component IN ('CLAUDE_MD', 'MEMORY')`)
24
+ .get(workspaceId);
25
+ const observed = db
26
+ .prepare(`SELECT COUNT(*) AS observed_turns, AVG(context_tokens) AS observed_context_tokens
27
+ FROM turns
28
+ WHERE workspace_id = ? AND ts >= ? AND ts < ?`)
29
+ .get(workspaceId, fromIso, toIso);
30
+ const alwaysLoaded = Math.max(inventory.always_loaded, 0);
31
+ const observedContext = observed.observed_context_tokens;
32
+ const residual = observedContext === null ? 0 : Math.max(observedContext - alwaysLoaded, 0);
33
+ const total = observedContext === null ? 0 : alwaysLoaded + residual;
34
+ const share = (tokens) => (total > 0 ? tokens / total : null);
35
+ const rows = [
36
+ {
37
+ key: "always_loaded",
38
+ label: "always loaded",
39
+ tokens: alwaysLoaded,
40
+ share: share(alwaysLoaded),
41
+ },
42
+ {
43
+ key: "session_residual",
44
+ label: "session history + tool outputs (not itemized in v1)",
45
+ tokens: residual,
46
+ share: share(residual),
47
+ },
48
+ ];
49
+ return buildResponse({
50
+ workspace_id: workspaceId,
51
+ observed_context_tokens: observedContext,
52
+ observed_turns: observed.observed_turns,
53
+ inventory_rows: inventory.inventory_rows,
54
+ rows,
55
+ }, {
56
+ claim_kind: "OBS_PROXY",
57
+ n: observed.observed_turns,
58
+ window: { from: fromIso, to: toIso, preset: "7d" },
59
+ qualification: {
60
+ provisional_excluded: false,
61
+ unpriced_turns: 0,
62
+ claim_kinds_count: 1,
63
+ note: "Provisional turns are included. v1 uses the current estimated CLAUDE.md and MEMORY inventory (±5–10% tokenizer error), excludes the system prompt and dynamic MCP schemas, and does not itemize the residual.",
64
+ },
65
+ drilldown_ids: { workspace_id: workspaceId },
66
+ });
67
+ }