@saadnvd1/agent-os 0.3.461 → 0.3.463

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1241,6 +1241,10 @@ scripts/redeploy # pull, install, build; restarts via $AGENTOS_RESTART only
1241
1241
  scripts/redeploy --rollback # put the previous build back and restart
1242
1242
  ```
1243
1243
 
1244
+ One Node process serves every terminal, chat stream and request, so a route
1245
+ handler in `app/api/` may not call a `*Sync` child_process or fs function:
1246
+ ESLint refuses it. Use the async form with a timeout.
1247
+
1244
1248
  `scripts/redeploy` (and `scripts/autodeploy`, which calls it) builds into
1245
1249
  `.next-build` while the live server keeps serving `.next`. The build is swapped
1246
1250
  in by rename only once it finished with a `BUILD_ID`, right before the restart.
@@ -1262,8 +1266,16 @@ descriptors, child processes and spawns per minute; `scripts/perf/execs.mjs
1262
1266
  spawns with eslogger (macOS) or strace (Linux) and need passwordless sudo for
1263
1267
  that one number. Run them on a machine with room to spare.
1264
1268
 
1265
- A pre-commit hook formats and lints staged files, then typechecks and runs the
1266
- tests. CI runs `scripts/check --build` on every pull request and push to main,
1269
+ A pre-commit hook (`.husky/pre-commit`, installed by `npm install`) checks
1270
+ only what is staged, in well under two seconds: `prettier --check`, failing
1271
+ with the `npx prettier --write …` command that fixes it, and a scan of the
1272
+ added lines for token-shaped secrets with the merge gate's own rule
1273
+ (`scripts/hooks/staged-secrets.ts` reads `secretsIn` from
1274
+ `lib/orchestrator/diff.ts`), naming the file and line. Build a test's fake
1275
+ token at runtime (`"gh" + "p_" + "a".repeat(36)`) rather than writing it
1276
+ whole. It runs in the worktrees AgentOS makes too, which have no `.husky/_`
1277
+ until `npm install` runs in them. Typecheck, lint and tests are left to CI.
1278
+ In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
1267
1279
  which also holds the app's first-load JavaScript to a budget
1268
1280
  (`scripts/check-bundle.mjs`; `npx next experimental-analyze` shows what's in
1269
1281
  it), and fails a pull request whose body has no Code review section for its head
@@ -3,7 +3,7 @@ import { isRipgrepAvailable } from "@/lib/code-search";
3
3
 
