pi-orchestrator-config 4.4.0 → 4.4.1

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.
Files changed (36) hide show
  1. package/README.md +43 -1
  2. package/extensions/openai-compatible-discovery/index.ts +29 -1
  3. package/extensions/orchestrator/async-agents.ts +3 -1
  4. package/extensions/orchestrator/graft.ts +306 -0
  5. package/extensions/orchestrator/index.ts +2 -0
  6. package/extensions/orchestrator/memory-tools.ts +1 -1
  7. package/extensions/orchestrator/project-settings.ts +7 -1
  8. package/extensions/orchestrator/settings-tui-helpers.ts +2 -3
  9. package/extensions/orchestrator/settings-tui.ts +1 -3
  10. package/extensions/orchestrator/status-bar.ts +1 -0
  11. package/extensions/orchestrator/subagent-tool.ts +3 -2
  12. package/extensions/pidash/model-info.ts +7 -0
  13. package/extensions/pidash/pidash-ui/dist/assets/{index-Ehfwjr9d.js → index-C1VH6cPk.js} +6 -6
  14. package/extensions/pidash/pidash-ui/dist/assets/index-hM6gC5Yg.css +2 -0
  15. package/extensions/pidash/pidash-ui/dist/index.html +2 -2
  16. package/extensions/pidash/pidash-ui/src/App.tsx +2 -1
  17. package/extensions/pidash/pidash-ui/src/components/InfoBar.tsx +79 -15
  18. package/extensions/pidash/pidash-ui/src/lib/format-total.ts +16 -0
  19. package/extensions/pidash/pidash-ui/src/lib/model-capabilities.ts +11 -0
  20. package/extensions/pidash/pidash.ts +24 -6
  21. package/extensions/pidiff/pidiff-ui/package.json +1 -1
  22. package/extensions/providers/index.ts +16 -4
  23. package/extensions/providers/last-thinking-level.ts +174 -0
  24. package/extensions/shared/file-logger.ts +5 -2
  25. package/extensions/shared/format-total.ts +11 -0
  26. package/extensions/shared/install-logger.mjs +18 -0
  27. package/extensions/shared/logger-core.mjs +35 -0
  28. package/extensions/shared/logger.ts +6 -27
  29. package/extensions/shared/openai-compatible-discovery.ts +28 -0
  30. package/extensions/shared/types.ts +4 -0
  31. package/package.json +2 -1
  32. package/scripts/graft-allow-scripts.mjs +47 -0
  33. package/scripts/pidash-server.ts +7 -3
  34. package/scripts/pidash-session-state.ts +10 -0
  35. package/settings-keys.json +21 -0
  36. package/extensions/pidash/pidash-ui/dist/assets/index-j_XBAdtq.css +0 -2
package/README.md CHANGED
@@ -28,6 +28,7 @@ Single extension that provides:
28
28
  | **Git status** | Live git status in status line with colored icons — updates after every tool call. Shows clickable `#N` when the branch has an open PR. Last-activity clock `⏱ HH:MM (Xm/Xh ago)` shows time since last response |
29
29
  | **Desktop notifications** | Notifies via `notify-send` on task completion, waiting for input, and action required |
30
30
  | **File preview** | Serves generated HTML/frontend files via HTTP for browser preview from container |
31
+ | **Graft integration** | When `graft_enable=true`, main agents and subagents query the local Graft graph before raw project navigation on substantive prompts. Children only consume graphs. Stale graphs remain usable, retrieval failures fall back to raw tools, and main processes coordinate rebuilds with a cross-process lock. |
31
32
  | **Pidash dashboard** | Live web dashboard — multi-session monitoring, browser messaging, model switching, live session name updates, reasoning token display |
32
33
  | **Pidiff viewer** | Per-project diff viewer with review comments — branch diffs, file tree, inline comments, git-based ignore rules |
33
34
  | **Dreaming** | Background memory consolidation — extracts memories from sessions, deduplicates, maintains topic-based memory |
@@ -157,7 +158,7 @@ The installer covers:
157
158
 
158
159
  - **Pi Packages** — pi-config, pi-vertex-claude, pi-web-access, myk-pi-tools, bun
159
160
  - **Python Tools** — prek
160
- - **npm Packages** — mcpc, acpx, agent-browser
161
+ - **npm Packages** — mcpc, acpx, agent-browser, graft
161
162
  - **Browser Automation** — playwright + chromium
162
163
  - **Environment Setup** — gitignore configuration
163
164
 
@@ -404,6 +405,29 @@ Set `image_model` in `pi-config-settings.json` or use `PI_IMAGE_MODEL` env var.
404
405
 
405
406
  In containers, images are auto-served via HTTP for browser preview.
406
407
 
408
+ ### Graft repository graph (optional)
409
+
410
+ Set `graft_enable` to `true` in `.pi/pi-config-settings.json` (or set
411
+ `PI_GRAFT_ENABLE=true`) to enable local Graft repository graph context:
412
+
413
+ ```json
414
+ {
415
+ "graft_enable": true
416
+ }
417
+ ```
418
+
419
+ Graft requires Node.js 22+, npm 12+, node-gyp prerequisites (Python 3, `make`, and
420
+ G++), and the `graft` CLI. The container includes them; native installs can use
421
+ `uv run scripts/install.py`. The installer downloads `@nanonets/graft@latest` with
422
+ scripts disabled into a dedicated npm prefix, audits that isolated dependency tree
423
+ for lifecycle scripts, and strictly rebuilds it with only those packages approved.
424
+ The completed prefix is then published under the global npm prefix and its `graft`
425
+ binary linked globally, so unrelated global packages are never evaluated. The
426
+ installer verifies `graft --version`; `DO_NOT_TRACK=1` disables Graft telemetry.
427
+ When enabled, Graft reads the trusted project and writes a regenerable `./graft/`
428
+ cache. Delete that directory or set `graft_enable` to `false` to disable it. If the
429
+ native CLI is unavailable or its installation fails, pi continues without Graft.
430
+
407
431
  ### Cache Miss Notices
408
432
 
409
433
  Enable `showCacheMissNotices` in pi settings to see transcript notices on significant prompt-cache misses — useful for investigating unexpected token costs:
@@ -425,6 +449,23 @@ Run pi inside a disposable container for **filesystem isolation** — the agent
425
449
  - **Consistent tooling** — All required tools pre-installed in a single image
426
450
  - **Disposable** — Container is destroyed after each session (`--rm`)
427
451
 
