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,221 @@
1
+ /**
2
+ * src/apply/open-terminal.ts — O11 Apply Console phase 1 (Option B).
3
+ *
4
+ * "Open in Claude Code ↗": launch the user's real terminal, detached, in a
5
+ * recommendation's workspace folder running an interactive `claude` session
6
+ * seeded with the INT-2 prompt artifact. The daemon NEVER runs the edit — the
7
+ * user's own session is the permission surface and git is the rollback. This
8
+ * module only spawns a detached terminal; it writes nothing to apply_jobs.
9
+ *
10
+ * Two invariants fixed by the 2026-09-04 spike (spec-apply-console.md §5):
11
+ * - Q3 env strip: a detached child inherits the daemon's env, which carries
12
+ * CLAUDECODE=1 + CLAUDE_CODE_* — those trip the nested-claude guard crash
13
+ * (DR1). stripClaudeEnv() removes them (plus the CLAUDE_PID/PLUGIN_DATA/
14
+ * EFFORT runtime residuals a fresh session must not inherit).
15
+ * - Q4 no shell interpolation: the pinned CLI has no --append-system-prompt-file,
16
+ * so the prompt is written to a temp file and only the file PATH rides the
17
+ * terminal's command line. A tiny node wrapper (open-terminal-child.mjs)
18
+ * reads the file and execs claude with the prompt as a single argv element,
19
+ * so prompt CONTENT never touches any shell/command-line parser.
20
+ */
21
+ import { spawn as realSpawn } from "node:child_process";
22
+ import * as fs from "node:fs";
23
+ import * as os from "node:os";
24
+ import * as path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { getRecommendationCard } from "../query/api/recommendations.js";
27
+ import { getQueryDb } from "../query/db-context.js";
28
+ const WRAPPER_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "open-terminal-child.mjs");
29
+ /**
30
+ * Strip the Claude Code runtime env vars from a child's environment. Removing
31
+ * CLAUDECODE is what prevents the nested-claude guard crash (DR1); the rest are
32
+ * parent-session runtime state a fresh interactive session must not inherit.
33
+ */
34
+ export function stripClaudeEnv(env) {
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(env)) {
37
+ if (k === "CLAUDECODE")
38
+ continue;
39
+ if (k.startsWith("CLAUDE_CODE_"))
40
+ continue;
41
+ if (k === "CLAUDE_PID" || k === "CLAUDE_PLUGIN_DATA" || k === "CLAUDE_EFFORT")
42
+ continue;
43
+ out[k] = v;
44
+ }
45
+ return out;
46
+ }
47
+ /** PATH-search a launcher command; returns an absolute path or null. */
48
+ function defaultResolveCommand(command, env) {
49
+ if (path.isAbsolute(command))
50
+ return fs.existsSync(command) ? command : null;
51
+ const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter((d) => d.length > 0);
52
+ // cmd.exe always lives in System32 even if PATH is unusual.
53
+ if (process.platform === "win32" && env.SystemRoot) {
54
+ dirs.push(path.join(env.SystemRoot, "System32"));
55
+ }
56
+ const exts = process.platform === "win32"
57
+ ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.toLowerCase())
58
+ : [""];
59
+ for (const dir of dirs) {
60
+ if (path.extname(command) !== "") {
61
+ const p = path.join(dir, command);
62
+ if (fs.existsSync(p))
63
+ return p;
64
+ continue;
65
+ }
66
+ for (const ext of exts) {
67
+ const p = path.join(dir, command + ext);
68
+ if (fs.existsSync(p))
69
+ return p;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ /** Shell-quote a single token for an AppleScript `do script` string (macOS arm, untested). */
75
+ function shq(s) {
76
+ return `'${s.replace(/'/g, "'\\''")}'`;
77
+ }
78
+ function candidatesFor(platform) {
79
+ const child = (ctx) => [
80
+ ctx.nodePath,
81
+ WRAPPER_PATH,
82
+ ctx.promptFile,
83
+ ctx.cwd,
84
+ ];
85
+ if (platform === "win32") {
86
+ return [
87
+ { launcher: "wt", command: "wt.exe", args: (ctx) => ["-d", ctx.cwd, ...child(ctx)] },
88
+ {
89
+ launcher: "cmd",
90
+ command: "cmd.exe",
91
+ args: (ctx) => ["/c", "start", "", "/d", ctx.cwd, ...child(ctx)],
92
+ },
93
+ ];
94
+ }
95
+ if (platform === "darwin") {
96
+ // UNTESTED (spec §5 Q4): no macOS host this session. Only file PATHS are
97
+ // interpolated into the AppleScript string; prompt content stays in the file.
98
+ return [
99
+ {
100
+ launcher: "osascript",
101
+ command: "osascript",
102
+ args: (ctx) => [
103
+ "-e",
104
+ `tell application "Terminal" to do script "cd ${shq(ctx.cwd)} && ${shq(ctx.nodePath)} ${shq(WRAPPER_PATH)} ${shq(ctx.promptFile)} ${shq(ctx.cwd)}"`,
105
+ ],
106
+ },
107
+ ];
108
+ }
109
+ // linux + others — UNTESTED (spec §5 Q4). argv arrays, no shell.
110
+ return [
111
+ {
112
+ launcher: "x-terminal-emulator",
113
+ command: "x-terminal-emulator",
114
+ args: (ctx) => ["-e", ...child(ctx)],
115
+ },
116
+ { launcher: "gnome-terminal", command: "gnome-terminal", args: (ctx) => ["--", ...child(ctx)] },
117
+ { launcher: "xterm", command: "xterm", args: (ctx) => ["-e", ...child(ctx)] },
118
+ ];
119
+ }
120
+ function defaultDeps() {
121
+ const env = process.env;
122
+ return {
123
+ spawn: realSpawn,
124
+ platform: process.platform,
125
+ env,
126
+ tmpRoot: os.tmpdir(),
127
+ nodePath: process.execPath,
128
+ resolveCommand: (command) => defaultResolveCommand(command, env),
129
+ };
130
+ }
131
+ /**
132
+ * Launch a detached terminal in `cwd` running an interactive claude seeded with
133
+ * `prompt`. Pure w.r.t. the DB — takes cwd + prompt directly. Injectable deps
134
+ * make terminal selection, env strip, and prompt hand-off unit-testable.
135
+ */
136
+ export function openTerminal(input, deps = {}) {
137
+ const d = { ...defaultDeps(), ...deps };
138
+ const { cwd, prompt } = input;
139
+ if (typeof prompt !== "string" || prompt.length === 0) {
140
+ return { launched: false, reason: "No prompt to seed — Copy prompt instead." };
141
+ }
142
+ // Validate absoluteness against the TARGET platform, not the host: a daemon on
143
+ // Linux (or a cross-platform CI run) must still recognize a Windows `C:\…` cwd.
144
+ const targetPath = d.platform === "win32" ? path.win32 : path.posix;
145
+ if (typeof cwd !== "string" || cwd.length === 0 || !targetPath.isAbsolute(cwd)) {
146
+ return { launched: false, reason: "Workspace folder is not a valid absolute path." };
147
+ }
148
+ const candidate = candidatesFor(d.platform).find((c) => d.resolveCommand(c.command) !== null);
149
+ if (candidate === undefined) {
150
+ return { launched: false, reason: "No terminal emulator found — Copy prompt instead." };
151
+ }
152
+ const resolved = d.resolveCommand(candidate.command);
153
+ // Write the prompt to a temp file (Q4). The wrapper unlinks it after reading,
154
+ // so prompt content is transient (SEC-101) and never rides a command line.
155
+ const promptDir = fs.mkdtempSync(path.join(d.tmpRoot, "aw-open-"));
156
+ const promptFile = path.join(promptDir, "prompt.txt");
157
+ fs.writeFileSync(promptFile, prompt, "utf8");
158
+ const args = candidate.args({ nodePath: d.nodePath, promptFile, cwd });
159
+ try {
160
+ const child = d.spawn(resolved, args, {
161
+ cwd,
162
+ detached: true,
163
+ stdio: "ignore",
164
+ windowsHide: false,
165
+ env: stripClaudeEnv(d.env),
166
+ });
167
+ child.unref();
168
+ }
169
+ catch (e) {
170
+ try {
171
+ fs.rmSync(promptDir, { recursive: true, force: true });
172
+ }
173
+ catch {
174
+ // best-effort cleanup
175
+ }
176
+ return {
177
+ launched: false,
178
+ reason: `Couldn't start ${candidate.launcher} — Copy prompt instead. (${e instanceof Error ? e.message : String(e)})`,
179
+ };
180
+ }
181
+ return { launched: true, launcher: candidate.launcher };
182
+ }
183
+ /** Persisted counts-only demand signal for Option A (spec §4/§7). SEC-101: a scalar. */
184
+ function incrementOpenTerminalCount(db) {
185
+ db.prepare(`INSERT INTO user_config (key, value, updated_at)
186
+ VALUES ('open_terminal_click_count', '1', ?)
187
+ ON CONFLICT(key) DO UPDATE
188
+ SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT), updated_at = excluded.updated_at`).run(new Date().toISOString());
189
+ }
190
+ /**
191
+ * Resolve a recommendation's workspace folder and open a terminal there. The
192
+ * cwd comes from workspaces.repo_path (server-side) so reach is every
193
+ * workspace-scoped rec with a local folder, not just file-ref recs. `prompt` is
194
+ * the UI-built INT-2 artifact (the daemon is fenced off from the UI templates).
195
+ */
196
+ export function openTerminalForRec(recId, prompt, deps = {}) {
197
+ const db = getQueryDb();
198
+ const rec = getRecommendationCard(recId);
199
+ if (rec === null)
200
+ return { launched: false, reason: "Recommendation not found." };
201
+ if (rec.scope_workspace_id === null) {
202
+ return {
203
+ launched: false,
204
+ reason: "This is a cross-workspace recommendation with no single folder — Copy prompt instead.",
205
+ };
206
+ }
207
+ const row = db
208
+ .prepare("SELECT repo_path FROM workspaces WHERE workspace_id = ?")
209
+ .get(rec.scope_workspace_id);
210
+ const cwd = row?.repo_path ?? null;
211
+ if (cwd === null || !path.isAbsolute(cwd) || !fs.existsSync(cwd)) {
212
+ return {
213
+ launched: false,
214
+ reason: "This recommendation's workspace folder isn't available locally — Copy prompt instead.",
215
+ };
216
+ }
217
+ const result = openTerminal({ cwd, prompt }, deps);
218
+ if (result.launched)
219
+ incrementOpenTerminalCount(db);
220
+ return result;
221
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-job Claude Code settings generation for W3-A assisted apply.
3
+ *
4
+ * The settings file is a best-effort pre-edit boundary. Headless Claude can
5
+ * silently ignore invalid settings, so callers must still run the post-apply
6
+ * path audit before marking a job APPLIED.
7
+ */
8
+ function globPath(fileRef) {
9
+ return fileRef.replace(/\\/g, "/");
10
+ }
11
+ export function generateJobSettings(fileRef, mode) {
12
+ const escaped = globPath(fileRef);
13
+ const settings = mode === "apply"
14
+ ? {
15
+ permissions: {
16
+ allow: [
17
+ `Edit(${escaped})`,
18
+ `Write(${escaped})`,
19
+ "Read(**)",
20
+ "Bash(git diff*)",
21
+ "Bash(git status*)",
22
+ ],
23
+ deny: ["Edit(**)", "Write(**)", "Bash(**)", "WebFetch(**)", "WebSearch(**)"],
24
+ },
25
+ }
26
+ : {
27
+ permissions: {
28
+ deny: ["Edit(**)", "Write(**)", "Bash(**)", "WebFetch(**)", "WebSearch(**)"],
29
+ },
30
+ };
31
+ return JSON.stringify(settings, null, 2);
32
+ }
33
+ export function assertValidSettingsJson(raw) {
34
+ JSON.parse(raw);
35
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ // This CLI ships compiled to dist/cli/agentwrangler.js and spawns the compiled
6
+ // daemon at dist/daemon/index.js — no tsx at runtime. The prebuilt UI ships in
7
+ // dist/ui, so there is no build-UI-if-missing step (that only applied to the
8
+ // tsx clone path, which now builds dist via the package `prepare` script).
9
+ // Browser auto-open, --smoke, --no-open and the hook commands are all handled
10
+ // by the daemon itself.
11
+ const cliDir = path.dirname(fileURLToPath(import.meta.url));
12
+ const daemonPath = path.resolve(cliDir, "..", "daemon", "index.js");
13
+ const daemon = spawn(process.execPath, [daemonPath, ...process.argv.slice(2)], {
14
+ stdio: "inherit",
15
+ });
16
+ // A null code means the daemon was terminated by a signal — surface that as a
17
+ // non-zero exit so an abnormal end is visible to the calling shell / npx.
18
+ daemon.on("exit", (code) => process.exit(code ?? 1));
@@ -0,0 +1,51 @@
1
+ /**
2
+ * src/daemon/config.ts — daemon configuration.
3
+ *
4
+ * Config is layered:
5
+ * 1. Compile-time defaults (below).
6
+ * 2. Environment variable overrides (AW_* prefix).
7
+ * 3. Runtime overrides from the user_config table (persisted by WP4).
8
+ *
9
+ * WP4 implements the user_config read/write path. This module provides the
10
+ * defaults and the environment-variable layer used at boot time.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ /** Default scan root for ~/.claude/projects/ transcript discovery. */
17
+ const DEFAULT_SCAN_ROOT = path.join(os.homedir(), ".claude", "projects");
18
+ /** Default TCP port for the loopback HTTP server. */
19
+ const DEFAULT_PORT = 47821;
20
+ /**
21
+ * Activity window in seconds: sessions with last_turn_at within this many
22
+ * seconds of now are considered LIVE. Beyond this, they are reconciled.
23
+ */
24
+ const DEFAULT_ACTIVITY_WINDOW_SECS = 5 * 60; // 5 minutes
25
+ /** Default on-disk database path. */
26
+ const DEFAULT_DB_PATH = path.join(os.homedir(), ".agentwrangler", "db.sqlite");
27
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
28
+ const DEFAULT_UI_ROOT = path.resolve(MODULE_DIR, "..", "..", "dist", "ui");
29
+ /**
30
+ * Load the daemon config from compile-time defaults and environment variables.
31
+ * WP4 will augment this with user_config table values after DB is open.
32
+ */
33
+ export function loadConfig(overrides = {}) {
34
+ const dbPath = process.env.AW_DB_PATH ?? DEFAULT_DB_PATH;
35
+ const portEnv = process.env.AW_PORT;
36
+ const port = portEnv !== undefined ? Number.parseInt(portEnv, 10) : DEFAULT_PORT;
37
+ const scanRootEnv = process.env.AW_SCAN_ROOT;
38
+ const scanRoots = scanRootEnv !== undefined ? [scanRootEnv] : [DEFAULT_SCAN_ROOT];
39
+ const windowEnv = process.env.AW_ACTIVITY_WINDOW_SECS;
40
+ const activityWindowSecs = windowEnv !== undefined ? Number.parseInt(windowEnv, 10) : DEFAULT_ACTIVITY_WINDOW_SECS;
41
+ const uiRootEnv = process.env.AW_UI_ROOT;
42
+ const uiRoot = uiRootEnv !== undefined ? uiRootEnv : fs.existsSync(DEFAULT_UI_ROOT) ? DEFAULT_UI_ROOT : null;
43
+ return {
44
+ dbPath,
45
+ port,
46
+ scanRoots,
47
+ activityWindowSecs,
48
+ uiRoot,
49
+ ...overrides,
50
+ };
51
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * src/daemon/http.ts — HTTP request handler with security baseline.
3
+ *
4
+ * Security layers (applied in order, before any handler):
5
+ *
6
+ * 1. Host guard (SEC-102 / ADR-100 loopback-only):
7
+ * Refuse any request whose Host header doesn't resolve to a loopback address.
8
+ * Strips the port before checking so the guard works regardless of the
9
+ * ephemeral port chosen by the OS. Response: 421 Misdirected Request.
10
+ *
11
+ * 2. CSRF same-origin gate (POST/PUT/PATCH/DELETE only):
12
+ * Implements the Fetch Metadata / OWASP Fetch-site algorithm:
13
+ * (a) If Sec-Fetch-Site present: allow only "same-origin" or "none"; else 403.
14
+ * (b) Else if Origin present: allow only exact-match against
15
+ * {http://127.0.0.1:<port>, http://localhost:<port>, http://[::1]:<port>};
16
+ * reject "null" Origin and all others; → 403.
17
+ * (c) If neither header present: allow (legacy same-origin, no CORS context).
18
+ *
19
+ * 3. Content-Type gate (POST/PUT/PATCH/DELETE only):
20
+ * Write requests must carry Content-Type: application/json; else → 415.
21
+ *
22
+ * NEVER emits permissive CORS headers. The SPA is served from the same origin;
23
+ * no cross-origin API access is needed or allowed.
24
+ */
25
+ import { timingSafeEqual } from "node:crypto";
26
+ import * as http from "node:http";
27
+ import { isReady } from "./readiness.js";
28
+ import { handleApiRequest } from "./router.js";
29
+ import { createStaticHandler } from "./static.js";
30
+ /** Inline loading page served while the daemon's initial back-scan is running. */
31
+ const LOADING_HTML = `<!doctype html>
32
+ <html lang="en">
33
+ <head>
34
+ <meta charset="utf-8">
35
+ <meta name="viewport" content="width=device-width,initial-scale=1">
36
+ <title>AgentWrangler — Starting up</title>
37
+ <style>
38
+ *{box-sizing:border-box;margin:0;padding:0}
39
+ body{background:#0b0f17;color:#c9d1d9;font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}
40
+ .wrap{padding:2rem}
41
+ h1{font-size:1.5rem;font-weight:700;color:#f0f6fc;letter-spacing:.05em}
42
+ h2{font-size:1rem;font-weight:400;color:#38bdf8;margin:.75rem 0 .5rem}
43
+ p{font-size:.875rem;color:#8b949e;max-width:36ch;margin:0 auto 2rem}
44
+ .spinner{width:40px;height:40px;border:3px solid #1e293b;border-top-color:#38bdf8;border-radius:50%;animation:spin 1s linear infinite;margin:0 auto}
45
+ .count{font-variant-numeric:tabular-nums;color:#8b949e;font-size:.875rem;margin:1.25rem auto 0;min-height:1.2em}
46
+ @keyframes spin{to{transform:rotate(360deg)}}
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div class="wrap">
51
+ <h1>AgentWrangler</h1>
52
+ <h2>Starting up…</h2>
53
+ <p>Scanning your Claude Code transcripts — this can take a few minutes on first run; later loads are instant.</p>
54
+ <div class="spinner"></div>
55
+ <p class="count" id="count" aria-live="polite"></p>
56
+ </div>
57
+ <script>
58
+ (function poll(){
59
+ fetch('/api/ready')
60
+ .then(function(r){return r.json();})
61
+ .then(function(d){
62
+ if(d.ready){location.reload();return;}
63
+ // Reuse the first-run onboarding counter so a large first scan shows live
64
+ // progress instead of a bare spinner. Cadence unchanged (1s).
65
+ fetch('/api/status')
66
+ .then(function(r){return r.json();})
67
+ .then(function(s){
68
+ if(s&&typeof s.files_seen==='number'&&s.files_seen>0){
69
+ document.getElementById('count').textContent=
70
+ 'Scanning transcripts — '+s.files_parsed+' of '+s.files_seen+' files';
71
+ }
72
+ })
73
+ .catch(function(){})
74
+ .then(function(){setTimeout(poll,1000);});
75
+ })
76
+ .catch(function(){setTimeout(poll,1000);});
77
+ })();
78
+ </script>
79
+ </body>
80
+ </html>`;
81
+ /**
82
+ * Strict allowlist for loopback Host headers.
83
+ * Matches 127.0.0.1, localhost, [::1] — each optionally with :PORT — and
84
+ * nothing else. Using a full-pattern match prevents userinfo-syntax bypass
85
+ * (e.g. "localhost:user@evil.com") that a bare lastIndexOf(':') strip allows.
86
+ */
87
+ const LOOPBACK_HOST_RE = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/;
88
+ /** Return true if the Host header is a loopback address (with optional port). */
89
+ function isLoopbackHost(host) {
90
+ if (!host)
91
+ return false;
92
+ return LOOPBACK_HOST_RE.test(host);
93
+ }
94
+ const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
95
+ /**
96
+ * Apply the CSRF same-origin gate to a write request.
97
+ * Returns null on success, or a {status, message} on failure.
98
+ */
99
+ function csrfCheck(req, port) {
100
+ const sfs = req.headers["sec-fetch-site"];
101
+ if (sfs !== undefined) {
102
+ // Fetch Metadata present: enforce same-origin or none only.
103
+ if (sfs === "same-origin" || sfs === "none")
104
+ return null;
105
+ return { status: 403, message: "Forbidden — cross-origin write rejected (Sec-Fetch-Site)" };
106
+ }
107
+ const origin = req.headers.origin;
108
+ if (origin !== undefined) {
109
+ // Legacy CORS check: allow only the exact loopback origins (scheme+host+port).
110
+ const allowed = new Set([
111
+ `http://127.0.0.1:${port}`,
112
+ `http://localhost:${port}`,
113
+ `http://[::1]:${port}`,
114
+ ]);
115
+ if (origin === "null" || !allowed.has(origin)) {
116
+ return {
117
+ status: 403,
118
+ message: "Forbidden — cross-origin write rejected (Origin)",
119
+ };
120
+ }
121
+ return null;
122
+ }
123
+ // Neither Sec-Fetch-Site nor Origin — legacy same-origin request (e.g. curl).
124
+ return null;
125
+ }
126
+ /** Return true when a write path requires X-AgentWrangler-Token. */
127
+ function requiresSessionToken(pathname) {
128
+ if (pathname === "/api/idle-sessions/end")
129
+ return true;
130
+ if (pathname === "/api/hook/install" || pathname === "/api/hook/uninstall")
131
+ return true;
132
+ if (pathname === "/api/recommendations/adopt" || pathname === "/api/recommendations/dismiss") {
133
+ return true;
134
+ }
135
+ if (/^\/api\/recommendations\/[^/]+\/apply$/.test(pathname))
136
+ return true;
137
+ if (/^\/api\/recommendations\/[^/]+\/open-terminal$/.test(pathname))
138
+ return true;
139
+ if (/^\/api\/recommendations\/jobs\/[^/]+\/(confirm|rollback)$/.test(pathname))
140
+ return true;
141
+ return false;
142
+ }
143
+ /**
144
+ * Create the Node HTTP request handler for the daemon.
145
+ *
146
+ * @param db Open SQLite database.
147
+ * @param port The port the server is bound to (used for CSRF Origin checks).
148
+ * @param uiRoot Path to the built SPA assets directory. May be null in test/CI.
149
+ * @param sessionToken In-memory CSRF session token (crypto.randomUUID() at startup).
150
+ * When provided, GET /api/token returns it and POST write endpoints
151
+ * for adopt/dismiss require it in X-AgentWrangler-Token. When null/
152
+ * undefined, the token gate is inactive (legacy/test mode).
153
+ */
154
+ export function createHandler(db, port, uiRoot, sessionToken, kickBootScan) {
155
+ const staticHandler = uiRoot ? createStaticHandler(uiRoot) : null;
156
+ return function handler(req, res) {
157
+ // ── 1. Host guard ────────────────────────────────────────────────────────
158
+ const host = req.headers.host ?? "";
159
+ if (!isLoopbackHost(host)) {
160
+ res.writeHead(421, { "Content-Type": "text/plain" });
161
+ res.end("421 Misdirected Request — loopback only");
162
+ return;
163
+ }
164
+ const method = (req.method ?? "GET").toUpperCase();
165
+ const url = req.url ?? "/";
166
+ const pathname = url.split("?")[0] ?? url;
167
+ // ── GET /api/token — expose session token (same-origin readable only) ────
168
+ if (method === "GET" && pathname === "/api/token" && sessionToken != null) {
169
+ const body = JSON.stringify({ token: sessionToken });
170
+ res.writeHead(200, {
171
+ "Content-Type": "application/json; charset=utf-8",
172
+ "Content-Length": Buffer.byteLength(body),
173
+ });
174
+ res.end(body);
175
+ return;
176
+ }
177
+ // ── 2 & 3. CSRF + Content-Type gate (writes only) ────────────────────────
178
+ if (WRITE_METHODS.has(method)) {
179
+ const err = csrfCheck(req, port);
180
+ if (err) {
181
+ res.writeHead(err.status, { "Content-Type": "text/plain" });
182
+ res.end(err.message);
183
+ return;
184
+ }
185
+ const ct = req.headers["content-type"] ?? "";
186
+ if (!ct.startsWith("application/json")) {
187
+ res.writeHead(415, { "Content-Type": "text/plain" });
188
+ res.end("415 Unsupported Media Type — Content-Type must be application/json");
189
+ return;
190
+ }
191
+ // ── 4. Session-token gate (mutating recommendation actions, when token active) ─────
192
+ if (sessionToken != null && requiresSessionToken(pathname)) {
193
+ const provided = req.headers["x-agentwrangler-token"];
194
+ const providedStr = Array.isArray(provided) ? provided[0] : provided;
195
+ let tokenOk = false;
196
+ if (providedStr !== undefined) {
197
+ try {
198
+ // Compare by bytes so a multi-byte non-ASCII header can't bypass the
199
+ // length guard and cause timingSafeEqual to throw ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH.
200
+ const a = Buffer.from(providedStr, "utf8");
201
+ const b = Buffer.from(sessionToken, "utf8");
202
+ if (a.length === b.length) {
203
+ // Constant-time compare to prevent timing oracle on token value.
204
+ tokenOk = timingSafeEqual(a, b);
205
+ }
206
+ }
207
+ catch {
208
+ // Defense-in-depth: any unexpected error → tokenOk stays false → 401.
209
+ }
210
+ }
211
+ if (!tokenOk) {
212
+ res.writeHead(401, { "Content-Type": "text/plain" });
213
+ res.end("401 Unauthorized — missing or invalid X-AgentWrangler-Token");
214
+ return;
215
+ }
216
+ }
217
+ }
218
+ // ── Loading page (daemon not ready yet) ──────────────────────────────────
219
+ // Serve while the initial back-scan is running. Only intercepts top-level
220
+ // HTML navigations — /api/* paths (including /api/ready) pass through so
221
+ // the polling script can detect readiness even while the loop is blocked.
222
+ if (!isReady() &&
223
+ (pathname === "/" ||
224
+ (!pathname.startsWith("/api/") && (req.headers.accept ?? "").includes("text/html")))) {
225
+ kickBootScan?.();
226
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
227
+ res.end(LOADING_HTML);
228
+ return;
229
+ }
230
+ // ── API routes (/api/*) ──────────────────────────────────────────────────
231
+ if (url.startsWith("/api/")) {
232
+ handleApiRequest(db, req, res, method, url);
233
+ return;
234
+ }
235
+ // ── Static SPA assets ────────────────────────────────────────────────────
236
+ if (staticHandler) {
237
+ staticHandler(req, res, () => {
238
+ // sirv calls next() when the file is not found — send 404.
239
+ res.writeHead(404, { "Content-Type": "text/plain" });
240
+ res.end("404 Not Found");
241
+ });
242
+ return;
243
+ }
244
+ // No static handler (UI not built yet).
245
+ res.writeHead(200, { "Content-Type": "text/plain" });
246
+ res.end("AgentWrangler daemon running — build the UI with `npm run build:ui`");
247
+ };
248
+ }
249
+ /**
250
+ * Create and return an HTTP server using the loopback handler.
251
+ * Pass sessionToken (generated by src/daemon/index.ts via crypto.randomUUID()) to
252
+ * enable the X-AgentWrangler-Token gate on write endpoints. Omit in tests that
253
+ * do not exercise the token gate.
254
+ */
255
+ export function createServer(db, port, uiRoot, sessionToken, kickBootScan) {
256
+ const handler = createHandler(db, port, uiRoot, sessionToken, kickBootScan);
257
+ return http.createServer(handler);
258
+ }