Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed

- **Unified codebase explorer**: Replace separate architecture/network/symbol views with a categorized module directory and one module → exact file → symbol drill-down. Stable caller → focus → callee layouts use paged neighbors, direct navigation, and inspectable original callsites, including self-calls. Refine typography, category cards, snapshot totals, metadata density, and evidence panels; show module-anchored display paths while preserving original locations and add sticky narrow-layout section jumps. Keep whole-snapshot Changes, Hotspots, and Cycles in Analyze without losing exploration state. Preserve offline HTML, public export/tool arguments, safe snapshot data, search/history, responsive dialogs, and dark/light themes; remove draggable-network layout code.
- **Node.js runtime floor and Chokidar 6**: Raise both package identities' minimum from Node.js 22.13.0 to 22.22.0 to satisfy Chokidar 6's declared engine requirement. Existing platform, binary, and host contracts remain unchanged.

- **Complete history sync scheduling**: Reconcile up to four entities concurrently through one sync-local request budget of four active calls and eight starts per rolling second, without skipping historical edits, deletions, or conditional page validation. Drain real requests before publication, cancellation settlement, or cleanup; retain resumable checkpoints on transient failures and let late access denials take precedence.
- **Compatible dependency updates**: Update ignore to 7.0.13, NAPI CLI to 3.10.9, ESLint to 10.13.0, Rollup to 4.65.0, and typescript-eslint to 8.72.0; retain OpenCode SDK/plugin 1.3.13 together and the published Node.js 22.22.0 minimum.

### Fixed

- **Concurrent branch materialization**: Use invocation-unique worktree registration names and a common-Git-directory lease around registration/deregistration, preventing overlapping calls from reading or recycling partially initialized peer metadata. Keep fetches and indexing callbacks concurrent, preserve cancellation cleanup and fail-closed retention, and cover peer preservation plus real multiprocess overlap.
- **Directory watcher permission recovery**: Recover readable, policy-selected directory and project-root registrations rejected with `EPERM` or `EACCES`, including empty directories that later gain source files. Reuse the one-shot polling cutover and catch-up reconciliation without broadening ignored, excluded, restricted, protected-index, unreadable, or external-configuration scope. Exercise actual Chokidar 6 directory resources in the watcher/Indexer acceptance checks.
- **Visualizer cyclic groups and graph scale**: Detect module and symbol strongly connected components iteratively, including direct recursion and loops longer than five symbols. Preserve distinct symbol IDs and page search, hotspot, relationship, and cyclic-group lists instead of silently capping results.

## [0.38.2] - 2026-10-26

Expand Down
29 changes: 24 additions & 5 deletions commands/visualize.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
---
description: Generate temporal HTML call graph visualization
description: Generate an offline HTML codebase explorer
---

Generate an interactive temporal call graph visualization that starts with recent movement/onboarding context, then supports module overview, symbol exploration, hotspots, and cycles.
Generate a self-contained interactive HTML codebase explorer. Start with the categorized Overview directory, drill into modules, exact files and symbols, and inspect directed caller → focus → callee relationships and original callsites. Use Analyze for snapshot activity, hotspots and cyclic groups.

User input: $ARGUMENTS

Parse input for optional parameters:
- Plain text → directory filter (e.g., `/visualize src/services`)
- `max=N` or "limit N" → sets maxNodes
- `orphans` or `include-orphans` → sets includeOrphans=true
- No input → visualize recent code movement plus entire call graph
- No input → explore the entire exported call graph, starting with Overview

Call `index_visualize` with parsed parameters.

Examples:
- `/visualize` → temporal onboarding view with changes, modules, symbols, hotspots, and cycles
- `/visualize` → Overview by default, module/file/symbol drill-down, and whole-snapshot Changes, Hotspots and Cycles under Analyze
- `/visualize src/tools` → only symbols in src/tools/
- `/visualize max=1000` → limit to 1000 nodes
- `/visualize src/indexer orphans` → include disconnected nodes
Expand All @@ -27,4 +27,23 @@ Return the generated file path and open instructions.
Terminology:
- Modules are path-based code areas, such as `src/tools` or `native`.
- Symbols are indexed named code units, such as functions, methods, classes, or Rust modules.
- Cycles are loops where code can eventually call or depend back on itself.
- Cyclic groups contain mutually reachable modules or symbols; a group is not an ordered cycle path. Single-symbol groups indicate direct recursion.

The generated file works offline without a server or external assets. Open the returned HTML path in a browser. The equivalent CLI is `npm run visualize -- --project /path/to/project`; its directory, max-node, and orphan options are unchanged.

