@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/src/prefetch.ts CHANGED
@@ -1,142 +1,167 @@
1
- import { spawn } from "node:child_process";
2
- import { join } from "node:path";
3
- import { CODEGRAPH_MCP_TOOL } from "./agents.ts";
4
-
5
- export const PREFETCH_TIMEOUT_MS = 20_000;
6
- export const PREFETCH_MAX_CHARS = 12_000;
7
- export const QUERY_MAX_CHARS = 300;
8
- const READ_LIMIT_BYTES = 200_000;
9
-
10
- const REQUESTED_TOOLS_ENV = "GENTLE_PI_AGENTS_REQUESTED_TOOLS";
11
- const CHILD_ENV = "GENTLE_PI_AGENTS_CHILD";
12
- const WRITING_TOOLS = ["bash", "edit", "write", "bash_background"];
13
-
14
- /**
15
- * gentle-pi gives a subagent child no env var with the agent's name, only its tool list.
16
- * The explorer is the one child that reads only and holds the CodeGraph MCP tool.
17
- */
18
- export function isExplorerChild(env: Record<string, string | undefined>): boolean {
19
- if (env[CHILD_ENV] !== "1") return false;
20
- const tools = (env[REQUESTED_TOOLS_ENV] ?? "").split(",").map((tool) => tool.trim());
21
- return tools.includes(CODEGRAPH_MCP_TOOL) && !tools.some((tool) => WRITING_TOOLS.includes(tool));
22
- }
23
-
24
- /**
25
- * The task text becomes arguments of a command that Windows runs through cmd.exe, so only
26
- * letters, digits and a few harmless marks survive, and no word can look like an option.
27
- */
28
- export function sanitizeQuery(task: string): string {
29
- const words = task
30
- .replace(/[^\p{L}\p{N}_.,:/-]+/gu, " ")
31
- .split(" ")
32
- .map((word) => word.replace(/^-+/, ""))
33
- .filter((word) => word.length > 0);
34
- return words.join(" ").slice(0, QUERY_MAX_CHARS).trim();
35
- }
36
-
37
- export function boundOutput(text: string, max = PREFETCH_MAX_CHARS): string {
38
- return text.length <= max ? text : `${text.slice(0, max)}\n\n[truncated: output cut at ${max} characters]`;
39
- }
40
-
41
- export interface RunOptions {
42
- command: string;
43
- args: string[];
44
- cwd: string;
45
- timeoutMs: number;
46
- maxBytes: number;
47
- /** Windows needs a shell to run the .cmd shim that npm installs for codegraph. */
48
- shell?: boolean;
49
- }
50
-
51
- function killTree(pid: number | undefined, child: { kill(): boolean }): void {
52
- if (process.platform === "win32" && pid !== undefined) {
53
- try {
54
- spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }).on("error", () => {});
55
- return;
56
- } catch { /* fall through */ }
57
- }
58
- try { child.kill(); } catch { /* already gone */ }
59
- }
60
-
61
- /** Output of a command that exits 0 and prints something; undefined for anything else. Never rejects. */
62
- export function runCommand(options: RunOptions): Promise<string | undefined> {
63
- return new Promise((resolve) => {
64
- let settled = false;
65
- const finish = (value: string | undefined) => {
66
- if (settled) return;
67
- settled = true;
68
- clearTimeout(timer);
69
- resolve(value);
70
- };
71
- let child;
72
- try {
73
- child = options.shell
74
- ? spawn([options.command, ...options.args].join(" "), { cwd: options.cwd, shell: true, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] })
75
- : spawn(options.command, options.args, { cwd: options.cwd, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
76
- } catch {
77
- resolve(undefined);
78
- return;
79
- }
80
- const timer = setTimeout(() => {
81
- killTree(child.pid, child);
82
- finish(undefined);
83
- }, options.timeoutMs);
84
- const chunks: Buffer[] = [];
85
- let size = 0;
86
- child.stdout?.on("data", (chunk: Buffer) => {
87
- size += chunk.length;
88
- if (size > options.maxBytes) {
89
- killTree(child.pid, child);
90
- finish(Buffer.concat(chunks).toString("utf8").slice(0, options.maxBytes));
91
- return;
92
- }
93
- chunks.push(chunk);
94
- });
95
- child.on("error", () => finish(undefined));
96
- child.on("close", (code) => {
97
- const text = Buffer.concat(chunks).toString("utf8");
98
- finish(code === 0 && text.trim().length > 0 ? text : undefined);
99
- });
100
- });
101
- }
102
-
103
- /** The CLI's explore output is the same as the codegraph_explore MCP tool. */
104
- export function exploreWithCodegraph(query: string, cwd: string): Promise<string | undefined> {
105
- const windows = process.platform === "win32";
106
- return runCommand({
107
- command: "codegraph",
108
- args: windows ? ["explore", ...query.split(" ")] : ["explore", query],
109
- cwd,
110
- timeoutMs: PREFETCH_TIMEOUT_MS,
111
- maxBytes: READ_LIMIT_BYTES,
112
- shell: windows,
113
- });
114
- }
115
-
116
- export interface PrefetchInput {
117
- env: Record<string, string | undefined>;
118
- prompt: string | undefined;
119
- cwd: string;
120
- exists(path: string): boolean;
121
- explore(query: string, cwd: string): Promise<string | undefined>;
122
- }
123
-
124
- /** Text to hand to the explorer before its first step; undefined means the run goes on untouched. */
125
- export async function prefetchForExplorer(input: PrefetchInput): Promise<string | undefined> {
126
- try {
127
- if (!isExplorerChild(input.env) || input.prompt === undefined) return undefined;
128
- const query = sanitizeQuery(input.prompt);
129
- if (query.length === 0) return undefined;
130
- if (!input.exists(join(input.cwd, ".codegraph"))) return undefined;
131
- const output = await input.explore(query, input.cwd);
132
- if (output === undefined || output.trim().length === 0) return undefined;
133
- return [
134
- "CodeGraph pre-fetch for your task, run for you before your first step with `codegraph explore` (bounded in size and time).",
135
- `Use it as a starting point and call ${CODEGRAPH_MCP_TOOL} when you need more.`,
136
- "",
137
- boundOutput(output.trim()),
138
- ].join("\n");
139
- } catch {
140
- return undefined;
141
- }
142
- }
1
+ import { spawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { CODEGRAPH_MCP_TOOL } from "./agents.ts";
4
+ import type { DaemonExploreResult } from "./daemonclient.ts";
5
+
6
+ export const PREFETCH_TIMEOUT_MS = 20_000;
7
+ export const PREFETCH_MAX_CHARS = 12_000;
8
+ export const QUERY_MAX_CHARS = 300;
9
+ const READ_LIMIT_BYTES = 200_000;
10
+
11
+ const REQUESTED_TOOLS_ENV = "GENTLE_PI_AGENTS_REQUESTED_TOOLS";
12
+ const CHILD_ENV = "GENTLE_PI_AGENTS_CHILD";
13
+ const WRITING_TOOLS = ["bash", "edit", "write", "bash_background"];
14
+
15
+ /**
16
+ * gentle-pi gives a subagent child no env var with the agent's name, only its tool list.
17
+ * The explorer is the one child that reads only and holds the CodeGraph MCP tool.
18
+ */
19
+ export function isExplorerChild(env: Record<string, string | undefined>): boolean {
20
+ if (env[CHILD_ENV] !== "1") return false;
21
+ const tools = (env[REQUESTED_TOOLS_ENV] ?? "").split(",").map((tool) => tool.trim());
22
+ return tools.includes(CODEGRAPH_MCP_TOOL) && !tools.some((tool) => WRITING_TOOLS.includes(tool));
23
+ }
24
+
25
+ /**
26
+ * The task text becomes arguments of a command that Windows runs through cmd.exe, so only
27
+ * letters, digits and a few harmless marks survive, and no word can look like an option.
28
+ */
29
+ export function sanitizeQuery(task: string): string {
30
+ const words = task
31
+ .replace(/[^\p{L}\p{N}_.,:/-]+/gu, " ")
32
+ .split(" ")
33
+ .map((word) => word.replace(/^-+/, ""))
34
+ .filter((word) => word.length > 0);
35
+ return words.join(" ").slice(0, QUERY_MAX_CHARS).trim();
36
+ }
37
+
38
+ export function boundOutput(text: string, max = PREFETCH_MAX_CHARS): string {
39
+ return text.length <= max ? text : `${text.slice(0, max)}\n\n[truncated: output cut at ${max} characters]`;
40
+ }
41
+
42
+ export interface RunOptions {
43
+ command: string;
44
+ args: string[];
45
+ cwd: string;
46
+ timeoutMs: number;
47
+ maxBytes: number;
48
+ /** Windows needs a shell to run the .cmd shim that npm installs for codegraph. */
49
+ shell?: boolean;
50
+ }
51
+
52
+ export function killTree(pid: number | undefined, child: { kill(): boolean }): void {
53
+ if (process.platform === "win32" && pid !== undefined) {
54
+ try {
55
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }).on("error", () => {});
56
+ return;
57
+ } catch { /* fall through */ }
58
+ }
59
+ try { child.kill(); } catch { /* already gone */ }
60
+ }
61
+
62
+ /** Output of a command that exits 0 and prints something; undefined for anything else. Never rejects. */
63
+ export function runCommand(options: RunOptions): Promise<string | undefined> {
64
+ return new Promise((resolve) => {
65
+ let settled = false;
66
+ const finish = (value: string | undefined) => {
67
+ if (settled) return;
68
+ settled = true;
69
+ clearTimeout(timer);
70
+ resolve(value);
71
+ };
72
+ let child;
73
+ try {
74
+ child = options.shell
75
+ ? spawn([options.command, ...options.args].join(" "), { cwd: options.cwd, shell: true, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] })
76
+ : spawn(options.command, options.args, { cwd: options.cwd, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
77
+ } catch {
78
+ resolve(undefined);
79
+ return;
80
+ }
81
+ const timer = setTimeout(() => {
82
+ killTree(child.pid, child);
83
+ finish(undefined);
84
+ }, options.timeoutMs);
85
+ const chunks: Buffer[] = [];
86
+ let size = 0;
87
+ child.stdout?.on("data", (chunk: Buffer) => {
88
+ size += chunk.length;
89
+ if (size > options.maxBytes) {
90
+ killTree(child.pid, child);
91
+ finish(Buffer.concat(chunks).toString("utf8").slice(0, options.maxBytes));
92
+ return;
93
+ }
94
+ chunks.push(chunk);
95
+ });
96
+ child.on("error", () => finish(undefined));
97
+ child.on("close", (code) => {
98
+ const text = Buffer.concat(chunks).toString("utf8");
99
+ finish(code === 0 && text.trim().length > 0 ? text : undefined);
100
+ });
101
+ });
102
+ }
103
+
104
+ /** The CLI's explore output is the same as the codegraph_explore MCP tool. */
105
+ export function exploreWithCodegraph(query: string, cwd: string): Promise<string | undefined> {
106
+ const windows = process.platform === "win32";
107
+ return runCommand({
108
+ command: "codegraph",
109
+ args: windows ? ["explore", ...query.split(" ")] : ["explore", query],
110
+ cwd,
111
+ timeoutMs: PREFETCH_TIMEOUT_MS,
112
+ maxBytes: READ_LIMIT_BYTES,
113
+ shell: windows,
114
+ });
115
+ }
116
+
117
+ export interface DaemonFirstDeps {
118
+ daemon(query: string, cwd: string): Promise<DaemonExploreResult>;
119
+ cli(query: string, cwd: string): Promise<string | undefined>;
120
+ }
121
+
122
+ /**
123
+ * Asks the project daemon first; only when there is no usable one does the CLI answer, as it always did.
124
+ * A daemon that was there but too slow is not retried through the CLI, which would be slower still.
125
+ */
126
+ export async function exploreDaemonFirst(query: string, cwd: string, deps: DaemonFirstDeps): Promise<string | undefined> {
127
+ try {
128
+ const result = await deps.daemon(query, cwd);
129
+ if (result.status === "ok") return result.text;
130
+ if (result.status === "timeout") return undefined;
131
+ } catch {
132
+ // Anything unexpected in the daemon path means the CLI takes over.
133
+ }
134
+ return deps.cli(query, cwd);
135
+ }
136
+
137
+ export interface PrefetchInput {
138
+ env: Record<string, string | undefined>;
139
+ prompt: string | undefined;
140
+ cwd: string;
141
+ exists(path: string): boolean;
142
+ /** Whether another session is creating the index of this cwd; nothing may touch it meanwhile. */
143
+ initRunning?(cwd: string): boolean;
144
+ explore(query: string, cwd: string): Promise<string | undefined>;
145
+ }
146
+
147
+ /** Text to hand to the explorer before its first step; undefined means the run goes on untouched. */
148
+ export async function prefetchForExplorer(input: PrefetchInput): Promise<string | undefined> {
149
+ try {
150
+ if (!isExplorerChild(input.env) || input.prompt === undefined) return undefined;
151
+ const query = sanitizeQuery(input.prompt);
152
+ if (query.length === 0) return undefined;
153
+ if (!input.exists(join(input.cwd, ".codegraph"))) return undefined;
154
+ // A daemon or CLI call while an init builds the index can hang that init for good, so this waits for the next run.
155
+ if (input.initRunning?.(input.cwd)) return undefined;
156
+ const output = await input.explore(query, input.cwd);
157
+ if (output === undefined || output.trim().length === 0) return undefined;
158
+ return [
159
+ "CodeGraph pre-fetch for your task, run for you before your first step with `codegraph explore` (bounded in size and time).",
160
+ `Use it as a starting point and call ${CODEGRAPH_MCP_TOOL} when you need more.`,
161
+ "",
162
+ boundOutput(output.trim()),
163
+ ].join("\n");
164
+ } catch {
165
+ return undefined;
166
+ }
167
+ }
@@ -0,0 +1,33 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ export const WORKER_MARKER = "<!-- codegraph-workflow:worker -->";
5
+ export const SKILL_REMINDER_TYPE = "codegraph-workflow-reminder";
6
+
7
+ interface LoadedSkill {
8
+ name: string;
9
+ filePath: string;
10
+ disableModelInvocation?: boolean;
11
+ }
12
+
13
+ function normalizedPath(path: string): string {
14
+ let result: string;
15
+ try { result = realpathSync(path); } catch { result = resolve(path); }
16
+ return process.platform === "win32" ? result.toLowerCase() : result;
17
+ }
18
+
19
+ /** Only refer to the packaged skill when Pi actually selected it, not a same-named user skill. */
20
+ export function chooseSkillReminder(input: {
21
+ expectedPath: string;
22
+ skills: LoadedSkill[];
23
+ child: boolean;
24
+ systemPrompt: string;
25
+ alreadySent: boolean;
26
+ isUserPrompt: boolean;
27
+ }): string | undefined {
28
+ if (!input.isUserPrompt || input.alreadySent) return undefined;
29
+ if (input.child && !input.systemPrompt.includes(WORKER_MARKER)) return undefined;
30
+ const skill = input.skills.find((entry) => entry.name === "codegraph-workflow");
31
+ if (!skill || skill.disableModelInvocation || normalizedPath(skill.filePath) !== normalizedPath(input.expectedPath)) return undefined;
32
+ return `For applicable code tasks, read and follow the codegraph-workflow skill at ${skill.filePath}.`;
33
+ }
package/src/warmup.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { spawn } from "node:child_process";
2
+ import { pathToFileURL } from "node:url";
3
+ import { NO_PROJECT_TEXT } from "./daemonclient.ts";
4
+
5
+ /** A cold daemon on a network share can need over a minute for its first read. */
6
+ export const WARMUP_TIMEOUT_MS = 120_000;
7
+ /** Any query works: the point is that a read call makes the daemon open its database and start a worker. */
8
+ export const WARMUP_QUERY = "main";
9
+ /** The command line the warm-up runs; only tests ask for another one. */
10
+ export const WARMUP_COMMAND = "codegraph serve --mcp";
11
+
12
+ export interface ExploreOnceResult {
13
+ /** The server answered the explore call without an error. */
14
+ ok: boolean;
15
+ /** How long the explore call alone took. */
16
+ exploreMs: number;
17
+ }
18
+
19
+ /**
20
+ * Talks MCP to `codegraph serve --mcp` in `cwd`: initialize, one `codegraph_explore`, then closes stdin and
21
+ * lets the server go on its own. The project daemon is shared with the user's own sessions, so this never
22
+ * kills the process tree (that would take the daemon with it) and gives up waiting instead; it releases the
23
+ * server's stdout so a server that never leaves cannot keep the process alive. Never rejects.
24
+ */
25
+ export function exploreOnce(cwd: string, query: string, timeoutMs: number, command: string = WARMUP_COMMAND): Promise<ExploreOnceResult> {
26
+ return new Promise((resolve) => {
27
+ let settled = false;
28
+ let exploreStart = 0;
29
+ const finish = (ok: boolean) => {
30
+ if (settled) return;
31
+ settled = true;
32
+ clearTimeout(timer);
33
+ try { child?.stdin?.end(); } catch { /* already closed */ }
34
+ // The server may never leave on its own: its piped stdout would keep the event loop of Pi alive.
35
+ try { child?.stdout?.destroy(); } catch { /* already gone */ }
36
+ child?.unref();
37
+ resolve({ ok, exploreMs: exploreStart === 0 ? 0 : Date.now() - exploreStart });
38
+ };
39
+ let child: ReturnType<typeof spawn> | undefined;
40
+ const timer = setTimeout(() => finish(false), timeoutMs);
41
+ try {
42
+ // Windows needs a shell for the .cmd shim; the default command line is fixed and nothing user-controlled goes into it.
43
+ child = spawn(command, { cwd, shell: true, windowsHide: true, stdio: ["pipe", "pipe", "ignore"] });
44
+ } catch {
45
+ finish(false);
46
+ return;
47
+ }
48
+ const send = (message: object) => child?.stdin?.write(JSON.stringify(message) + "\n");
49
+ let buffer = "";
50
+ child.stdout?.on("data", (chunk: Buffer) => {
51
+ buffer += chunk.toString("utf8");
52
+ let newline: number;
53
+ while ((newline = buffer.indexOf("\n")) >= 0) {
54
+ const line = buffer.slice(0, newline).trim();
55
+ buffer = buffer.slice(newline + 1);
56
+ if (line.length === 0) continue;
57
+ let message: { id?: number; error?: unknown; result?: { isError?: boolean; content?: { text?: string }[] } };
58
+ try { message = JSON.parse(line); } catch { continue; }
59
+ if (message.id === 1) {
60
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
61
+ exploreStart = Date.now();
62
+ send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "codegraph_explore", arguments: { query } } });
63
+ } else if (message.id === 2) {
64
+ // CodeGraph reports a crashed query worker with isError and a text, not with a JSON-RPC error.
65
+ const text = message.result?.content?.[0]?.text;
66
+ const failed = message.error !== undefined || message.result?.isError === true;
67
+ finish(!failed && typeof text === "string" && !text.startsWith(NO_PROJECT_TEXT));
68
+ }
69
+ }
70
+ });
71
+ child.on("error", () => finish(false));
72
+ child.on("close", () => finish(false));
73
+ child.stdin?.on("error", () => { /* the server exited first */ });
74
+ send({
75
+ jsonrpc: "2.0",
76
+ id: 1,
77
+ method: "initialize",
78
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "gentle-shell-codegraph-subagents", version: "0" }, rootUri: pathToFileURL(cwd).href },
79
+ });
80
+ });
81
+ }
82
+
83
+ /** Makes the project's CodeGraph daemon open its database and start a read worker, so the next real call is fast. */
84
+ export async function warmUpDaemon(cwd: string, timeoutMs: number = WARMUP_TIMEOUT_MS): Promise<boolean> {
85
+ return (await exploreOnce(cwd, WARMUP_QUERY, timeoutMs)).ok;
86
+ }