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,195 @@
1
+ /**
2
+ * src/outcomes/github/credential.ts — GitHub token reader.
3
+ *
4
+ * Reads "AgentWrangler-GithubToken" from Windows Credential Manager via a
5
+ * PowerShell P/Invoke call. Never logs the token. Never throws.
6
+ *
7
+ * Pattern mirrors src/oauth/usage.ts: typed {ok:true,data}|{ok:false,reason}.
8
+ *
9
+ * Clean degradation (HARD constraint from plan §4):
10
+ * - AW_GITHUB_TOKEN environment variable → {ok:true,data:trimmed token}
11
+ * - Token absent / empty blob → {ok:false,reason:'github-token-not-found'}
12
+ * - PowerShell unavailable → {ok:false,reason:'powershell-unavailable'}
13
+ * - Non-Windows → {ok:false,reason:'non-windows-platform'}
14
+ * - Any exception → {ok:false,reason:message}
15
+ * NEVER throws; NEVER crashes the daemon.
16
+ */
17
+ import { execFile } from "node:child_process";
18
+ import * as os from "node:os";
19
+ const CREDENTIAL_TARGET = "AgentWrangler-GithubToken";
20
+ // execFile's own timeout. Bumped from 10s: a cold `Add-Type` C# compile
21
+ // (csc.exe) under the CPU/IO load right after a full-corpus back-scan can
22
+ // legitimately exceed 10s. Node tries to kill the child at this bound.
23
+ const EXECFILE_TIMEOUT_MS = 15_000;
24
+ // Absolute settle bound. MUST exceed EXECFILE_TIMEOUT_MS so execFile's own
25
+ // kill gets first chance; this is the backstop for the Windows failure mode
26
+ // where execFile's timeout kill does NOT reap the grandchild (csc.exe) that
27
+ // inherited the stdout pipe — the pipe stays open, the execFile callback
28
+ // never fires, and without this guard the Promise (and outcomesRunning) would
29
+ // hang forever. See runPowerShell.
30
+ const HARD_TIMEOUT_MS = 20_000;
31
+ /**
32
+ * PowerShell script that reads the credential blob via advapi32.
33
+ * Outputs only the token on stdout (or nothing on failure).
34
+ * Avoids cmdkey (can't reveal blobs); uses CredRead P/Invoke instead.
35
+ */
36
+ const PS_SCRIPT = `
37
+ $ErrorActionPreference = 'Stop'
38
+ Add-Type @"
39
+ using System;
40
+ using System.Runtime.InteropServices;
41
+ using System.Text;
42
+ public class CredMgr {
43
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
44
+ public struct CREDENTIAL {
45
+ public int Flags;
46
+ public int Type;
47
+ public string TargetName;
48
+ public string Comment;
49
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
50
+ public int CredentialBlobSize;
51
+ public IntPtr CredentialBlob;
52
+ public int Persist;
53
+ public int AttributeCount;
54
+ public IntPtr Attributes;
55
+ public string TargetAlias;
56
+ public string UserName;
57
+ }
58
+ [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
59
+ public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
60
+ [DllImport("advapi32.dll", SetLastError=true)]
61
+ public static extern void CredFree([In] IntPtr buffer);
62
+ }
63
+ "@
64
+ $ptr = [IntPtr]::Zero
65
+ if ([CredMgr]::CredRead("${CREDENTIAL_TARGET}", 1, 0, [ref]$ptr)) {
66
+ $cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type][CredMgr+CREDENTIAL])
67
+ if ($cred.CredentialBlobSize -gt 0) {
68
+ $bytes = New-Object byte[] $cred.CredentialBlobSize
69
+ [System.Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
70
+ [CredMgr]::CredFree($ptr)
71
+ $tok = [System.Text.Encoding]::Unicode.GetString($bytes).Trim()
72
+ if ($tok.Length -gt 0) { Write-Output $tok }
73
+ } else {
74
+ [CredMgr]::CredFree($ptr)
75
+ }
76
+ }
77
+ `.trim();
78
+ function defaultRawExec(script) {
79
+ return new Promise((resolve, reject) => {
80
+ const child = execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: EXECFILE_TIMEOUT_MS, killSignal: "SIGKILL", windowsHide: true }, (err, stdout, stderr) => {
81
+ if (err) {
82
+ reject(new Error(`powershell exit ${err.code}: ${stderr || err.message}`));
83
+ return;
84
+ }
85
+ resolve(stdout.trim());
86
+ });
87
+ child.on("error", (e) => reject(e));
88
+ });
89
+ }
90
+ /**
91
+ * Run a PowerShell script and ALWAYS settle within HARD_TIMEOUT_MS.
92
+ *
93
+ * `execFile`'s own timeout is not a reliable settle guarantee on Windows: if
94
+ * the timeout kill fails to reap a grandchild (e.g. csc.exe spawned by
95
+ * Add-Type) that still holds the inherited stdout pipe, the callback never
96
+ * fires and the underlying Promise hangs forever — which would stick the
97
+ * caller's `outcomesRunning` flag `true` and silently kill all future polls.
98
+ * This wrapper races the raw exec against a hard timer that rejects, so the
99
+ * returned Promise cannot hang regardless of the child's fate.
100
+ *
101
+ * `raw` is injectable for tests.
102
+ */
103
+ export function runPowerShell(script, raw = defaultRawExec) {
104
+ return new Promise((resolve, reject) => {
105
+ let settled = false;
106
+ const timer = setTimeout(() => {
107
+ if (settled)
108
+ return;
109
+ settled = true;
110
+ reject(new Error(`powershell hard-timeout after ${HARD_TIMEOUT_MS}ms`));
111
+ }, HARD_TIMEOUT_MS);
112
+ raw(script).then((out) => {
113
+ if (settled)
114
+ return;
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ resolve(out);
118
+ }, (err) => {
119
+ if (settled)
120
+ return;
121
+ settled = true;
122
+ clearTimeout(timer);
123
+ reject(err instanceof Error ? err : new Error(String(err)));
124
+ });
125
+ });
126
+ }
127
+ // Successful token, cached for the daemon's lifetime. The first read runs at
128
+ // boot when the event loop is free (the caller defers it until after the
129
+ // synchronous back-scan); every later 10-min poll then reuses this value
130
+ // instead of re-spawning powershell.exe + recompiling Add-Type under load —
131
+ // which is precisely the condition that can make the read slow/hang. Only
132
+ // successful reads are cached, so a not-yet-configured token is still picked
133
+ // up on a later poll. (A token rotated while the daemon runs needs a restart.)
134
+ let cachedToken = null;
135
+ /**
136
+ * Read the GitHub PAT from Windows Credential Manager.
137
+ * Returns {ok:true,data:token} or {ok:false,reason} — never throws.
138
+ */
139
+ export async function readGithubToken(reader) {
140
+ if (reader !== undefined)
141
+ return reader();
142
+ const envToken = process.env.AW_GITHUB_TOKEN;
143
+ if (typeof envToken === "string") {
144
+ const token = envToken.trim();
145
+ if (token.length > 0) {
146
+ return { ok: true, data: token };
147
+ }
148
+ }
149
+ if (cachedToken !== null) {
150
+ return { ok: true, data: cachedToken };
151
+ }
152
+ if (os.platform() !== "win32") {
153
+ return { ok: false, reason: "non-windows-platform" };
154
+ }
155
+ try {
156
+ const token = await runPowerShell(PS_SCRIPT);
157
+ if (token.length === 0) {
158
+ return { ok: false, reason: "github-token-not-found" };
159
+ }
160
+ cachedToken = token;
161
+ return { ok: true, data: token };
162
+ }
163
+ catch (e) {
164
+ const msg = e instanceof Error ? e.message : String(e);
165
+ if (msg.includes("ENOENT") || msg.includes("not found") || msg.includes("spawn")) {
166
+ return { ok: false, reason: "powershell-unavailable" };
167
+ }
168
+ return { ok: false, reason: `credential-read-failed: ${msg}` };
169
+ }
170
+ }
171
+ /**
172
+ * Non-secret token status for the Settings UI. Mirrors the O9 usage-reader
173
+ * status pattern (getOAuthStatus): reports whether outcomes sync has a token
174
+ * and from which source, WITHOUT ever returning the token value. When no token
175
+ * is available the reason names the remedy (AW_GITHUB_TOKEN) so the feature is
176
+ * never silently dark. `reader` is injectable for tests.
177
+ */
178
+ export async function getGithubTokenStatus(reader) {
179
+ if (reader === undefined) {
180
+ const envToken = process.env.AW_GITHUB_TOKEN;
181
+ if (typeof envToken === "string" && envToken.trim().length > 0) {
182
+ return { configured: true, source: "env" };
183
+ }
184
+ }
185
+ // reader===undefined here means no env token; delegate to the platform read.
186
+ // An injected reader stands in for the platform credential source.
187
+ const result = await readGithubToken(reader);
188
+ if (result.ok) {
189
+ return { configured: true, source: "credential-manager" };
190
+ }
191
+ const reason = os.platform() === "win32"
192
+ ? "outcomes sync: no GitHub token — set AW_GITHUB_TOKEN, or store AgentWrangler-GithubToken in Credential Manager"
193
+ : "outcomes sync: no GitHub token — set AW_GITHUB_TOKEN";
194
+ return { configured: false, source: null, reason };
195
+ }
@@ -0,0 +1,340 @@
1
+ /**
2
+ * src/outcomes/github/gh-cli-client.ts — GitHub reads via the `gh` CLI.
3
+ *
4
+ * Why this exists (T1 round 2 — event-loop starvation):
5
+ * The fetch-based GithubSyncClient does its socket reads AND its
6
+ * AbortSignal.timeout on the daemon's single event loop. During the outcomes
7
+ * pass that loop is monopolised by the synchronous transcript-harvest / SHA
8
+ * loop, so undici's socket callbacks are starved past the abort deadline and
9
+ * even a prompt GitHub reply (~0.25s) aborts at the timeout — the pass never
10
+ * reaches writeObservedOutcomes.
11
+ *
12
+ * GhCliClient moves every GitHub read into a `gh api` child process. That
13
+ * process has its OWN event loop, so the network completes independently of
14
+ * the jammed parent loop; the parent only reads the finished result off an
15
+ * OS-buffered pipe once it drains (linkSessions' setImmediate yields keep jam
16
+ * windows short). No undici timers on the hot loop → no starvation aborts.
17
+ *
18
+ * Same contract as GithubSyncClient (implements GithubClient):
19
+ * - Disabled (token ok:false) → every method returns {ok:false} and spawns
20
+ * ZERO processes.
21
+ * - Never throws; always {ok,data}|{ok:false,reason} (clean degradation).
22
+ * - If `gh` is not installed the spawn error degrades to {ok:false,reason}.
23
+ *
24
+ * Auth: the daemon's own credential (Windows Credential Manager PAT) is passed
25
+ * to `gh` via GH_TOKEN, so AgentWrangler keeps using its configured token
26
+ * rather than whatever account `gh auth login` happens to hold.
27
+ *
28
+ * Note vs the fetch client: no ETag/304 conditional-request caching on this
29
+ * path (gh does not expose it); the sync watermark still bounds volume.
30
+ */
31
+ import { spawn } from "node:child_process";
32
+ import { aggregateCheckConclusion, normalizePRBody } from "../conclusions.js";
33
+ import { projectPRHeadKey, projectPRPage, } from "./client.js";
34
+ /**
35
+ * Termination bound for a single `gh` invocation. `gh` returns in ~0.25s;
36
+ * this generous backstop only fires if the parent loop is wedged continuously.
37
+ * Settlement follows the child's `close` event so cleanup never races open stdio.
38
+ */
39
+ const GH_HARD_TIMEOUT_MS = 20_000;
40
+ /** gh: "... (HTTP 404)" → 404. */
41
+ function parseHttpStatus(stderr) {
42
+ const m = /\(HTTP (\d+)\)/.exec(stderr);
43
+ return m?.[1] !== undefined ? Number.parseInt(m[1], 10) : undefined;
44
+ }
45
+ /** Build the shared runner around an injectable spawn implementation. */
46
+ export function createGhRunner(spawnFn = spawn) {
47
+ return (args, opts) => new Promise((resolve) => {
48
+ const env = { ...process.env, GH_PROMPT_DISABLED: "1" };
49
+ if (opts.token !== null)
50
+ env.GH_TOKEN = opts.token;
51
+ let child;
52
+ try {
53
+ child = spawnFn("gh", args, { env, windowsHide: true });
54
+ }
55
+ catch (e) {
56
+ resolve({
57
+ ok: false,
58
+ stdout: "",
59
+ stderr: e instanceof Error ? e.message : String(e),
60
+ code: null,
61
+ });
62
+ return;
63
+ }
64
+ // FIX 2: Collect Buffer chunks and decode once on close to avoid UTF-8
65
+ // corruption at pipe-chunk boundaries (e.g. multibyte chars in PR titles).
66
+ const outChunks = [];
67
+ const errChunks = [];
68
+ let stdoutBytes = 0;
69
+ let termination;
70
+ let settled = false;
71
+ const settle = (r) => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ resolve(r);
77
+ };
78
+ const timer = setTimeout(() => {
79
+ if (termination !== undefined)
80
+ return;
81
+ termination = "timeout";
82
+ child.kill("SIGKILL");
83
+ // Do not settle here. `close` proves the child and its stdio handles are done.
84
+ }, opts.timeoutMs ?? GH_HARD_TIMEOUT_MS);
85
+ child.stdout?.on("data", (d) => {
86
+ if (termination !== undefined)
87
+ return;
88
+ if (opts.maxStdoutBytes !== undefined && stdoutBytes + d.byteLength > opts.maxStdoutBytes) {
89
+ termination = "stdout-limit";
90
+ child.kill("SIGKILL");
91
+ return;
92
+ }
93
+ outChunks.push(d);
94
+ stdoutBytes += d.byteLength;
95
+ });
96
+ child.stderr?.on("data", (d) => {
97
+ errChunks.push(d);
98
+ });
99
+ // ENOENT (gh not installed) and other spawn failures arrive here.
100
+ child.on("error", (e) => {
101
+ if (termination !== undefined)
102
+ return;
103
+ const stdout = Buffer.concat(outChunks).toString("utf8");
104
+ settle({ ok: false, stdout, stderr: e.message, code: null });
105
+ });
106
+ child.on("close", (code) => {
107
+ const stdout = Buffer.concat(outChunks).toString("utf8");
108
+ const stderr = Buffer.concat(errChunks).toString("utf8");
109
+ const httpStatus = parseHttpStatus(stderr);
110
+ const terminationMarker = termination === "timeout"
111
+ ? "\n[gh hard-timeout]"
112
+ : termination === "stdout-limit"
113
+ ? "\n[gh stdout-limit]"
114
+ : "";
115
+ settle({
116
+ ok: termination === undefined && code === 0,
117
+ stdout,
118
+ stderr: `${stderr}${terminationMarker}`,
119
+ code,
120
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
121
+ ...(termination !== undefined ? { failure: termination } : {}),
122
+ });
123
+ });
124
+ // FIX 1: Guard stdin against EPIPE — if gh exits before reading stdin (e.g.
125
+ // ENOENT when gh is absent, or a fast auth failure), writing to the closed
126
+ // pipe would emit an unhandled 'error' event and crash the daemon.
127
+ child.stdin?.on("error", () => { });
128
+ if (opts.input !== undefined)
129
+ child.stdin?.write(opts.input);
130
+ child.stdin?.end();
131
+ });
132
+ }
133
+ export const defaultGhRunner = createGhRunner();
134
+ // ---------------------------------------------------------------------------
135
+ // Client
136
+ // ---------------------------------------------------------------------------
137
+ export class GhCliClient {
138
+ token;
139
+ runner;
140
+ constructor(tokenResult, runner = defaultGhRunner) {
141
+ this.token = tokenResult.ok ? tokenResult.data : null;
142
+ this.runner = runner;
143
+ }
144
+ get enabled() {
145
+ return this.token !== null;
146
+ }
147
+ disabledResult() {
148
+ return { ok: false, reason: "github-token-unset" };
149
+ }
150
+ errReason(prefix, r) {
151
+ if (r.httpStatus !== undefined)
152
+ return `${prefix}:${r.httpStatus}`;
153
+ if (/ENOENT|not recognized|no such file/i.test(r.stderr))
154
+ return `${prefix}:gh-not-found`;
155
+ return `${prefix}:${r.code ?? "spawn"}`;
156
+ }
157
+ run(args, input) {
158
+ return this.runner(args, { token: this.token, ...(input !== undefined ? { input } : {}) });
159
+ }
160
+ async listPRs(owner, repo, since) {
161
+ if (!this.enabled)
162
+ return this.disabledResult();
163
+ const qs = new URLSearchParams({
164
+ state: "all",
165
+ sort: "updated",
166
+ direction: "desc",
167
+ per_page: "100",
168
+ });
169
+ if (since !== undefined)
170
+ qs.set("since", since);
171
+ const r = await this.run(["api", `repos/${owner}/${repo}/pulls?${qs}`]);
172
+ if (!r.ok)
173
+ return { ok: false, reason: this.errReason("github-api-error", r) };
174
+ try {
175
+ return projectPRPage(JSON.parse(r.stdout));
176
+ }
177
+ catch {
178
+ return { ok: false, reason: "github-pr-payload-invalid" };
179
+ }
180
+ }
181
+ async getPRHeadKey(owner, repo, prNumber) {
182
+ if (!this.enabled)
183
+ return this.disabledResult();
184
+ const r = await this.run(["api", `repos/${owner}/${repo}/pulls/${prNumber}`]);
185
+ if (!r.ok)
186
+ return { ok: false, reason: this.errReason("github-pr-error", r) };
187
+ try {
188
+ return projectPRHeadKey(JSON.parse(r.stdout));
189
+ }
190
+ catch {
191
+ return { ok: false, reason: "github-pr-payload-invalid" };
192
+ }
193
+ }
194
+ async getCheckConclusion(owner, repo, ref) {
195
+ if (!this.enabled)
196
+ return this.disabledResult();
197
+ const r = await this.run([
198
+ "api",
199
+ `repos/${owner}/${repo}/commits/${ref}/check-runs?per_page=100`,
200
+ ]);
201
+ if (!r.ok)
202
+ return { ok: false, reason: this.errReason("github-checks-error", r) };
203
+ try {
204
+ return aggregateCheckConclusion(JSON.parse(r.stdout));
205
+ }
206
+ catch (e) {
207
+ return {
208
+ ok: false,
209
+ reason: `github-checks-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
210
+ };
211
+ }
212
+ }
213
+ async listPRCommits(owner, repo, prNumber) {
214
+ if (!this.enabled)
215
+ return this.disabledResult();
216
+ // GitHub caps commits per page at 100; PRs with >100 commits truncate the SHA set
217
+ // (pre-existing limitation, same on both transports).
218
+ const r = await this.run([
219
+ "api",
220
+ `repos/${owner}/${repo}/pulls/${prNumber}/commits?per_page=100`,
221
+ ]);
222
+ if (!r.ok)
223
+ return { ok: false, reason: this.errReason("github-commits-error", r) };
224
+ try {
225
+ const data = JSON.parse(r.stdout);
226
+ return {
227
+ ok: true,
228
+ data: data.map((c) => ({ sha: c.sha, message: c.commit?.message ?? "" })),
229
+ };
230
+ }
231
+ catch (e) {
232
+ return {
233
+ ok: false,
234
+ reason: `github-commits-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
235
+ };
236
+ }
237
+ }
238
+ async listPullsForCommit(owner, repo, sha) {
239
+ if (!this.enabled)
240
+ return this.disabledResult();
241
+ const r = await this.run(["api", `repos/${owner}/${repo}/commits/${sha}/pulls`]);
242
+ // 404 = SHA not found in this repo → no PRs (non-fatal, mirror fetch transport).
243
+ // Any other failure (auth/rate-limit/network) propagates so coverage isn't silently lowered.
244
+ if (!r.ok) {
245
+ if (r.httpStatus === 404)
246
+ return { ok: true, data: [] };
247
+ return { ok: false, reason: this.errReason("github-commit-pulls-error", r) };
248
+ }
249
+ try {
250
+ const data = JSON.parse(r.stdout);
251
+ return { ok: true, data: data.map((p) => p.number) };
252
+ }
253
+ catch (e) {
254
+ return {
255
+ ok: false,
256
+ reason: `github-commit-pulls-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
257
+ };
258
+ }
259
+ }
260
+ async getPRBody(owner, repo, prNumber) {
261
+ if (!this.enabled)
262
+ return this.disabledResult();
263
+ const r = await this.run(["api", `repos/${owner}/${repo}/pulls/${prNumber}`]);
264
+ if (!r.ok)
265
+ return { ok: false, reason: this.errReason("github-pr-error", r) };
266
+ try {
267
+ const data = JSON.parse(r.stdout);
268
+ return { ok: true, data: normalizePRBody(data) };
269
+ }
270
+ catch (e) {
271
+ return {
272
+ ok: false,
273
+ reason: `github-pr-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
274
+ };
275
+ }
276
+ }
277
+ async getPRDiff(owner, repo, prNumber) {
278
+ if (!this.enabled)
279
+ return this.disabledResult();
280
+ const r = await this.run([
281
+ "api",
282
+ `repos/${owner}/${repo}/pulls/${prNumber}`,
283
+ "-H",
284
+ "Accept: application/vnd.github.diff",
285
+ ]);
286
+ if (!r.ok)
287
+ return { ok: false, reason: this.errReason("github-diff-error", r) };
288
+ return { ok: true, data: r.stdout };
289
+ }
290
+ async getReviewThreads(owner, repo, prNumber) {
291
+ if (!this.enabled)
292
+ return this.disabledResult();
293
+ const query = `
294
+ query($owner:String!, $repo:String!, $pr:Int!, $cursor:String) {
295
+ repository(owner:$owner, name:$repo) {
296
+ pullRequest(number:$pr) {
297
+ reviewThreads(first:100, after:$cursor) {
298
+ pageInfo { hasNextPage endCursor }
299
+ nodes { id isResolved }
300
+ }
301
+ }
302
+ }
303
+ }
304
+ `;
305
+ const allThreads = [];
306
+ let cursor = null;
307
+ do {
308
+ const input = JSON.stringify({
309
+ query,
310
+ variables: { owner, repo, pr: prNumber, cursor },
311
+ });
312
+ const r = await this.run(["api", "graphql", "--input", "-"], input);
313
+ if (!r.ok)
314
+ return { ok: false, reason: this.errReason("github-graphql-error", r) };
315
+ let body;
316
+ try {
317
+ body = JSON.parse(r.stdout);
318
+ }
319
+ catch (e) {
320
+ return {
321
+ ok: false,
322
+ reason: `github-graphql-fetch-error: ${e instanceof Error ? e.message : String(e)}`,
323
+ };
324
+ }
325
+ if (body.errors !== undefined && body.errors.length > 0) {
326
+ return {
327
+ ok: false,
328
+ reason: `github-graphql-errors: ${body.errors.map((e) => e.message).join("; ")}`,
329
+ };
330
+ }
331
+ const reviewThreads = body.data?.repository?.pullRequest?.reviewThreads;
332
+ allThreads.push(...(reviewThreads?.nodes ?? []));
333
+ const pageInfo = reviewThreads?.pageInfo;
334
+ if (pageInfo?.hasNextPage !== true)
335
+ break;
336
+ cursor = pageInfo.endCursor ?? null;
337
+ } while (cursor !== null);
338
+ return { ok: true, data: allThreads };
339
+ }
340
+ }