@zhalker/gentle-shell-codegraph-subagents 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +61 -46
- package/extensions/index.ts +99 -38
- package/package.json +39 -20
- package/skills/codegraph-workflow/SKILL.md +45 -0
- package/src/agents.ts +116 -111
- package/src/apply.ts +119 -60
- package/src/autoindex.ts +167 -0
- package/src/daemonclient.ts +122 -0
- package/src/initlock.ts +230 -0
- package/src/mcp.ts +93 -25
- package/src/prefetch.ts +167 -142
- package/src/skill-reminder.ts +33 -0
- package/src/warmup.ts +86 -0
package/src/agents.ts
CHANGED
|
@@ -1,111 +1,116 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"gentle-ai-
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"jd-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
} else {
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
let
|
|
68
|
-
let
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
1
|
+
import { WORKER_MARKER } from "./skill-reminder.ts";
|
|
2
|
+
|
|
3
|
+
export const CODEGRAPH_MCP_TOOL = "mcp__codegraph__codegraph_explore";
|
|
4
|
+
|
|
5
|
+
const EXPLORER = "gentle-ai-explore";
|
|
6
|
+
const AGENTS_WITH_MCP_TOOL = new Set([
|
|
7
|
+
"gentle-ai-explore",
|
|
8
|
+
"gentle-ai-verify",
|
|
9
|
+
"gentle-ai-worker",
|
|
10
|
+
"jd-fix-agent",
|
|
11
|
+
"jd-judge-a",
|
|
12
|
+
"jd-judge-b",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const FRONTMATTER = /^(---\r?\n)([\s\S]*?)(\r?\n---)([\s\S]*)$/;
|
|
16
|
+
const GENTLE_AI_BLOCK = /<!-- gentle-ai:pi-codegraph-(?:tool|guidance) -->[\s\S]*?<!-- \/gentle-ai:pi-codegraph -->/g;
|
|
17
|
+
const MARKER = "\u0000";
|
|
18
|
+
|
|
19
|
+
function unquote(value: string): string {
|
|
20
|
+
const v = value.trim();
|
|
21
|
+
return v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0] ? v.slice(1, -1) : v;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The gentle-pi agent files ship a `- "*": false` list entry; Pi reads it as a tool named `*": false`.
|
|
25
|
+
function isBrokenEntry(item: string): boolean {
|
|
26
|
+
return /\*["']?\s*:\s*false/.test(item);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const EXPLORER_STRUCTURAL_LINE = "- For structural questions, use the cwd-scoped `codegraph` tool";
|
|
30
|
+
const EXPLORER_INDEX_LINE = "- `codegraph` may create or update only the current workspace `.codegraph/` index";
|
|
31
|
+
const EXPLORER_READ_ONLY_PHRASE = "Other than the explicit `.codegraph/` index exception, read and search only.";
|
|
32
|
+
|
|
33
|
+
// The explorer loses its `codegraph` extension tool, so the lines that tell it to use or initialize that tool would send it nowhere.
|
|
34
|
+
function rewriteExplorerInstructions(body: string, eol: string): string {
|
|
35
|
+
if (!body.includes("codegraph")) return body;
|
|
36
|
+
const out: string[] = [];
|
|
37
|
+
for (const line of body.split(eol)) {
|
|
38
|
+
if (line.startsWith(EXPLORER_STRUCTURAL_LINE)) {
|
|
39
|
+
out.push(`- For structural questions, use the \`${CODEGRAPH_MCP_TOOL}\` tool before broad filesystem searches. It reads the existing \`.codegraph/\` index and cannot create one; when the project has none, use \`read\`, \`grep\`, and \`find\` and say so in the handoff.`);
|
|
40
|
+
} else if (line.startsWith(EXPLORER_INDEX_LINE)) {
|
|
41
|
+
continue;
|
|
42
|
+
} else {
|
|
43
|
+
out.push(line.replace(EXPLORER_READ_ONLY_PHRASE, "Read and search only."));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out.join(eol);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function removeBlocks(body: string, eol: string): string {
|
|
50
|
+
if (!body.includes("gentle-ai:pi-codegraph")) return body;
|
|
51
|
+
const marked = body.replace(GENTLE_AI_BLOCK, MARKER);
|
|
52
|
+
return marked.replace(/\s*\u0000(?:\s*\u0000)*\s*/g, (match, offset: number) => {
|
|
53
|
+
if (offset === 0) return "";
|
|
54
|
+
return offset + match.length >= marked.length ? eol : eol + eol;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
function editTools(front: string, eol: string): string {
|
|
60
|
+
const lines = front.split(/\r?\n/);
|
|
61
|
+
const nameLine = lines.find((line) => /^name:/.test(line));
|
|
62
|
+
const name = nameLine ? unquote(nameLine.slice("name:".length)) : "";
|
|
63
|
+
const at = lines.findIndex((line) => /^tools:/.test(line));
|
|
64
|
+
if (at === -1) return front;
|
|
65
|
+
|
|
66
|
+
const rest = lines[at].slice("tools:".length).trim();
|
|
67
|
+
let style: "bracket" | "csv" | "list";
|
|
68
|
+
let items: string[];
|
|
69
|
+
let consumed = 1;
|
|
70
|
+
let indent = " ";
|
|
71
|
+
if (rest.startsWith("[")) {
|
|
72
|
+
style = "bracket";
|
|
73
|
+
items = rest.replace(/^\[/, "").replace(/\]$/, "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
74
|
+
} else if (rest.length > 0) {
|
|
75
|
+
style = "csv";
|
|
76
|
+
items = rest.split(",").map((s) => s.trim()).filter(Boolean);
|
|
77
|
+
} else {
|
|
78
|
+
style = "list";
|
|
79
|
+
items = [];
|
|
80
|
+
for (let i = at + 1; i < lines.length; i++) {
|
|
81
|
+
const m = /^(\s*)-\s+(.*)$/.exec(lines[i]);
|
|
82
|
+
if (!m) break;
|
|
83
|
+
if (items.length === 0) indent = m[1];
|
|
84
|
+
items.push(m[2].trim());
|
|
85
|
+
consumed++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (items.length === 0) return front;
|
|
89
|
+
|
|
90
|
+
let next = items.filter((item) => unquote(item) !== "mcp" && !isBrokenEntry(item));
|
|
91
|
+
if (name === EXPLORER) next = next.filter((item) => unquote(item) !== "codegraph");
|
|
92
|
+
if (AGENTS_WITH_MCP_TOOL.has(name) && !next.some((item) => unquote(item) === CODEGRAPH_MCP_TOOL)) next.push(CODEGRAPH_MCP_TOOL);
|
|
93
|
+
// An empty list would hand the agent Pi's default tools; keep what was there instead.
|
|
94
|
+
if (next.length === 0 || next.join("\n") === items.join("\n")) return front;
|
|
95
|
+
|
|
96
|
+
const replacement =
|
|
97
|
+
style === "list" ? ["tools:", ...next.map((item) => `${indent}- ${item}`)]
|
|
98
|
+
: style === "bracket" ? [`tools: [${next.join(", ")}]`]
|
|
99
|
+
: [`tools: ${next.join(", ")}`];
|
|
100
|
+
return [...lines.slice(0, at), ...replacement, ...lines.slice(at + consumed)].join(eol);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Returns the agent definition with the CodeGraph wiring applied; identical text when nothing changes. */
|
|
104
|
+
export function normalizeAgent(text: string): string {
|
|
105
|
+
const match = FRONTMATTER.exec(text);
|
|
106
|
+
if (!match) return text;
|
|
107
|
+
const [, open, front, close, body] = match;
|
|
108
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
109
|
+
const name = /^name:\s*(.+)$/m.exec(front)?.[1];
|
|
110
|
+
const cleaned = removeBlocks(body, eol);
|
|
111
|
+
let rewritten = name !== undefined && unquote(name) === EXPLORER ? rewriteExplorerInstructions(cleaned, eol) : cleaned;
|
|
112
|
+
if (name !== undefined && unquote(name) === "gentle-ai-worker" && !rewritten.includes(WORKER_MARKER)) {
|
|
113
|
+
rewritten = rewritten.trimEnd() + eol + eol + WORKER_MARKER + eol;
|
|
114
|
+
}
|
|
115
|
+
return open + editTools(front, eol) + close + rewritten;
|
|
116
|
+
}
|
package/src/apply.ts
CHANGED
|
@@ -1,60 +1,119 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { normalizeAgent } from "./agents.ts";
|
|
4
|
-
import { normalizeMcpConfig } from "./mcp.ts";
|
|
5
|
-
|
|
6
|
-
export interface ApplyResult {
|
|
7
|
-
/** Files that were rewritten. */
|
|
8
|
-
changed: string[];
|
|
9
|
-
/** One message per file that could not be processed. */
|
|
10
|
-
errors: string[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const BACKUP_SUFFIX = ".pre-codegraph-subagents.bak";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { normalizeAgent } from "./agents.ts";
|
|
4
|
+
import { inspectEffectiveMcpExposure, isCodegraphServerMissing, normalizeMcpConfig } from "./mcp.ts";
|
|
5
|
+
|
|
6
|
+
export interface ApplyResult {
|
|
7
|
+
/** Files that were rewritten. */
|
|
8
|
+
changed: string[];
|
|
9
|
+
/** One message per file that could not be processed. */
|
|
10
|
+
errors: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BACKUP_SUFFIX = ".pre-codegraph-subagents.bak";
|
|
14
|
+
|
|
15
|
+
/** Reads the effective file settings only; untrusted project configuration is not loaded by Pi. */
|
|
16
|
+
export function readExposureWarning(agentDir: string, cwd: string, projectTrusted: boolean): string | undefined {
|
|
17
|
+
const read = (path: string): string | undefined => {
|
|
18
|
+
try { return readFileSync(path, "utf8"); } catch { return undefined; }
|
|
19
|
+
};
|
|
20
|
+
const global = read(join(agentDir, "mcp.json")) ?? "{}";
|
|
21
|
+
const project = projectTrusted ? read(join(cwd, ".pi", "mcp.json")) : undefined;
|
|
22
|
+
return inspectEffectiveMcpExposure(global, project);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Reports absent file configuration without creating it; registered servers also count. */
|
|
26
|
+
export function readMissingServerWarning(agentDir: string, cwd: string, projectTrusted: boolean, registered = false): boolean {
|
|
27
|
+
const read = (path: string): string | undefined => {
|
|
28
|
+
try { return readFileSync(path, "utf8"); } catch (error) {
|
|
29
|
+
return codeOf(error) === "ENOENT" ? undefined : "invalid";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const global = read(join(agentDir, "mcp.json")) ?? "{}";
|
|
33
|
+
const project = projectTrusted ? read(join(cwd, ".pi", "mcp.json")) : undefined;
|
|
34
|
+
return isCodegraphServerMissing(global, project, registered);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Write beside the target and rename, so a reader never sees a half-written file.
|
|
38
|
+
function writeAtomic(file: string, text: string): void {
|
|
39
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(temp, text);
|
|
42
|
+
renameSync(temp, file);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
try { unlinkSync(temp); } catch { /* nothing to clean */ }
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Several sessions can start at the same moment and rewrite the same files: on Windows a rename over a file
|
|
50
|
+
// another session is replacing fails with one of these, and a retry finds the work already done.
|
|
51
|
+
const TRANSIENT_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOENT"]);
|
|
52
|
+
const ATTEMPTS = 8;
|
|
53
|
+
|
|
54
|
+
const codeOf = (error: unknown): string | undefined => (error as { code?: string } | null)?.code;
|
|
55
|
+
|
|
56
|
+
function pauseBriefly(): void {
|
|
57
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 + Math.floor(Math.random() * 30));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The backup holds the original, so whoever gets there first wins and the others leave it alone.
|
|
61
|
+
function saveBackupOnce(file: string, original: string): void {
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(file + BACKUP_SUFFIX, original, { flag: "wx" });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (codeOf(error) !== "EEXIST") throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function alreadyNormalized(file: string, normalize: (text: string) => string): boolean {
|
|
70
|
+
try {
|
|
71
|
+
const text = readFileSync(file, "utf8");
|
|
72
|
+
return normalize(text) === text;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function rewrite(file: string, normalize: (text: string) => string, result: ApplyResult): void {
|
|
79
|
+
let failure: unknown;
|
|
80
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
const before = readFileSync(file, "utf8");
|
|
83
|
+
const after = normalize(before);
|
|
84
|
+
// Already right: nothing to do, or another session just did it.
|
|
85
|
+
if (after === before) return;
|
|
86
|
+
saveBackupOnce(file, before);
|
|
87
|
+
writeAtomic(file, after);
|
|
88
|
+
result.changed.push(file);
|
|
89
|
+
return;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
failure = error;
|
|
92
|
+
if (!TRANSIENT_CODES.has(codeOf(error) ?? "")) break;
|
|
93
|
+
pauseBriefly();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (alreadyNormalized(file, normalize)) return;
|
|
97
|
+
result.errors.push(`${file}: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Applies the CodeGraph wiring to the global agent definitions and to mcp.json
|
|
102
|
+
* under `agentDir`. Idempotent and never throws: failures are returned in `errors`.
|
|
103
|
+
*/
|
|
104
|
+
export function applyCodegraphSubagents(agentDir: string): ApplyResult {
|
|
105
|
+
const result: ApplyResult = { changed: [], errors: [] };
|
|
106
|
+
for (const folder of ["agents", "subagents"]) {
|
|
107
|
+
const dir = join(agentDir, folder);
|
|
108
|
+
let entries: string[];
|
|
109
|
+
try {
|
|
110
|
+
entries = readdirSync(dir).filter((entry) => entry.toLowerCase().endsWith(".md")).sort();
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
for (const entry of entries) rewrite(join(dir, entry), normalizeAgent, result);
|
|
115
|
+
}
|
|
116
|
+
const mcp = join(agentDir, "mcp.json");
|
|
117
|
+
if (existsSync(mcp)) rewrite(mcp, normalizeMcpConfig, result);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
package/src/autoindex.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { codegraphWritingIndex, type InitLock } from "./initlock.ts";
|
|
4
|
+
import { killTree } from "./prefetch.ts";
|
|
5
|
+
|
|
6
|
+
export { codegraphWritingIndex } from "./initlock.ts";
|
|
7
|
+
|
|
8
|
+
/** One of these next to a `.git` is what makes a directory a project worth indexing automatically. */
|
|
9
|
+
export const INIT_PROJECT_MARKERS = ["composer.json", "package.json", "pyproject.toml", "go.mod", "Cargo.toml", "Gemfile", "pom.xml", "build.gradle", "build.gradle.kts"];
|
|
10
|
+
|
|
11
|
+
export const SYNC_TIMEOUT_MS = 120_000;
|
|
12
|
+
export const INIT_TIMEOUT_MS = 600_000;
|
|
13
|
+
|
|
14
|
+
const CHILD_ENV = "GENTLE_PI_AGENTS_CHILD";
|
|
15
|
+
|
|
16
|
+
export type IndexAction = "sync" | "init" | "none";
|
|
17
|
+
export type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
|
|
18
|
+
|
|
19
|
+
/** An existing index is synced; a fresh git project with a marker file is initialised; anything else is left alone. */
|
|
20
|
+
export function planIndexAction(cwd: string, exists: (path: string) => boolean): IndexAction {
|
|
21
|
+
if (exists(join(cwd, ".codegraph"))) return "sync";
|
|
22
|
+
if (exists(join(cwd, ".git")) && INIT_PROJECT_MARKERS.some((marker) => exists(join(cwd, marker)))) return "init";
|
|
23
|
+
return "none";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AutoIndexInput {
|
|
27
|
+
/** Where Pi was started. */
|
|
28
|
+
cwd: string;
|
|
29
|
+
env: Record<string, string | undefined>;
|
|
30
|
+
reason: SessionStartReason;
|
|
31
|
+
exists(path: string): boolean;
|
|
32
|
+
/** One init per project across sessions; absent means no coordination (the unit tests). */
|
|
33
|
+
lock?: InitLock;
|
|
34
|
+
/** Called right before the command starts, so the user can be told what is going on. */
|
|
35
|
+
onStart?(action: "sync" | "init"): void;
|
|
36
|
+
/** Whether CodeGraph is writing this index right now; defaults to {@link codegraphWritingIndex}. */
|
|
37
|
+
codegraphBusy?(cwd: string): boolean;
|
|
38
|
+
/** Runs `codegraph <args>` in `cwd`; true when it exited 0. `onSpawn` gets the pid of that process. */
|
|
39
|
+
run(args: string[], cwd: string, timeoutMs: number, onSpawn?: (pid: number, options?: { proxy?: boolean }) => void): Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Subagent children share the parent's cwd and the parent already did the work; a /reload is not a new session. */
|
|
43
|
+
function skipsSession(input: Pick<AutoIndexInput, "env" | "reason">): boolean {
|
|
44
|
+
return input.env[CHILD_ENV] === "1" || input.reason === "reload";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AutoIndexResult {
|
|
48
|
+
action: IndexAction;
|
|
49
|
+
ok: boolean;
|
|
50
|
+
/** Another session was already creating the index, so this one left it alone. */
|
|
51
|
+
joined?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Brings the CodeGraph index of the project Pi started in up to date, or creates it. Never rejects. */
|
|
55
|
+
export async function autoIndex(input: AutoIndexInput): Promise<AutoIndexResult> {
|
|
56
|
+
try {
|
|
57
|
+
if (skipsSession(input)) return { action: "none", ok: true };
|
|
58
|
+
const action = planIndexAction(input.cwd, input.exists);
|
|
59
|
+
if (action === "none") return { action, ok: true };
|
|
60
|
+
const announce = () => { try { input.onStart?.(action); } catch { /* telling the user is never worth stopping the index */ } };
|
|
61
|
+
if (action === "sync") {
|
|
62
|
+
// Another session is creating the index: nothing may touch it meanwhile, a sync included.
|
|
63
|
+
if (input.lock?.isHeld(input.cwd)) return { action, ok: true, joined: true };
|
|
64
|
+
announce();
|
|
65
|
+
return { action, ok: await input.run(["sync", "-q"], input.cwd, SYNC_TIMEOUT_MS) };
|
|
66
|
+
}
|
|
67
|
+
const attempt = input.lock?.acquire(input.cwd);
|
|
68
|
+
if (attempt?.status === "busy") return { action, ok: true, joined: true };
|
|
69
|
+
const adopt = attempt?.status === "acquired" ? attempt.adopt : () => {};
|
|
70
|
+
try {
|
|
71
|
+
if (input.exists(join(input.cwd, ".codegraph"))) {
|
|
72
|
+
// Another party (another tool, a terminal) created the index while this session waited for the lock.
|
|
73
|
+
return { action, ok: true, joined: true };
|
|
74
|
+
}
|
|
75
|
+
announce();
|
|
76
|
+
// The lock records the pid of the init, so it stays held while that process lives, even after this session.
|
|
77
|
+
const initialised = await input.run(["init", "-y"], input.cwd, INIT_TIMEOUT_MS, (pid, options) => adopt(pid, options));
|
|
78
|
+
if (!initialised && (input.codegraphBusy ?? codegraphWritingIndex)(input.cwd)) {
|
|
79
|
+
// CodeGraph's own lock still names a live init: another party is building this index, not this session.
|
|
80
|
+
return { action, ok: true, joined: true };
|
|
81
|
+
}
|
|
82
|
+
// A failure of this session's own init, with or without a half-built index, is a failure.
|
|
83
|
+
return { action, ok: initialised };
|
|
84
|
+
} finally {
|
|
85
|
+
attempt?.release();
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
return { action: planIndexAction(input.cwd, input.exists), ok: false };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** How long a killed init may take to be gone before the timeout is reported anyway. */
|
|
93
|
+
const KILL_WAIT_MS = 10_000;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Runs the codegraph CLI with fixed arguments; the project is the working directory, never an argument.
|
|
97
|
+
* `onSpawn` gets the pid of the process, which the init lock records, and whether that pid is only the shell
|
|
98
|
+
* that was spawned around the real init (Windows runs the npm shim through a shell). A timeout kills the
|
|
99
|
+
* process tree and only reports the failure once that process is gone: its pid must not outlive the result.
|
|
100
|
+
*/
|
|
101
|
+
export function runCodegraph(args: string[], cwd: string, timeoutMs: number, onSpawn?: (pid: number, options?: { proxy?: boolean }) => void): Promise<boolean> {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
let settled = false;
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
+
const finish = (ok: boolean) => {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
clearTimeout(grace);
|
|
111
|
+
resolve(ok);
|
|
112
|
+
};
|
|
113
|
+
let child;
|
|
114
|
+
// Windows needs a shell to run the .cmd shim that npm installs for codegraph: the pid that comes back is
|
|
115
|
+
// that shell, not the process that ends up writing the index.
|
|
116
|
+
const shelled = process.platform === "win32";
|
|
117
|
+
try {
|
|
118
|
+
child = shelled
|
|
119
|
+
? spawn(["codegraph", ...args].join(" "), { cwd, shell: true, windowsHide: true, stdio: "ignore" })
|
|
120
|
+
: spawn("codegraph", args, { cwd, windowsHide: true, stdio: "ignore" });
|
|
121
|
+
} catch {
|
|
122
|
+
resolve(false);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (child.pid !== undefined) {
|
|
126
|
+
try { onSpawn?.(child.pid, { proxy: shelled }); } catch { /* recording the pid is never worth failing the init */ }
|
|
127
|
+
}
|
|
128
|
+
timer = setTimeout(() => {
|
|
129
|
+
killTree(child.pid, child);
|
|
130
|
+
// A killed init may still be closing; the lock lookup waits for it, up to a point.
|
|
131
|
+
grace = setTimeout(() => finish(false), KILL_WAIT_MS);
|
|
132
|
+
}, timeoutMs);
|
|
133
|
+
child.on("error", () => finish(false));
|
|
134
|
+
child.on("close", (code) => finish(code === 0));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PrepareInput extends AutoIndexInput {
|
|
139
|
+
/** Called as soon as the sync or init is over, before the warm-up starts. */
|
|
140
|
+
onIndexed?(result: AutoIndexResult): void;
|
|
141
|
+
/** Called right before the daemon warm-up starts. */
|
|
142
|
+
onWarmStart?(): void;
|
|
143
|
+
/** Makes the project's daemon ready for its first real call; true when it worked. */
|
|
144
|
+
warm(cwd: string): Promise<boolean>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PrepareResult {
|
|
148
|
+
index: AutoIndexResult;
|
|
149
|
+
warm: "ok" | "failed" | "skipped";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The index first (sync or init), then, when an index exists, the daemon warm-up. Never rejects. */
|
|
153
|
+
export async function prepareProject(input: PrepareInput): Promise<PrepareResult> {
|
|
154
|
+
const index = await autoIndex(input);
|
|
155
|
+
if (index.action !== "none") {
|
|
156
|
+
try { input.onIndexed?.(index); } catch { /* telling the user is never worth stopping the warm-up */ }
|
|
157
|
+
}
|
|
158
|
+
// A sync that failed still leaves an index to warm; a failed init leaves nothing worth warming.
|
|
159
|
+
const hasIndex = !index.joined && (index.action === "sync" || (index.action === "init" && index.ok));
|
|
160
|
+
if (skipsSession(input) || !hasIndex) return { index, warm: "skipped" };
|
|
161
|
+
try { input.onWarmStart?.(); } catch { /* telling the user is never worth stopping the warm-up */ }
|
|
162
|
+
try {
|
|
163
|
+
return { index, warm: (await input.warm(input.cwd)) ? "ok" : "failed" };
|
|
164
|
+
} catch {
|
|
165
|
+
return { index, warm: "failed" };
|
|
166
|
+
}
|
|
167
|
+
}
|