Interaction:
- Search names, full paths, and kinds; `/` focuses search. Results are paginated, not capped.
- Overview: browse Code, Tests & fixtures, and Supporting files. Category checkboxes affect the directory only, not global search or snapshot totals.
- Open a module for incoming/outgoing module relationships and Files and symbols. Filter within the module, open an exact file disclosure, then select a symbol. File and symbol lists page at 50.
- Symbol focus shows callers, the selected definition and callees. Neighbor lanes page at 12; selecting a neighbor replaces the focus rather than growing a network. Self-calls appear once on the focus card.
- Each neighbor has separate navigation and **Inspect N calls** actions. Inspect retains the focus and lists original callsites in Details. Back/Forward and parent breadcrumbs stay within the artifact; URL fragments restore scope and evidence.
- Analyze opens whole-snapshot Changes, Hotspots or Cycles without losing the underlying scope. **Back to exploration** restores it. Use normal scrolling and browser zoom; narrow layouts provide sticky section jumps.
- On smaller screens, Search and Details open keyboard-accessible dialogs; Escape closes them and restores focus.

Scope:
- Counts describe the exported resolved-call snapshot, not all dependencies or live state. Input counts precede filtering. A persistent **Partial graph** warning indicates node-limit truncation.
- Modules use existing path-based grouping. Distinct symbol definitions remain individually navigable.
- Directed relationships represent exported calls. Module relationships aggregate cross-module records; internal calls remain available at symbol level. Original callsite lines are distinct from definition lines. Counts across module and symbol levels overlap and are not additive.
- Git activity covers the existing 90-day window and represented indexed files. **Structural context** is call-graph-derived, not recent history. Attention labels are heuristic and file paths are sampled.
- Hotspots rank incoming plus outgoing call-edge records. Missing relationships or cyclic groups do not prove their absence outside this snapshot.
- Copy location copies the definition path and line; source bodies are not embedded.
18 changes: 16 additions & 2 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ OpenCode adds one extra tool beyond the shared MCP and OpenCode set:

- `index_visualize`

`index_visualize` generates an interactive HTML view of recent code movement and the call graph.
`index_visualize` generates a self-contained offline HTML codebase explorer. Browse categorized path-based modules, drill into exact files and symbols, and inspect directed call relationships and original callsites. Analyze offers whole-snapshot Changes, Hotspots, and Cycles without losing the underlying exploration scope.