452
+ **Graft (optional `graft_enable`):** The image installs the latest
453
+ `@nanonets/graft` with `DO_NOT_TRACK=1`. It downloads with scripts disabled, derives
454
+ an exact script allowlist by auditing the installed package's reachable dependency
455
+ metadata in a dedicated npm prefix, then strictly rebuilds that isolated tree with
456
+ those approvals. Only after the rebuild succeeds is the completed prefix moved under
457
+ the global npm prefix and its `graft` binary linked globally. This prevents unrelated
458
+ global packages from entering npm's strict preflight. The allowlist is command-scoped
459
+ and changes with `@latest`; other npm commands keep npm 12's install-script
460
+ protections. The image includes Python 3,
461
+ `make`, and the GCC/G++ toolchain required by node-gyp for Graft and other native
462
+ npm packages; these remain available for reliable package rebuilds. The build then
463
+ verifies `graft --version`. Enable Graft in trusted project or global pi-config
464
+ settings; it reads the project locally and stores its regenerable graph in `./graft/`. Set
465
+ `graft_enable` to `false` and delete that directory to disable and remove the cache.
466
+ Native installations require Node.js 22+, npm 12+, Python 3, `make`, and G++; the
467
+ installer uses the same audited strict installation and failure remains non-fatal.
468
+
428
469
  **CLI provider binaries (optional `cli_agents`):** The image installs the CLIs used by
429
470
  `cli-*` providers — `claude` (Claude Code), `gemini` (`@google/gemini-cli`), and
430
471
  `agent` (Cursor Agent CLI). Enable with `cli_agents` in settings (e.g.
@@ -691,6 +732,7 @@ PI_PIDIFF_ENABLE=false pi
691
732
  | `acpx` | Agent proxy for remote models |
692
733
  | `kubectl` / `oc` | Kubernetes and OpenShift CLI |
693
734
  | `agent-browser` | Browser automation CLI (navigate, click, screenshot, forms) |
735
+ | `graft` | Local repository code graph for opt-in `graft_enable`; graph cache is stored in the mounted project’s `./graft/` directory |
694
736
  | `procps` | Process utilities (ps, top, pgrep, pkill) |
695
737
  | `passwd` | `usermod`/`groupmod` — init remaps user `node` to `PI_HOST_UID`/`PI_HOST_GID` |
696
738
  | `docker` / `podman` | Container CLIs (used via `docker-safe` read-only wrapper) |
@@ -4,13 +4,16 @@ import { createRuntimeProvider } from "../shared/create-runtime-provider.js";
4
4
  import { createLogger } from "../shared/logger.js";
5
5
  import {
6
6
  OpenAiCompatibleDiscoveryCache,
7
+ buildLiteLlmCapabilitiesUrl,
7
8
  buildOpenAiCompatibleModelsRequest,
9
+ enrichLiteLlmReasoning,
8
10
  findEligibleOpenAiCompatibleProviderConfigsResult,
9
11
  formatOpenAiCompatibleDiscoverySummary,
10
12
  materializeOpenAiCompatibleModels,
11
13
  openAiCompatibleConnectionFingerprint,
12
14
  redactOpenAiCompatibleDiagnostic,
13
15
  resolveStaticOpenAiCompatibleHeaders,
16
+ type LiteLlmCapabilityRecord,
14
17
  type OpenAiCompatibleModelRecord,
15
18
  type ResolvedOpenAiCompatibleConnection,
16
19
  } from "../shared/openai-compatible-discovery.js";
@@ -28,6 +31,18 @@ function responseRecords(payload: unknown): OpenAiCompatibleModelRecord[] {
28
31
  });
29
32
  }
30
33
 
