@saadnvd1/agent-os 0.3.462 → 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 +4 -0
- package/app/api/code-search/available/route.ts +1 -1
- package/app/api/code-search/route.ts +5 -3
- package/app/api/files/upload-temp/route.ts +3 -5
- package/app/api/git/file-content/route.ts +15 -7
- package/app/api/git/pr/route.ts +43 -28
- package/app/api/sessions/[id]/send-keys/route.ts +2 -4
- package/app/api/sessions/[id]/summarize/route.ts +16 -7
- package/app/api/sessions/init-script/route.ts +1 -1
- package/data/code-search/queries.ts +5 -3
- package/lib/code-search.ts +86 -60
- package/lib/db/queries.ts +13 -0
- package/lib/hosts/discover-signature.ts +36 -5
- package/lib/hosts/discover.ts +10 -3
- package/lib/pr-generation.ts +37 -27
- package/lib/pr.ts +57 -35
- package/lib/status-detector.ts +7 -0
- package/package.json +1 -1
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.
|
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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) {
|
package/app/api/git/pr/route.ts
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 =
|
|
45
|
-
|
|
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
|
-
//
|
|
56
|
-
const existingPR =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
47
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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,
|
package/lib/code-search.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
101
|
+
if (parsed.type !== "match") return;
|
|
102
|
+
matches.push(parsed);
|
|
103
|
+
if (matches.length >= maxResults) stop();
|
|
89
104
|
} catch {
|
|
90
|
-
|
|
105
|
+
// A line cut off at the output cap.
|
|
91
106
|
}
|
|
92
|
-
}
|
|
107
|
+
});
|
|
93
108
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
1
|
+
import { getDb, queries } from "../db";
|
|
2
2
|
import { statusDetector } from "../status-detector";
|
|
3
|
-
import {
|
|
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.
|
|
13
|
-
queries.
|
|
36
|
+
queries.getProjectFolders(db).all() as ProjectFolder[],
|
|
37
|
+
queries.getManagedTmuxNames(db).all() as ManagedTmux[]
|
|
14
38
|
);
|
|
15
|
-
|
|
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
|
}
|
package/lib/hosts/discover.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
53
|
-
managed:
|
|
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(
|
package/lib/pr-generation.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 =
|
|
229
|
+
const output = await run(
|
|
213
230
|
"gh",
|
|
214
231
|
["pr", "create", "--title", title, "--base", baseBranch, "--body", body],
|
|
215
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
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
|
}
|
package/lib/status-detector.ts
CHANGED
|
@@ -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
|
}
|