@saadnvd1/agent-os 0.3.462 → 0.3.464
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 +11 -2
- 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/orchestrator/rules.ts +1 -1
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/pr-generation.ts +37 -27
- package/lib/pr.ts +57 -35
- package/lib/sidebar/machines.ts +1 -1
- package/lib/status-detector.ts +7 -0
- package/package.json +1 -2
- package/scripts/check +2 -1
- package/scripts/hooks/banned-terms.test.ts +93 -0
- package/scripts/hooks/banned-terms.ts +103 -0
- package/scripts/hooks/staged-secrets.ts +13 -8
package/README.md
CHANGED
|
@@ -947,7 +947,7 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
947
947
|
- **Decision log:** `note` and the brakes and escalations write to
|
|
948
948
|
`orchestrator_notes`, and each line shows in its chat.
|
|
949
949
|
- **Standing rules:** what it must keep knowing ("never merge #111", "briefs
|
|
950
|
-
must not name
|
|
950
|
+
must not name third-party products") it keeps with `rule_add` and drops with
|
|
951
951
|
`rule_remove`, in `orchestrator_rules`. You see, add, edit and remove them in
|
|
952
952
|
Settings → Workspaces, each change with your passkey (any process on the
|
|
953
953
|
machine reaches the route, and a rule of yours is your word to it). A rule restricts or informs; it never approves an
|
|
@@ -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.
|
|
@@ -1269,7 +1273,12 @@ added lines for token-shaped secrets with the merge gate's own rule
|
|
|
1269
1273
|
(`scripts/hooks/staged-secrets.ts` reads `secretsIn` from
|
|
1270
1274
|
`lib/orchestrator/diff.ts`), naming the file and line. Build a test's fake
|
|
1271
1275
|
token at runtime (`"gh" + "p_" + "a".repeat(36)`) rather than writing it
|
|
1272
|
-
whole. It
|
|
1276
|
+
whole. It also refuses words that must not land in this public repo
|
|
1277
|
+
(`scripts/hooks/banned-terms.ts`): a short built-in list, plus, when it
|
|
1278
|
+
exists, a private list of regexes kept outside the repo in
|
|
1279
|
+
`~/.config/agent-os/banned-names` (`AGENTOS_BANNED_NAMES` overrides the
|
|
1280
|
+
path), checked against added lines and file names; `scripts/check` runs the
|
|
1281
|
+
same check over every tracked file. It runs in the worktrees AgentOS makes too, which have no `.husky/_`
|
|
1273
1282
|
until `npm install` runs in them. Typecheck, lint and tests are left to CI.
|
|
1274
1283
|
In an emergency, `git commit --no-verify` skips it. CI runs `scripts/check --build` on every pull request and push to main,
|
|
1275
1284
|
which also holds the app's first-load JavaScript to a budget
|
|
@@ -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(
|