34
+ function capabilityRecords(payload: unknown): LiteLlmCapabilityRecord[] {
35
+ if (!payload || typeof payload !== "object" || !Array.isArray((payload as { data?: unknown }).data))
36
+ throw new Error("LiteLLM capability response must contain a data array");
37
+ return (payload as { data: unknown[] }).data.filter(
38
+ (record): record is LiteLlmCapabilityRecord => Boolean(record) && typeof record === "object" && !Array.isArray(record),
39
+ );
40
+ }
41
+
42
+ function isLiteLlmProvider(sourceProviderId: string, source: Provider): boolean {
43
+ return `${sourceProviderId} ${source.name ?? ""}`.toLowerCase().includes("litellm");
44
+ }
45
+
31
46
  function resolvedConnection(source: Provider, auth?: AuthResult, staticHeaders?: Record<string, string>): ResolvedOpenAiCompatibleConnection {
32
47
  return {
33
48
  baseUrl: auth?.auth.baseUrl ?? source.baseUrl,
@@ -84,7 +99,20 @@ async function discoverProvider(
84
99
  signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
85
100
  });
86
101
  if (!response.ok) throw new Error(`OpenAI-compatible /v1/models returned HTTP ${response.status}`);
87
- return responseRecords(await response.json());
102
+ const records = responseRecords(await response.json());
103
+ if (!isLiteLlmProvider(sourceProviderId, source)) return records;
104
+ try {
105
+ const capabilityResponse = await fetch(buildLiteLlmCapabilitiesUrl(request.url), {
106
+ headers: request.headers,
107
+ redirect: "error",
108
+ signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
109
+ });
110
+ if (!capabilityResponse.ok) throw new Error(`LiteLLM capability endpoint returned HTTP ${capabilityResponse.status}`);
111
+ return enrichLiteLlmReasoning(records, capabilityRecords(await capabilityResponse.json()));
112
+ } catch (error) {
113
+ log.warn(`${sourceProviderId}: reasoning capability enrichment unavailable`, redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection));
114
+ return records;
115
+ }
88
116
  } catch (error) {
89
117
  log.warn(`${sourceProviderId}: discovery refresh failed`, redactOpenAiCompatibleDiagnostic(error instanceof Error ? error.message : String(error), connection));
90
118
  throw error;
@@ -15,6 +15,7 @@ const require = createRequire(import.meta.url);
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
  import type { AgentConfig, AgentDiscoveryResult } from "./agents.js";
17
17
  import { resolveAgentModelProvider } from "./resolve-agent-model.js";
18
+ import { withGraftTools } from "./graft.js";
18
19
  import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
19
20
  import { addReviewerPending, recordReviewerResult, countFindings, readReviewState } from "./pi-config-review-state.js";
20
21
  import {
@@ -924,7 +925,8 @@ export function registerAsyncAgents(
924
925
  piArgs.push("--session-id", deterministicSessionId);
925
926
  if (effectiveModel) piArgs.push("--model", effectiveModel);
926
927
  if (effectiveProvider) piArgs.push("--provider", effectiveProvider);
927
- if (agent.tools?.length) piArgs.push("--tools", agent.tools.join(","));
928
+ const tools = withGraftTools(agent.tools, getSetting(cwd, "graft_enable"));
929
+ if (tools?.length) piArgs.push("--tools", tools.join(","));
928
930
 
929
931
  if (agent.systemPrompt?.trim()) {
930
932
  const promptPath = path.join(workerDir, "system-prompt.md");
@@ -0,0 +1,306 @@
1
+ /** Opt-in, local Graft graph integration. */
2
+ import { execFile } from "node:child_process";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { chmodSync, closeSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import { createLogger } from "../shared/logger.js";
10
+ import { isPiOneshotInvocation } from "../shared/oneshot.js";
11
+ import { getSetting } from "./project-settings.js";
12
+ import { clearSlot, setSlot } from "./status-bar.js";
13
+ import { getProjectTmpDir, resolveWorktreeRoot } from "./utils.js";
14
+ import { formatCompactTotal } from "../shared/format-total.js";
15
+
16
+ const exec = promisify(execFile);
17
+ const log = createLogger("graft");
18
+ const TIMEOUT_MS = 45_000;
19
+ const MAX_BUFFER = 64 * 1024;
20
+ const MIN_PROMPT_CHARS = 12;
21
+ const INTERACTIVE_TIMEOUT_MS = 2_000;
22
+ export const GRAFT_QUERY_TOOLS = ["graft_find_code", "graft_find_all", "graft_file_api", "graft_trace_calls", "graft_repo_map"] as const;
23
+ export function withGraftTools(tools: readonly string[] | undefined, enabled: boolean): string[] | undefined {
24
+ if (!tools?.length || !enabled) return tools ? [...tools] : undefined;
25
+ return [...new Set([...tools, ...GRAFT_QUERY_TOOLS])];
26
+ }
27
+ export type GraftState = "syncing" | "ready" | "stale" | "failed";
28
+ export type GraftRun = (args: readonly string[], options: { cwd: string; timeoutMs?: number }) => Promise<{ stdout: string; stderr: string; code: number }>;
29
+ type GraphStatus = "fresh" | "stale" | "absent";
30
+ type Options = { enabled: boolean; setting?: (cwd: string) => boolean; executable?: () => Promise<boolean>; graph?: (cwd: string) => Promise<GraphStatus>; retrieve?: (query: string, cwd: string) => Promise<{ pointers: string[] }>; run?: GraftRun; interactiveTimeoutMs?: number };
31
+ type StatusTheme = { fg: (color: string, value: string) => string };
32
+ type Hit = { title?: string; pointer?: string; snippet?: string };
33
+ type Ask = { coverage?: number; coverageStrong?: number; hits?: Hit[] };
34
+
35
+ /** Graft's statusline shape, adapted to Pi's theme API. */
36
+ export function formatGraftFooter({ state, nodeCount, tokenSavings }: { state: GraftState; nodeCount: number; tokenSavings: number }, theme?: StatusTheme): string {
37
+ const fg = (color: string, value: string) => theme?.fg(color, value) ?? value;
38
+ const freshness = state === "syncing" ? fg("warning", "syncing…") : state === "stale" ? fg("warning", "⚠ stale") : state === "failed" ? fg("error", "failed") : fg("success", "✓ synced");
39
+ const parts = [fg("dim", "◤ ") + fg("accent", "graft"), fg("dim", `${nodeCount} nodes`), freshness];
40
+ if (tokenSavings > 0) parts.push(fg("success", `~${formatCompactTotal(tokenSavings)} tok saved`));
41
+ return parts.join(fg("dim", " · "));
42
+ }
43
+ function text(value: unknown, max = 500): string | null { return typeof value === "string" && value.trim() && value.length <= max && !/[\0\r\n]/.test(value) ? value.trim() : null; }
44
+ function projectPath(root: string, value: unknown): string | null {
45
+ const file = text(value); if (!file || isAbsolute(file)) return null;
46
+ const absolute = resolve(root, file); if (relative(root, absolute).startsWith("..")) return null;
47
+ try { const realRoot = realpathSync(root); const realFile = realpathSync(absolute); return realFile === realRoot || realFile.startsWith(realRoot + sep) ? file : null; } catch { return null; }
48
+ }
49
+ function wiring(root: string): any | null { try { return JSON.parse(readFileSync(resolve(root, "graft/.graph/wiring.json"), "utf8")); } catch { return null; } }
50
+ function nodes(root: string): number { const graph = wiring(root); return Number.isSafeInteger(graph?.meta?.nodeCount) ? graph.meta.nodeCount : graph?.nodes?.length ?? 0; }
51
+ function savedTokens(output: string): number { return [...output.matchAll(/\[graft\] tokens saved ≈ ([\d,]+)/g)].reduce((total, match) => total + (Number(match[1].replaceAll(",", "")) || 0), 0); }
52
+ function savingsStore(ctx: any, root: string): string {
53
+ let id: unknown = process.env.PI_SUBAGENT_CHILD === "1" ? process.env.__PI_PARENT_SESSION_ID : undefined;
54
+ const source = id ? "parent" : "session";
55
+ try { id ||= ctx.sessionManager?.getSessionId?.(); } catch { log.debug("savings_store_resolved", { source, available: false }); return ""; }
56
+ if (typeof id !== "string" || !id) { log.debug("savings_store_resolved", { source, available: false }); return ""; }
57
+ const dir = getProjectTmpDir(root); chmodSync(dir, 0o700);
58
+ log.debug("savings_store_resolved", { source, available: true });
59
+ return join(dir, `graft-savings-${createHash("sha256").update(id).digest("hex")}.json`);
60
+ }
61
+ function validSavings(value: unknown): number { return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : 0; }
62
+ function readSavings(file: string): number {
63
+ if (!file) return 0;
64
+ try { return validSavings(JSON.parse(readFileSync(file, "utf8"))?.tokenSavings); } catch { return 0; }
65
+ }
66
+ function writeSavings(file: string, delta: number): number {
67
+ if (!file || !delta) return readSavings(file);
68
+ const lock = `${file}.lock`; const owner = { pid: process.pid, token: randomUUID() }; const ownerTmp = `${lock}.${owner.token}.tmp`; const valueTmp = `${file}.${owner.token}.tmp`;
69
+ for (let attempt = 0; attempt < 100; attempt++) {
70
+ let acquired = false;
71
+ try {
72
+ writeFileSync(ownerTmp, JSON.stringify(owner), { mode: 0o600, flag: "wx" }); linkSync(ownerTmp, lock); acquired = true; unlinkSync(ownerTmp);
73
+ const total = readSavings(file) + delta;
74
+ writeFileSync(valueTmp, JSON.stringify({ tokenSavings: total }), { mode: 0o600 }); renameSync(valueTmp, file);
75
+ return total;
76
+ } catch (error: any) {
77
+ if (error?.code !== "EEXIST") { log.warn("savings_persist_failed", { code: error?.code }); return readSavings(file); }
78
+ const existing = lockOwner(lock);
79
+ if (existing && !ownerAlive(existing)) {
80
+ const claim = `${lock}.claim-${owner.token}`;
81
+ try { renameSync(lock, claim); if (lockOwner(claim)?.token === existing.token && !ownerAlive(existing)) unlinkSync(claim); else try { renameSync(claim, lock); } catch {} } catch {}
82
+ }
83
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2);
84
+ } finally {
85
+ try { unlinkSync(ownerTmp); } catch {}
86
+ try { unlinkSync(valueTmp); } catch {}
87
+ if (acquired) try { if (lockOwner(lock)?.token === owner.token) unlinkSync(lock); } catch {}
88
+ }
89
+ }
90
+ log.warn("savings_persist_failed", { code: "LOCK_BUSY" }); return readSavings(file);
91
+ }
92
+ function toolText(content: unknown): string { return Array.isArray(content) ? content.map((part: any) => typeof part?.text === "string" ? part.text : "").join("\n") : ""; }
93
+ export function classifyToolUse(name: unknown, input: any): "graft" | "source" | null {
94
+ const tool = typeof name === "string" ? name.toLowerCase() : "";
95
+ if (tool.startsWith("graft_")) return "graft";
96
+ if (["read", "grep", "find", "ls"].includes(tool)) return "source";
97
+ if (["bash", "powershell"].includes(tool) && /(^|[|&;]\s*)(npx\s+(-y\s+)?(@nanonets\/)?\s*)?graft(-dev)?\b/i.test(String(input?.command ?? ""))) return "graft";
98
+ return null;
99
+ }
100
+ function forceUseGuidance(stale = false): string { return `[graft] This trusted project has a ${stale ? "stale but usable" : "ready"} local Graft graph. Use Graft before raw grep/read: graft_find_code for where/how, graft_trace_calls for callers/callees or blast radius, graft_file_api for a file API, graft_repo_map for orientation, and graft_find_all only when every occurrence is required. Use raw tools only to inspect a Graft result or when Graft has no answer.${stale ? " The owner is rebuilding the graph; verify pointers against source." : ""}`; }
101
+ function substantive(query: string): boolean { return query.length >= MIN_PROMPT_CHARS && !query.startsWith("/"); }
102
+ function pointerPath(pointer: string): string | null { const match = /^(.+?):\d+(?::\d+)?$/.exec(pointer.trim()); return match?.[1] ?? null; }
103
+ function navigationTool(event: any): boolean {
104
+ const tool = String(event?.toolName ?? event?.name ?? "").toLowerCase();
105
+ if (["grep", "find", "glob", "search"].includes(tool)) return true;
106
+ if (tool !== "bash" && tool !== "powershell") return false;
107
+ return /(^|[|;&]\s*)(?:\S+=\S+\s+)*(?:rg|grep|find|ag|ack)\b/i.test(String(event?.input?.command ?? ""));
108
+ }
109
+ function graftGateReason(): string { return "[graft] Project navigation is blocked until Graft retrieval runs for this turn. Use graft_find_code (or graft_repo_map/graft_trace_calls), then inspect its returned file pointers. If Graft has no relevant result, retry the raw navigation."; }
110
+ function diagnostic(stderr: string): string { return text(stderr.split(/\r?\n/, 1)[0], 240) ?? "no diagnostic output"; }
111
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
112
+ let timer: NodeJS.Timeout | undefined; let timedOut = false;
113
+ log.debug("retrieval_timeout_setup", { timeoutMs });
114
+ try { return await Promise.race([promise, new Promise<never>((_, reject) => { timer = setTimeout(() => { timedOut = true; log.warn("retrieval_timeout", { timeoutMs }); reject(new Error("automatic retrieval timed out")); }, timeoutMs); })]); }
115
+ finally { if (timer) clearTimeout(timer); log.debug("retrieval_timeout_cleanup", { timeoutMs, timedOut }); }
116
+ }
117
+ type LockOwner = { pid: number; token: string };
118
+ function lockOwner(lock: string): LockOwner | null { try { const raw = readFileSync(lock, "utf8"); const legacyPid = Number(raw); if (Number.isSafeInteger(legacyPid)) return { pid: legacyPid, token: `legacy-${legacyPid}` }; const owner = JSON.parse(raw); return Number.isSafeInteger(owner?.pid) && typeof owner?.token === "string" ? owner : null; } catch { return null; } }
119
+ function ownerAlive(owner: LockOwner | null): boolean { if (!owner) return false; try { process.kill(owner.pid, 0); return true; } catch (error: any) { return error?.code === "EPERM"; } }
120
+ export function acquireBuildLock(root: string, afterClaim?: () => void): (() => void) | null {
121
+ const lock = resolve(root, "graft/.graph/pi-build.lock"); const claim = `${lock}.claim`; const owner = { pid: process.pid, token: randomUUID() };
122
+ try { mkdirSync(dirname(lock), { recursive: true }); } catch { log.warn("build_lock_unavailable", { root }); return null; }
123
+ for (let attempt = 0; attempt < 2; attempt++) {
124
+ const claimant = lockOwner(claim);
125
+ if (claimant) { if (ownerAlive(claimant)) return null; try { unlinkSync(claim); } catch { return null; } }
126
+ try {
127
+ const fd = openSync(lock, "wx", 0o600); writeFileSync(fd, JSON.stringify(owner)); closeSync(fd);
128
+ if (lockOwner(claim)) { try { if (lockOwner(lock)?.token === owner.token) unlinkSync(lock); } catch {} return null; }
129
+ return () => { try { if (lockOwner(lock)?.token === owner.token) unlinkSync(lock); } catch {} };
130
+ } catch (error: any) {
131
+ if (error?.code !== "EEXIST") return null;
132
+ try {
133
+ renameSync(lock, claim);
134
+ const claimedOwner = lockOwner(claim);
135
+ if (ownerAlive(claimedOwner)) { try { renameSync(claim, lock); } catch {} return null; }
136
+ afterClaim?.(); unlinkSync(claim);
137
+ } catch { return null; }
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+ function trusted(ctx: any): boolean { return typeof ctx?.isProjectTrusted !== "function" || ctx.isProjectTrusted() === true; }
143
+ function relevant(ask: Ask): Hit[] { const hits = ask.hits?.filter(hit => text(hit.pointer) && text(hit.title)) ?? []; if (!hits.length) return []; if ((typeof ask.coverage === "number" || typeof ask.coverageStrong === "number") && (ask.coverageStrong ?? 0) < .1 && (ask.coverage ?? 0) < .5) return []; return hits.slice(0, 3); }
144
+ function pointerText(hits: Hit[]): string { return hits.map((hit, i) => { const snippet = text(hit.snippet, 140); return ` ${i + 1}. ${text(hit.title)!}: ${text(hit.pointer)!}${snippet ? `\n ${snippet.replace(/\s+/g, " ")}` : ""}`; }).join("\n"); }
145
+ function blastHint(root: string, path: unknown): string | null {
146
+ const file = typeof path === "string" ? path : null; const graph = file && wiring(root); if (!file || !graph) return null;
147
+ const names = new Map((graph.nodes ?? []).map((node: any) => [node.id, node]));
148
+ const ids = new Set((graph.nodes ?? []).filter((node: any) => node.path === file || file.endsWith(`/${node.path}`)).map((node: any) => node.id));
149
+ const dependents = (graph.edges ?? []).filter((edge: any) => ids.has(edge.target) && !ids.has(edge.source)).slice(0, 8).map((edge: any) => { const node = names.get(edge.source); return ` • ${edge.relation} ← ${node ? `${node.name} (${basename(node.path)})` : edge.source}`; });
150
+ return dependents.length ? `[graft] blast radius for ${basename(file)}, who depends on it:\n${dependents.join("\n")}` : null;
151
+ }
152
+ export function isEligibleGraftStartup(event: any, ctx: any): boolean { return event?.reason === "startup" && ctx?.mode !== "print" && ctx?.mode !== "json" && process.env.PI_SUBAGENT_CHILD !== "1" && !isPiOneshotInvocation(); }
153
+
154
+ export function createGraftIntegration(options: Options) {
155
+ const child = process.env.PI_SUBAGENT_CHILD === "1";
156
+ const state = { generation: 0, root: "", store: "", dirtySignal: "", enabled: false, events: undefined as ExtensionAPI["events"] | undefined, value: "stale" as GraftState, nodeCount: 0, tokenSavings: 0, graftReads: 0, sourceReads: 0, dirty: false, refresh: undefined as Promise<boolean> | undefined, ctx: undefined as any, gate: { active: false, retrieved: false, pointers: new Set<string>() } };
157
+ const publishSavings = () => {
158
+ if (child || !state.events) return;
159
+ state.events.emit("pidash:graft-savings", { tokenSavings: state.tokenSavings });
160
+ log.debug("savings_published", { tokenSavings: state.tokenSavings });
161
+ };
162
+ const setState = (value: GraftState) => { state.value = value; setSlot("graft", formatGraftFooter({ state: value, nodeCount: state.nodeCount, tokenSavings: state.tokenSavings }, state.ctx?.ui?.theme), state.ctx); publishSavings(); };
163
+ const addSavings = (amount: number) => { if (!amount) return; state.tokenSavings = state.store ? writeSavings(state.store, amount) : state.tokenSavings + amount; };
164
+ const rawRun: GraftRun = options.run ?? (async (args, { cwd, timeoutMs }) => { try { const result = await exec("graft", args as string[], { cwd, timeout: timeoutMs ?? TIMEOUT_MS, maxBuffer: MAX_BUFFER, env: { ...process.env, DO_NOT_TRACK: "1", npm_config_offline: "true" } }); return { stdout: String(result.stdout).slice(0, MAX_BUFFER), stderr: String(result.stderr).slice(0, MAX_BUFFER), code: 0 }; } catch (error: any) { return { stdout: String(error?.stdout ?? "").slice(0, MAX_BUFFER), stderr: String(error?.stderr || error?.message || "").slice(0, MAX_BUFFER), code: Number(error?.code) || 1 }; } });
165
+ const run: GraftRun = async (args, options) => {
166
+ const started = Date.now();
167
+ // Do not log ask arguments: they contain the user's prompt.
168
+ log.info("subprocess_start", { command: args[0], argCount: args.length, cwd: options.cwd });
169
+ const result = await rawRun(args, options);
170
+ const timing = { command: args[0], argCount: args.length, code: result.code, elapsedMs: Date.now() - started, stdoutBytes: result.stdout.length, stderrBytes: result.stderr.length };
171
+ if (result.code) log.warn("subprocess_end", timing); else log.info("subprocess_end", timing);
172
+ return result;
173
+ };
174
+ const available = options.executable ?? (async () => (await run(["--version"], { cwd: state.root })).code === 0);
175
+ const graph = options.graph ?? (async cwd => (await run(["check", "--json"], { cwd })).code === 0 ? "fresh" : nodes(cwd) ? "stale" : "absent");
176
+ const refresh = async (): Promise<boolean> => {
177
+ if (child) return false;
178
+ if (state.refresh) { log.info("refresh_join", { dirty: state.dirty }); return state.refresh; }
179
+ const generation = state.generation;
180
+ const root = state.root;
181
+ const release = acquireBuildLock(root);
182
+ if (!release) { log.info("refresh_locked", { root }); return false; }
183
+ const started = Date.now();
184
+ log.info("refresh_start", { root, dirty: state.dirty });
185
+ setState(state.value === "stale" || state.nodeCount ? "stale" : "syncing");
186
+ let pending!: Promise<boolean>;
187
+ pending = (async () => {
188
+ const result = await run(["build"], { cwd: root });
189
+ const nodeCount = nodes(root);
190
+ if (state.generation !== generation || state.root !== root) {
191
+ log.info("refresh_end_stale", { root, ok: result.code === 0, nodes: nodeCount, elapsedMs: Date.now() - started });
192
+ return false;
193
+ }
194
+ state.nodeCount = nodeCount; state.dirty = result.code !== 0;
195
+ if (!result.code && state.dirtySignal) try { for (const signal of readdirSync(state.dirtySignal)) try { unlinkSync(join(state.dirtySignal, signal)); } catch {} } catch {}
196
+ setState(result.code === 0 ? "ready" : state.nodeCount ? "stale" : "failed");
197
+ log.info("refresh_end", { ok: result.code === 0, nodes: state.nodeCount, elapsedMs: Date.now() - started });
198
+ return result.code === 0;
199
+ })().finally(() => { release(); if (state.refresh === pending) state.refresh = undefined; });
200
+ state.refresh = pending;
201
+ return pending;
202
+ };
203
+ const markDirty = (source: string) => { if (state.dirty) { log.debug("graph_dirty_repeat", { source }); return; } state.dirty = true; setState("stale"); log.info("graph_dirty", { source }); };
204
+ const consumeDirtySignals = () => {
205
+ if (child || !state.dirtySignal) return;
206
+ try { const signals = readdirSync(state.dirtySignal); if (!signals.length) return; markDirty("child"); log.info("child_dirty_consumed", { count: signals.length }); } catch {}
207
+ };
208
+ const invalidate = (ctx: any, reason: string) => {
209
+ state.generation++; state.ctx = ctx; state.enabled = false; state.root = ""; state.store = ""; state.dirtySignal = ""; state.refresh = undefined; state.dirty = false; state.nodeCount = 0; state.tokenSavings = 0; state.gate = { active: false, retrieved: false, pointers: new Set<string>() };
210
+ clearSlot("graft", ctx); publishSavings(); log.info("session_invalidated", { reason });
211
+ };
212
+ const invoke = async (args: string[]) => {
213
+ log.info("tool_invoke", { command: args[0], enabled: state.enabled, dirty: state.dirty }); if (!state.enabled || !state.root || !trusted(state.ctx) || resolveWorktreeRoot(state.ctx.cwd) !== state.root) return "Graft is disabled or untrusted for this project.";
214
+ if (args[0] === "build") return await refresh() ? "Graft graph rebuilt." : "Graft rebuild was skipped or failed; the existing graph remains available when present.";
215
+ let result = await run(args, { cwd: state.root });
216
+ const scoped = args.indexOf("--in");
217
+ if (result.code && scoped >= 0) { const retry = [...args]; retry.splice(scoped, 2); log.info("scope_retry_root", { command: args[0] }); result = await run(retry, { cwd: state.root }); }
218
+ if (result.code) return `Graft ${args[0]} failed: ${diagnostic(result.stderr)}`;
219
+ state.nodeCount = nodes(state.root); setState(state.dirty || state.value === "stale" ? "stale" : "ready"); return result.stdout.slice(0, MAX_BUFFER);
220
+ };
221
+ const register = (pi: ExtensionAPI) => {
222
+ if (!options.enabled) return;
223
+ state.events = pi.events;
224
+ let toolsRegistered = false;
225
+ const registerTools = () => { if (toolsRegistered) return; toolsRegistered = true; const tool = (name: string, description: string, parameters: any, args: (params: any) => string[] | null) => pi.registerTool({ name, label: name.replace("graft_", "Graft ").replace(/_/g, " "), description, parameters, async execute(_id, params) { const command = args(params); return { content: [{ type: "text" as const, text: command ? await invoke(command) : "Invalid input: use an existing project-relative path." }], details: {} }; } });
226
+ tool("graft_find_code", "Find compact ranked code references in the local Graft graph.", Type.Object({ query: Type.String(), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })), path: Type.Optional(Type.String()) }), p => { const q = text(p.query), file = p.path === undefined ? undefined : projectPath(state.root, p.path); return q && (p.path === undefined || file) ? ["ask", q, ".", "--limit", String(p.limit ?? 8), ...(file ? ["--in", file] : [])] : null; });
227
+ tool("graft_find_all", "Search indexed project files.", Type.Object({ pattern: Type.String(), path: Type.Optional(Type.String()), fixed: Type.Optional(Type.Boolean()) }), p => { const pattern = text(p.pattern), file = p.path === undefined ? undefined : projectPath(state.root, p.path); return pattern && (p.path === undefined || file) ? ["grep", ...(p.fixed ? ["--fixed"] : []), ...(file ? ["--in", file] : []), pattern] : null; });
228
+ tool("graft_file_api", "Get a signatures-only API view for a project file.", Type.Object({ path: Type.String() }), p => { const file = projectPath(state.root, p.path); return file ? ["skeleton", file] : null; });
229
+ tool("graft_trace_calls", "Trace callers or callees of a symbol.", Type.Object({ symbol: Type.String(), direction: Type.Optional(Type.Union([Type.Literal("in"), Type.Literal("out")])), depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })) }), p => { const symbol = text(p.symbol); return symbol ? ["callers", "--direction", p.direction ?? "in", "--depth", String(p.depth ?? 1), symbol] : null; });
230
+ tool("graft_repo_map", "Show a compact graph-based repository map.", Type.Object({ max_dirs: Type.Optional(Type.Integer({ minimum: 1, maximum: 30 })) }), p => ["map", "--max-dirs", String(p.max_dirs ?? 16)]);
231
+ if (!child) { tool("graft_refresh", "Rebuild the local Graft graph.", Type.Object({}), () => ["build"]); tool("graft_blast_radius", "Find dependencies affected by the local diff.", Type.Object({ depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })) }), p => ["blast", "--depth", String(p.depth ?? 2)]); }
232
+ };
233
+ pi.on("session_start", async (event: any, ctx: any) => {
234
+ state.ctx = ctx; log.info("session_start_enter", { reason: event?.reason, mode: ctx?.mode, trusted: trusted(ctx) });
235
+ if (!trusted(ctx)) { invalidate(ctx, "untrusted"); log.info("session_start_exit", { decision: "untrusted" }); return; }
236
+ const enabled = options.setting?.(ctx.cwd) ?? options.enabled;
237
+ if (!enabled) { invalidate(ctx, "disabled"); log.info("session_start_exit", { decision: "disabled" }); return; }
238
+ registerTools(); const generation = ++state.generation; state.enabled = true; state.refresh = undefined; state.dirty = false; state.root = resolveWorktreeRoot(ctx.cwd); const root = state.root; state.nodeCount = nodes(root); state.value = "failed"; state.gate = { active: false, retrieved: false, pointers: new Set<string>() };
239
+ try { state.store = savingsStore(ctx, root); state.dirtySignal = state.store ? `${state.store}.dirty` : ""; if (state.dirtySignal) { mkdirSync(state.dirtySignal, { recursive: true, mode: 0o700 }); chmodSync(state.dirtySignal, 0o700); } state.tokenSavings = readSavings(state.store); } catch (error: any) { state.store = ""; state.dirtySignal = ""; state.tokenSavings = 0; log.warn("savings_restore_failed", { code: error?.code }); }
240
+ setState("failed");
241
+ if (!child && !isEligibleGraftStartup(event, ctx) && !["new", "reload", "resume", "fork"].includes(event?.reason)) { log.info("session_start_exit", { decision: "ineligible" }); return; }
242
+ if (!child) { const executable = await available(); if (state.generation !== generation || state.root !== root) return; if (!executable) { setState("failed"); log.info("session_start_exit", { decision: "unavailable" }); return; } }
243
+ const status = await graph(root);
244
+ if (state.generation !== generation || state.root !== root) return;
245
+ if (status === "fresh") setState("ready");
246
+ else { setState(state.nodeCount ? "stale" : "failed"); if (!child) void refresh(); }
247
+ log.info("session_start_exit", { decision: status, nodes: state.nodeCount });
248
+ });
249
+ pi.on("tool_call", (event: any, ctx: any) => {
250
+ const tool = String(event?.toolName ?? event?.name ?? "").toLowerCase();
251
+ const allowedPointerRead = tool === "read" && typeof event?.input?.path === "string" && state.gate.pointers.has(event.input.path.replace(/^\.\//, ""));
252
+ if (!state.enabled || !["ready", "stale"].includes(state.value) || !state.gate.active || state.gate.retrieved || allowedPointerRead || !(navigationTool(event) || tool === "read")) return;
253
+ log.info("navigation_blocked", { tool: event.toolName });
254
+ return { block: true, reason: graftGateReason() };
255
+ });
256
+ pi.on("tool_result", (event: any, ctx: any) => {
257
+ state.ctx = ctx;
258
+ const attemptedGraft = String(event.toolName).toLowerCase().startsWith("graft_");
259
+ if (attemptedGraft) { state.gate.retrieved = true; state.gate.active = false; }
260
+ const kind = !event.isError ? classifyToolUse(event.toolName, event.input) : null;
261
+ const savings = !event.isError ? savedTokens(toolText(event.content)) : 0;
262
+ if (kind === "graft") state.graftReads++; else if (kind === "source") state.sourceReads++;
263
+ addSavings(savings);
264
+ if (kind || savings) { setState(state.dirty || state.value === "stale" ? "stale" : "ready"); log.info("session_metric", { tool: event.toolName, kind, savings, graftReads: state.graftReads, sourceReads: state.sourceReads, tokenSavings: state.tokenSavings }); }
265
+ const eligible = state.enabled && !event.isError && ["write", "edit"].includes(event.toolName);
266
+ log.info("tool_result", { tool: event.toolName, eligible, kind, savings }); if (!eligible) return;
267
+ if (child) { if (!state.dirtySignal) { log.warn("child_dirty_signal_unavailable", { tool: event.toolName }); return; } try { writeFileSync(join(state.dirtySignal, `${process.pid}-${randomUUID()}`), event.toolName, { mode: 0o600, flag: "wx" }); log.info("child_dirty_signaled", { tool: event.toolName }); } catch (error: any) { log.warn("child_dirty_signal_failed", { code: error?.code }); } return; }
268
+ markDirty(event.toolName); const hint = blastHint(state.root, event.input?.path);
269
+ return hint ? { content: [...(event.content ?? []), { type: "text", text: hint }] } : undefined;
270
+ });
271
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
272
+ const started = Date.now(); state.ctx = ctx; const query = text(event?.prompt); const generation = state.generation; const root = state.root;
273
+ state.gate = { active: false, retrieved: false, pointers: new Set<string>() }; const gate = state.gate;
274
+ const current = () => {
275
+ const generationMatches = state.generation === generation; const rootMatches = state.root === root; const gateMatches = state.gate === gate;
276
+ log.debug("retrieval_current", { generationMatches, rootMatches, gateMatches });
277
+ return generationMatches && rootMatches && gateMatches;
278
+ };
279
+ const finish = (decision: string, extra = {}) => log.info("prompt_retrieval_exit", { decision, elapsedMs: Date.now() - started, ...extra });
280
+ log.info("prompt_retrieval_enter", { enabled: state.enabled, trusted: trusted(ctx), dirty: state.dirty, refreshing: Boolean(state.refresh), queryLength: query?.length ?? 0 });
281
+ if (!trusted(ctx) || !state.enabled || !state.root || resolveWorktreeRoot(ctx.cwd) !== state.root) { finish("disabled_or_untrusted"); return; }
282
+ consumeDirtySignals(); state.tokenSavings = Math.max(state.tokenSavings, readSavings(state.store)); setState(state.value);
283
+ if (!["ready", "stale"].includes(state.value)) {
284
+ const status = await graph(root);
285
+ if (!current()) return;
286
+ if (status === "fresh") setState("ready"); else if (status === "stale") setState("stale"); else { finish("graph_absent"); return; }
287
+ }
288
+ const guidance = forceUseGuidance(state.value === "stale");
289
+ if (!query || !substantive(query)) { finish("guidance_only_control_prompt"); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` }; }
290
+ state.gate.active = true;
291
+ let hits: Hit[] = [];
292
+ try {
293
+ if (options.retrieve) { const result = await withTimeout(options.retrieve(query, root), options.interactiveTimeoutMs ?? INTERACTIVE_TIMEOUT_MS); if (!current()) return; hits = result.pointers.map(pointer => ({ title: pointer, pointer })); }
294
+ else { const result = await withTimeout(run(["ask", query, ".", "--json", "-n", "3"], { cwd: root, timeoutMs: options.interactiveTimeoutMs ?? INTERACTIVE_TIMEOUT_MS }), options.interactiveTimeoutMs ?? INTERACTIVE_TIMEOUT_MS); if (!current()) return; if (result.code) { state.gate.active = false; finish("ask_failed", { diagnostic: diagnostic(result.stderr) }); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}\n\n[graft] Retrieval failed: ${diagnostic(result.stderr)}. Raw navigation is available.` }; } const savings = savedTokens(result.stdout); addSavings(savings); setState(state.value); try { hits = relevant(JSON.parse(result.stdout.replace(/^\[graft\] tokens saved ≈ [\d,]+\s*$/gm, "").trim())); } catch { state.gate.active = false; finish("invalid_ask_json"); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}\n\n[graft] Retrieval returned malformed output. Raw navigation is available.` }; } }
295
+ } catch (error: any) { if (!current()) return; state.gate.active = false; finish("ask_error"); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}\n\n[graft] Retrieval failed: ${text(error?.message, 240) ?? "unknown error"}. Raw navigation is available.` }; }
296
+ if (!hits.length) { state.gate.active = false; finish("guidance_only_no_relevant_hits"); return { systemPrompt: `${event.systemPrompt}\n\n${guidance}` }; }
297
+ hits.forEach(hit => { const path = pointerPath(hit.pointer!); if (path) state.gate.pointers.add(path); });
298
+ state.gate.retrieved = true; finish("injected", { hits: hits.length });
299
+ return { systemPrompt: `${event.systemPrompt}\n\n${guidance}\n\n[graft] starting points for this task:\n${pointerText(hits)}` };
300
+ });
301
+ pi.on("agent_end", (_event: any, ctx: any) => { state.ctx = ctx; consumeDirtySignals(); const shouldRefresh = !child && trusted(ctx) && state.enabled && state.dirty && !state.refresh; log.info("agent_end", { dirty: state.dirty, refreshing: Boolean(state.refresh), shouldRefresh }); if (shouldRefresh) void refresh(); });
302
+ pi.on("session_shutdown", () => { log.info("session_shutdown", { state: state.value, dirty: state.dirty, graftReads: state.graftReads, sourceReads: state.sourceReads, tokenSavings: state.tokenSavings }); clearSlot("graft", state.ctx); });
303
+ };
304
+ return { register };
305
+ }
306
+ export function registerGraft(pi: ExtensionAPI): void { createGraftIntegration({ enabled: true, setting: cwd => getSetting(cwd, "graft_enable") }).register(pi); }
@@ -36,6 +36,7 @@ import { registerReviewUI } from "./review-ui.js";
36
36
  import { registerSessionSearch } from "./session-search.js";
