@selesai/code 0.13.22 → 0.13.23

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.
@@ -0,0 +1,167 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import graftExtension, { bashLooksMutating, detectGraphPresence, GRAFT_EXTENSION_PATH, isMutatingResult } from "./index.ts";
6
+ import { makePi } from "./test-support.ts";
7
+
8
+ describe("extension registration", () => {
9
+ it("registers all six read-only tools, the command, and the lifecycle handlers it needs", () => {
10
+ const { pi, tools, commands, handlers } = makePi();
11
+ graftExtension(pi as never);
12
+
13
+ expect([...tools.keys()].sort()).toEqual([
14
+ "graft_check_freshness",
15
+ "graft_file_api",
16
+ "graft_find_all",
17
+ "graft_find_code",
18
+ "graft_repo_map",
19
+ "graft_trace_calls",
20
+ ]);
21
+ expect([...commands.keys()]).toEqual(["graft"]);
22
+ expect([...handlers.keys()].sort()).toEqual([
23
+ "agent_end",
24
+ "before_agent_start",
25
+ "session_shutdown",
26
+ "session_start",
27
+ "tool_result",
28
+ "turn_start",
29
+ ]);
30
+ });
31
+
32
+ it("starts no process and touches no file from the factory", () => {
33
+ const { pi, exec } = makePi();
34
+ graftExtension(pi as never);
35
+ expect(exec).not.toHaveBeenCalled();
36
+ });
37
+
38
+ it("automatically contributes Graft tools to pi-subagents' code-facing builtins", async () => {
39
+ const harness = makePi();
40
+ graftExtension(harness.pi as never);
41
+ const modulePath = ["..", "pi-subagents", "src", "agents", "builtin-agent-augmentations.ts"].join("/");
42
+ const { requestBuiltinAgentAugmentations } = await import(modulePath);
43
+
44
+ expect(requestBuiltinAgentAugmentations(harness.pi as never)).toEqual([{
45
+ id: "pi-graft",
46
+ agentNames: ["scout", "reviewer", "worker", "delegate", "oracle"],
47
+ addTools: [
48
+ "graft_check_freshness", "graft_file_api", "graft_find_all",
49
+ "graft_find_code", "graft_repo_map", "graft_trace_calls",
50
+ ],
51
+ subagentOnlyExtensions: [GRAFT_EXTENSION_PATH],
52
+ }]);
53
+ });
54
+
55
+ it("registers no capability that could mutate the repository or another agent's config", () => {
56
+ const { pi, tools, commands } = makePi();
57
+ graftExtension(pi as never);
58
+ const surface = [...tools.keys(), ...commands.keys()].join(" ");
59
+ for (const forbidden of ["init", "uninstall", "upgrade", "install", "viz", "mcp"]) {
60
+ expect(surface).not.toContain(forbidden);
61
+ }
62
+ });
63
+ });
64
+
65
+ describe("detectGraphPresence", () => {
66
+ let root: string;
67
+
68
+ beforeAll(() => {
69
+ root = mkdtempSync(join(tmpdir(), "graft-presence-"));
70
+ });
71
+
72
+ afterAll(() => rmSync(root, { recursive: true, force: true }));
73
+
74
+ function makeRepo(name: string, files: Record<string, string>): string {
75
+ const dir = join(root, name);
76
+ for (const [path, content] of Object.entries(files)) {
77
+ mkdirSync(join(dir, path, ".."), { recursive: true });
78
+ writeFileSync(join(dir, path), content, "utf-8");
79
+ }
80
+ mkdirSync(dir, { recursive: true });
81
+ return dir;
82
+ }
83
+
84
+ it("reports no graph for a repository that has never been built", () => {
85
+ const dir = makeRepo("bare", { "src/a.ts": "" });
86
+ expect(detectGraphPresence(dir)).toEqual({ built: false, deep: false });
87
+ });
88
+
89
+ it("recognizes a structural graph from its wiring index", () => {
90
+ const dir = makeRepo("structural", { "graft/.graph/wiring.json": "{}" });
91
+ expect(detectGraphPresence(dir)).toEqual({ built: true, deep: false });
92
+ });
93
+
94
+ it("treats the markdown index as evidence of a build, not of the deep tier", () => {
95
+ const dir = makeRepo("indexed", { "graft/INDEX.md": "# Index" });
96
+ expect(detectGraphPresence(dir)).toEqual({ built: true, deep: false });
97
+ });
98
+
99
+ it("recognizes the provider-backed tier by the concept nodes it adds", () => {
100
+ const dir = makeRepo("deep", { "graft/.graph/wiring.json": "{}", "graft/auth.md": "# Auth" });
101
+ expect(detectGraphPresence(dir)).toEqual({ built: true, deep: true });
102
+
103
+ const shallow = makeRepo("shallow", { "graft/.graph/wiring.json": "{}", "graft/INDEX.md": "# Index" });
104
+ // INDEX.md is the index, not a concept node.
105
+ expect(detectGraphPresence(shallow)).toEqual({ built: true, deep: false });
106
+ });
107
+
108
+ it("answers without throwing on an unreadable graph directory", () => {
109
+ expect(detectGraphPresence(join(root, "does-not-exist"))).toEqual({ built: false, deep: false });
110
+ expect(detectGraphPresence("")).toEqual({ built: false, deep: false });
111
+ });
112
+ });
113
+
114
+ describe("bashLooksMutating", () => {
115
+ it("recognizes commands that certainly change the working tree", () => {
116
+ const mutating = [
117
+ "rm -rf dist",
118
+ "mv a b",
119
+ "mkdir -p out",
120
+ "echo hi > file.txt",
121
+ "echo hi >> file.txt",
122
+ "sed -i '' 's/a/b/' src/a.ts",
123
+ "git checkout -b feature",
124
+ "git commit -m x",
125
+ "npm install left-pad",
126
+ "pip install requests",
127
+ "ls && rm b",
128
+ ];
129
+ for (const command of mutating) expect(bashLooksMutating(command), command).toBe(true);
130
+ });
131
+
132
+ it("leaves read-only inspection alone", () => {
133
+ const readOnly = [
134
+ "ls -la",
135
+ "cat src/a.ts",
136
+ "git status",
137
+ "git diff HEAD",
138
+ "git log --oneline",
139
+ "grep -rn foo src",
140
+ "node --test",
141
+ "echo hello",
142
+ "npm test",
143
+ "graft build",
144
+ ];
145
+ for (const command of readOnly) expect(bashLooksMutating(command), command).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe("isMutatingResult", () => {
150
+ it("counts successful edits and writes", () => {
151
+ expect(isMutatingResult({ toolName: "edit", input: {} })).toBe(true);
152
+ expect(isMutatingResult({ toolName: "write", input: {} })).toBe(true);
153
+ });
154
+
155
+ it("counts only clearly mutating bash commands", () => {
156
+ expect(isMutatingResult({ toolName: "bash", input: { command: "rm -rf dist" } })).toBe(true);
157
+ expect(isMutatingResult({ toolName: "bash", input: { command: "git status" } })).toBe(false);
158
+ expect(isMutatingResult({ toolName: "bash", input: {} })).toBe(false);
159
+ expect(isMutatingResult({ toolName: "bash", input: { command: 42 } })).toBe(false);
160
+ });
161
+
162
+ it("ignores read-only built-ins", () => {
163
+ for (const toolName of ["read", "grep", "find", "ls", "graft_find_code"]) {
164
+ expect(isMutatingResult({ toolName, input: {} }), toolName).toBe(false);
165
+ }
166
+ });
167
+ });
@@ -0,0 +1,521 @@
1
+ /**
2
+ * pi-graft — native Selesai integration for the Graft codebase-context CLI.
3
+ *
4
+ * Graft builds a local, regenerable graph of a repository (deterministic
5
+ * tree-sitter structure, plus optional provider-backed summaries) and answers
6
+ * semantic questions against it. This extension owns that integration the way a
7
+ * Selesai user expects: executable discovery, graph lifecycle, status, consent,
8
+ * semantic tools, retrieval strategy, and mutation-aware freshness — all native,
9
+ * none of it MCP, and none of it touching another agent's configuration.
10
+ *
11
+ * Scope discipline:
12
+ * - Graft's upstream agent-wiring command (`init`) is never invoked.
13
+ * - A missing compatible CLI is automatically installed once through npm;
14
+ * incompatible user installations are never overwritten.
15
+ * - Builds require explicit consent; deep (provider-backed) builds need their own.
16
+ * - Every tool is read-only; the graph is a local cache, not a committed artifact.
17
+ * - Graft processes default to telemetry opt-out without touching Graft's settings.
18
+ *
19
+ * Lifecycle: the factory only registers. Repository resolution, probing, status,
20
+ * and the telemetry default all begin in `session_start` and are torn down in
21
+ * `session_shutdown`, so no invocation that skips a session starts any work.
22
+ */
23
+
24
+ import { existsSync, readdirSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import type { ExtensionAPI, ExtensionContext, SessionStartEvent, SessionShutdownEvent } from "@selesai/code";
28
+ import {
29
+ applyTelemetryDefault,
30
+ GRAPH_DIR_NAME,
31
+ graftFailureMessage,
32
+ installGraft,
33
+ parseAskJson,
34
+ probeGraft,
35
+ runGraft,
36
+ type ExecLike,
37
+ type GraftInstallRun,
38
+ type GraftOp,
39
+ type GraftProbe,
40
+ type GraftRun,
41
+ type TelemetryDefaultHandle,
42
+ } from "./cli.ts";
43
+ import { PROVIDER_ENV_NAMES, registerGraftCommands } from "./commands.ts";
44
+ import {
45
+ buildInjectionPack,
46
+ createInjectionGuard,
47
+ injectionBudget,
48
+ injectionMessage,
49
+ planInjection,
50
+ } from "./prompt.ts";
51
+ import {
52
+ ABSENT_GRAPH,
53
+ DEFAULT_MAX_INJECTION_BYTES,
54
+ DEFAULT_RETRIEVAL_MODE,
55
+ DEFAULT_REFRESH_DEBOUNCE_SECONDS,
56
+ type GraftEvent,
57
+ type GraftSettings,
58
+ type GraftState,
59
+ type GraphPresence,
60
+ INITIAL_GRAFT_STATE,
61
+ INJECTION_ENTRY_TYPE,
62
+ readBranchMode,
63
+ readGraftSettings,
64
+ recoveryFor,
65
+ reduceGraftState,
66
+ type RetrievalMode,
67
+ STATUS_KEY,
68
+ statusText,
69
+ } from "./state.ts";
70
+ import { GRAFT_TOOL_NAMES, registerGraftTools } from "./tools.ts";
71
+
72
+ export type { GraftProbe, GraftRun } from "./cli.ts";
73
+ export type { GraftSettings, GraftStateName, RetrievalMode } from "./state.ts";
74
+
75
+ /** The loaded module is the provider foreground children must load. */
76
+ export const GRAFT_EXTENSION_PATH = fileURLToPath(import.meta.url);
77
+ const BUILTIN_AGENT_AUGMENTATION_REQUEST_EVENT = "pi-subagents:request-builtin-agent-augmentations";
78
+ const GRAFT_AWARE_AGENT_NAMES = ["scout", "reviewer", "worker", "delegate", "oracle"];
79
+
80
+ type BuiltinAgentAugmentationRequest = {
81
+ register(augmentation: {
82
+ id: string;
83
+ agentNames: readonly string[];
84
+ addTools: readonly string[];
85
+ subagentOnlyExtensions: readonly string[];
86
+ }): void;
87
+ };
88
+
89
+ function registerBuiltinAgentAugmentation(pi: ExtensionAPI): void {
90
+ pi.events.on(BUILTIN_AGENT_AUGMENTATION_REQUEST_EVENT, (request: unknown) => {
91
+ if (!request || typeof request !== "object" || typeof (request as BuiltinAgentAugmentationRequest).register !== "function") return;
92
+ (request as BuiltinAgentAugmentationRequest).register({
93
+ id: "pi-graft",
94
+ agentNames: GRAFT_AWARE_AGENT_NAMES,
95
+ addTools: GRAFT_TOOL_NAMES,
96
+ subagentOnlyExtensions: [GRAFT_EXTENSION_PATH],
97
+ });
98
+ });
99
+ }
100
+
101
+ /** Session-scoped state. Created in `session_start`, cleared in `session_shutdown`. */
102
+ interface GraftSession {
103
+ settings: GraftSettings;
104
+ enabled: boolean;
105
+ repoRoot: string | undefined;
106
+ presence: GraphPresence;
107
+ state: GraftState;
108
+ probe: GraftProbe | undefined;
109
+ mode: RetrievalMode;
110
+ telemetry: TelemetryDefaultHandle | undefined;
111
+ disposed: boolean;
112
+ lastRefreshAt: number;
113
+ }
114
+
115
+ function freshSession(): GraftSession {
116
+ return {
117
+ settings: {},
118
+ enabled: false,
119
+ repoRoot: undefined,
120
+ presence: ABSENT_GRAPH,
121
+ state: INITIAL_GRAFT_STATE,
122
+ probe: undefined,
123
+ mode: DEFAULT_RETRIEVAL_MODE,
124
+ telemetry: undefined,
125
+ disposed: true,
126
+ lastRefreshAt: 0,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Best-effort graph detection on disk.
132
+ *
133
+ * `ponytail:` a filesystem check, not `graft check`. It answers "is there a
134
+ * graph here" cheaply, which is what a session-start status line needs; it does
135
+ * not pretend to answer "is it current" — that is `graft_check_freshness`'s job,
136
+ * for which Graft itself is authoritative.
137
+ */
138
+ export function detectGraphPresence(repoRoot: string): GraphPresence {
139
+ if (!repoRoot) return ABSENT_GRAPH;
140
+ const graphDir = join(repoRoot, GRAPH_DIR_NAME);
141
+ const built = existsSync(join(graphDir, ".graph", "wiring.json")) || existsSync(join(graphDir, "INDEX.md"));
142
+ if (!built) return ABSENT_GRAPH;
143
+ let deep = false;
144
+ try {
145
+ deep = readdirSync(graphDir).some((entry) => entry.endsWith(".md") && entry !== "INDEX.md");
146
+ } catch {
147
+ deep = false;
148
+ }
149
+ return { built: true, deep };
150
+ }
151
+
152
+ /**
153
+ * Bash commands that surely change the working tree.
154
+ *
155
+ * `ponytail:` a lexical list, not a shell parser, and deliberately biased toward
156
+ * false negatives. A missed mutation costs only a stale status label: Graft
157
+ * refreshes its graph against the working tree before every query, so retrieval
158
+ * correctness never depends on this. Upgrade path is a real parser if the label
159
+ * ever becomes load-bearing.
160
+ */
161
+ const MUTATING_BASH =
162
+ /(^|[;&|]\s*)(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|ln|tee|truncate|patch|dd)\b|>>?\s*[^&|\s]|sed\s+-i|perl\s+-i|\bgit\s+(add|rm|mv|checkout|switch|restore|apply|commit|merge|rebase|cherry-pick|reset|stash|clean|revert)\b|\b(npm|pnpm|yarn|bun)\s+(install|i|add|remove|uninstall|update|upgrade)\b|\b(pip|poetry|uv|cargo|go|gem|composer)\s+(install|add|get|remove|update)\b/;
163
+
164
+ export function bashLooksMutating(command: string): boolean {
165
+ return MUTATING_BASH.test(command);
166
+ }
167
+
168
+ /** True when a successful tool result changed the working tree. */
169
+ export function isMutatingResult(event: { toolName: string; input: Record<string, unknown> }): boolean {
170
+ if (event.toolName === "edit" || event.toolName === "write") return true;
171
+ if (event.toolName === "bash") {
172
+ const command = event.input.command;
173
+ return typeof command === "string" && bashLooksMutating(command);
174
+ }
175
+ return false;
176
+ }
177
+
178
+ export default function graftExtension(pi: ExtensionAPI): void {
179
+ registerBuiltinAgentAugmentation(pi);
180
+ const exec: ExecLike = (command, args, options) => pi.exec(command, args, options);
181
+ let session = freshSession();
182
+ let installPromise: Promise<GraftInstallRun> | undefined;
183
+ const guard = createInjectionGuard();
184
+
185
+ const applyEvent = (ctx: ExtensionContext | undefined, event: GraftEvent): void => {
186
+ session.state = reduceGraftState(session.state, event);
187
+ if (ctx?.hasUI) ctx.ui.setStatus(STATUS_KEY, statusText(session.state));
188
+ };
189
+
190
+ const setMode = (mode: RetrievalMode, ctx?: ExtensionContext): void => {
191
+ session.mode = mode;
192
+ if (ctx?.hasUI) ctx.ui.setStatus(`${STATUS_KEY}-mode`, `graft mode: ${mode}`);
193
+ };
194
+
195
+ /** Share one global npm install between startup and a concurrent `/graft` command. */
196
+ const installCli = (ctx: ExtensionContext): Promise<GraftInstallRun> => {
197
+ if (installPromise) return installPromise;
198
+ const pending = installGraft(exec, ctx.cwd);
199
+ installPromise = pending;
200
+ void pending.finally(() => {
201
+ if (installPromise === pending) installPromise = undefined;
202
+ });
203
+ return pending;
204
+ };
205
+
206
+ /** Resolve the repository root the way Graft does: the Git working tree. */
207
+ const resolveRepoRoot = async (ctx: ExtensionContext): Promise<string | undefined> => {
208
+ try {
209
+ const result = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: ctx.cwd, timeout: 5_000 });
210
+ if (result.killed || result.code !== 0) return undefined;
211
+ return result.stdout.trim() || undefined;
212
+ } catch {
213
+ return undefined;
214
+ }
215
+ };
216
+
217
+ /**
218
+ * Re-resolve repository, executable, and graph presence.
219
+ *
220
+ * Called from `session_start`, from the `/graft` commands, and lazily before
221
+ * the first tool call. Never from the factory, never on a timer.
222
+ */
223
+ const recheck = async (ctx: ExtensionContext): Promise<void> => {
224
+ if (!session.enabled || session.disposed) return;
225
+ const repoRoot = await resolveRepoRoot(ctx);
226
+ session.repoRoot = repoRoot;
227
+ applyEvent(ctx, { type: "resolved", gitRepo: repoRoot !== undefined });
228
+ if (!repoRoot) {
229
+ session.probe = undefined;
230
+ return;
231
+ }
232
+ session.presence = detectGraphPresence(repoRoot);
233
+ const probe = await probeGraft(exec, repoRoot, ctx.signal);
234
+ session.probe = probe;
235
+ if (probe.kind === "ok") {
236
+ applyEvent(ctx, { type: "probe-ok", version: probe.version, graph: session.presence });
237
+ } else if (probe.kind === "missing") {
238
+ applyEvent(ctx, { type: "probe-missing", detail: probe.detail });
239
+ } else {
240
+ applyEvent(ctx, { type: "probe-incompatible", version: probe.version, detail: probe.detail });
241
+ }
242
+ };
243
+
244
+ /** Install a missing CLI without blocking session startup or racing another session. */
245
+ const autoInstall = (ctx: ExtensionContext, activeSession: GraftSession): void => {
246
+ if (ctx.hasUI) ctx.ui.notify("Installing Graft CLI…", "info");
247
+ void (async () => {
248
+ const install = await installCli(ctx);
249
+ if (session !== activeSession || activeSession.disposed) return;
250
+ if (install.code !== 0 || install.killed) {
251
+ const detail = (install.stderr.trim() || install.stdout.trim() || `exit status ${install.code}`).split("\n")[0];
252
+ if (ctx.hasUI) ctx.ui.notify(`Graft CLI install failed: ${detail}`, "warning");
253
+ return;
254
+ }
255
+ await recheck(ctx);
256
+ if (session !== activeSession || activeSession.disposed) return;
257
+ if (session.state.name === "unavailable" || session.state.name === "incompatible") {
258
+ if (ctx.hasUI) ctx.ui.notify("Graft CLI installed, but is not yet usable. Restart Selesai and run /graft doctor.", "warning");
259
+ return;
260
+ }
261
+ if (ctx.hasUI) ctx.ui.notify("Graft CLI installed.", "info");
262
+ })();
263
+ };
264
+
265
+ /**
266
+ * Refuse a call that cannot produce an honest answer, with the recovery step
267
+ * for the current state. `graft check` is the exception among reads: it is the
268
+ * drift report, so it is allowed to answer for a graph that is not there.
269
+ */
270
+ const ensureReady = async (ctx: ExtensionContext, op: GraftOp): Promise<void> => {
271
+ if (session.disposed) throw new Error("Graft is not ready: no active Selesai session.");
272
+ if (!session.enabled) {
273
+ throw new Error(
274
+ "The Graft extension is disabled by settings. Set `graft.enabled: true` in Selesai settings to use its tools.",
275
+ );
276
+ }
277
+ // Re-resolve when an earlier attempt could not resolve anything: the user
278
+ // may have installed the CLI or opened the repository since.
279
+ if (!session.probe || session.repoRoot === undefined) await recheck(ctx);
280
+ if (!session.repoRoot) {
281
+ throw new Error("Graft needs a Git repository, and this working directory is not inside one. Run /graft doctor.");
282
+ }
283
+ if (session.probe?.kind !== "ok") {
284
+ const detail = session.probe?.detail ?? "the graft executable is unavailable";
285
+ throw new Error(`Graft is not usable here: ${detail} Run /graft doctor for a recovery step.`);
286
+ }
287
+ if (!session.presence.built && op.kind !== "check-freshness" && op.kind !== "build") {
288
+ throw new Error(
289
+ `There is no ${GRAPH_DIR_NAME}/ graph in ${session.repoRoot} yet. ${
290
+ recoveryFor(session.state) ?? "Run /graft build to index this repository."
291
+ }`,
292
+ );
293
+ }
294
+ };
295
+
296
+ const runOp = async (ctx: ExtensionContext, op: GraftOp, signal?: AbortSignal): Promise<GraftRun> => {
297
+ await ensureReady(ctx, op);
298
+ return runGraft(exec, session.repoRoot!, op, { signal });
299
+ };
300
+
301
+ /** A read-only query answered, and Graft refreshed the graph before answering. */
302
+ const noteQuery = (ctx: ExtensionContext, run: GraftRun): void => {
303
+ if (run.code !== 0) return;
304
+ if (run.op.kind === "check-freshness" || run.op.kind === "build") return;
305
+ session.presence = detectGraphPresence(session.repoRoot ?? ctx.cwd);
306
+ applyEvent(ctx, { type: "query-succeeded", presence: session.presence });
307
+ };
308
+
309
+ const recordBuild = (ctx: ExtensionContext, run: GraftRun, deep: boolean): void => {
310
+ session.presence = detectGraphPresence(session.repoRoot ?? ctx.cwd);
311
+ applyEvent(
312
+ ctx,
313
+ run.code === 0
314
+ ? { type: "build-succeeded", deep, presence: session.presence }
315
+ : { type: "build-failed", detail: graftFailureMessage(run) },
316
+ );
317
+ };
318
+
319
+ // -------------------------------------------------------------------------
320
+ // Lifecycle
321
+ // -------------------------------------------------------------------------
322
+
323
+ pi.on("session_start", async (event: SessionStartEvent, ctx: ExtensionContext) => {
324
+ // A start without a preceding shutdown (a reload that raced one, a second
325
+ // start in the same process) must not leak the previous defaults.
326
+ session.telemetry?.restore();
327
+ session = freshSession();
328
+ session.disposed = false;
329
+ session.settings = readGraftSettings({ cwd: ctx.cwd, trusted: ctx.isProjectTrusted() });
330
+ session.enabled = session.settings.enabled !== false;
331
+ if (!session.enabled) {
332
+ if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
333
+ return;
334
+ }
335
+ if (session.settings.telemetry !== "inherit") session.telemetry = applyTelemetryDefault();
336
+ setMode(readBranchMode(ctx.sessionManager.getBranch()) ?? session.settings.mode ?? DEFAULT_RETRIEVAL_MODE, ctx);
337
+
338
+ await recheck(ctx);
339
+ if (session.state.name === "unavailable") autoInstall(ctx, session);
340
+ });
341
+
342
+ pi.on("session_shutdown", (_event: SessionShutdownEvent, ctx: ExtensionContext) => {
343
+ session.disposed = true;
344
+ session.telemetry?.restore();
345
+ if (ctx.hasUI) {
346
+ ctx.ui.setStatus(STATUS_KEY, undefined);
347
+ ctx.ui.setStatus(`${STATUS_KEY}-mode`, undefined);
348
+ }
349
+ guard.beginTurn();
350
+ session = freshSession();
351
+ });
352
+
353
+ pi.on("turn_start", () => {
354
+ guard.beginTurn();
355
+ });
356
+
357
+ // -------------------------------------------------------------------------
358
+ // Retrieval strategy
359
+ // -------------------------------------------------------------------------
360
+
361
+ pi.on("before_agent_start", async (event, ctx) => {
362
+ if (!session.enabled || session.disposed) return;
363
+ const plan = planInjection({
364
+ mode: session.mode,
365
+ prompt: event.prompt,
366
+ state: session.state,
367
+ enabled: session.enabled,
368
+ });
369
+ if (!plan.inject || !guard.shouldInject()) return;
370
+
371
+ try {
372
+ const budget = injectionBudget(
373
+ plan.mode,
374
+ session.settings.maxInjectionBytes ?? DEFAULT_MAX_INJECTION_BYTES,
375
+ );
376
+ const run = await runOp(
377
+ ctx,
378
+ { kind: "find-code", question: plan.query, limit: budget.limit, in: session.settings.in },
379
+ ctx.signal,
380
+ );
381
+ if (run.code !== 0) {
382
+ // The turn proceeds without context, but not silently: a failed
383
+ // lookup the user cannot see is a context gap they cannot explain.
384
+ if (ctx.hasUI) ctx.ui.notify(`Graft context unavailable: ${graftFailureMessage(run)}`, "warning");
385
+ return;
386
+ }
387
+ if (session.disposed) return;
388
+ noteQuery(ctx, run);
389
+
390
+ const payload = parseAskJson(run.stdout);
391
+ if (!payload) return;
392
+ const pack = buildInjectionPack(payload, {
393
+ maxBytes: budget.maxBytes,
394
+ minCoverage: session.settings.minCoverage,
395
+ });
396
+ // A low-coverage pack is noise the model would have to reason around, so
397
+ // it is dropped rather than injected.
398
+ if (!pack) return;
399
+
400
+ guard.markInjected();
401
+ const references = pack.references.map((r) => (r.span ? `${r.path}:${r.span}` : r.path));
402
+ pi.appendEntry(INJECTION_ENTRY_TYPE, {
403
+ mode: plan.mode,
404
+ query: plan.query,
405
+ state: session.state.name,
406
+ references,
407
+ bytes: pack.bytes,
408
+ truncated: pack.truncated,
409
+ coverage: pack.coverage,
410
+ });
411
+
412
+ return {
413
+ message: {
414
+ customType: "graft-context",
415
+ content: injectionMessage({ pack, mode: plan.mode, state: session.state.name }),
416
+ display: true,
417
+ details: {
418
+ mode: plan.mode,
419
+ references: pack.references,
420
+ bytes: pack.bytes,
421
+ truncated: pack.truncated,
422
+ coverage: pack.coverage,
423
+ },
424
+ },
425
+ };
426
+ } catch (error) {
427
+ // Injection must never break a turn: a failed Graft query just means the
428
+ // model starts without a pack, exactly as in pull mode.
429
+ if (ctx.hasUI) {
430
+ ctx.ui.notify(
431
+ `Graft context unavailable: ${error instanceof Error ? error.message : String(error)}`,
432
+ "warning",
433
+ );
434
+ }
435
+ return;
436
+ }
437
+ });
438
+
439
+ // -------------------------------------------------------------------------
440
+ // Mutation awareness
441
+ // -------------------------------------------------------------------------
442
+
443
+ pi.on("tool_result", async (event, ctx) => {
444
+ if (!session.enabled || session.disposed || event.isError) return;
445
+ if (!isMutatingResult({ toolName: event.toolName, input: event.input })) return;
446
+ applyEvent(ctx, { type: "mutation-observed" });
447
+ });
448
+
449
+ /**
450
+ * Debounced, idle-aware proactive refresh.
451
+ *
452
+ * `agent_end` is the runtime's own idle boundary, so this needs no timer: a
453
+ * burst of edits inside one turn costs at most one rebuild, and a structural
454
+ * rebuild is free and offline. Refresh never *creates* a graph — an unbuilt
455
+ * repository stays unbuilt until the user consents to a build.
456
+ */
457
+ pi.on("agent_end", (_event, ctx) => {
458
+ if (!session.enabled || session.disposed) return;
459
+ if (session.state.name !== "changed" || !session.presence.built) return;
460
+ const debounceMs = (session.settings.refreshDebounceSeconds ?? DEFAULT_REFRESH_DEBOUNCE_SECONDS) * 1000;
461
+ if (Date.now() - session.lastRefreshAt < debounceMs) return;
462
+ session.lastRefreshAt = Date.now();
463
+ if (ctx.hasUI) {
464
+ ctx.ui.setStatus(STATUS_KEY, statusText(reduceGraftState(session.state, { type: "refresh-started" })));
465
+ }
466
+
467
+ void (async () => {
468
+ try {
469
+ const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep: false });
470
+ if (session.disposed) return;
471
+ recordBuild(ctx, run, false);
472
+ } catch (error) {
473
+ if (session.disposed) return;
474
+ applyEvent(ctx, {
475
+ type: "build-failed",
476
+ detail: error instanceof Error ? error.message : String(error),
477
+ });
478
+ }
479
+ })();
480
+ });
481
+
482
+ // -------------------------------------------------------------------------
483
+ // Registration
484
+ // -------------------------------------------------------------------------
485
+
486
+ registerGraftTools(pi, {
487
+ run: runOp,
488
+ settings: () => session.settings,
489
+ state: () => session.state,
490
+ noteQuery,
491
+ });
492
+
493
+ registerGraftCommands(pi, {
494
+ recheck,
495
+ state: () => session.state,
496
+ settings: () => session.settings,
497
+ mode: () => session.mode,
498
+ setMode,
499
+ repoRoot: () => session.repoRoot,
500
+ graphPresence: () => session.presence,
501
+ installCli,
502
+ runBuild: async (deep, ctx) => {
503
+ await ensureReady(ctx, { kind: "build", deep });
504
+ applyEvent(ctx, { type: "build-started", deep });
505
+ const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep });
506
+ recordBuild(ctx, run, deep);
507
+ return run;
508
+ },
509
+ runRefresh: async (ctx) => {
510
+ if (!session.presence.built) return undefined;
511
+ await ensureReady(ctx, { kind: "build", deep: false });
512
+ session.lastRefreshAt = Date.now();
513
+ applyEvent(ctx, { type: "refresh-started" });
514
+ const run = await runGraft(exec, session.repoRoot!, { kind: "build", deep: false });
515
+ recordBuild(ctx, run, false);
516
+ return run;
517
+ },
518
+ providerEnvNames: () => PROVIDER_ENV_NAMES.filter((name) => Boolean(process.env[name])),
519
+ telemetryDefaultApplied: () => session.telemetry !== undefined,
520
+ });
521
+ }