4
4
  export async function GET() {
5
5
  try {
6
- const available = isRipgrepAvailable();
6
+ const available = await isRipgrepAvailable();
7
7
  return NextResponse.json({ available });
8
8
  } catch (error) {
9
9
  console.error("Error checking ripgrep availability:", error);
@@ -25,9 +25,11 @@ export async function GET(request: NextRequest) {
25
25
 
26
26
  const expandedPath = path.replace(/^~/, process.env.HOME || "");
27
27
 
28
- const rawMatches = searchCode(expandedPath, query, {
29
- maxResults,
30
- contextLines,
28
+ // A search the client gave up on (a newer keystroke) stops rg.
29
+ const rawMatches = await searchCode(expandedPath, query, {
30
+ maxResults: Number.isFinite(maxResults) ? maxResults : 100,
31
+ contextLines: Number.isFinite(contextLines) ? contextLines : 2,
32
+ signal: request.signal,
31
33
  });
32
34
 
33
35
  const results = formatSearchResults(rawMatches);
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import fs from "fs";
2
+ import { mkdir, writeFile } from "fs/promises";
3
3
  import path from "path";
4
4
  import os from "os";
5
5
 
@@ -13,9 +13,7 @@ export async function POST(request: Request) {
13
13
 
14
14
  // Create temp directory for screenshots if it doesn't exist
15
15
  const tempDir = path.join(os.tmpdir(), "agent-os-screenshots");
16
- if (!fs.existsSync(tempDir)) {
17
- fs.mkdirSync(tempDir, { recursive: true });
18
- }
16
+ await mkdir(tempDir, { recursive: true });
19
17
 
20
18
  // Generate unique filename
21
19
  const ext = mimeType?.split("/")[1] || "png";
@@ -28,7 +26,7 @@ export async function POST(request: Request) {
28
26
 
29
27
  // Decode base64 and write file
30
28
  const buffer = Buffer.from(base64, "base64");
31
- fs.writeFileSync(filePath, buffer);
29
+ await writeFile(filePath, buffer);
32
30
 
33
31
  return NextResponse.json({ path: filePath });
34
32
  } catch (error) {
@@ -1,7 +1,10 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { execFileSync } from "child_process";
2
+ import { execFile } from "child_process";
3
+ import { promisify } from "util";
3
4
  import { expandPath } from "@/lib/git-status";
4
5
 
6
+ const execFileAsync = promisify(execFile);
7
+
5
8
  /**
6
9
  * GET /api/git/file-content?path=...&file=...
7
10
  * Get file content from git HEAD (original version before changes)
@@ -30,12 +33,17 @@ export async function GET(request: NextRequest) {
30
33
 
31
34
  try {
32
35
  // Get file content from HEAD
33
- const content = execFileSync("git", ["show", `HEAD:${file}`], {
34
- cwd: expandedPath,
35
- encoding: "utf-8",
36
- stdio: ["ignore", "pipe", "pipe"],
37
- maxBuffer: 10 * 1024 * 1024, // 10MB
38
- });
36
+ const { stdout: content } = await execFileAsync(
37
+ "git",
38
+ ["show", `HEAD:${file}`],
39
+ {
40
+ cwd: expandedPath,
41
+ encoding: "utf-8",
42
+ maxBuffer: 10 * 1024 * 1024, // 10MB
43
+ timeout: 30_000,
44
+ killSignal: "SIGKILL",
45
+ }
46
+ );
39
47
 
40
48
  return NextResponse.json({ content });
41
49
  } catch (gitError: unknown) {
@@ -11,6 +11,20 @@ import {
11
11
  getBaseBranch,
12
12
  } from "@/lib/pr";
13
13
  import { generatePRContent } from "@/lib/pr-generation";
14
+ import { GhBackoffError } from "@/lib/tasks/gh-limit";
15
+
16
+ // gh's login, or why it can't be asked now: GitHub's rate limit backs every
17
+ // gh call off, and that isn't a missing login.
18
+ async function ghUnavailable(message: string): Promise<NextResponse | null> {
19
+ try {
20
+ if (await checkGhCli()) return null;
21
+ } catch (error) {
22
+ if (error instanceof GhBackoffError)
23
+ return NextResponse.json({ error: error.message }, { status: 503 });
24
+ throw error;
25
+ }
26
+ return NextResponse.json({ error: message }, { status: 400 });
27
+ }
14
28
 
15
29
  // GET /api/git/pr - Get PR status (fast - no AI generation)
16
30
  // Use ?generate=true to also generate suggested title/body (slow - uses Claude CLI)
@@ -30,19 +44,16 @@ export async function GET(request: NextRequest) {
30
44
  );
31
45
  }
32
46
 
33
- if (!checkGhCli()) {
34
- return NextResponse.json(
35
- {
36
- error:
37
- "GitHub CLI not installed or not authenticated. Run 'gh auth login' first.",
38
- },
39
- { status: 400 }
40
- );
41
- }
47
+ const noGh = await ghUnavailable(
48
+ "GitHub CLI not installed or not authenticated. Run 'gh auth login' first."
49
+ );
50
+ if (noGh) return noGh;
42
51
 
43
52
  try {
44
- const branch = getCurrentBranch(path);
45
- const baseBranch = getBaseBranch(path);
53
+ const [branch, baseBranch] = await Promise.all([
54
+ getCurrentBranch(path),
55
+ getBaseBranch(path),
56
+ ]);
46
57
 
47
58
  // Check if on main/master (can't create PR from there)
48
59
  if (branch === "main" || branch === "master") {
@@ -52,11 +63,11 @@ export async function GET(request: NextRequest) {
52
63
  );
53
64
  }
54
65
 
55
- // Check if PR already exists
56
- const existingPR = getPRForBranch(path, branch);
57
-
58
- // Get commits for listing
59
- const commits = getCommitsSinceBase(path, baseBranch);
66
+ // The existing PR (network) and the commits since base, side by side.
67
+ const [existingPR, commits] = await Promise.all([
68
+ getPRForBranch(path, branch),
69
+ getCommitsSinceBase(path, baseBranch),
70
+ ]);
60
71
 
61
72
  // Only generate suggested content if explicitly requested (for PR creation flow)
62
73
  let suggestedTitle: string | undefined;
@@ -87,7 +98,7 @@ export async function GET(request: NextRequest) {
87
98
  {
88
99
  error: error instanceof Error ? error.message : "Failed to get PR info",
89
100
  },
90
- { status: 500 }
101
+ { status: error instanceof GhBackoffError ? 503 : 500 }
91
102
  );
92
103
  }
93
104
  }
@@ -123,15 +134,13 @@ export async function POST(request: NextRequest) {
123
134
  );
124
135
  }
125
136
 
126
- if (!checkGhCli()) {
127
- return NextResponse.json(
128
- { error: "GitHub CLI not installed or not authenticated" },
129
- { status: 400 }
130
- );
131
- }
137
+ const noGh = await ghUnavailable(
138
+ "GitHub CLI not installed or not authenticated"
139
+ );
140
+ if (noGh) return noGh;
132
141
 
133
- const branch = getCurrentBranch(path);
134
- const baseBranch = customBase || getBaseBranch(path);
142
+ const branch = await getCurrentBranch(path);
143
+ const baseBranch = customBase || (await getBaseBranch(path));
135
144
 
136
145
  // Check if on main/master
137
146
  if (branch === "main" || branch === "master") {
@@ -142,7 +151,7 @@ export async function POST(request: NextRequest) {
142
151
  }
143
152
 
144
153
  // Check if PR already exists
145
- const existingPR = getPRForBranch(path, branch);
154
+ const existingPR = await getPRForBranch(path, branch);
146
155
  if (existingPR) {
147
156
  return NextResponse.json(
148
157
  { error: "PR already exists for this branch", pr: existingPR },
@@ -151,14 +160,20 @@ export async function POST(request: NextRequest) {
151
160
  }
152
161
 
153
162
  // Create the PR
154
- const pr = createPR(path, branch, baseBranch, title, description || "");
163
+ const pr = await createPR(
164
+ path,
165
+ branch,
166
+ baseBranch,
167
+ title,
168
+ description || ""
169
+ );
155
170
 
156
171
  return NextResponse.json({ pr }, { status: 201 });
157
172
  } catch (error) {
158
173
  const message = error instanceof Error ? error.message : "Unknown error";
159
174
  return NextResponse.json(
160
175
  { error: `Failed to create PR: ${message}` },
161
- { status: 500 }
176
+ { status: error instanceof GhBackoffError ? 503 : 500 }
162
177
  );
163
178
  }
164
179
  }
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { getDb, queries, type Session } from "@/lib/db";
3
3
  import { hostExec, isRemoteHost } from "@/lib/hosts";
4
4
  import { shellQuote } from "@/lib/hosts/ssh";
5
- import { appendFileSync } from "fs";
5
+ import { appendFile } from "fs/promises";
6
6
  import { pasteText, tmux } from "@/lib/tmux/exec";
7
7
 
8
8
  // Log to file for debugging
@@ -11,9 +11,7 @@ function log(msg: string) {
11
11
  const timestamp = new Date().toISOString();
12
12
  const line = `[${timestamp}] ${msg}\n`;
13
13
  console.log(`[send-keys] ${msg}`);
14
- try {
15
- appendFileSync(LOG_FILE, line);
16
- } catch {}
14
+ appendFile(LOG_FILE, line).catch(() => {});
17
15
  }
18
16
 
19
17
  // POST /api/sessions/[id]/send-keys - Send text to a tmux session
@@ -3,7 +3,7 @@ import { isRemoteHost } from "@/lib/hosts";
3
3
  import { spawn } from "child_process";
4
4
  import { getDb, queries, type Session } from "@/lib/db";
5
5
  import { randomUUID } from "crypto";
6
- import { readFileSync, existsSync } from "fs";
6
+ import { readFile } from "fs/promises";
7
7
  import { homedir } from "os";
8
8
  import { pasteText, tmux } from "@/lib/tmux/exec";
9
9
 
@@ -36,20 +36,26 @@ function encodeProjectPath(cwd: string): string {
36
36
  }
37
37
 
38
38
  // Read and parse Claude session JSONL file
39
- function readClaudeSessionHistory(
39
+ async function readClaudeSessionHistory(
40
40
  cwd: string,
41
41
  claudeSessionId: string
42
- ): string | null {
42
+ ): Promise<string | null> {
43
43
  const projectPath = encodeProjectPath(cwd);
44
44
  const jsonlPath = `${homedir()}/.claude/projects/${projectPath}/${claudeSessionId}.jsonl`;
45
45
 
46
- if (!existsSync(jsonlPath)) {
47
- console.log(`[summarize] JSONL not found: ${jsonlPath}`);
46
+ let content: string;
47
+ try {
48
+ content = await readFile(jsonlPath, "utf-8");
49
+ } catch (error) {
50
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
51
+ console.log(`[summarize] JSONL not found: ${jsonlPath}`);
52
+ return null;
53
+ }
54
+ console.error(`[summarize] Error reading JSONL:`, error);
48
55
  return null;
49
56
  }
50
57
 
51
58
  try {
52
- const content = readFileSync(jsonlPath, "utf-8");
53
59
  const lines = content.trim().split("\n");
54
60
  const messages: string[] = [];
55
61
 
@@ -240,7 +246,10 @@ export async function POST(
240
246
  const claudeSessionId = await getClaudeSessionId(tmuxSessionName);
241
247
  if (claudeSessionId && cwdExpanded) {
242
248
  console.log(`[summarize] Found Claude session ID: ${claudeSessionId}`);
243
- conversation = readClaudeSessionHistory(cwdExpanded, claudeSessionId);
249
+ conversation = await readClaudeSessionHistory(
250
+ cwdExpanded,
251
+ claudeSessionId
252
+ );
244
253
  if (conversation) {
245
254
  console.log(
246
255
  `[summarize] Read ${conversation.length} chars from JSONL`
@@ -73,7 +73,7 @@ export async function POST(request: NextRequest) {
73
73
  const scriptContent = generateInitScript(agentCommand);
74
74
  const scriptPath = path.join(os.tmpdir(), `agent-os-init-${Date.now()}.sh`);
75
75
 
76
- fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
76
+ await fs.promises.writeFile(scriptPath, scriptContent, { mode: 0o755 });
77
77
 
78
78
  return NextResponse.json({ scriptPath, command: `bash ${scriptPath}` });
79
79
  } catch (error) {
@@ -22,7 +22,8 @@ async function fetchRipgrepAvailability(): Promise<boolean> {
22
22
 
23
23
  async function fetchCodeSearch(
24
24
  path: string,
25
- query: string
25
+ query: string,
26
+ signal?: AbortSignal
26
27
  ): Promise<SearchResponse> {
27
28
  const params = new URLSearchParams({
28
29
  path,
@@ -31,7 +32,7 @@ async function fetchCodeSearch(
31
32
  contextLines: "2",
32
33
  });
33
34
 
34
- const res = await fetch(`/api/code-search?${params}`);
35
+ const res = await fetch(`/api/code-search?${params}`, { signal });
35
36
  const data = await res.json();
36
37
 
37
38
  if (!res.ok) {
@@ -53,7 +54,8 @@ export function useRipgrepAvailable() {
53
54
  export function useCodeSearch(path: string, query: string, enabled = false) {
54
55
  return useQuery({
55
56
  queryKey: codeSearchKeys.search(path, query),
56
- queryFn: () => fetchCodeSearch(path, query),
57
+ // A newer query cancels this one, and the server stops its rg.
58
+ queryFn: ({ signal }) => fetchCodeSearch(path, query, signal),
57
59
  enabled: enabled && query.length > 2,
58
60
  staleTime: 30000,
59
61
  gcTime: 60000,
@@ -1,15 +1,20 @@
1
- import { execSync, spawnSync } from "child_process";
1
+ import { execFile, spawn } from "child_process";
2
+ import { createInterface } from "readline";
3
+
4
+ // rg runs off the event loop: a search over a big tree never stalls the
5
+ // server's other requests. It stops at the result cap, the output cap or the
6
+ // timeout, whichever comes first, and when the caller aborts.
7
+ export const SEARCH_TIMEOUT_MS = 10_000;
8
+ export const MAX_RESULTS_CAP = 500;
9
+ const MAX_OUTPUT_BYTES = 5 * 1024 * 1024;
2
10
 
3
11
  /**
4
12
  * Check if ripgrep is available on the system
5
13
  */
6
- export function isRipgrepAvailable(): boolean {
7
- try {
8
- execSync("which rg", { encoding: "utf-8", stdio: "pipe" });
9
- return true;
10
- } catch {
11
- return false;
12
- }
14
+ export function isRipgrepAvailable(): Promise<boolean> {
15
+ return new Promise((resolve) => {
16
+ execFile("which", ["rg"], { timeout: 5000 }, (error) => resolve(!error));
17
+ });
13
18
  }
14
19
 
15
20
  export interface SearchOptions {
@@ -17,6 +22,9 @@ export interface SearchOptions {
17
22
  contextLines?: number;
18
23
  filePattern?: string;
19
24
  caseSensitive?: boolean;
25
+ /** Stops rg (the request went away). */
26
+ signal?: AbortSignal;
27
+ timeoutMs?: number;
20
28
  }
21
29
 
22
30
  export interface SearchMatch {
@@ -38,71 +46,89 @@ export interface FormattedMatch {
38
46
  lineText: string;
39
47
  }
40
48
 
41
- export function searchCode(
49
+ export async function searchCode(
42
50
  workingDir: string,
43
51
  query: string,
44
52
  options: SearchOptions = {}
45
- ): SearchMatch[] {
46
- const { maxResults = 100, contextLines = 2 } = options;
53
+ ): Promise<SearchMatch[]> {
54
+ const { contextLines = 2, signal, timeoutMs = SEARCH_TIMEOUT_MS } = options;
55
+ const maxResults = Math.min(options.maxResults ?? 100, MAX_RESULTS_CAP);
47
56
 
48
- try {
49
- const args = [
50
- "--json",
51
- `--max-count=${Math.ceil(maxResults / 10)}`,
52
- `--context=${contextLines}`,
53
- "--ignore-case",
54
- // -e: a query starting with "-" is a pattern, never an option (--pre).
55
- "-e",
56
- query,
57
- "--",
58
- ".", // CRITICAL: Tell ripgrep to search current directory explicitly
59
- ];
57
+ const args = [
58
+ "--json",
59
+ `--max-count=${Math.ceil(maxResults / 10)}`,
60
+ `--context=${contextLines}`,
61
+ "--ignore-case",
62
+ // -e: a query starting with "-" is a pattern, never an option (--pre).
63
+ "-e",
64
+ query,
65
+ "--",
66
+ ".", // CRITICAL: Tell ripgrep to search current directory explicitly
67
+ ];
60
68
 
61
- const result = spawnSync("rg", args, {
69
+ return new Promise<SearchMatch[]>((resolve, reject) => {
70
+ const matches: SearchMatch[] = [];
71
+ // Ignore stdin so ripgrep doesn't wait for it.
72
+ const rg = spawn("rg", args, {
62
73
  cwd: workingDir,
63
- encoding: "utf-8",
64
- timeout: 10000,
65
- maxBuffer: 1024 * 1024 * 5,
66
- stdio: ["ignore", "pipe", "pipe"], // Ignore stdin so ripgrep doesn't wait for it
74
+ stdio: ["ignore", "pipe", "ignore"],
67
75
  });
76
+ let bytes = 0;
77
+ let stopped = false;
78
+ const stop = () => {
79
+ if (stopped) return;
80
+ stopped = true;
81
+ rg.kill("SIGKILL");
82
+ };
83
+ const timer = setTimeout(stop, timeoutMs);
84
+ signal?.addEventListener("abort", stop, { once: true });
85
+ if (signal?.aborted) stop();
86
+ const done = () => {
87
+ clearTimeout(timer);
88
+ signal?.removeEventListener("abort", stop);
89
+ };
68
90
 
69
- if (result.error) {
70
- throw result.error;
71
- }
72
-
73
- // Status 1 = no matches (not an error for ripgrep)
74
- if (result.status !== 0 && result.status !== 1) {
75
- return [];
76
- }
77
-
78
- const output = result.stdout || "";
79
- const matches: SearchMatch[] = [];
80
- const lines = output.trim().split("\n").filter(Boolean);
81
-
82
- for (const line of lines) {
91
+ rg.stdout.on("data", (chunk: Buffer) => {
92
+ bytes += chunk.length;
93
+ if (bytes > MAX_OUTPUT_BYTES) stop();
94
+ });
95
+ const lines = createInterface({ input: rg.stdout, crlfDelay: Infinity });
96
+ lines.on("line", (line) => {
97
+ // What rg printed before a stop still counts, up to the cap.
98
+ if (!line || matches.length >= maxResults) return;
83
99
  try {
84
100
  const parsed = JSON.parse(line);
85
- if (parsed.type === "match") {
86
- matches.push(parsed);
87
- if (matches.length >= maxResults) break;
88
- }
101
+ if (parsed.type !== "match") return;
102
+ matches.push(parsed);
103
+ if (matches.length >= maxResults) stop();
89
104
  } catch {
90
- continue;
105
+ // A line cut off at the output cap.
91
106
  }
92
- }
107
+ });
93
108
 
94
- return matches;
95
- } catch (error) {
96
- console.error("Error in searchCode:", error);
97
- // ENOENT = command not found
98
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
99
- throw new Error(
100
- "ripgrep (rg) not found. Install with: brew install ripgrep"
101
- );
102
- }
103
- // Other errors - return empty
104
- return [];
105
- }
109
+ rg.on("error", (error: NodeJS.ErrnoException) => {
110
+ done();
111
+ // ENOENT = command not found
112
+ if (error.code === "ENOENT") {
113
+ reject(
114
+ new Error(
115
+ "ripgrep (rg) not found. Install with: brew install ripgrep"
116
+ )
117
+ );
118
+ return;
119
+ }
120
+ console.error("Error in searchCode:", error);
121
+ resolve([]);
122
+ });
123
+ // "close": stdout is drained, so every line has been read.
124
+ rg.on("close", (code) => {
125
+ done();
126
+ // What it found before a cap, the timeout or an abort stopped it.
127
+ if (stopped) return resolve(matches);
128
+ // Status 1 = no matches (not an error for ripgrep)
129
+ resolve(code === 0 || code === 1 ? matches : []);
130
+ });
131
+ });
106
132
  }
107
133
 
108
134
  export function formatSearchResults(matches: SearchMatch[]): FormattedMatch[] {
package/lib/db/queries.ts CHANGED
@@ -28,6 +28,13 @@ export const queries = {
28
28
  getAllSessions: (db: Database.Database) =>
29
29
  getStmt(db, `SELECT * FROM sessions ORDER BY updated_at DESC`),
30
30
 
31
+ // The tmux names AgentOS owns, for telling discovered sessions apart.
32
+ getManagedTmuxNames: (db: Database.Database) =>
33
+ getStmt(
34
+ db,
35
+ `SELECT host_id, tmux_name FROM sessions WHERE tmux_name IS NOT NULL AND tmux_name != ''`
36
+ ),
37
+
31
38
  updateSessionStatus: (db: Database.Database) =>
32
39
  getStmt(
33
40
  db,
@@ -196,6 +203,12 @@ export const queries = {
196
203
  getProject: (db: Database.Database) =>
197
204
  getStmt(db, `SELECT * FROM projects WHERE id = ?`),
198
205
 
206
+ getProjectFolders: (db: Database.Database) =>
207
+ getStmt(
208
+ db,
209
+ `SELECT id, host_id, working_directory, is_uncategorized FROM projects`
210
+ ),
211
+
199
212
  getAllProjects: (db: Database.Database) =>
200
213
  getStmt(
201
214
  db,
@@ -1,19 +1,50 @@
1
- import { getDb, queries, type Project, type Session } from "../db";
1
+ import { getDb, queries } from "../db";
2
2
  import { statusDetector } from "../status-detector";
3
- import { discoverSessions } from "./discover";
3
+ import {
4
+ discoverSessions,
5
+ type ManagedTmux,
6
+ type ProjectFolder,
7
+ } from "./discover";
4
8
 
5
9
  // What the discovered-sessions list shows, minus activity times: when this
6
10
  // changes, browsers refetch /api/tmux/discover (pushed as "discovered").
7
11
  // Reads the status detector's last list-sessions, so it runs no tmux.
12
+ //
13
+ // The status hub asks once a second while anyone watches. The answer only
14
+ // moves when the sessions or projects tables are written or tmux is listed
15
+ // anew, so it's recomputed only then, from the columns matching reads.
16
+ let last: { versions: string; listing: object; signature: string } | null =
17
+ null;
18
+
19
+ function tableVersions(): string {
20
+ const rows = getDb()
21
+ .prepare(
22
+ `SELECT topic, version FROM change_versions WHERE topic IN ('sessions', 'projects') ORDER BY topic`
23
+ )
24
+ .all() as { topic: string; version: number }[];
25
+ return rows.map((r) => `${r.topic}:${r.version}`).join(",");
26
+ }
27
+
8
28
  export function discoveredSignature(): string {
29
+ const versions = tableVersions();
30
+ const listing = statusDetector.listingStamp();
31
+ if (last && last.versions === versions && last.listing === listing)
32
+ return last.signature;
9
33
  const db = getDb();
10
34
  const found = discoverSessions(
11
35
  statusDetector.cachedSessions(),
12
- queries.getAllProjects(db).all() as Project[],
13
- queries.getAllSessions(db).all() as Session[]
36
+ queries.getProjectFolders(db).all() as ProjectFolder[],
37
+ queries.getManagedTmuxNames(db).all() as ManagedTmux[]
14
38
  );
15
- return JSON.stringify([
39
+ const signature = JSON.stringify([
16
40
  found.map((s) => [s.hostId, s.name, s.projectId, s.path]).sort(),
17
41
  statusDetector.hostErrors(),
18
42
  ]);
43
+ last = { versions, listing, signature };
44
+ return signature;
45
+ }
46
+
47
+ /** Forget the remembered answer (tests). */
48
+ export function resetDiscoveredSignature(): void {
49
+ last = null;
19
50
  }
@@ -27,8 +27,15 @@ const hostKey = (hostId: string | null | undefined, name: string) =>
27
27
  `${hostId || "local"}\t${name}`;
28
28
 
29
29
  /** The project a folder on a machine sits in (the deepest), or null. */
30
+ // Only what matching reads, so a narrow query can feed it.
31
+ export type ProjectFolder = Pick<
32
+ Project,
33
+ "id" | "host_id" | "working_directory" | "is_uncategorized"
34
+ >;
35
+ export type ManagedTmux = Pick<Session, "host_id" | "tmux_name">;
36
+
30
37
  export function projectMatcher(
31
- projects: Project[]
38
+ projects: ProjectFolder[]
32
39
  ): (hostId: string, path: string) => string | null {
33
40
  const dirs = projects
34
41
  .filter((p) => !p.is_uncategorized)
@@ -49,8 +56,8 @@ export function projectMatcher(
49
56
 
50
57
  export function discoverSessions(
51
58
  tmuxSessions: TmuxSessionInfo[],
52
- projects: Project[],
53
- managed: Session[]
59
+ projects: ProjectFolder[],
60
+ managed: ManagedTmux[]
54
61
  ): DiscoveredSession[] {
55
62
  // By machine and name: a session here doesn't hide one elsewhere.
56
63
  const managedNames = new Set(
@@ -1,4 +1,5 @@
1
- import { execFileSync } from "child_process";
1
+ import { execFile } from "child_process";
2
+ import { run } from "./tasks/gh";
2
3
  import { UNTRUSTED_RULE, untrusted } from "./orchestrator/untrusted";
3
4
 
4
5
  export interface GeneratedPRContent {
@@ -15,7 +16,7 @@ export async function generatePRContent(
15
16
  ): Promise<GeneratedPRContent> {
16
17
  try {
17
18
  // Get git context
18
- const { diff, commits, changedFiles } = getGitContext(
19
+ const { diff, commits, changedFiles } = await getGitContext(
19
20
  workingDir,
20
21
  baseBranch
21
22
  );
@@ -45,23 +46,17 @@ export async function generatePRContent(
45
46
  /**
46
47
  * Get git context for PR generation
47
48
  */
48
- function getGitContext(
49
+ async function getGitContext(
49
50
  workingDir: string,
50
51
  baseBranch: string
51
- ): { diff: string; commits: string[]; changedFiles: string[] } {
52
+ ): Promise<{ diff: string; commits: string[]; changedFiles: string[] }> {
52
53
  let diff = "";
53
54
  let commits: string[] = [];
54
55
  let changedFiles: string[] = [];
55
56
 
56
57
  // Names and text here come from the repository; every one is an
57
58
  // argument to git, never shell text or an option.
58
- const git = (args: string[]) =>
59
- execFileSync("git", args, {
60
- cwd: workingDir,
61
- encoding: "utf-8",
62
- stdio: ["ignore", "pipe", "pipe"],
63
- maxBuffer: 10 * 1024 * 1024,
64
- });
59
+ const git = (args: string[]) => run("git", args, workingDir, 10_000);
65
60
  const lines = (out: string) =>
66
61
  out
67
62
  .split("\n")
@@ -72,7 +67,7 @@ function getGitContext(
72
67
  // Try to get the remote base branch reference
73
68
  let baseBranchRef = baseBranch;
74
69
  try {
75
- git([
70
+ await git([
76
71
  "rev-parse",
77
72
  "--verify",
78
73
  "--end-of-options",
@@ -82,7 +77,7 @@ function getGitContext(
82
77
  } catch {
83
78
  // Fall back to local branch
84
79
  try {
85
- git(["rev-parse", "--verify", "--end-of-options", baseBranch]);
80
+ await git(["rev-parse", "--verify", "--end-of-options", baseBranch]);
86
81
  } catch {
87
82
  // Base branch doesn't exist
88
83
  return { diff, commits, changedFiles };
@@ -91,18 +86,18 @@ function getGitContext(
91
86
  const range = `${baseBranchRef}...HEAD`;
92
87
 
93
88
  try {
94
- diff = git(["diff", "--stat", "--end-of-options", range, "--"]);
89
+ diff = await git(["diff", "--stat", "--end-of-options", range, "--"]);
95
90
  } catch {}
96
91
 
97
92
  try {
98
93
  changedFiles = lines(
99
- git(["diff", "--name-only", "--end-of-options", range, "--"])
94
+ await git(["diff", "--name-only", "--end-of-options", range, "--"])
100
95
  );
101
96
  } catch {}
102
97
 
103
98
  try {
104
99
  commits = lines(
105
- git([
100
+ await git([
106
101
  "log",
107
102
  "--pretty=format:%s",
108
103
  "--end-of-options",
@@ -114,11 +109,11 @@ function getGitContext(
114
109
 
115
110
  // Also include uncommitted changes
116
111
  try {
117
- const workingDiff = git(["diff", "--stat"]);
112
+ const workingDiff = await git(["diff", "--stat"]);
118
113
  if (workingDiff) {
119
114
  diff = diff ? `${diff}\n${workingDiff}` : workingDiff;
120
115
  }
121
- const uncommittedFiles = lines(git(["diff", "--name-only"]));
116
+ const uncommittedFiles = lines(await git(["diff", "--name-only"]));
122
117
  changedFiles = [...new Set([...changedFiles, ...uncommittedFiles])];
123
118
  } catch {}
124
119
  } catch (error) {
@@ -138,7 +133,7 @@ async function generateWithClaude(
138
133
  ): Promise<GeneratedPRContent | null> {
139
134
  // Check if Claude CLI is available
140
135
  try {
141
- execFileSync("claude", ["--version"], { stdio: "pipe", timeout: 5000 });
136
+ await run("claude", ["--version"], workingDir, 5000);
142
137
  } catch {
143
138
  return null;
144
139
  }
@@ -148,14 +143,7 @@ async function generateWithClaude(
148
143
  try {
149
144
  // Use claude CLI with --print flag for non-interactive output
150
145
  // The prompt goes on stdin: never argv, never a shell.
151
- const output = execFileSync("claude", ["--print"], {
152
- cwd: workingDir,
153
- input: prompt,
154
- encoding: "utf-8",
155
- timeout: 30000,
156
- maxBuffer: 1024 * 1024,
157
- });
158
-
146
+ const output = await claudePrint(workingDir, prompt);
159
147
  return parseClaudeResponse(output);
160
148
  } catch (error) {
161
149
  console.debug("Claude CLI invocation failed", error);
@@ -163,6 +151,28 @@ async function generateWithClaude(
163
151
  }
164
152
  }
165
153
 
154
+ // `claude --print` with the prompt on stdin, off the event loop and killed
155
+ // at the ceiling.
156
+ function claudePrint(cwd: string, prompt: string): Promise<string> {
157
+ return new Promise((resolve, reject) => {
158
+ const child = execFile(
159
+ "claude",
160
+ ["--print"],
161
+ {
162
+ cwd,
163
+ encoding: "utf-8",
164
+ timeout: 30_000,
165
+ killSignal: "SIGKILL",
166
+ maxBuffer: 1024 * 1024,
167
+ },
168
+ (error, stdout) => (error ? reject(error) : resolve(stdout))
169
+ );
170
+ // claude exiting before it read the prompt is its failure, not a crash.
171
+ child.stdin?.on("error", () => {});
172
+ child.stdin?.end(prompt);
173
+ });
174
+ }
175
+
166
176
  /**
167
177
  * Build prompt for PR generation
168
178
  */
package/lib/pr.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { execFileSync } from "child_process";
2
+ import { run } from "./tasks/gh";
3
+ import { GhBackoffError, isRateLimit } from "./tasks/gh-limit";
2
4
 
3
5
  /** The remote's default branch, or "main" when it has none. */
4
6
  export function remoteDefaultBranch(workingDir: string): string {
@@ -32,14 +34,19 @@ export interface CommitInfo {
32
34
  body: string;
33
35
  }
34
36
 
37
+ // Every call here waits on git, gh or the network: all of it runs off the
38
+ // event loop, so a slow GitHub never stalls the server's other requests.
39
+
35
40
  /**
36
- * Check if gh CLI is installed and authenticated
41
+ * Check if gh CLI is installed and authenticated. Throws GhBackoffError while
42
+ * every gh call waits out GitHub's rate limit: that isn't a missing login.
37
43
  */
38
- export function checkGhCli(): boolean {
44
+ export async function checkGhCli(): Promise<boolean> {
39
45
  try {
40
- execFileSync("gh", ["auth", "status"], { timeout: 5000, stdio: "pipe" });
46
+ await run("gh", ["auth", "status"], process.cwd(), 5000);
41
47
  return true;
42
- } catch {
48
+ } catch (error) {
49
+ if (error instanceof GhBackoffError) throw error;
43
50
  return false;
44
51
  }
45
52
  }
@@ -47,22 +54,25 @@ export function checkGhCli(): boolean {
47
54
  /**
48
55
  * Get commits between current branch and base branch
49
56
  */
50
- export function getCommitsSinceBase(
57
+ export async function getCommitsSinceBase(
51
58
  workingDir: string,
52
59
  baseBranch = "main"
53
- ): CommitInfo[] {
60
+ ): Promise<CommitInfo[]> {
54
61
  try {
55
62
  // Get the merge base
56
63
  // Names come from the repository (its remote's default branch): they
57
64
  // are arguments, never shell text or options.
58
- const mergeBase = execFileSync(
59
- "git",
60
- ["merge-base", "--end-of-options", baseBranch, "HEAD"],
61
- { cwd: workingDir, encoding: "utf-8" }
65
+ const mergeBase = (
66
+ await run(
67
+ "git",
68
+ ["merge-base", "--end-of-options", baseBranch, "HEAD"],
69
+ workingDir,
70
+ 10_000
71
+ )
62
72
  ).trim();
63
73
 
64
74
  // Get commits since merge base
65
- const output = execFileSync(
75
+ const output = await run(
66
76
  "git",
67
77
  [
68
78
  "log",
@@ -70,7 +80,8 @@ export function getCommitsSinceBase(
70
80
  "--format=COMMIT_START%n%H%n%s%n%b%nCOMMIT_END",
71
81
  "--",
72
82
  ],
73
- { cwd: workingDir, encoding: "utf-8" }
83
+ workingDir,
84
+ 10_000
74
85
  );
75
86
 
76
87
  const commits: CommitInfo[] = [];
@@ -160,12 +171,12 @@ export function generatePRBody(commits: CommitInfo[]): string {
160
171
  /**
161
172
  * Get PR for a branch
162
173
  */
163
- export function getPRForBranch(
174
+ export async function getPRForBranch(
164
175
  workingDir: string,
165
176
  branchName: string
166
- ): PRInfo | null {
177
+ ): Promise<PRInfo | null> {
167
178
  try {
168
- const output = execFileSync(
179
+ const output = await run(
169
180
  "gh",
170
181
  [
171
182
  "pr",
@@ -177,11 +188,16 @@ export function getPRForBranch(
177
188
  "--limit",
178
189
  "1",
179
190
  ],
180
- { cwd: workingDir, encoding: "utf-8", timeout: 10000 }
191
+ workingDir,
192
+ 10_000
181
193
  );
182
194
  const prs = JSON.parse(output);
183
195
  return prs.length > 0 ? prs[0] : null;
184
- } catch {
196
+ } catch (error) {
197
+ // GitHub's rate limit says nothing about whether a PR exists.
198
+ if (error instanceof GhBackoffError) throw error;
199
+ if (isRateLimit(error))
200
+ throw new GhBackoffError("GitHub's rate limit is spent; try again later");
185
201
  return null;
186
202
  }
187
203
  }
@@ -189,30 +205,32 @@ export function getPRForBranch(
189
205
  /**
190
206
  * Create a new PR
191
207
  */
192
- export function createPR(
208
+ export async function createPR(
193
209
  workingDir: string,
194
210
  branchName: string,
195
211
  baseBranch: string,
196
212
  title: string,
197
213
  body: string
198
- ): PRInfo {
214
+ ): Promise<PRInfo> {
199
215
  // First ensure branch is pushed
200
216
  try {
201
- execFileSync("git", ["push", "-u", "origin", "--", branchName], {
202
- cwd: workingDir,
203
- timeout: 30000,
204
- stdio: "pipe",
205
- });
217
+ await run(
218
+ "git",
219
+ ["push", "-u", "origin", "--", branchName],
220
+ workingDir,
221
+ 30_000
222
+ );
206
223
  } catch {
207
224
  // Branch might already be pushed
208
225
  }
209
226
 
210
227
  // Create PR using gh CLI
211
228
  // gh pr create outputs the PR URL on success
212
- const output = execFileSync(
229
+ const output = await run(
213
230
  "gh",
214
231
  ["pr", "create", "--title", title, "--base", baseBranch, "--body", body],
215
- { cwd: workingDir, encoding: "utf-8", timeout: 30000 }
232
+ workingDir,
233
+ 30_000
216
234
  );
217
235
 
218
236
  // Parse URL from output (gh pr create prints the URL)
@@ -235,20 +253,24 @@ export function createPR(
235
253
  /**
236
254
  * Get current branch name
237
255
  */
238
- export function getCurrentBranch(workingDir: string): string {
239
- return execFileSync("git", ["branch", "--show-current"], {
240
- cwd: workingDir,
241
- encoding: "utf-8",
242
- }).trim();
256
+ export async function getCurrentBranch(workingDir: string): Promise<string> {
257
+ return (
258
+ await run("git", ["branch", "--show-current"], workingDir, 10_000)
259
+ ).trim();
243
260
  }
244
261
 
245
262
  /**
246
- * Get the default base branch (main or master)
263
+ * Get the default base branch: the remote's, or "main" when it has none.
247
264
  */
248
- export function getBaseBranch(workingDir: string): string {
265
+ export async function getBaseBranch(workingDir: string): Promise<string> {
249
266
  try {
250
- // Try to get from remote HEAD
251
- return remoteDefaultBranch(workingDir);
267
+ const ref = await run(
268
+ "git",
269
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
270
+ workingDir,
271
+ 10_000
272
+ );
273
+ return ref.trim().replace(/^origin\//, "");
252
274
  } catch {
253
275
  return "main";
254
276
  }
@@ -662,6 +662,13 @@ class SessionStatusDetector {
662
662
  return [...this.cache.data.values()];
663
663
  }
664
664
 
665
+ // A new object whenever the listing or its errors change (the cache is
666
+ // replaced, never edited): readers derived from them can skip the work
667
+ // while it's the same one.
668
+ listingStamp(): object {
669
+ return this.cache;
670
+ }
671
+
665
672
  hostErrors(): Record<string, string> {
666
673
  return Object.fromEntries(this.cache.hostErrors);
667
674
  }
@@ -85,10 +85,19 @@ async function ownHooks(worktree: string): Promise<string> {
85
85
  return sh(path.join(common, "hooks"));
86
86
  }
87
87
 
88
+ // Husky writes `.husky/_` on `npm install`, and it's gitignored, so a new
89
+ // worktree has none and its hooks would silently not run: run the hook
90
+ // script from `.husky/` the way husky's own runner does.
88
91
  const delegate = (own: string, hook: string) =>
89
92
  `root="$(git rev-parse --show-toplevel)"
90
93
  own=${own}/${hook}
91
94
  [ -x "$own" ] && exec "$own" "$@"
95
+ case "$own" in
96
+ */.husky/_/${hook})
97
+ script="\${own%/_/${hook}}/${hook}"
98
+ [ -f "$script" ] && [ "\${HUSKY-}" != 0 ] && PATH="node_modules/.bin:$PATH" exec sh -e "$script" "$@"
99
+ ;;
100
+ esac
92
101
  exit 0
93
102
  `;
94
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.461",
3
+ "version": "0.3.463",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { execFileSync, spawnSync } from "child_process";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import path from "path";
6
+ import { scanDiff } from "./staged-secrets";
7
+
8
+ // Fakes are built here, never written whole: a literal would be the very
9
+ // thing this scan (and the merge gate) refuses.
10
+ const fake = {
11
+ github: ["gh", "p_", "a1B2".repeat(9)].join(""),
12
+ anthropic: ["sk", "-ant-", "x9Y8".repeat(8)].join(""),
13
+ aws: ["AK", "IA", "Q7".repeat(8)].join(""),
14
+ };
15
+
16
+ const diffOf = (file: string, start: number, lines: string[]) =>
17
+ [
18
+ `diff --git a/${file} b/${file}`,
19
+ "index 0000000..1111111 100644",
20
+ `--- a/${file}`,
21
+ `+++ b/${file}`,
22
+ `@@ -0,0 +${start},${lines.length} @@`,
23
+ ...lines.map((l) => `+${l}`),
24
+ ].join("\n");
25
+
26
+ describe("scanDiff", () => {
27
+ it("names the file and line of each token-shaped string", () => {
28
+ const diff = [
29
+ diffOf("lib/a.ts", 10, ["const ok = 1;", `const t = "${fake.github}";`]),
30
+ diffOf("docs/b.md", 3, [`key: ${fake.anthropic}`, "", fake.aws]),
31
+ ].join("\n");
32
+ expect(scanDiff(diff)).toEqual([
33
+ { file: "lib/a.ts", line: 11, what: expect.stringMatching(/^ghp_/) },
34
+ { file: "docs/b.md", line: 3, what: expect.stringMatching(/^sk-/) },
35
+ { file: "docs/b.md", line: 5, what: expect.stringMatching(/^AKIA/) },
36
+ ]);
37
+ });
38
+
39
+ it("flags an env-style assignment of a real-looking value, not a placeholder", () => {
40
+ const diff = diffOf(".env.example", 1, [
41
+ `API_TOKEN=${"q7R".repeat(6)}42`,
42
+ "API_KEY=${API_KEY}",
43
+ "SECRET_KEY=changeme",
44
+ ]);
45
+ expect(scanDiff(diff)).toEqual([
46
+ { file: ".env.example", line: 1, what: "API_TOKEN = …" },
47
+ ]);
48
+ });
49
+
50
+ it("passes code that builds a fake at runtime", () => {
51
+ const diff = diffOf("lib/c.test.ts", 1, [
52
+ 'const t = ["gh", "p_", "a".repeat(36)].join("");',
53
+ ]);
54
+ expect(scanDiff(diff)).toEqual([]);
55
+ });
56
+
57
+ it("doesn't take an added line starting with ++ for a file header", () => {
58
+ const diff = diffOf("lib/d.ts", 1, [`++ ${fake.github}`]);
59
+ expect(scanDiff(diff)).toEqual([
60
+ { file: "lib/d.ts", line: 1, what: expect.stringMatching(/^ghp_/) },
61
+ ]);
62
+ });
63
+
64
+ it("fails the commit on a staged secret, and passes once it's gone", () => {
65
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "staged-secrets-"));
66
+ const git = (...args: string[]) =>
67
+ execFileSync("git", args, { cwd: repo, stdio: "pipe" });
68
+ const scan = () =>
69
+ spawnSync(
70
+ path.resolve("node_modules/.bin/tsx"),
71
+ [path.resolve("scripts/hooks/staged-secrets.ts")],
72
+ { cwd: repo, encoding: "utf-8" }
73
+ );
74
+ try {
75
+ git("init", "-q");
76
+ fs.writeFileSync(
77
+ path.join(repo, "x.ts"),
78
+ `const t = "${fake.github}";\n`
79
+ );
80
+ git("add", "x.ts");
81
+ const bad = scan();
82
+ expect(bad.status).toBe(1);
83
+ expect(bad.stderr).toContain("x.ts:1");
84
+ expect(bad.stderr).toContain("at runtime");
85
+ fs.writeFileSync(path.join(repo, "x.ts"), "const t = process.env.T;\n");
86
+ git("add", "x.ts");
87
+ expect(scan().status).toBe(0);
88
+ } finally {
89
+ fs.rmSync(repo, { recursive: true, force: true });
90
+ }
91
+ });
92
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The pre-commit secret scan: the staged diff's added lines, read with the
3
+ * merge gate's own rule (lib/orchestrator/diff.ts secretsIn), so a commit
4
+ * that would fail the gate fails here first, naming the file and line.
5
+ */
6
+
7
+ import { execFileSync } from "child_process";
8
+ import { secretsIn } from "../../lib/orchestrator/diff";
9
+
10
+ export interface SecretHit {
11
+ file: string;
12
+ line: number;
13
+ what: string;
14
+ }
15
+
16
+ // Each added line of a `git diff -U0` with the file and new line number it
17
+ // lands on. Headers are only read before a file's first hunk, so an added
18
+ // line that itself starts with "++" isn't taken for one.
19
+ export function scanDiff(diff: string): SecretHit[] {
20
+ const hits: SecretHit[] = [];
21
+ let file = "";
22
+ let line = 0;
23
+ let header = false;
24
+ for (const l of diff.split("\n")) {
25
+ if (l.startsWith("diff --git ")) {
26
+ header = true;
27
+ } else if (header && l.startsWith("+++ ")) {
28
+ file = l.slice(4).replace(/^b\//, "");
29
+ } else if (l.startsWith("@@")) {
30
+ header = false;
31
+ line = Number(l.match(/\+(\d+)/)?.[1] ?? 0);
32
+ } else if (!header && l.startsWith("+")) {
33
+ const what = secretsIn(l.slice(1));
34
+ if (what) hits.push({ file, line, what });
35
+ line++;
36
+ }
37
+ }
38
+ return hits;
39
+ }
40
+
41
+ if (process.argv[1]?.endsWith("staged-secrets.ts")) {
42
+ const diff = execFileSync(
43
+ "git",
44
+ [
45
+ "diff",
46
+ "--cached",
47
+ "-U0",
48
+ "--no-color",
49
+ "--no-ext-diff",
50
+ "--diff-filter=ACMR",
51
+ ],
52
+ { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }
53
+ );
54
+ const hits = scanDiff(diff);
55
+ for (const h of hits)
56
+ console.error(`✗ ${h.file}:${h.line}: looks like a secret (${h.what})`);
57
+ if (hits.length) {
58
+ console.error(
59
+ "The merge gate refuses token-shaped strings. For a test fixture, build the fake at runtime\n" +
60
+ '(e.g. "gh" + "p_" + "a".repeat(36)) instead of writing it as a literal.'
61
+ );
62
+ process.exit(1);
63
+ }
64
+ }