37
37
  import { registerSettingsTui } from "./settings-tui.js";
38
38
  import { registerMarkdownTransformer } from "./markdown-transformer.js";
39
+ import { registerGraft } from "./graft.js";
39
40
  import { ensureGitSshTimeout, isRunningInContainer, terminalNotify } from "./utils.js";
40
41
  import { createLogger } from "../shared/logger.js";
41
42
  import { setGlobalSessionId } from "../shared/file-logger.js";
@@ -94,6 +95,7 @@ export default function (pi: ExtensionAPI) {
94
95
  const { spawnAsyncAgent, killAsyncAgent, getAsyncJobs } = registerAsyncAgents(pi, terminalNotify);
95
96
  registerSubagentTool(pi, spawnAsyncAgent, killAsyncAgent);
96
97
  registerProjectSettings(pi);
98
+ registerGraft(pi);
97
99
  registerEnforcement(pi, IN_CONTAINER);
98
100
  registerReviewUI(pi);
99
101
  registerRules(pi, getAsyncJobs);
@@ -339,7 +339,7 @@ function registerMemoryAdd(pi: ExtensionAPI): void {
339
339
  return {
340
340
  content: [{
341
341
  type: "text",
342
- text: `Invalid action "${params.action}". run_after requires a command (e.g., 'run_after .dev/deploy-all.sh')`,
342
+ text: `Invalid action "${params.action}". run_after requires a command (e.g., 'run_after npm test')`,
343
343
  }],
344
344
  };
345
345
  }
@@ -41,6 +41,8 @@ interface ProjectSettings {
41
41
  cli_agents?: string | string[];
42
42
  pidash_enable?: boolean;
43
43
  pidiff_enable?: boolean;
44
+ /** Enable local Graft repository graph integration. Default: false. */
45
+ graft_enable?: boolean;
44
46
  pidash_port?: number;
45
47
  task_auto_clear_enabled?: boolean;
46
48
  task_auto_clear_minutes?: number;
@@ -105,6 +107,7 @@ export interface SettingsKeyDef {
105
107
  max?: number;
106
108
  strict_digits?: boolean;
107
109
  per_key_resolution?: boolean;
110
+ enum?: string[];
108
111
  }
109
112
 
110
113
  const SETTINGS_FILENAMES = ["pi-config-settings.jsonc", "pi-config-settings.json"];
@@ -136,6 +139,7 @@ const PROJECT_SETTINGS_KEYS: (keyof ProjectSettings)[] = [
136
139
  "cli_agents",
137
140
  "pidash_enable",
138
141
  "pidiff_enable",
142
+ "graft_enable",
139
143
  "pidash_port",
140
144
  "task_auto_clear_enabled",
141
145
  "task_auto_clear_minutes",
@@ -196,6 +200,7 @@ export function parseSettingsFile(filePath: string): ProjectSettings {
196
200
  if (typeof raw.orchestrator_edit_write_block === "boolean") result.orchestrator_edit_write_block = raw.orchestrator_edit_write_block;
197
201
  if (typeof raw.pidash_enable === "boolean") result.pidash_enable = raw.pidash_enable;
198
202
  if (typeof raw.pidiff_enable === "boolean") result.pidiff_enable = raw.pidiff_enable;
203
+ if (typeof raw.graft_enable === "boolean") result.graft_enable = raw.graft_enable;
199
204
  if (typeof raw.pidash_port === "number" && Number.isInteger(raw.pidash_port) && raw.pidash_port > 0 && raw.pidash_port <= 65535) {
200
205
  result.pidash_port = raw.pidash_port;
201
206
  }
@@ -248,7 +253,7 @@ export function parseSettingsFile(filePath: string): ProjectSettings {
248
253
  const SPECIAL_CASE_KEYS = new Set<string>([
249
254
  "commit_trailer", "allow_push_to_protected_branches", "use_worktrees",
250
255
  "dream_interval_hours", "dco", "comment_signature", "review_loop_enforcement",
251
- "orchestrator_edit_write_block", "pidash_enable", "pidiff_enable", "pidash_port",
256
+ "orchestrator_edit_write_block", "pidash_enable", "pidiff_enable", "graft_enable", "pidash_port",
252
257
  "image_model", "internal_operations_provider", "internal_operations_model",
253
258
  "review_loop_max_cycles", "acpx_agents", "cli_agents", "agent_provider",
254
259
  "agent_model", "agent_overrides",
@@ -466,6 +471,7 @@ export function getSetting(cwd: string, key: "acpx_agents"): string[];
466
471
  export function getSetting(cwd: string, key: "cli_agents"): string[];
467
472
  export function getSetting(cwd: string, key: "pidash_enable"): boolean;
468
473
  export function getSetting(cwd: string, key: "pidiff_enable"): boolean;
474
+ export function getSetting(cwd: string, key: "graft_enable"): boolean;
469
475
  export function getSetting(cwd: string, key: "pidash_port"): number;
470
476
  export function getSetting(cwd: string, key: "task_auto_clear_enabled"): boolean;
471
477
  export function getSetting(cwd: string, key: "task_auto_clear_minutes"): number;
@@ -9,6 +9,7 @@ import { dirname, join as joinPath } from "node:path";
9
9
  import stripJsonComments from "strip-json-comments";
10
10
  import {
11
11
  type SettingsKeyDef,
12
+ SETTINGS_KEYS,
12
13
  getSettingsPath,
13
14
  getGlobalSettingsPath,
14
15
  } from "./project-settings.js";
@@ -40,14 +41,12 @@ export interface CategoryDef {
40
41
  keys: string[];
41
42
  }
42
43
 
43
- import settingsSchema from "../../settings-keys.json" with { type: "json" };
44
-
45
44
  // Build CATEGORIES dynamically from schema `group` field.
46
45
  // Preserves insertion order from settings-keys.json — groups appear
47
46
  // in the order their first key is declared.
48
47
  export const CATEGORIES: CategoryDef[] = (() => {
49
48
  const groupMap = new Map<string, string[]>();
50
- for (const [key, def] of Object.entries(settingsSchema)) {
49
+ for (const [key, def] of Object.entries(SETTINGS_KEYS)) {
51
50
  const group = (def as any).group || "Other";
52
51
  if (!groupMap.has(group)) groupMap.set(group, []);
53
52
  groupMap.get(group)!.push(key);
@@ -55,7 +55,6 @@ import {
55
55
  AgentOverridesSubmenu,
56
56
  SelectSubmenu,
57
57
  } from "./settings-tui-submenus.js";
58
- import settingsSchema from "../../settings-keys.json" with { type: "json" };
59
58
 
60
59
  // Re-export helpers for external consumers
61
60
  const log = createLogger("settings_tui");
@@ -229,8 +228,7 @@ export function buildCategoryItems(
229
228
  break;
230
229
 
231
230
  case "string": {
232
- const schemaDef = settingsSchema[key as keyof typeof settingsSchema] as any;
233
- const enumValues: string[] | undefined = schemaDef?.enum;
231
+ const enumValues = def.enum;
234
232
 
235
233
  if (enumValues && enumValues.length > 0) {
236
234
  item.submenu = (current: string, done: (val?: string) => void): Component => {
@@ -15,6 +15,7 @@ const SLOTS = {
15
15
  crons: "3-crons",
16
16
  dream: "3b-dream",
17
17
  git: "4-git",
18
+ graft: "4b-graft",
18
19
  review: "5-review",
19
20
  } as const;
20
21