OpenCode also exposes slash commands (see [OpenCode slash commands](#opencode-slash-commands)).

Expand Down Expand Up @@ -317,13 +317,27 @@ Output includes community summaries, hub node rows, and `Community couplings` en

Analyzes changed files, affected symbols, transitive dependencies, graph communities, hub nodes, conflicts, and merge risk for a branch or pull request.

Branch materialization uses invocation-unique temporary worktrees. A cross-process lease scoped to the common Git directory protects registration and deregistration, including linked-worktree callers; fetches and indexing callbacks remain concurrent. Cancellation while waiting for registration removes the unused temporary checkout, and unverified cleanup preserves its path rather than deleting potentially registered data.

## OpenCode-native tools

OpenCode registers the portable tools, the three shared knowledge-base tools, and one OpenCode-only tool:

- `index_visualize`

`index_visualize` generates an interactive HTML view of recent code movement and the call graph.
`index_visualize` generates a self-contained offline HTML codebase explorer. Open the returned file in a browser; no server, CDN, or external assets are required. The CLI equivalent is `npm run visualize -- --project /path/to/project`.

Overview is the initial module directory, grouped into **Code**, **Tests & fixtures**, and **Supporting files**. Category checkboxes affect only this directory, not global search or snapshot totals. Cards show exported-symbol, represented-file, and distinct neighboring-module counts. These are path-based groups, not authoritative architecture.

Open a module for **Incoming modules → selected module → Outgoing modules**, followed by **Files and symbols**. The independent module-local filter matches full file paths, symbol names, and kinds. File disclosures create symbol rows only when opened. File and symbol lists use 50-item pages; focused neighbor lanes use 12-item pages, replacing rather than accumulating rows. Open a symbol for **Caller → focus → callee**, exact definition location, Module navigation, and Copy location. Self-calls appear once on the center card. Module edges represent cross-module calls only.

Each neighbor has separate navigation and **Inspect N calls** buttons. Inspect keeps the focus and shows navigable endpoint definitions, original caller file/callsite lines, call types, and confidence in Details. Horizontal connectors offer the same action by pointer; the native Inspect buttons remain keyboard-accessible. Narrow focus containers stack center, incoming, and outgoing sections, with sticky **Focus / Callers / Callees** jump controls (or **Incoming / Outgoing / Files** for modules). Use normal scrolling and browser zoom; there is no draggable global network.

Global search matches names, full paths, and kinds with 50-item pages; `/` focuses it. Typing highlights visible matches without rebuilding the focused scene. Back/Forward and parent breadcrumbs navigate within the artifact. **Analyze** replaces the main surface with whole-snapshot Changes, Hotspots, or Cycles; **Back to exploration** restores the same scope. Search, category choices, filters, disclosure state, pagination, and scoped scroll positions remain document-local; reload restores only the hash-selected scope and evidence. Smaller screens expose Search and Details as keyboard-accessible dialogs; Escape restores their opener.

Cards display shorter paths anchored to their exported module prefix where possible; full original locations remain in path tooltips, the symbol inspector's **Original definition path** disclosure, and **Copy location**. File-group symbol rows show definition lines without repeating the containing path. The header shows exported totals; **Export details** exposes pre-filter input counts and scope. Single-page lists omit inactive pagination controls; larger lists retain all pages.

The view describes an exported resolved-call snapshot, not all dependencies or live state. Input counts precede filtering; **Partial graph** means the node limit omitted symbols/relationships. Modules retain path-based grouping. Recent Git activity retains the 90-day/indexed-file scope; call-graph fallback records appear separately as **Structural context**. Attention is heuristic and provided file paths are sampled. Hotspots rank incoming plus outgoing edge records. Cycles show mutually reachable groups (including self-recursion), not ordered paths or a complete-program guarantee.

Pi also provides native integration for knowledge bases using `knowledge_base_list`, `knowledge_base_add`, and `knowledge_base_remove` tool names.

Expand Down
4 changes: 2 additions & 2 deletions src/adapters/mcp/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,9 @@ async function handleVisualizeCommand(argv: string[], cwd: string): Promise<numb

const outputPath = path.join(os.tmpdir(), `call-graph-${Date.now()}.html`);
writeFileSync(outputPath, generateVisualizationHtml(vizData), "utf-8");
console.log(`Temporal call graph visualization generated: ${outputPath}`);
console.log(`Codebase visualization generated: ${outputPath}`);
console.log(`Nodes: ${vizData.nodes.length} | Edges: ${vizData.edges.length}`);
console.log(`Recent change lenses: ${vizData.changes?.length ?? 0}`);
console.log(`Activity entries: ${vizData.changes?.length ?? 0}`);
if (vizData.metadata.truncated) {
console.log(`Graph truncated to ${args.maxNodes} most-connected nodes.`);
}
Expand Down
7 changes: 3 additions & 4 deletions src/adapters/opencode/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,7 @@ export const code_communities: ToolDefinition = tool({

export const index_visualize: ToolDefinition = tool({
description:
"Generate an interactive HTML visualization of recent code movement and the call graph. " +
"Starts with temporal onboarding context from Git history, then supports module, symbol, hotspot, and cycle drill-down.",
"Generate a self-contained interactive HTML codebase explorer. Browse categorized modules, files, symbols and directed callsites; analyze snapshot activity, hotspots, and cycles.",
args: {
directory: z.string().optional().describe("Filter to symbols in this directory (e.g., 'src/services')"),
maxNodes: z.number().optional().default(5000).describe("Maximum nodes to include (default 5000)"),
Expand Down Expand Up @@ -472,9 +471,9 @@ export const index_visualize: ToolDefinition = tool({
const outputPath = path.join(os.tmpdir(), `call-graph-${Date.now()}.html`);
writeFileSync(outputPath, html, "utf-8");

let result = `Temporal call graph visualization generated: ${outputPath}\n\n`;
let result = `Codebase visualization generated: ${outputPath}\n\n`;
result += `Nodes: ${vizData.nodes.length} | Edges: ${vizData.edges.length}\n`;
result += `Recent change lenses: ${vizData.changes?.length ?? 0}\n`;
result += `Activity entries: ${vizData.changes?.length ?? 0}\n`;
result += `Files: ${new Set(vizData.nodes.map(n => n.filePath)).size}\n`;
result += `Directories: ${new Set(vizData.nodes.map(n => n.directory)).size}`;
if (vizData.metadata.truncated) {
Expand Down
92 changes: 72 additions & 20 deletions src/git/branch-materialization.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { promises as fsPromises } from "fs";
import * as os from "os";
import * as path from "path";
import { setTimeout as delay } from "node:timers/promises";

import { isIndexLockContentionError, withIndexLock } from "../indexer/index-lock.js";
import { canonicalizePathForComparison } from "../utils/canonical-path.js";
import { throwIfOperationAborted } from "../utils/operation-control.js";

Expand Down Expand Up @@ -43,6 +45,39 @@ export {
resolveLocalPullRequestRefs,
resolveGitCommit,
};

async function withWorktreeRegistrationLease<T>(
lockDirectory: string,
callback: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const deadline = Date.now() + 30_000;
for (;;) {
throwIfOperationAborted(signal);
// Do not recreate a Git directory removed or renamed while the callback was running.
try {
await fsPromises.mkdir(lockDirectory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
let acquired = false;
try {
return await withIndexLock(lockDirectory, "worktree", async () => {
acquired = true;
throwIfOperationAborted(signal);
return callback();
});
} catch (error) {
if (acquired || !isIndexLockContentionError(error) || error.reason === "legacy-lock") throw error;
// Release/republication can hide the owner briefly. Wait, but never reclaim an unknown owner.
if (Date.now() >= deadline) {
throw new Error("Timed out waiting for the Git worktree registration lease.", { cause: error });
}
}
await delay(25);
}
}

async function pathExists(targetPath: string): Promise<boolean> {
return fsPromises.stat(targetPath).then(() => true, () => false);
}
Expand Down Expand Up @@ -213,8 +248,12 @@ export async function withMaterializedBranch<T>(
);
}

const commonDir = await fsPromises.realpath(await runGit(request.projectRoot,
["rev-parse", "--path-format=absolute", "--git-common-dir"], { signal: request.signal }));
const registrationLockDirectory = path.join(commonDir, "codebase-index-worktrees");
const temporaryRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "codebase-index-branch-"));
const worktreePath = path.join(temporaryRoot, "worktree");
// Git derives registration IDs from this basename and can recycle an incomplete same-name peer.
const worktreePath = path.join(temporaryRoot, path.basename(temporaryRoot));
const hooksPath = path.join(temporaryRoot, "hooks");
const info: BranchMaterializationInfo = {
branch: request.branch,
Expand All @@ -225,27 +264,31 @@ export async function withMaterializedBranch<T>(

let result: { value: T; info: BranchMaterializationInfo } | undefined;
let operationError: unknown;
let registrationAttempted = false;
try {
throwIfOperationAborted(request.signal);
await fsPromises.mkdir(hooksPath);
throwIfOperationAborted(request.signal);
await runGit(request.projectRoot, [
"-c",
`core.hooksPath=${hooksPath}`,
"worktree",
"add",
"--detach",
"--",
worktreePath,
resolved.commit,
], { signal: request.signal });

const materializedHead = await tryResolveCommit(worktreePath, "HEAD", request.signal);
if (materializedHead !== resolved.commit) {
throw new Error(
`Materialized worktree HEAD ${materializedHead ?? "did not resolve"}; expected ${resolved.commit}.`,
);
}
await withWorktreeRegistrationLease(registrationLockDirectory, async () => {
registrationAttempted = true;
await runGit(request.projectRoot, [
"-c",
`core.hooksPath=${hooksPath}`,
"worktree",
"add",
"--detach",
"--",
worktreePath,
resolved.commit,
], { signal: request.signal });

const materializedHead = await tryResolveCommit(worktreePath, "HEAD", request.signal);
if (materializedHead !== resolved.commit) {
throw new Error(
`Materialized worktree HEAD ${materializedHead ?? "did not resolve"}; expected ${resolved.commit}.`,
);
}
}, request.signal);

throwIfOperationAborted(request.signal);
const value = await callback(worktreePath, info);
Expand All @@ -257,9 +300,18 @@ export async function withMaterializedBranch<T>(

let cleanupError: unknown;
try {
await cleanupTemporaryWorktree(request.projectRoot, worktreePath, temporaryRoot);
if (registrationAttempted) {
await withWorktreeRegistrationLease(registrationLockDirectory,
() => cleanupTemporaryWorktree(request.projectRoot, worktreePath, temporaryRoot));
} else {
await fsPromises.rm(temporaryRoot, { recursive: true, force: true });
}
} catch (error) {
cleanupError = error;
cleanupError = error instanceof AggregateError ? error : new AggregateError(
[asError(error)],
`Could not safely clean up temporary worktree; preserved ${temporaryRoot}`,
{ cause: error },
);
}

if (operationError !== undefined && cleanupError !== undefined) {
Expand Down
Loading
Loading