index.ts
   1  import { spawnSync } from "node:child_process";
   2  import * as path from "node:path";
   3  
   4  import type { ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
   5  import { highlightCode, Theme } from "@mariozechner/pi-coding-agent";
   6  import { Text } from "@mariozechner/pi-tui";
   7  import { Type } from "@sinclair/typebox";
   8  import * as Diff from "diff";
   9  
  10  import { loadConfig } from "./config.js";
  11  import {
  12    computeSliceRangeFromReadArgs,
  13    countFileLines,
  14    inferSelectionStatus,
  15    toPosixPath,
  16  } from "./auto-select.js";
  17  import { RP_READCACHE_CUSTOM_TYPE, SCOPE_FULL, scopeRange } from "./readcache/constants.js";
  18  import { buildInvalidationV1 } from "./readcache/meta.js";
  19  import { getStoreStats, pruneObjectsOlderThan } from "./readcache/object-store.js";
  20  import { readFileWithCache } from "./readcache/read-file.js";
  21  import { clearReplayRuntimeState, createReplayRuntimeState } from "./readcache/replay.js";
  22  import { clearRootsCache, resolveReadFilePath } from "./readcache/resolve.js";
  23  import type { RpReadcacheMetaV1, ScopeKey } from "./readcache/types.js";
  24  import type {
  25    AutoSelectionEntryData,
  26    AutoSelectionEntryRangeData,
  27    AutoSelectionEntrySliceData,
  28    RpCliBindingEntryData,
  29  } from "./types.js";
  30  
  31  let parseBash: ((input: string) => any) | null = null;
  32  let justBashLoadPromise: Promise<void> | null = null;
  33  let justBashLoadDone = false;
  34  
  35  async function ensureJustBashLoaded(): Promise<void> {
  36    if (justBashLoadDone) return;
  37  
  38    if (!justBashLoadPromise) {
  39      justBashLoadPromise = import("just-bash")
  40        .then((mod: any) => {
  41          parseBash = typeof mod?.parse === "function" ? mod.parse : null;
  42        })
  43        .catch(() => {
  44          parseBash = null;
  45        })
  46        .finally(() => {
  47          justBashLoadDone = true;
  48        });
  49    }
  50  
  51    await justBashLoadPromise;
  52  }
  53  
  54  let warnedAstUnavailable = false;
  55  function maybeWarnAstUnavailable(ctx: any): void {
  56    if (warnedAstUnavailable) return;
  57    if (parseBash) return;
  58    if (!ctx?.hasUI) return;
  59  
  60    warnedAstUnavailable = true;
  61    ctx.ui.notify(
  62      "repoprompt-cli: just-bash >= 2 is not available; falling back to best-effort command parsing",
  63      "warning",
  64    );
  65  }
  66  
  67  type BashInvocation = {
  68    statementIndex: number;
  69    pipelineIndex: number;
  70    pipelineLength: number;
  71    commandNameRaw: string;
  72    commandName: string;
  73    args: string[];
  74  };
  75  
  76  function commandBaseName(value: string): string {
  77    const normalized = value.replace(/\\+/g, "/");
  78    const idx = normalized.lastIndexOf("/");
  79    const base = idx >= 0 ? normalized.slice(idx + 1) : normalized;
  80    return base.toLowerCase();
  81  }
  82  
  83  function partToText(part: any): string {
  84    if (!part || typeof part !== "object") return "";
  85  
  86    switch (part.type) {
  87      case "Literal":
  88      case "SingleQuoted":
  89      case "Escaped":
  90        return typeof part.value === "string" ? part.value : "";
  91      case "DoubleQuoted":
  92        return Array.isArray(part.parts) ? part.parts.map(partToText).join("") : "";
  93      case "Glob":
  94        return typeof part.pattern === "string" ? part.pattern : "";
  95      case "TildeExpansion":
  96        return typeof part.user === "string" && part.user.length > 0 ? `~${part.user}` : "~";
  97      case "ParameterExpansion":
  98        return typeof part.parameter === "string" && part.parameter.length > 0
  99          ? "${" + part.parameter + "}"
 100          : "${}";
 101      case "CommandSubstitution":
 102        return "$(...)";
 103      case "ProcessSubstitution":
 104        return part.direction === "output" ? ">(...)" : "<(...)";
 105      case "ArithmeticExpansion":
 106        return "$((...))";
 107      default:
 108        return "";
 109    }
 110  }
 111  
 112  function wordToText(word: any): string {
 113    if (!word || typeof word !== "object" || !Array.isArray(word.parts)) return "";
 114    return word.parts.map(partToText).join("");
 115  }
 116  
 117  function analyzeTopLevelBashScript(command: string): { parseError?: string; topLevelInvocations: BashInvocation[] } {
 118    try {
 119      if (!parseBash) {
 120        return { parseError: "just-bash parse unavailable", topLevelInvocations: [] };
 121      }
 122  
 123      const ast: any = parseBash(command);
 124      const topLevelInvocations: BashInvocation[] = [];
 125  
 126      if (!ast || typeof ast !== "object" || !Array.isArray(ast.statements)) {
 127        return { topLevelInvocations };
 128      }
 129  
 130      ast.statements.forEach((statement: any, statementIndex: number) => {
 131        if (!statement || typeof statement !== "object" || !Array.isArray(statement.pipelines)) return;
 132  
 133        statement.pipelines.forEach((pipeline: any, pipelineIndex: number) => {
 134          if (!pipeline || typeof pipeline !== "object" || !Array.isArray(pipeline.commands)) return;
 135  
 136          const pipelineLength = pipeline.commands.length;
 137          pipeline.commands.forEach((commandNode: any) => {
 138            if (!commandNode || commandNode.type !== "SimpleCommand") return;
 139  
 140            const commandNameRaw = wordToText(commandNode.name).trim();
 141            if (!commandNameRaw) return;
 142  
 143            const args = Array.isArray(commandNode.args)
 144              ? commandNode.args.map((arg: any) => wordToText(arg)).filter(Boolean)
 145              : [];
 146  
 147            topLevelInvocations.push({
 148              statementIndex,
 149              pipelineIndex,
 150              pipelineLength,
 151              commandNameRaw,
 152              commandName: commandBaseName(commandNameRaw),
 153              args,
 154            });
 155          });
 156        });
 157      });
 158  
 159      return { topLevelInvocations };
 160    } catch (error: any) {
 161      return {
 162        parseError: error?.message ?? String(error),
 163        topLevelInvocations: [],
 164      };
 165    }
 166  }
 167  
 168  function hasSemicolonOutsideQuotes(script: string): boolean {
 169    let inSingleQuote = false;
 170    let inDoubleQuote = false;
 171    let escaped = false;
 172  
 173    for (let i = 0; i < script.length; i += 1) {
 174      const ch = script[i];
 175  
 176      if (escaped) {
 177        escaped = false;
 178        continue;
 179      }
 180  
 181      if (ch === "\\") {
 182        escaped = true;
 183        continue;
 184      }
 185  
 186      if (!inDoubleQuote && ch === "'") {
 187        inSingleQuote = !inSingleQuote;
 188        continue;
 189      }
 190  
 191      if (!inSingleQuote && ch === '"') {
 192        inDoubleQuote = !inDoubleQuote;
 193        continue;
 194      }
 195  
 196      if (!inSingleQuote && !inDoubleQuote && ch === ";") {
 197        return true;
 198      }
 199    }
 200  
 201    return false;
 202  }
 203  
 204  function hasPipeOutsideQuotes(script: string): boolean {
 205    let inSingleQuote = false;
 206    let inDoubleQuote = false;
 207    let escaped = false;
 208  
 209    for (let i = 0; i < script.length; i += 1) {
 210      const ch = script[i];
 211  
 212      if (escaped) {
 213        escaped = false;
 214        continue;
 215      }
 216  
 217      if (ch === "\\") {
 218        escaped = true;
 219        continue;
 220      }
 221  
 222      if (!inDoubleQuote && ch === "'") {
 223        inSingleQuote = !inSingleQuote;
 224        continue;
 225      }
 226  
 227      if (!inSingleQuote && ch === '"') {
 228        inDoubleQuote = !inDoubleQuote;
 229        continue;
 230      }
 231  
 232      if (!inSingleQuote && !inDoubleQuote && ch === "|") {
 233        return true;
 234      }
 235    }
 236  
 237    return false;
 238  }
 239  
 240  /**
 241   * RepoPrompt CLI ↔ Pi integration extension
 242   *
 243   * Registers two Pi tools:
 244   * - `rp_bind`: binds a RepoPrompt window + compose tab (routing)
 245   * - `rp_exec`: runs `rp-cli -e <cmd>` against that binding (quiet defaults, output truncation)
 246   *
 247   * Safety goals:
 248   * - Prevent "unbound" rp_exec calls from operating on an unintended window/workspace
 249   * - Prevent in-place workspace switches by default (they can clobber selection/prompt/context)
 250   * - Block delete-like commands unless explicitly allowed
 251   *
 252   * UX goals:
 253   * - Persist binding across session reloads via `pi.appendEntry()` (does not enter LLM context)
 254   * - Provide actionable error messages when blocked
 255   * - For best command parsing (AST-based), install `just-bash` >= 2; otherwise it falls back to a legacy splitter
 256   * - Syntax-highlight fenced code blocks in output (read, structure, etc.)
 257   * - Delta-powered diff highlighting (with graceful fallback when delta is unavailable)
 258   */
 259  
 260  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
 261  const DEFAULT_MAX_OUTPUT_CHARS = 12000;
 262  const BINDING_CUSTOM_TYPE = "repoprompt-binding";
 263  const AUTO_SELECTION_CUSTOM_TYPE = "repoprompt-cli-auto-selection";
 264  const WINDOWS_CACHE_TTL_MS = 5000;
 265  const BINDING_VALIDATION_TTL_MS = 5000;
 266  
 267  interface RpCliWindow {
 268    windowId: number;
 269    workspaceId?: string;
 270    workspaceName?: string;
 271    rootFolderPaths?: string[];
 272  }
 273  
 274  const BindParams = Type.Object({
 275    windowId: Type.Number({ description: "RepoPrompt window id (from `rp-cli -e windows`)" }),
 276    tab: Type.String({ description: "RepoPrompt compose tab name or UUID" }),
 277  });
 278  
 279  const ExecParams = Type.Object({
 280    cmd: Type.String({ description: "rp-cli exec string (e.g. `tree`, `select set src/ && context`)" }),
 281    rawJson: Type.Optional(Type.Boolean({ description: "Pass --raw-json to rp-cli" })),
 282    quiet: Type.Optional(Type.Boolean({ description: "Pass -q/--quiet to rp-cli (default: true)" })),
 283    failFast: Type.Optional(Type.Boolean({ description: "Pass --fail-fast to rp-cli (default: true)" })),
 284    timeoutMs: Type.Optional(Type.Number({ description: "Timeout in ms (default: 15 minutes)" })),
 285    maxOutputChars: Type.Optional(Type.Number({ description: "Truncate output to this many chars (default: 12000)" })),
 286    windowId: Type.Optional(Type.Number({ description: "Override bound window id for this call" })),
 287    tab: Type.Optional(Type.String({ description: "Override bound tab for this call" })),
 288    allowDelete: Type.Optional(
 289      Type.Boolean({ description: "Allow delete commands like `file delete ...` or `workspace delete ...` (default: false)" }),
 290    ),
 291    allowWorkspaceSwitchInPlace: Type.Optional(
 292      Type.Boolean({
 293        description:
 294          "Allow in-place workspace changes (e.g. `workspace switch <name>` or `workspace create ... --switch`) without --new-window (default: false). In-place switching can disrupt other sessions",
 295      }),
 296    ),
 297    failOnNoopEdits: Type.Optional(
 298      Type.Boolean({
 299        description: "Treat edit commands that apply 0 changes (or produce empty output) as errors (default: true)",
 300      }),
 301    ),
 302  });
 303  
 304  function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } {
 305    if (maxChars <= 0) return { text: "", truncated: text.length > 0 };
 306    if (text.length <= maxChars) return { text, truncated: false };
 307    return {
 308      text: `${text.slice(0, maxChars)}\n… [truncated; redirect output to a file if needed]`,
 309      truncated: true,
 310    };
 311  }
 312  
 313  type ParsedCommandChain = {
 314    commands: string[];
 315    invocations: BashInvocation[];
 316    hasSemicolonOutsideQuotes: boolean;
 317  };
 318  
 319  function parseCommandChainLegacy(cmd: string): { commands: string[]; hasSemicolonOutsideQuotes: boolean } {
 320    const commands: string[] = [];
 321    let current = "";
 322    let inSingleQuote = false;
 323    let inDoubleQuote = false;
 324    let escaped = false;
 325    let hasSemicolonOutsideQuotes = false;
 326  
 327    const pushCurrent = () => {
 328      const trimmed = current.trim();
 329      if (trimmed.length > 0) commands.push(trimmed);
 330      current = "";
 331    };
 332  
 333    for (let i = 0; i < cmd.length; i += 1) {
 334      const ch = cmd[i];
 335  
 336      if (escaped) {
 337        current += ch;
 338        escaped = false;
 339        continue;
 340      }
 341  
 342      if (ch === "\\") {
 343        current += ch;
 344        escaped = true;
 345        continue;
 346      }
 347  
 348      if (!inDoubleQuote && ch === "'") {
 349        inSingleQuote = !inSingleQuote;
 350        current += ch;
 351        continue;
 352      }
 353  
 354      if (!inSingleQuote && ch === '"') {
 355        inDoubleQuote = !inDoubleQuote;
 356        current += ch;
 357        continue;
 358      }
 359  
 360      if (!inSingleQuote && !inDoubleQuote) {
 361        if (ch === "&" && cmd[i + 1] === "&") {
 362          pushCurrent();
 363          i += 1;
 364          continue;
 365        }
 366  
 367        if (ch === ";") {
 368          hasSemicolonOutsideQuotes = true;
 369          pushCurrent();
 370          continue;
 371        }
 372      }
 373  
 374      current += ch;
 375    }
 376  
 377    pushCurrent();
 378    return { commands, hasSemicolonOutsideQuotes };
 379  }
 380  
 381  function renderInvocation(invocation: BashInvocation): string {
 382    return [invocation.commandNameRaw, ...invocation.args].filter(Boolean).join(" ").trim();
 383  }
 384  
 385  function parseCommandChain(cmd: string): ParsedCommandChain {
 386    const semicolonOutsideQuotes = hasSemicolonOutsideQuotes(cmd);
 387    const analysis = analyzeTopLevelBashScript(cmd);
 388  
 389    if (!analysis.parseError && analysis.topLevelInvocations.length > 0) {
 390      const commands = analysis.topLevelInvocations
 391        .map(renderInvocation)
 392        .filter((command) => command.length > 0);
 393  
 394      return {
 395        commands,
 396        invocations: analysis.topLevelInvocations,
 397        hasSemicolonOutsideQuotes: semicolonOutsideQuotes,
 398      };
 399    }
 400  
 401    const legacy = parseCommandChainLegacy(cmd);
 402    return {
 403      commands: legacy.commands,
 404      invocations: [],
 405      hasSemicolonOutsideQuotes: legacy.hasSemicolonOutsideQuotes || semicolonOutsideQuotes,
 406    };
 407  }
 408  
 409  function looksLikeDeleteCommand(cmd: string): boolean {
 410    const parsed = parseCommandChain(cmd);
 411  
 412    if (parsed.invocations.length > 0) {
 413      for (const invocation of parsed.invocations) {
 414        const commandName = invocation.commandName;
 415        const args = invocation.args.map((arg) => arg.toLowerCase());
 416  
 417        if (commandName === "file" && args[0] === "delete") return true;
 418        if (commandName === "workspace" && args[0] === "delete") return true;
 419  
 420        if (commandName === "call") {
 421          const normalized = args.join(" ");
 422          if (
 423            /\baction\s*=\s*delete\b/.test(normalized)
 424            || /"action"\s*:\s*"delete"/.test(normalized)
 425            || /'action'\s*:\s*'delete'/.test(normalized)
 426          ) {
 427            return true;
 428          }
 429        }
 430      }
 431      return false;
 432    }
 433  
 434    // Fallback when parsing fails
 435    for (const command of parsed.commands) {
 436      const normalized = command.trim().toLowerCase();
 437      if (normalized === "file delete" || normalized.startsWith("file delete ")) return true;
 438      if (normalized === "workspace delete" || normalized.startsWith("workspace delete ")) return true;
 439  
 440      if (
 441        normalized.startsWith("call ")
 442        && (
 443          /\baction\s*=\s*delete\b/.test(normalized)
 444          || /"action"\s*:\s*"delete"/.test(normalized)
 445          || /'action'\s*:\s*'delete'/.test(normalized)
 446        )
 447      ) {
 448        return true;
 449      }
 450    }
 451  
 452    return false;
 453  }
 454  
 455  function looksLikeWorkspaceSwitchInPlace(cmd: string): boolean {
 456    const parsed = parseCommandChain(cmd);
 457  
 458    if (parsed.invocations.length > 0) {
 459      for (const invocation of parsed.invocations) {
 460        if (invocation.commandName !== "workspace") continue;
 461  
 462        const args = invocation.args.map((arg) => arg.toLowerCase());
 463        const action = args[0] ?? "";
 464        const hasNewWindow = args.includes("--new-window");
 465        const hasSwitchFlag = args.includes("--switch");
 466  
 467        if (action === "switch" && !hasNewWindow) return true;
 468        if (action === "create" && hasSwitchFlag && !hasNewWindow) return true;
 469      }
 470  
 471      return false;
 472    }
 473  
 474    // Fallback when parsing fails
 475    for (const command of parsed.commands) {
 476      const normalized = command.toLowerCase();
 477  
 478      if (normalized.startsWith("workspace switch ") && !normalized.includes("--new-window")) return true;
 479  
 480      const isCreate = normalized.startsWith("workspace create ");
 481      const requestsSwitch = /\B--switch\b/.test(normalized);
 482      if (isCreate && requestsSwitch && !normalized.includes("--new-window")) return true;
 483    }
 484  
 485    return false;
 486  }
 487  
 488  function looksLikeEditCommand(cmd: string): boolean {
 489    const parsed = parseCommandChain(cmd);
 490  
 491    if (parsed.invocations.length > 0) {
 492      return parsed.invocations.some((invocation) => {
 493        if (invocation.commandName === "edit") return true;
 494        if (invocation.commandName !== "call") return false;
 495  
 496        return invocation.args.some((arg) => arg.toLowerCase().includes("apply_edits"));
 497      });
 498    }
 499  
 500    return parsed.commands.some((command) => {
 501      const normalized = command.trim().toLowerCase();
 502      if (normalized === "edit" || normalized.startsWith("edit ")) return true;
 503      return normalized.startsWith("call ") && normalized.includes("apply_edits");
 504    });
 505  }
 506  
 507  type ParsedReadFileRequest = {
 508    cmdToRun: string;
 509    path: string;
 510    startLine?: number;
 511    limit?: number;
 512    bypassCache: boolean;
 513  
 514    // Whether it is safe to apply readcache substitution (marker/diff) for this request
 515    // When false, we may still rewrite cmdToRun to strip wrapper-only args like bypass_cache=true
 516    cacheable: boolean;
 517  };
 518  
 519  function parseReadFileRequest(cmd: string): ParsedReadFileRequest | null {
 520    const parsed = parseCommandChain(cmd);
 521  
 522    // Only handle simple, single-invocation commands to avoid surprising behavior
 523    if (parsed.hasSemicolonOutsideQuotes) return null;
 524  
 525    let commandNameRaw: string;
 526    let commandName: string;
 527    let rawArgs: string[];
 528  
 529    if (parsed.invocations.length === 1) {
 530      const invocation = parsed.invocations[0];
 531      if (!invocation) return null;
 532      if (invocation.pipelineLength !== 1) return null;
 533  
 534      commandNameRaw = invocation.commandNameRaw;
 535      commandName = invocation.commandName;
 536      rawArgs = invocation.args;
 537    } else if (parsed.invocations.length === 0 && parsed.commands.length === 1) {
 538      const commandText = parsed.commands[0]?.trim() ?? "";
 539      if (!commandText) return null;
 540  
 541      // Legacy parsing fallback (just-bash unavailable): only attempt for trivially-tokenizable, single commands
 542      if (hasPipeOutsideQuotes(commandText)) return null;
 543      if (commandText.includes("\\")) return null;
 544      if (commandText.includes("\"") || commandText.includes("'") || commandText.includes("`")) return null;
 545  
 546      const parts = commandText.split(/\s+/).filter(Boolean);
 547      if (parts.length === 0) return null;
 548  
 549      commandNameRaw = parts[0] ?? "";
 550      commandName = commandBaseName(commandNameRaw);
 551      rawArgs = parts.slice(1);
 552    } else {
 553      return null;
 554    }
 555  
 556    if (commandName !== "read" && commandName !== "cat" && commandName !== "read_file") {
 557      return null;
 558    }
 559  
 560    let inputPath: string | undefined;
 561    let startLine: number | undefined;
 562    let limit: number | undefined;
 563    let bypassCache = false;
 564    let sawUnknownArg = false;
 565  
 566    const getNumber = (value: string): number | undefined => {
 567      if (!/^-?\d+$/.test(value.trim())) {
 568        return undefined;
 569      }
 570  
 571      const parsedInt = Number.parseInt(value, 10);
 572      return Number.isFinite(parsedInt) ? parsedInt : undefined;
 573    };
 574  
 575    const normalizeKey = (raw: string): string => {
 576      const trimmed = raw.trim().toLowerCase();
 577      const withoutDashes = trimmed.replace(/^--+/, "");
 578      return withoutDashes.replace(/-/g, "_");
 579    };
 580  
 581    const parseSliceSuffix = (value: string): { basePath: string; startLine: number; limit?: number } | null => {
 582      // Slice notation: path:start-end OR path:start
 583      // Example: file.swift:10-50
 584      const match = /^(.*?):(\d+)(?:-(\d+))?$/.exec(value);
 585      if (!match) return null;
 586  
 587      const basePath = match[1];
 588      const start = Number.parseInt(match[2] ?? "", 10);
 589      const end = match[3] ? Number.parseInt(match[3], 10) : undefined;
 590  
 591      if (!basePath || !Number.isFinite(start) || start <= 0) {
 592        return null;
 593      }
 594  
 595      if (end === undefined) {
 596        return { basePath, startLine: start };
 597      }
 598  
 599      if (!Number.isFinite(end) || end < start) {
 600        return null;
 601      }
 602  
 603      return { basePath, startLine: start, limit: end - start + 1 };
 604    };
 605  
 606    const filteredArgs: string[] = [];
 607  
 608    for (let i = 0; i < rawArgs.length; i += 1) {
 609      const arg = rawArgs[i] ?? "";
 610  
 611      // Flags: --start-line 10, --limit 50, also support --start-line=10
 612      if (arg.startsWith("--")) {
 613        const eqIdx = arg.indexOf("=");
 614        if (eqIdx > 0) {
 615          const rawKey = arg.slice(0, eqIdx);
 616          const key = normalizeKey(rawKey);
 617          const value = arg.slice(eqIdx + 1).trim();
 618  
 619          if (key === "start_line") {
 620            const parsedNumber = getNumber(value);
 621            if (parsedNumber === undefined) {
 622              sawUnknownArg = true;
 623            } else {
 624              startLine = parsedNumber;
 625            }
 626            filteredArgs.push(arg);
 627            continue;
 628          }
 629  
 630          if (key === "limit") {
 631            const parsedNumber = getNumber(value);
 632            if (parsedNumber === undefined) {
 633              sawUnknownArg = true;
 634            } else {
 635              limit = parsedNumber;
 636            }
 637            filteredArgs.push(arg);
 638            continue;
 639          }
 640  
 641          sawUnknownArg = true;
 642          filteredArgs.push(arg);
 643          continue;
 644        }
 645  
 646        const key = normalizeKey(arg);
 647        if (key === "start_line") {
 648          const value = rawArgs[i + 1];
 649          if (typeof value === "string") {
 650            const parsedNumber = getNumber(value);
 651            if (parsedNumber === undefined) {
 652              sawUnknownArg = true;
 653            } else {
 654              startLine = parsedNumber;
 655            }
 656            i += 1;
 657            filteredArgs.push(arg, value);
 658            continue;
 659          }
 660        }
 661  
 662        if (key === "limit") {
 663          const value = rawArgs[i + 1];
 664          if (typeof value === "string") {
 665            const parsedNumber = getNumber(value);
 666            if (parsedNumber === undefined) {
 667              sawUnknownArg = true;
 668            } else {
 669              limit = parsedNumber;
 670            }
 671            i += 1;
 672            filteredArgs.push(arg, value);
 673            continue;
 674          }
 675        }
 676  
 677        // Unknown flag: keep it
 678        sawUnknownArg = true;
 679        filteredArgs.push(arg);
 680        continue;
 681      }
 682  
 683      // key=value pairs (rp-cli supports key=value and also dash->underscore)
 684      const eqIdx = arg.indexOf("=");
 685      if (eqIdx > 0) {
 686        const key = normalizeKey(arg.slice(0, eqIdx));
 687        const value = arg.slice(eqIdx + 1).trim();
 688  
 689        // wrapper-only knob (do not forward)
 690        if (key === "bypass_cache") {
 691          bypassCache = value === "true" || value === "1";
 692          continue;
 693        }
 694  
 695        if (key === "path") {
 696          const slice = parseSliceSuffix(value);
 697          if (slice) {
 698            inputPath = slice.basePath;
 699            if (startLine === undefined) startLine = slice.startLine;
 700            if (limit === undefined && slice.limit !== undefined) limit = slice.limit;
 701          } else {
 702            inputPath = value;
 703          }
 704  
 705          filteredArgs.push(arg);
 706          continue;
 707        }
 708  
 709        if (key === "start_line") {
 710          const parsedNumber = getNumber(value);
 711          if (parsedNumber === undefined) {
 712            sawUnknownArg = true;
 713          } else {
 714            startLine = parsedNumber;
 715          }
 716          filteredArgs.push(arg);
 717          continue;
 718        }
 719  
 720        if (key === "limit") {
 721          const parsedNumber = getNumber(value);
 722          if (parsedNumber === undefined) {
 723            sawUnknownArg = true;
 724          } else {
 725            limit = parsedNumber;
 726          }
 727          filteredArgs.push(arg);
 728          continue;
 729        }
 730  
 731        sawUnknownArg = true;
 732        filteredArgs.push(arg);
 733        continue;
 734      }
 735  
 736      // positional path
 737      if (!inputPath && !arg.startsWith("-")) {
 738        const slice = parseSliceSuffix(arg);
 739        if (slice) {
 740          inputPath = slice.basePath;
 741          if (startLine === undefined) startLine = slice.startLine;
 742          if (limit === undefined && slice.limit !== undefined) limit = slice.limit;
 743        } else {
 744          inputPath = arg;
 745        }
 746  
 747        filteredArgs.push(arg);
 748        continue;
 749      }
 750  
 751      // positional start/limit (shorthand: read <path> [start] [limit])
 752      if (inputPath && startLine === undefined) {
 753        const startCandidate = getNumber(arg);
 754        if (typeof startCandidate === "number") {
 755          startLine = startCandidate;
 756          filteredArgs.push(arg);
 757          continue;
 758        }
 759      }
 760  
 761      if (inputPath && startLine !== undefined && limit === undefined) {
 762        const limitCandidate = getNumber(arg);
 763        if (typeof limitCandidate === "number") {
 764          limit = limitCandidate;
 765          filteredArgs.push(arg);
 766          continue;
 767        }
 768      }
 769  
 770      sawUnknownArg = true;
 771      filteredArgs.push(arg);
 772    }
 773  
 774    if (!inputPath) {
 775      return null;
 776    }
 777  
 778    let cmdToRun = [commandNameRaw, ...filteredArgs].filter(Boolean).join(" ");
 779  
 780    // Canonicalize into rp-cli's documented read shorthand syntax so that equivalent forms behave consistently
 781    // (especially for bypass_cache=true tests)
 782    const safePathForRewrite = /^\S+$/.test(inputPath);
 783    if (!sawUnknownArg && safePathForRewrite) {
 784      if (commandName === "read_file") {
 785        const parts: string[] = [commandNameRaw, `path=${inputPath}`];
 786        if (typeof startLine === "number") parts.push(`start_line=${startLine}`);
 787        if (typeof limit === "number") parts.push(`limit=${limit}`);
 788        cmdToRun = parts.join(" ");
 789      } else {
 790        const parts: string[] = [commandNameRaw, inputPath];
 791        if (typeof startLine === "number") parts.push(String(startLine));
 792        if (typeof limit === "number") parts.push(String(limit));
 793        cmdToRun = parts.join(" ");
 794      }
 795    }
 796  
 797    return {
 798      cmdToRun,
 799      path: inputPath,
 800      ...(typeof startLine === "number" ? { startLine } : {}),
 801      ...(typeof limit === "number" ? { limit } : {}),
 802      bypassCache,
 803      cacheable: !sawUnknownArg,
 804    };
 805  }
 806  
 807  function parseLeadingInt(text: string): number | undefined {
 808    const trimmed = text.trimStart();
 809    let digits = '';
 810  
 811    for (const ch of trimmed) {
 812      if (ch >= '0' && ch <= '9') {
 813        digits += ch;
 814      } else {
 815        break;
 816      }
 817    }
 818  
 819    return digits.length > 0 ? Number.parseInt(digits, 10) : undefined;
 820  }
 821  
 822  function looksLikeNoopEditOutput(output: string): boolean {
 823    const trimmed = output.trim();
 824    if (trimmed.length === 0) return true;
 825  
 826    const lower = trimmed.toLowerCase();
 827  
 828    if (lower.includes('search block not found')) return true;
 829  
 830    const appliedIndex = lower.indexOf('applied');
 831    if (appliedIndex !== -1) {
 832      const afterLabel = trimmed.slice(appliedIndex + 'applied'.length);
 833      const colonIndex = afterLabel.indexOf(':');
 834  
 835      if (colonIndex !== -1 && colonIndex < 10) {
 836        const appliedCount = parseLeadingInt(afterLabel.slice(colonIndex + 1));
 837        if (appliedCount !== undefined) return appliedCount === 0;
 838      }
 839    }
 840  
 841    // Fallback heuristics when the output format doesn't include an explicit applied count
 842    if (lower.includes('lines changed: 0')) return true;
 843    if (lower.includes('lines_changed') && lower.includes(': 0')) return true;
 844  
 845    return false;
 846  }
 847  
 848  function isSafeSingleCommandToRunUnbound(cmd: string): boolean {
 849    const parsed = parseCommandChain(cmd);
 850  
 851    if (parsed.invocations.length > 0) {
 852      if (parsed.invocations.length !== 1) return false;
 853      const invocation = parsed.invocations[0];
 854      const commandName = invocation.commandName;
 855      const args = invocation.args.map((arg) => arg.toLowerCase());
 856  
 857      if (commandName === "windows") return true;
 858      if (commandName === "help") return true;
 859      if (commandName === "refresh" && args.length === 0) return true;
 860      if (commandName === "tabs" && (args.length === 0 || args[0] === "list")) return true;
 861  
 862      if (commandName === "workspace") {
 863        const action = args[0] ?? "";
 864        if (action === "list") return true;
 865        if (action === "tabs") return true;
 866        if (action === "switch" && args.includes("--new-window")) return true;
 867        if (action === "create" && args.includes("--new-window")) return true;
 868      }
 869  
 870      return false;
 871    }
 872  
 873    // Fallback when parsing fails
 874    const normalized = cmd.trim().toLowerCase();
 875  
 876    if (normalized === "windows" || normalized.startsWith("windows ")) return true;
 877    if (normalized === "help" || normalized.startsWith("help ")) return true;
 878    if (normalized === "refresh") return true;
 879  
 880    if (normalized === "workspace list") return true;
 881    if (normalized === "workspace tabs") return true;
 882    if (normalized === "tabs" || normalized === "tabs list") return true;
 883  
 884    if (normalized.startsWith("workspace switch ") && normalized.includes("--new-window")) return true;
 885    if (normalized.startsWith("workspace create ") && normalized.includes("--new-window")) return true;
 886  
 887    return false;
 888  }
 889  
 890  function isSafeToRunUnbound(cmd: string): boolean {
 891    // Allow `&&` chains, but only if *every* sub-command is safe before binding
 892    const parsed = parseCommandChain(cmd);
 893    if (parsed.hasSemicolonOutsideQuotes) return false;
 894  
 895    if (parsed.invocations.length > 0) {
 896      return parsed.invocations.every((invocation) => {
 897        const commandText = renderInvocation(invocation);
 898        return isSafeSingleCommandToRunUnbound(commandText);
 899      });
 900    }
 901  
 902    if (parsed.commands.length === 0) return false;
 903    return parsed.commands.every((command) => isSafeSingleCommandToRunUnbound(command));
 904  }
 905  
 906  function parseRpbindArgs(args: unknown): { windowId: number; tab: string } | { error: string } {
 907    const parts = Array.isArray(args) ? args : [];
 908    if (parts.length < 2) return { error: "Usage: /rpbind <window_id> <tab_name_or_uuid>" };
 909  
 910    const rawWindowId = String(parts[0]).trim();
 911    const windowId = Number.parseInt(rawWindowId, 10);
 912    if (!Number.isFinite(windowId)) return { error: `Invalid window_id: ${rawWindowId}` };
 913  
 914    const tab = parts.slice(1).join(" ").trim();
 915    if (!tab) return { error: "Tab cannot be empty" };
 916  
 917    return { windowId, tab };
 918  }
 919  
 920  // ─────────────────────────────────────────────────────────────────────────────
 921  // Rendering utilities for rp_exec output
 922  // ─────────────────────────────────────────────────────────────────────────────
 923  
 924  interface FencedBlock {
 925    lang: string | undefined;
 926    code: string;
 927    startIndex: number;
 928    endIndex: number;
 929  }
 930  
 931  /**
 932   * Parse fenced code blocks from text. Handles:
 933   * - Multiple blocks
 934   * - Various language identifiers (typescript, diff, shell, etc.)
 935   * - Empty/missing language
 936   * - Unclosed fences (treated as extending to end of text)
 937   */
 938  function parseFencedBlocks(text: string): FencedBlock[] {
 939    const blocks: FencedBlock[] = [];
 940    const lines = text.split("\n");
 941    let i = 0;
 942  
 943    while (i < lines.length) {
 944      const line = lines[i];
 945      const fenceMatch = line.match(/^\s*```(\S*)\s*$/);
 946  
 947      if (fenceMatch) {
 948        const lang = fenceMatch[1] || undefined;
 949        const startLine = i;
 950        const codeLines: string[] = [];
 951        i++;
 952  
 953        // Find closing fence (```)
 954        while (i < lines.length) {
 955          const closingMatch = lines[i].match(/^\s*```\s*$/);
 956          if (closingMatch) {
 957            i++;
 958            break;
 959          }
 960          codeLines.push(lines[i]);
 961          i++;
 962        }
 963  
 964        // Calculate character indices
 965        const startIndex = lines.slice(0, startLine).join("\n").length + (startLine > 0 ? 1 : 0);
 966        const endIndex = lines.slice(0, i).join("\n").length;
 967  
 968        blocks.push({
 969          lang,
 970          code: codeLines.join("\n"),
 971          startIndex,
 972          endIndex,
 973        });
 974      } else {
 975        i++;
 976      }
 977    }
 978  
 979    return blocks;
 980  }
 981  
 982  const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
 983  const DELTA_TIMEOUT_MS = 5000;
 984  const DELTA_MAX_BUFFER = 8 * 1024 * 1024;
 985  const DELTA_CACHE_MAX_ENTRIES = 200;
 986  
 987  let deltaAvailable: boolean | null = null;
 988  const deltaDiffCache = new Map<string, string | null>();
 989  
 990  function isDeltaInstalled(): boolean {
 991    if (deltaAvailable !== null) {
 992      return deltaAvailable;
 993    }
 994  
 995    const check = spawnSync("delta", ["--version"], {
 996      stdio: "ignore",
 997      timeout: 1000,
 998    });
 999  
1000    deltaAvailable = !check.error && check.status === 0;
1001    return deltaAvailable;
1002  }
1003  
1004  function runDelta(diffText: string): string | null {
1005    const result = spawnSync("delta", ["--color-only", "--paging=never"], {
1006      encoding: "utf-8",
1007      input: diffText,
1008      timeout: DELTA_TIMEOUT_MS,
1009      maxBuffer: DELTA_MAX_BUFFER,
1010    });
1011  
1012    if (result.error || result.status !== 0) {
1013      return null;
1014    }
1015  
1016    return typeof result.stdout === "string" ? result.stdout : null;
1017  }
1018  
1019  function stripSyntheticHeader(deltaOutput: string): string {
1020    const outputLines = deltaOutput.split("\n");
1021    const bodyStart = outputLines.findIndex((line) => line.replace(ANSI_ESCAPE_RE, "").startsWith("@@"));
1022  
1023    if (bodyStart >= 0) {
1024      return outputLines.slice(bodyStart + 1).join("\n");
1025    }
1026  
1027    return deltaOutput;
1028  }
1029  
1030  function renderDiffBlockWithDelta(code: string): string | null {
1031    if (!isDeltaInstalled()) {
1032      return null;
1033    }
1034  
1035    const cached = deltaDiffCache.get(code);
1036    if (cached !== undefined) {
1037      return cached;
1038    }
1039  
1040    let rendered = runDelta(code);
1041  
1042    if (!rendered) {
1043      const syntheticDiff = [
1044        "--- a/file",
1045        "+++ b/file",
1046        "@@ -1,1 +1,1 @@",
1047        code,
1048      ].join("\n");
1049  
1050      const syntheticRendered = runDelta(syntheticDiff);
1051      if (syntheticRendered) {
1052        rendered = stripSyntheticHeader(syntheticRendered);
1053      }
1054    }
1055  
1056    if (deltaDiffCache.size >= DELTA_CACHE_MAX_ENTRIES) {
1057      deltaDiffCache.clear();
1058    }
1059  
1060    deltaDiffCache.set(code, rendered);
1061    return rendered;
1062  }
1063  
1064  /**
1065   * Compute word-level diff with inverse highlighting on changed parts
1066   */
1067  function renderIntraLineDiff(
1068    oldContent: string,
1069    newContent: string,
1070    theme: Theme
1071  ): { removedLine: string; addedLine: string } {
1072    const wordDiff = Diff.diffWords(oldContent, newContent);
1073  
1074    let removedLine = "";
1075    let addedLine = "";
1076    let isFirstRemoved = true;
1077    let isFirstAdded = true;
1078  
1079    for (const part of wordDiff) {
1080      if (part.removed) {
1081        let value = part.value;
1082        if (isFirstRemoved) {
1083          const leadingWs = value.match(/^(\s*)/)?.[1] || "";
1084          value = value.slice(leadingWs.length);
1085          removedLine += leadingWs;
1086          isFirstRemoved = false;
1087        }
1088        if (value) {
1089          removedLine += theme.inverse(value);
1090        }
1091      } else if (part.added) {
1092        let value = part.value;
1093        if (isFirstAdded) {
1094          const leadingWs = value.match(/^(\s*)/)?.[1] || "";
1095          value = value.slice(leadingWs.length);
1096          addedLine += leadingWs;
1097          isFirstAdded = false;
1098        }
1099        if (value) {
1100          addedLine += theme.inverse(value);
1101        }
1102      } else {
1103        removedLine += part.value;
1104        addedLine += part.value;
1105      }
1106    }
1107  
1108    return { removedLine, addedLine };
1109  }
1110  
1111  /**
1112   * Render diff lines with syntax highlighting (red/green, word-level inverse)
1113   */
1114  function renderDiffBlock(code: string, theme: Theme): string {
1115    const deltaRendered = renderDiffBlockWithDelta(code);
1116    if (deltaRendered !== null) {
1117      return deltaRendered;
1118    }
1119  
1120    const lines = code.split("\n");
1121    const result: string[] = [];
1122  
1123    let i = 0;
1124    while (i < lines.length) {
1125      const line = lines[i];
1126      const trimmed = line.trimStart();
1127      const indent = line.slice(0, line.length - trimmed.length);
1128  
1129      // File headers: --- a/file or +++ b/file
1130      if (trimmed.match(/^---\s+\S/) || trimmed.match(/^\+\+\+\s+\S/)) {
1131        result.push(indent + theme.fg("accent", trimmed));
1132        i++;
1133      }
1134      // Hunk headers: @@ -1,5 +1,6 @@
1135      else if (trimmed.match(/^@@\s+-\d+/)) {
1136        result.push(indent + theme.fg("muted", trimmed));
1137        i++;
1138      }
1139      // Removed lines (not file headers)
1140      else if (trimmed.startsWith("-") && !trimmed.match(/^---\s/)) {
1141        // Collect consecutive removed lines
1142        const removedLines: Array<{ indent: string; content: string }> = [];
1143        while (i < lines.length) {
1144          const l = lines[i];
1145          const t = l.trimStart();
1146          const ind = l.slice(0, l.length - t.length);
1147          if (t.startsWith("-") && !t.match(/^---\s/)) {
1148            removedLines.push({ indent: ind, content: t.slice(1) });
1149            i++;
1150          } else {
1151            break;
1152          }
1153        }
1154  
1155        // Collect consecutive added lines
1156        const addedLines: Array<{ indent: string; content: string }> = [];
1157        while (i < lines.length) {
1158          const l = lines[i];
1159          const t = l.trimStart();
1160          const ind = l.slice(0, l.length - t.length);
1161          if (t.startsWith("+") && !t.match(/^\+\+\+\s/)) {
1162            addedLines.push({ indent: ind, content: t.slice(1) });
1163            i++;
1164          } else {
1165            break;
1166          }
1167        }
1168  
1169        // Word-level highlighting for 1:1 line changes
1170        if (removedLines.length === 1 && addedLines.length === 1) {
1171          const { removedLine, addedLine } = renderIntraLineDiff(
1172            removedLines[0].content,
1173            addedLines[0].content,
1174            theme
1175          );
1176          result.push(removedLines[0].indent + theme.fg("toolDiffRemoved", "-" + removedLine));
1177          result.push(addedLines[0].indent + theme.fg("toolDiffAdded", "+" + addedLine));
1178        } else {
1179          for (const r of removedLines) {
1180            result.push(r.indent + theme.fg("toolDiffRemoved", "-" + r.content));
1181          }
1182          for (const a of addedLines) {
1183            result.push(a.indent + theme.fg("toolDiffAdded", "+" + a.content));
1184          }
1185        }
1186      }
1187      // Added lines (not file headers)
1188      else if (trimmed.startsWith("+") && !trimmed.match(/^\+\+\+\s/)) {
1189        result.push(indent + theme.fg("toolDiffAdded", trimmed));
1190        i++;
1191      }
1192      // Context lines (start with space in unified diff)
1193      else if (line.startsWith(" ")) {
1194        result.push(theme.fg("toolDiffContext", line));
1195        i++;
1196      }
1197      // Empty or other lines
1198      else {
1199        result.push(indent + theme.fg("dim", trimmed));
1200        i++;
1201      }
1202    }
1203  
1204    return result.join("\n");
1205  }
1206  
1207  /**
1208   * Render rp_exec output with syntax highlighting for fenced code blocks.
1209   * - ```diff blocks use delta when available, with word-level fallback
1210   * - Other fenced blocks get syntax highlighting via Pi's highlightCode
1211   * - Non-fenced content is rendered dim (no markdown parsing)
1212   */
1213  function renderRpExecOutput(text: string, theme: Theme): string {
1214    const blocks = parseFencedBlocks(text);
1215  
1216    if (blocks.length === 0) {
1217      // No code fences - render everything dim
1218      return text.split("\n").map(line => theme.fg("dim", line)).join("\n");
1219    }
1220  
1221    const result: string[] = [];
1222    let lastEnd = 0;
1223  
1224    for (const block of blocks) {
1225      // Render text before this block (dim)
1226      if (block.startIndex > lastEnd) {
1227        const before = text.slice(lastEnd, block.startIndex);
1228        result.push(before.split("\n").map(line => theme.fg("dim", line)).join("\n"));
1229      }
1230  
1231      // Render the fenced block
1232      if (block.lang?.toLowerCase() === "diff") {
1233        // Diff block: use word-level diff highlighting
1234        result.push(theme.fg("muted", "```diff"));
1235        result.push(renderDiffBlock(block.code, theme));
1236        result.push(theme.fg("muted", "```"));
1237      } else if (block.lang) {
1238        // Other language: use Pi's syntax highlighting
1239        result.push(theme.fg("muted", "```" + block.lang));
1240        const highlighted = highlightCode(block.code, block.lang);
1241        result.push(highlighted.join("\n"));
1242        result.push(theme.fg("muted", "```"));
1243      } else {
1244        // No language specified: render as dim
1245        result.push(theme.fg("muted", "```"));
1246        result.push(theme.fg("dim", block.code));
1247        result.push(theme.fg("muted", "```"));
1248      }
1249  
1250      lastEnd = block.endIndex;
1251    }
1252  
1253    // Render text after last block (dim)
1254    if (lastEnd < text.length) {
1255      const after = text.slice(lastEnd);
1256      result.push(after.split("\n").map(line => theme.fg("dim", line)).join("\n"));
1257    }
1258  
1259    return result.join("\n");
1260  }
1261  
1262  // Collapsed output settings
1263  const DEFAULT_COLLAPSED_MAX_LINES = 15;
1264  const COLLAPSED_MAX_CHARS = 2000;
1265  
1266  function stripNoiseForCollapsedView(lines: string[]): string[] {
1267    const filtered: string[] = [];
1268    let consecutiveEmpty = 0;
1269  
1270    for (const line of lines) {
1271      const trimmed = line.trim();
1272  
1273      if (trimmed.startsWith("```")) {
1274        continue;
1275      }
1276  
1277      if (trimmed.length === 0) {
1278        consecutiveEmpty += 1;
1279        if (consecutiveEmpty > 1) {
1280          continue;
1281        }
1282        filtered.push("");
1283        continue;
1284      }
1285  
1286      consecutiveEmpty = 0;
1287      filtered.push(line);
1288    }
1289  
1290    while (filtered.length > 0 && filtered[filtered.length - 1]?.trim().length === 0) {
1291      filtered.pop();
1292    }
1293  
1294    return filtered;
1295  }
1296  
1297  function prepareCollapsedView(
1298    text: string,
1299    theme: Theme,
1300    maxLines: number = DEFAULT_COLLAPSED_MAX_LINES
1301  ): { content: string; truncated: boolean; totalLines: number } {
1302    const lines = stripNoiseForCollapsedView(text.split("\n"));
1303    const totalLines = lines.length;
1304  
1305    if (maxLines <= 0) {
1306      return {
1307        content: "",
1308        truncated: totalLines > 0,
1309        totalLines,
1310      };
1311    }
1312  
1313    const normalizedText = lines.join("\n");
1314  
1315    if (lines.length <= maxLines && normalizedText.length <= COLLAPSED_MAX_CHARS) {
1316      return {
1317        content: renderRpExecOutput(normalizedText, theme),
1318        truncated: false,
1319        totalLines,
1320      };
1321    }
1322  
1323    return {
1324      content: renderRpExecOutput(lines.slice(0, maxLines).join("\n"), theme),
1325      truncated: true,
1326      totalLines,
1327    };
1328  }
1329  
1330  export default function (pi: ExtensionAPI) {
1331    let config = loadConfig();
1332  
1333    pi.on("before_agent_start", async () => {
1334      config = loadConfig();
1335    });
1336  
1337    // Replay-aware read_file caching state (optional; guarded by config.readcacheReadFile)
1338    const readcacheRuntimeState = createReplayRuntimeState();
1339  
1340    const clearReadcacheCaches = (): void => {
1341      clearReplayRuntimeState(readcacheRuntimeState);
1342    };
1343  
1344    let activeAutoSelectionState: AutoSelectionEntryData | null = null;
1345  
1346    let boundWindowId: number | undefined;
1347    let boundTab: string | undefined;
1348    let boundWorkspaceId: string | undefined;
1349    let boundWorkspaceName: string | undefined;
1350    let boundWorkspaceRoots: string[] | undefined;
1351  
1352    let windowsCache: { windows: RpCliWindow[]; fetchedAtMs: number } | null = null;
1353    let lastBindingValidationAtMs = 0;
1354  
1355    function sameOptionalText(a?: string, b?: string): boolean {
1356      return (a ?? undefined) === (b ?? undefined);
1357    }
1358  
1359    function clearWindowsCache(): void {
1360      windowsCache = null;
1361    }
1362  
1363    function markBindingValidationStale(): void {
1364      lastBindingValidationAtMs = 0;
1365    }
1366  
1367    function shouldRevalidateBinding(): boolean {
1368      const now = Date.now();
1369      return (now - lastBindingValidationAtMs) >= BINDING_VALIDATION_TTL_MS;
1370    }
1371  
1372    function markBindingValidatedNow(): void {
1373      lastBindingValidationAtMs = Date.now();
1374    }
1375  
1376    function normalizeWorkspaceRoots(roots: string[] | undefined): string[] {
1377      if (!Array.isArray(roots)) {
1378        return [];
1379      }
1380  
1381      return [...new Set(roots.map((root) => toPosixPath(String(root).trim())).filter(Boolean))].sort();
1382    }
1383  
1384    function workspaceRootsEqual(left: string[] | undefined, right: string[] | undefined): boolean {
1385      const leftNormalized = normalizeWorkspaceRoots(left);
1386      const rightNormalized = normalizeWorkspaceRoots(right);
1387      return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
1388    }
1389  
1390    function workspaceIdentityMatches(
1391      left: { workspaceId?: string; workspaceName?: string; workspaceRoots?: string[] },
1392      right: { workspaceId?: string; workspaceName?: string; workspaceRoots?: string[] }
1393    ): boolean {
1394      if (left.workspaceId && right.workspaceId) {
1395        return left.workspaceId === right.workspaceId;
1396      }
1397  
1398      const leftRoots = normalizeWorkspaceRoots(left.workspaceRoots);
1399      const rightRoots = normalizeWorkspaceRoots(right.workspaceRoots);
1400  
1401      if (leftRoots.length > 0 && rightRoots.length > 0) {
1402        return JSON.stringify(leftRoots) === JSON.stringify(rightRoots);
1403      }
1404  
1405      if (left.workspaceName && right.workspaceName) {
1406        return left.workspaceName === right.workspaceName;
1407      }
1408  
1409      return false;
1410    }
1411  
1412    function getCurrentBinding(): RpCliBindingEntryData | null {
1413      if (boundWindowId === undefined || !boundTab) {
1414        return null;
1415      }
1416  
1417      return {
1418        windowId: boundWindowId,
1419        tab: boundTab,
1420        workspaceId: boundWorkspaceId,
1421        workspaceName: boundWorkspaceName,
1422        workspaceRoots: boundWorkspaceRoots,
1423      };
1424    }
1425  
1426    function normalizeBindingEntry(binding: RpCliBindingEntryData): RpCliBindingEntryData {
1427      return {
1428        windowId: binding.windowId,
1429        tab: binding.tab,
1430        workspaceId: typeof binding.workspaceId === "string" ? binding.workspaceId : undefined,
1431        workspaceName: typeof binding.workspaceName === "string" ? binding.workspaceName : undefined,
1432        workspaceRoots: normalizeWorkspaceRoots(binding.workspaceRoots),
1433      };
1434    }
1435  
1436    function bindingEntriesEqual(a: RpCliBindingEntryData | null, b: RpCliBindingEntryData | null): boolean {
1437      if (!a && !b) {
1438        return true;
1439      }
1440  
1441      if (!a || !b) {
1442        return false;
1443      }
1444  
1445      const left = normalizeBindingEntry(a);
1446      const right = normalizeBindingEntry(b);
1447  
1448      return (
1449        left.windowId === right.windowId &&
1450        left.tab === right.tab &&
1451        sameOptionalText(left.workspaceId, right.workspaceId) &&
1452        sameOptionalText(left.workspaceName, right.workspaceName) &&
1453        workspaceRootsEqual(left.workspaceRoots, right.workspaceRoots)
1454      );
1455    }
1456  
1457    function setBinding(binding: RpCliBindingEntryData | null): void {
1458      const previousWindowId = boundWindowId;
1459  
1460      if (!binding) {
1461        boundWindowId = undefined;
1462        boundTab = undefined;
1463        boundWorkspaceId = undefined;
1464        boundWorkspaceName = undefined;
1465        boundWorkspaceRoots = undefined;
1466      } else {
1467        const normalized = normalizeBindingEntry(binding);
1468        boundWindowId = normalized.windowId;
1469        boundTab = normalized.tab;
1470        boundWorkspaceId = normalized.workspaceId;
1471        boundWorkspaceName = normalized.workspaceName;
1472        boundWorkspaceRoots = normalized.workspaceRoots;
1473      }
1474  
1475      if (previousWindowId !== boundWindowId) {
1476        if (previousWindowId !== undefined) {
1477          clearRootsCache(previousWindowId);
1478        }
1479  
1480        if (boundWindowId !== undefined) {
1481          clearRootsCache(boundWindowId);
1482        }
1483  
1484        clearWindowsCache();
1485      }
1486  
1487      markBindingValidationStale();
1488    }
1489  
1490    function parseBindingEntryData(value: unknown): RpCliBindingEntryData | null {
1491      if (!value || typeof value !== "object") {
1492        return null;
1493      }
1494  
1495      const obj = value as Record<string, unknown>;
1496  
1497      const windowId = typeof obj.windowId === "number" ? obj.windowId : undefined;
1498      const tab = typeof obj.tab === "string" ? obj.tab : undefined;
1499  
1500      if (windowId === undefined || !tab) {
1501        return null;
1502      }
1503  
1504      const workspaceId = typeof obj.workspaceId === "string"
1505        ? obj.workspaceId
1506        : (typeof obj.workspaceID === "string" ? obj.workspaceID : undefined);
1507  
1508      const workspaceName = typeof obj.workspaceName === "string"
1509        ? obj.workspaceName
1510        : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1511  
1512      const workspaceRoots = Array.isArray(obj.workspaceRoots)
1513        ? obj.workspaceRoots.filter((root): root is string => typeof root === "string")
1514        : (Array.isArray(obj.rootFolderPaths)
1515          ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1516          : undefined);
1517  
1518      return normalizeBindingEntry({
1519        windowId,
1520        tab,
1521        workspaceId,
1522        workspaceName,
1523        workspaceRoots,
1524      });
1525    }
1526  
1527    function persistBinding(binding: RpCliBindingEntryData): void {
1528      const normalized = normalizeBindingEntry(binding);
1529      const current = getCurrentBinding();
1530  
1531      if (bindingEntriesEqual(current, normalized)) {
1532        return;
1533      }
1534  
1535      setBinding(normalized);
1536      pi.appendEntry(BINDING_CUSTOM_TYPE, normalized);
1537    }
1538  
1539    function reconstructBinding(ctx: ExtensionContext): void {
1540      // Prefer persisted binding (appendEntry) from the *current branch*, then fall back to prior rp_bind tool results
1541      // Branch semantics: if the current branch has no binding state, stay unbound
1542      setBinding(null);
1543  
1544      let reconstructed: RpCliBindingEntryData | null = null;
1545  
1546      for (const entry of ctx.sessionManager.getBranch()) {
1547        if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) {
1548          continue;
1549        }
1550  
1551        const parsed = parseBindingEntryData(entry.data);
1552        if (parsed) {
1553          reconstructed = parsed;
1554        }
1555      }
1556  
1557      if (reconstructed) {
1558        setBinding(reconstructed);
1559        return;
1560      }
1561  
1562      for (const entry of ctx.sessionManager.getBranch()) {
1563        if (entry.type !== "message") {
1564          continue;
1565        }
1566  
1567        const msg = entry.message;
1568        if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") {
1569          continue;
1570        }
1571  
1572        const parsed = parseBindingEntryData(msg.details);
1573        if (parsed) {
1574          persistBinding(parsed);
1575        }
1576      }
1577    }
1578  
1579    function parseWindowsRawJson(raw: string): RpCliWindow[] {
1580      const trimmed = raw.trim();
1581      if (!trimmed) {
1582        return [];
1583      }
1584  
1585      let parsed: unknown;
1586      try {
1587        parsed = JSON.parse(trimmed);
1588      } catch {
1589        return [];
1590      }
1591  
1592      const pickRows = (value: unknown): unknown[] => {
1593        if (Array.isArray(value)) {
1594          return value;
1595        }
1596  
1597        if (!value || typeof value !== "object") {
1598          return [];
1599        }
1600  
1601        const obj = value as Record<string, unknown>;
1602  
1603        const directArrayKeys = ["windows", "items", "data", "result"];
1604        for (const key of directArrayKeys) {
1605          const candidate = obj[key];
1606          if (Array.isArray(candidate)) {
1607            return candidate;
1608          }
1609        }
1610  
1611        const nested = obj.data;
1612        if (nested && typeof nested === "object") {
1613          const nestedObj = nested as Record<string, unknown>;
1614          if (Array.isArray(nestedObj.windows)) {
1615            return nestedObj.windows;
1616          }
1617        }
1618  
1619        return [];
1620      };
1621  
1622      const rows = pickRows(parsed);
1623  
1624      return rows
1625        .map((row) => {
1626          if (!row || typeof row !== "object") {
1627            return null;
1628          }
1629  
1630          const obj = row as Record<string, unknown>;
1631          const windowId = typeof obj.windowID === "number"
1632            ? obj.windowID
1633            : (typeof obj.windowId === "number" ? obj.windowId : undefined);
1634  
1635          if (windowId === undefined) {
1636            return null;
1637          }
1638  
1639          const workspaceId = typeof obj.workspaceID === "string"
1640            ? obj.workspaceID
1641            : (typeof obj.workspaceId === "string" ? obj.workspaceId : undefined);
1642  
1643          const workspaceName = typeof obj.workspaceName === "string"
1644            ? obj.workspaceName
1645            : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1646  
1647          const rootFolderPaths = Array.isArray(obj.rootFolderPaths)
1648            ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1649            : (Array.isArray(obj.roots)
1650              ? obj.roots.filter((root): root is string => typeof root === "string")
1651              : undefined);
1652  
1653          return {
1654            windowId,
1655            workspaceId,
1656            workspaceName,
1657            rootFolderPaths,
1658          } as RpCliWindow;
1659        })
1660        .filter((window): window is RpCliWindow => window !== null);
1661    }
1662  
1663    async function fetchWindowsFromCli(forceRefresh = false): Promise<RpCliWindow[]> {
1664      const now = Date.now();
1665  
1666      if (!forceRefresh && windowsCache && (now - windowsCache.fetchedAtMs) < WINDOWS_CACHE_TTL_MS) {
1667        return windowsCache.windows;
1668      }
1669  
1670      try {
1671        const result = await pi.exec("rp-cli", ["--raw-json", "-q", "-e", "windows"], { timeout: 10_000 });
1672  
1673        if ((result.code ?? 0) !== 0) {
1674          return windowsCache?.windows ?? [];
1675        }
1676  
1677        const stdout = result.stdout ?? "";
1678        const stderr = result.stderr ?? "";
1679  
1680        const fromStdout = parseWindowsRawJson(stdout);
1681        const parsed = fromStdout.length > 0 ? fromStdout : parseWindowsRawJson(stderr);
1682  
1683        windowsCache = {
1684          windows: parsed,
1685          fetchedAtMs: now,
1686        };
1687  
1688        return parsed;
1689      } catch {
1690        return windowsCache?.windows ?? [];
1691      }
1692    }
1693  
1694    function windowToBinding(window: RpCliWindow, tab: string): RpCliBindingEntryData {
1695      return normalizeBindingEntry({
1696        windowId: window.windowId,
1697        tab,
1698        workspaceId: window.workspaceId,
1699        workspaceName: window.workspaceName,
1700        workspaceRoots: window.rootFolderPaths,
1701      });
1702    }
1703  
1704    async function enrichBinding(windowId: number, tab: string): Promise<RpCliBindingEntryData> {
1705      const windows = await fetchWindowsFromCli();
1706      const match = windows.find((window) => window.windowId === windowId);
1707  
1708      if (!match) {
1709        return normalizeBindingEntry({ windowId, tab });
1710      }
1711  
1712      return windowToBinding(match, tab);
1713    }
1714  
1715    async function ensureBindingTargetsLiveWindow(
1716      ctx: ExtensionContext,
1717      forceRefresh = false
1718    ): Promise<RpCliBindingEntryData | null> {
1719      const binding = getCurrentBinding();
1720      if (!binding) {
1721        return null;
1722      }
1723  
1724      const windows = await fetchWindowsFromCli(forceRefresh);
1725      if (windows.length === 0) {
1726        return binding;
1727      }
1728  
1729      const sameWindow = windows.find((window) => window.windowId === binding.windowId);
1730      if (sameWindow) {
1731        const hydrated = windowToBinding(sameWindow, binding.tab);
1732  
1733        if (binding.workspaceId && hydrated.workspaceId && binding.workspaceId !== hydrated.workspaceId) {
1734          // Window IDs were recycled to a different workspace. Fall through to workspace-based remap
1735        } else {
1736          if (!bindingEntriesEqual(binding, hydrated)) {
1737            persistBinding(hydrated);
1738            return hydrated;
1739          }
1740  
1741          setBinding(hydrated);
1742          return hydrated;
1743        }
1744      }
1745  
1746      const workspaceCandidates = windows.filter((window) => workspaceIdentityMatches(
1747        {
1748          workspaceId: binding.workspaceId,
1749          workspaceName: binding.workspaceName,
1750          workspaceRoots: binding.workspaceRoots,
1751        },
1752        {
1753          workspaceId: window.workspaceId,
1754          workspaceName: window.workspaceName,
1755          workspaceRoots: window.rootFolderPaths,
1756        }
1757      ));
1758  
1759      if (workspaceCandidates.length === 1) {
1760        const rebound = windowToBinding(workspaceCandidates[0], binding.tab);
1761        persistBinding(rebound);
1762        return rebound;
1763      }
1764  
1765      setBinding(null);
1766  
1767      if (ctx.hasUI) {
1768        const workspaceLabel = binding.workspaceName ?? binding.workspaceId ?? `window ${binding.windowId}`;
1769  
1770        if (workspaceCandidates.length > 1) {
1771          ctx.ui.notify(
1772            `repoprompt-cli: binding for ${workspaceLabel} is ambiguous after restart. Re-bind with /rpbind`,
1773            "warning"
1774          );
1775        } else {
1776          ctx.ui.notify(
1777            `repoprompt-cli: ${workspaceLabel} not found after restart. Re-bind with /rpbind`,
1778            "warning"
1779          );
1780        }
1781      }
1782  
1783      return null;
1784    }
1785  
1786    async function maybeEnsureBindingTargetsLiveWindow(ctx: ExtensionContext): Promise<RpCliBindingEntryData | null> {
1787      const binding = getCurrentBinding();
1788      if (!binding) {
1789        return null;
1790      }
1791  
1792      if (!shouldRevalidateBinding()) {
1793        return binding;
1794      }
1795  
1796      const validated = await ensureBindingTargetsLiveWindow(ctx, true);
1797  
1798      if (validated) {
1799        markBindingValidatedNow();
1800      }
1801  
1802      return validated;
1803    }
1804  
1805    function sameBindingForAutoSelection(
1806      binding: RpCliBindingEntryData | null,
1807      state: AutoSelectionEntryData | null
1808    ): boolean {
1809      if (!binding || !state) {
1810        return false;
1811      }
1812  
1813      if (!sameOptionalText(binding.tab, state.tab)) {
1814        return false;
1815      }
1816  
1817      if (binding.windowId === state.windowId) {
1818        return true;
1819      }
1820  
1821      return workspaceIdentityMatches(
1822        {
1823          workspaceId: binding.workspaceId,
1824          workspaceName: binding.workspaceName,
1825          workspaceRoots: binding.workspaceRoots,
1826        },
1827        {
1828          workspaceId: state.workspaceId,
1829          workspaceName: state.workspaceName,
1830          workspaceRoots: state.workspaceRoots,
1831        }
1832      );
1833    }
1834  
1835    function makeEmptyAutoSelectionState(binding: RpCliBindingEntryData): AutoSelectionEntryData {
1836      return {
1837        windowId: binding.windowId,
1838        tab: binding.tab,
1839        workspaceId: binding.workspaceId,
1840        workspaceName: binding.workspaceName,
1841        workspaceRoots: normalizeWorkspaceRoots(binding.workspaceRoots),
1842        fullPaths: [],
1843        slicePaths: [],
1844      };
1845    }
1846  
1847    function normalizeAutoSelectionRanges(ranges: AutoSelectionEntryRangeData[]): AutoSelectionEntryRangeData[] {
1848      const normalized = ranges
1849        .map((range) => ({
1850          start_line: Number(range.start_line),
1851          end_line: Number(range.end_line),
1852        }))
1853        .filter((range) => Number.isFinite(range.start_line) && Number.isFinite(range.end_line))
1854        .filter((range) => range.start_line > 0 && range.end_line >= range.start_line)
1855        .sort((a, b) => {
1856          if (a.start_line !== b.start_line) {
1857            return a.start_line - b.start_line;
1858          }
1859  
1860          return a.end_line - b.end_line;
1861        });
1862  
1863      const merged: AutoSelectionEntryRangeData[] = [];
1864      for (const range of normalized) {
1865        const last = merged[merged.length - 1];
1866        if (!last) {
1867          merged.push(range);
1868          continue;
1869        }
1870  
1871        if (range.start_line <= last.end_line + 1) {
1872          last.end_line = Math.max(last.end_line, range.end_line);
1873          continue;
1874        }
1875  
1876        merged.push(range);
1877      }
1878  
1879      return merged;
1880    }
1881  
1882    function normalizeAutoSelectionState(state: AutoSelectionEntryData): AutoSelectionEntryData {
1883      const fullPaths = [...new Set(state.fullPaths.map((p) => toPosixPath(String(p).trim())).filter(Boolean))].sort();
1884  
1885      const fullSet = new Set(fullPaths);
1886  
1887      const sliceMap = new Map<string, AutoSelectionEntryRangeData[]>();
1888      for (const item of state.slicePaths) {
1889        const pathKey = toPosixPath(String(item.path ?? "").trim());
1890        if (!pathKey || fullSet.has(pathKey)) {
1891          continue;
1892        }
1893  
1894        const existing = sliceMap.get(pathKey) ?? [];
1895        existing.push(...normalizeAutoSelectionRanges(item.ranges ?? []));
1896        sliceMap.set(pathKey, existing);
1897      }
1898  
1899      const slicePaths: AutoSelectionEntrySliceData[] = [...sliceMap.entries()]
1900        .map(([pathKey, ranges]) => ({
1901          path: pathKey,
1902          ranges: normalizeAutoSelectionRanges(ranges),
1903        }))
1904        .filter((item) => item.ranges.length > 0)
1905        .sort((a, b) => a.path.localeCompare(b.path));
1906  
1907      return {
1908        windowId: state.windowId,
1909        tab: state.tab,
1910        workspaceId: typeof state.workspaceId === "string" ? state.workspaceId : undefined,
1911        workspaceName: typeof state.workspaceName === "string" ? state.workspaceName : undefined,
1912        workspaceRoots: normalizeWorkspaceRoots(state.workspaceRoots),
1913        fullPaths,
1914        slicePaths,
1915      };
1916    }
1917  
1918    function autoSelectionStatesEqual(a: AutoSelectionEntryData | null, b: AutoSelectionEntryData | null): boolean {
1919      if (!a && !b) {
1920        return true;
1921      }
1922  
1923      if (!a || !b) {
1924        return false;
1925      }
1926  
1927      const left = normalizeAutoSelectionState(a);
1928      const right = normalizeAutoSelectionState(b);
1929  
1930      return JSON.stringify(left) === JSON.stringify(right);
1931    }
1932  
1933    function parseAutoSelectionEntryData(
1934      value: unknown,
1935      binding: RpCliBindingEntryData
1936    ): AutoSelectionEntryData | null {
1937      if (!value || typeof value !== "object") {
1938        return null;
1939      }
1940  
1941      const obj = value as Record<string, unknown>;
1942  
1943      const windowId = typeof obj.windowId === "number" ? obj.windowId : undefined;
1944      const tab = typeof obj.tab === "string" ? obj.tab : undefined;
1945  
1946      const workspaceId = typeof obj.workspaceId === "string"
1947        ? obj.workspaceId
1948        : (typeof obj.workspaceID === "string" ? obj.workspaceID : undefined);
1949  
1950      const workspaceName = typeof obj.workspaceName === "string"
1951        ? obj.workspaceName
1952        : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1953  
1954      const workspaceRoots = Array.isArray(obj.workspaceRoots)
1955        ? obj.workspaceRoots.filter((root): root is string => typeof root === "string")
1956        : (Array.isArray(obj.rootFolderPaths)
1957          ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1958          : undefined);
1959  
1960      const tabMatches = sameOptionalText(tab, binding.tab);
1961      const windowMatches = windowId === binding.windowId;
1962      const workspaceMatches = workspaceIdentityMatches(
1963        {
1964          workspaceId: binding.workspaceId,
1965          workspaceName: binding.workspaceName,
1966          workspaceRoots: binding.workspaceRoots,
1967        },
1968        {
1969          workspaceId,
1970          workspaceName,
1971          workspaceRoots,
1972        }
1973      );
1974  
1975      if (!tabMatches || (!windowMatches && !workspaceMatches)) {
1976        return null;
1977      }
1978  
1979      const fullPaths = Array.isArray(obj.fullPaths)
1980        ? obj.fullPaths.filter((item): item is string => typeof item === "string")
1981        : [];
1982  
1983      const slicePathsRaw = Array.isArray(obj.slicePaths) ? obj.slicePaths : [];
1984      const slicePaths: AutoSelectionEntrySliceData[] = slicePathsRaw
1985        .map((raw) => {
1986          if (!raw || typeof raw !== "object") {
1987            return null;
1988          }
1989  
1990          const row = raw as Record<string, unknown>;
1991          const pathValue = typeof row.path === "string" ? row.path : null;
1992          const rangesRaw = Array.isArray(row.ranges) ? row.ranges : [];
1993  
1994          if (!pathValue) {
1995            return null;
1996          }
1997  
1998          const ranges: AutoSelectionEntryRangeData[] = rangesRaw
1999            .map((rangeRaw) => {
2000              if (!rangeRaw || typeof rangeRaw !== "object") {
2001                return null;
2002              }
2003  
2004              const rangeObj = rangeRaw as Record<string, unknown>;
2005              const start = typeof rangeObj.start_line === "number" ? rangeObj.start_line : NaN;
2006              const end = typeof rangeObj.end_line === "number" ? rangeObj.end_line : NaN;
2007  
2008              if (!Number.isFinite(start) || !Number.isFinite(end)) {
2009                return null;
2010              }
2011  
2012              return {
2013                start_line: start,
2014                end_line: end,
2015              };
2016            })
2017            .filter((range): range is AutoSelectionEntryRangeData => range !== null);
2018  
2019          return {
2020            path: pathValue,
2021            ranges,
2022          };
2023        })
2024        .filter((item): item is AutoSelectionEntrySliceData => item !== null);
2025  
2026      return normalizeAutoSelectionState({
2027        windowId: binding.windowId,
2028        tab: binding.tab,
2029        workspaceId: binding.workspaceId ?? workspaceId,
2030        workspaceName: binding.workspaceName ?? workspaceName,
2031        workspaceRoots: binding.workspaceRoots ?? workspaceRoots,
2032        fullPaths,
2033        slicePaths,
2034      });
2035    }
2036  
2037    function getAutoSelectionStateFromBranch(
2038      ctx: ExtensionContext,
2039      binding: RpCliBindingEntryData
2040    ): AutoSelectionEntryData {
2041      const entries = ctx.sessionManager.getBranch();
2042  
2043      for (let i = entries.length - 1; i >= 0; i -= 1) {
2044        const entry = entries[i];
2045        if (entry.type !== "custom" || entry.customType !== AUTO_SELECTION_CUSTOM_TYPE) {
2046          continue;
2047        }
2048  
2049        const parsed = parseAutoSelectionEntryData(entry.data, binding);
2050        if (parsed) {
2051          return parsed;
2052        }
2053      }
2054  
2055      return makeEmptyAutoSelectionState(binding);
2056    }
2057  
2058    function persistAutoSelectionState(state: AutoSelectionEntryData): void {
2059      const normalized = normalizeAutoSelectionState(state);
2060      activeAutoSelectionState = normalized;
2061      pi.appendEntry(AUTO_SELECTION_CUSTOM_TYPE, normalized);
2062    }
2063  
2064    function autoSelectionManagedPaths(state: AutoSelectionEntryData): string[] {
2065      const fromSlices = state.slicePaths.map((item) => item.path);
2066      return [...new Set([...state.fullPaths, ...fromSlices])];
2067    }
2068  
2069    function autoSelectionSliceKey(item: AutoSelectionEntrySliceData): string {
2070      return JSON.stringify(normalizeAutoSelectionRanges(item.ranges));
2071    }
2072  
2073    function bindingForAutoSelectionState(state: AutoSelectionEntryData): RpCliBindingEntryData {
2074      return {
2075        windowId: state.windowId,
2076        tab: state.tab ?? "Compose",
2077        workspaceId: state.workspaceId,
2078        workspaceName: state.workspaceName,
2079        workspaceRoots: state.workspaceRoots,
2080      };
2081    }
2082  
2083    async function runRpCliForBinding(
2084      binding: RpCliBindingEntryData,
2085      cmd: string,
2086      rawJson = false,
2087      timeout = 10_000
2088    ): Promise<{ stdout: string; stderr: string; exitCode: number; output: string }> {
2089      const args: string[] = ["-w", String(binding.windowId)];
2090  
2091      if (binding.tab) {
2092        args.push("-t", binding.tab);
2093      }
2094  
2095      args.push("-q", "--fail-fast");
2096  
2097      if (rawJson) {
2098        args.push("--raw-json");
2099      }
2100  
2101      args.push("-e", cmd);
2102  
2103      const result = await pi.exec("rp-cli", args, { timeout });
2104  
2105      const stdout = result.stdout ?? "";
2106      const stderr = result.stderr ?? "";
2107      const exitCode = result.code ?? 0;
2108      const output = [stdout, stderr].filter(Boolean).join("\n").trim();
2109  
2110      if (exitCode !== 0) {
2111        throw new Error(output || `rp-cli exited with status ${exitCode}`);
2112      }
2113  
2114      return { stdout, stderr, exitCode, output };
2115    }
2116  
2117    async function callManageSelection(binding: RpCliBindingEntryData, args: Record<string, unknown>): Promise<string> {
2118      const cmd = `call manage_selection ${JSON.stringify(args)}`;
2119      const result = await runRpCliForBinding(binding, cmd, false);
2120      return result.output;
2121    }
2122  
2123    async function getSelectionFilesText(binding: RpCliBindingEntryData): Promise<string | null> {
2124      try {
2125        const text = await callManageSelection(binding, {
2126          op: "get",
2127          view: "files",
2128        });
2129  
2130        return text.length > 0 ? text : null;
2131      } catch {
2132        return null;
2133      }
2134    }
2135  
2136    function buildSelectionPathFromResolved(
2137      inputPath: string,
2138      resolved: { absolutePath: string | null; repoRoot: string | null }
2139    ): string {
2140      if (!resolved.absolutePath || !resolved.repoRoot) {
2141        return inputPath;
2142      }
2143  
2144      const rel = path.relative(resolved.repoRoot, resolved.absolutePath);
2145      if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
2146        return inputPath;
2147      }
2148  
2149      const rootHint = path.basename(resolved.repoRoot);
2150      const relPosix = rel.split(path.sep).join("/");
2151  
2152      return `${rootHint}/${relPosix}`;
2153    }
2154  
2155    function updateAutoSelectionStateAfterFullRead(binding: RpCliBindingEntryData, selectionPath: string): void {
2156      const normalizedPath = toPosixPath(selectionPath);
2157  
2158      const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2159        ? (activeAutoSelectionState as AutoSelectionEntryData)
2160        : makeEmptyAutoSelectionState(binding);
2161  
2162      const nextState: AutoSelectionEntryData = {
2163        ...baseState,
2164        fullPaths: [...baseState.fullPaths, normalizedPath],
2165        slicePaths: baseState.slicePaths.filter((entry) => entry.path !== normalizedPath),
2166      };
2167  
2168      const normalizedNext = normalizeAutoSelectionState(nextState);
2169      if (autoSelectionStatesEqual(baseState, normalizedNext)) {
2170        activeAutoSelectionState = normalizedNext;
2171        return;
2172      }
2173  
2174      persistAutoSelectionState(normalizedNext);
2175    }
2176  
2177    function existingSliceRangesForRead(
2178      binding: RpCliBindingEntryData,
2179      selectionPath: string
2180    ): AutoSelectionEntryRangeData[] | null {
2181      const normalizedPath = toPosixPath(selectionPath);
2182  
2183      const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2184        ? (activeAutoSelectionState as AutoSelectionEntryData)
2185        : makeEmptyAutoSelectionState(binding);
2186  
2187      if (baseState.fullPaths.includes(normalizedPath)) {
2188        return null;
2189      }
2190  
2191      const existing = baseState.slicePaths.find((entry) => entry.path === normalizedPath);
2192      return normalizeAutoSelectionRanges(existing?.ranges ?? []);
2193    }
2194  
2195    function subtractCoveredRanges(
2196      incomingRange: AutoSelectionEntryRangeData,
2197      existingRanges: AutoSelectionEntryRangeData[]
2198    ): AutoSelectionEntryRangeData[] {
2199      let pending: AutoSelectionEntryRangeData[] = [incomingRange];
2200  
2201      for (const existing of normalizeAutoSelectionRanges(existingRanges)) {
2202        const nextPending: AutoSelectionEntryRangeData[] = [];
2203  
2204        for (const candidate of pending) {
2205          const overlapStart = Math.max(candidate.start_line, existing.start_line);
2206          const overlapEnd = Math.min(candidate.end_line, existing.end_line);
2207  
2208          if (overlapStart > overlapEnd) {
2209            nextPending.push(candidate);
2210            continue;
2211          }
2212  
2213          if (candidate.start_line < overlapStart) {
2214            nextPending.push({
2215              start_line: candidate.start_line,
2216              end_line: overlapStart - 1,
2217            });
2218          }
2219  
2220          if (candidate.end_line > overlapEnd) {
2221            nextPending.push({
2222              start_line: overlapEnd + 1,
2223              end_line: candidate.end_line,
2224            });
2225          }
2226        }
2227  
2228        pending = nextPending;
2229        if (pending.length === 0) {
2230          return [];
2231        }
2232      }
2233  
2234      return normalizeAutoSelectionRanges(pending);
2235    }
2236  
2237    function updateAutoSelectionStateAfterSliceRead(
2238      binding: RpCliBindingEntryData,
2239      selectionPath: string,
2240      range: AutoSelectionEntryRangeData
2241    ): void {
2242      const normalizedPath = toPosixPath(selectionPath);
2243  
2244      const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2245        ? (activeAutoSelectionState as AutoSelectionEntryData)
2246        : makeEmptyAutoSelectionState(binding);
2247  
2248      if (baseState.fullPaths.includes(normalizedPath)) {
2249        return;
2250      }
2251  
2252      const existing = baseState.slicePaths.find((entry) => entry.path === normalizedPath);
2253  
2254      const nextSlicePaths = baseState.slicePaths.filter((entry) => entry.path !== normalizedPath);
2255      nextSlicePaths.push({
2256        path: normalizedPath,
2257        ranges: [...(existing?.ranges ?? []), range],
2258      });
2259  
2260      const nextState: AutoSelectionEntryData = {
2261        ...baseState,
2262        fullPaths: [...baseState.fullPaths],
2263        slicePaths: nextSlicePaths,
2264      };
2265  
2266      const normalizedNext = normalizeAutoSelectionState(nextState);
2267      if (autoSelectionStatesEqual(baseState, normalizedNext)) {
2268        activeAutoSelectionState = normalizedNext;
2269        return;
2270      }
2271  
2272      persistAutoSelectionState(normalizedNext);
2273    }
2274  
2275    async function removeAutoSelectionPaths(state: AutoSelectionEntryData, paths: string[]): Promise<void> {
2276      if (paths.length === 0) {
2277        return;
2278      }
2279  
2280      const binding = bindingForAutoSelectionState(state);
2281      await callManageSelection(binding, { op: "remove", paths });
2282    }
2283  
2284    async function addAutoSelectionFullPaths(state: AutoSelectionEntryData, paths: string[]): Promise<void> {
2285      if (paths.length === 0) {
2286        return;
2287      }
2288  
2289      const binding = bindingForAutoSelectionState(state);
2290      await callManageSelection(binding, {
2291        op: "add",
2292        mode: "full",
2293        paths,
2294      });
2295    }
2296  
2297    async function addAutoSelectionSlices(
2298      state: AutoSelectionEntryData,
2299      slices: AutoSelectionEntrySliceData[]
2300    ): Promise<void> {
2301      if (slices.length === 0) {
2302        return;
2303      }
2304  
2305      const binding = bindingForAutoSelectionState(state);
2306      await callManageSelection(binding, {
2307        op: "add",
2308        slices,
2309      });
2310    }
2311  
2312    async function reconcileAutoSelectionWithinBinding(
2313      currentState: AutoSelectionEntryData,
2314      desiredState: AutoSelectionEntryData
2315    ): Promise<void> {
2316      const currentModeByPath = new Map<string, "full" | "slices">();
2317      for (const p of currentState.fullPaths) {
2318        currentModeByPath.set(p, "full");
2319      }
2320  
2321      for (const s of currentState.slicePaths) {
2322        if (!currentModeByPath.has(s.path)) {
2323          currentModeByPath.set(s.path, "slices");
2324        }
2325      }
2326  
2327      const desiredModeByPath = new Map<string, "full" | "slices">();
2328      for (const p of desiredState.fullPaths) {
2329        desiredModeByPath.set(p, "full");
2330      }
2331  
2332      for (const s of desiredState.slicePaths) {
2333        if (!desiredModeByPath.has(s.path)) {
2334          desiredModeByPath.set(s.path, "slices");
2335        }
2336      }
2337  
2338      const desiredSliceByPath = new Map<string, AutoSelectionEntrySliceData>();
2339      for (const s of desiredState.slicePaths) {
2340        desiredSliceByPath.set(s.path, s);
2341      }
2342  
2343      const currentSliceByPath = new Map<string, AutoSelectionEntrySliceData>();
2344      for (const s of currentState.slicePaths) {
2345        currentSliceByPath.set(s.path, s);
2346      }
2347  
2348      const removePaths = new Set<string>();
2349      const addFullPaths: string[] = [];
2350      const addSlices: AutoSelectionEntrySliceData[] = [];
2351  
2352      for (const [pathKey] of currentModeByPath) {
2353        if (!desiredModeByPath.has(pathKey)) {
2354          removePaths.add(pathKey);
2355        }
2356      }
2357  
2358      for (const [pathKey, mode] of desiredModeByPath) {
2359        const currentMode = currentModeByPath.get(pathKey);
2360  
2361        if (mode === "full") {
2362          if (currentMode === "full") {
2363            continue;
2364          }
2365  
2366          if (currentMode === "slices") {
2367            removePaths.add(pathKey);
2368          }
2369  
2370          addFullPaths.push(pathKey);
2371          continue;
2372        }
2373  
2374        const desiredSlice = desiredSliceByPath.get(pathKey);
2375        if (!desiredSlice) {
2376          continue;
2377        }
2378  
2379        if (currentMode === "full") {
2380          removePaths.add(pathKey);
2381          addSlices.push(desiredSlice);
2382          continue;
2383        }
2384  
2385        if (currentMode === "slices") {
2386          const currentSlice = currentSliceByPath.get(pathKey);
2387          if (currentSlice && autoSelectionSliceKey(currentSlice) === autoSelectionSliceKey(desiredSlice)) {
2388            continue;
2389          }
2390  
2391          removePaths.add(pathKey);
2392          addSlices.push(desiredSlice);
2393          continue;
2394        }
2395  
2396        addSlices.push(desiredSlice);
2397      }
2398  
2399      await removeAutoSelectionPaths(currentState, [...removePaths]);
2400      await addAutoSelectionFullPaths(desiredState, addFullPaths);
2401      await addAutoSelectionSlices(desiredState, addSlices);
2402    }
2403  
2404    async function reconcileAutoSelectionStates(
2405      currentState: AutoSelectionEntryData | null,
2406      desiredState: AutoSelectionEntryData | null
2407    ): Promise<void> {
2408      if (autoSelectionStatesEqual(currentState, desiredState)) {
2409        return;
2410      }
2411  
2412      if (currentState && desiredState) {
2413        const sameBinding =
2414          currentState.windowId === desiredState.windowId &&
2415          sameOptionalText(currentState.tab, desiredState.tab);
2416  
2417        if (sameBinding) {
2418          await reconcileAutoSelectionWithinBinding(currentState, desiredState);
2419          return;
2420        }
2421  
2422        try {
2423          await removeAutoSelectionPaths(currentState, autoSelectionManagedPaths(currentState));
2424        } catch {
2425          // Old binding/window may no longer exist after RepoPrompt app restart
2426        }
2427  
2428        await addAutoSelectionFullPaths(desiredState, desiredState.fullPaths);
2429        await addAutoSelectionSlices(desiredState, desiredState.slicePaths);
2430        return;
2431      }
2432  
2433      if (currentState && !desiredState) {
2434        try {
2435          await removeAutoSelectionPaths(currentState, autoSelectionManagedPaths(currentState));
2436        } catch {
2437          // Old binding/window may no longer exist after RepoPrompt app restart
2438        }
2439        return;
2440      }
2441  
2442      if (!currentState && desiredState) {
2443        await addAutoSelectionFullPaths(desiredState, desiredState.fullPaths);
2444        await addAutoSelectionSlices(desiredState, desiredState.slicePaths);
2445      }
2446    }
2447  
2448    async function syncAutoSelectionToCurrentBranch(ctx: ExtensionContext): Promise<void> {
2449      if (config.autoSelectReadSlices !== true) {
2450        activeAutoSelectionState = null;
2451        return;
2452      }
2453  
2454      const binding = await ensureBindingTargetsLiveWindow(ctx);
2455      const desiredState = binding ? getAutoSelectionStateFromBranch(ctx, binding) : null;
2456  
2457      try {
2458        await reconcileAutoSelectionStates(activeAutoSelectionState, desiredState);
2459      } catch {
2460        // Fail-open
2461      }
2462  
2463      activeAutoSelectionState = desiredState;
2464    }
2465  
2466    function parseReadOutputHints(readOutputText: string | undefined): {
2467      selectionPath: string | null;
2468      totalLines: number | null;
2469    } {
2470      if (!readOutputText) {
2471        return { selectionPath: null, totalLines: null };
2472      }
2473  
2474      const pathMatch =
2475        /\*\*Path\*\*:\s*`([^`]+)`/i.exec(readOutputText) ??
2476        /\*\*Path\*\*:\s*([^\n]+)$/im.exec(readOutputText);
2477  
2478      const selectionPath = pathMatch?.[1]?.trim() ?? null;
2479  
2480      const linesRegexes = [
2481        /\*\*Lines\*\*:\s*(\d+)\s*[–—-]\s*(\d+)\s+of\s+(\d+)/i,
2482        /Lines(?:\s*:)?\s*(\d+)\s*[–—-]\s*(\d+)\s+of\s+(\d+)/i,
2483      ];
2484  
2485      let totalLines: number | null = null;
2486  
2487      for (const rx of linesRegexes) {
2488        const match = rx.exec(readOutputText);
2489        if (!match) {
2490          continue;
2491        }
2492  
2493        const parsed = Number.parseInt(match[3] ?? "", 10);
2494        if (Number.isFinite(parsed)) {
2495          totalLines = parsed;
2496          break;
2497        }
2498      }
2499  
2500      return { selectionPath, totalLines };
2501    }
2502  
2503    async function autoSelectReadFileInRepoPromptSelection(
2504      ctx: ExtensionContext,
2505      inputPath: string,
2506      startLine: number | undefined,
2507      limit: number | undefined,
2508      readOutputText: string | undefined
2509    ): Promise<void> {
2510      if (config.autoSelectReadSlices !== true) {
2511        return;
2512      }
2513  
2514      const outputHints = parseReadOutputHints(readOutputText);
2515  
2516      const binding = await maybeEnsureBindingTargetsLiveWindow(ctx);
2517      if (!binding) {
2518        return;
2519      }
2520  
2521      const selectionText = await getSelectionFilesText(binding);
2522      if (selectionText === null) {
2523        return;
2524      }
2525  
2526      const resolved = await resolveReadFilePath(pi, inputPath, ctx.cwd, binding.windowId, binding.tab);
2527  
2528      const resolvedSelectionPath = buildSelectionPathFromResolved(inputPath, resolved);
2529      const selectionPath =
2530        outputHints.selectionPath && outputHints.selectionPath.trim().length > 0
2531          ? outputHints.selectionPath
2532          : resolvedSelectionPath;
2533  
2534      const candidatePaths = new Set<string>();
2535      candidatePaths.add(toPosixPath(selectionPath));
2536      candidatePaths.add(toPosixPath(resolvedSelectionPath));
2537      candidatePaths.add(toPosixPath(inputPath));
2538  
2539      if (outputHints.selectionPath) {
2540        candidatePaths.add(toPosixPath(outputHints.selectionPath));
2541      }
2542  
2543      if (resolved.absolutePath) {
2544        candidatePaths.add(toPosixPath(resolved.absolutePath));
2545      }
2546  
2547      if (resolved.absolutePath && resolved.repoRoot) {
2548        const rel = path.relative(resolved.repoRoot, resolved.absolutePath);
2549        if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
2550          candidatePaths.add(toPosixPath(rel.split(path.sep).join("/")));
2551        }
2552      }
2553  
2554      let selectionStatus: ReturnType<typeof inferSelectionStatus> = null;
2555  
2556      for (const candidate of candidatePaths) {
2557        const status = inferSelectionStatus(selectionText, candidate);
2558        if (!status) {
2559          continue;
2560        }
2561  
2562        if (status.mode === "full") {
2563          selectionStatus = status;
2564          break;
2565        }
2566  
2567        if (status.mode === "codemap_only" && status.codemapManual === true) {
2568          selectionStatus = status;
2569          break;
2570        }
2571  
2572        if (selectionStatus === null) {
2573          selectionStatus = status;
2574          continue;
2575        }
2576  
2577        if (selectionStatus.mode === "codemap_only" && status.mode === "slices") {
2578          selectionStatus = status;
2579        }
2580      }
2581  
2582      if (selectionStatus?.mode === "full") {
2583        return;
2584      }
2585  
2586      if (selectionStatus?.mode === "codemap_only" && selectionStatus.codemapManual === true) {
2587        return;
2588      }
2589  
2590      let totalLines: number | undefined;
2591  
2592      if (typeof startLine === "number" && startLine < 0) {
2593        if (resolved.absolutePath) {
2594          try {
2595            totalLines = await countFileLines(resolved.absolutePath);
2596          } catch {
2597            totalLines = undefined;
2598          }
2599        }
2600  
2601        if (totalLines === undefined && typeof outputHints.totalLines === "number") {
2602          totalLines = outputHints.totalLines;
2603        }
2604      }
2605  
2606      const sliceRange = computeSliceRangeFromReadArgs(startLine, limit, totalLines);
2607  
2608  
2609      if (sliceRange) {
2610        const existingRanges = existingSliceRangesForRead(binding, selectionPath);
2611        if (!existingRanges) {
2612          return;
2613        }
2614  
2615        const uncoveredRanges = subtractCoveredRanges(sliceRange, existingRanges);
2616  
2617        if (uncoveredRanges.length === 0) {
2618          updateAutoSelectionStateAfterSliceRead(binding, selectionPath, sliceRange);
2619          return;
2620        }
2621  
2622        // Add only uncovered ranges to avoid touching unrelated selection state
2623        // (and to avoid global set semantics)
2624        const payload = {
2625          op: "add",
2626          slices: [
2627            {
2628              path: toPosixPath(selectionPath),
2629              ranges: uncoveredRanges,
2630            },
2631          ],
2632        };
2633  
2634        try {
2635          await callManageSelection(binding, payload);
2636        } catch {
2637          // Fail-open
2638          return;
2639        }
2640  
2641        updateAutoSelectionStateAfterSliceRead(binding, selectionPath, sliceRange);
2642        return;
2643      }
2644  
2645      const payload = {
2646        op: "add",
2647        mode: "full",
2648        paths: [toPosixPath(selectionPath)],
2649      };
2650  
2651      try {
2652        await callManageSelection(binding, payload);
2653      } catch {
2654        // Fail-open
2655        return;
2656      }
2657  
2658      updateAutoSelectionStateAfterFullRead(binding, selectionPath);
2659    }
2660  
2661  
2662    pi.on("session_start", async (_event, ctx) => {
2663      config = loadConfig();
2664      clearReadcacheCaches();
2665      clearWindowsCache();
2666      markBindingValidationStale();
2667      reconstructBinding(ctx);
2668  
2669      const binding = getCurrentBinding();
2670      activeAutoSelectionState =
2671        config.autoSelectReadSlices === true && binding
2672          ? getAutoSelectionStateFromBranch(ctx, binding)
2673          : null;
2674  
2675      if (config.readcacheReadFile === true) {
2676        void pruneObjectsOlderThan(ctx.cwd).catch(() => {
2677          // Fail-open
2678        });
2679      }
2680  
2681      try {
2682        await syncAutoSelectionToCurrentBranch(ctx);
2683      } catch {
2684        // Fail-open
2685      }
2686    });
2687  
2688    pi.on("session_switch", async (_event, ctx) => {
2689      config = loadConfig();
2690      clearReadcacheCaches();
2691      clearWindowsCache();
2692      markBindingValidationStale();
2693      reconstructBinding(ctx);
2694      await syncAutoSelectionToCurrentBranch(ctx);
2695    });
2696  
2697    // session_fork is the current event name; keep session_branch for backwards compatibility
2698    pi.on("session_fork", async (_event, ctx) => {
2699      config = loadConfig();
2700      clearReadcacheCaches();
2701      clearWindowsCache();
2702      markBindingValidationStale();
2703      reconstructBinding(ctx);
2704      await syncAutoSelectionToCurrentBranch(ctx);
2705    });
2706  
2707    pi.on("session_branch", async (_event, ctx) => {
2708      config = loadConfig();
2709      clearReadcacheCaches();
2710      clearWindowsCache();
2711      markBindingValidationStale();
2712      reconstructBinding(ctx);
2713      await syncAutoSelectionToCurrentBranch(ctx);
2714    });
2715  
2716    pi.on("session_tree", async (_event, ctx) => {
2717      config = loadConfig();
2718      clearReadcacheCaches();
2719      clearWindowsCache();
2720      markBindingValidationStale();
2721      reconstructBinding(ctx);
2722      await syncAutoSelectionToCurrentBranch(ctx);
2723    });
2724  
2725    pi.on("session_compact", async () => {
2726      clearReadcacheCaches();
2727    });
2728  
2729    pi.on("session_shutdown", async () => {
2730      clearReadcacheCaches();
2731      clearWindowsCache();
2732      activeAutoSelectionState = null;
2733      setBinding(null);
2734    });
2735  
2736    pi.registerCommand("rpbind", {
2737      description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>",
2738      handler: async (args, ctx) => {
2739        const parsed = parseRpbindArgs(args);
2740        if ("error" in parsed) {
2741          ctx.ui.notify(parsed.error, "error");
2742          return;
2743        }
2744  
2745        const binding = await enrichBinding(parsed.windowId, parsed.tab);
2746        persistBinding(binding);
2747  
2748        try {
2749          await syncAutoSelectionToCurrentBranch(ctx);
2750        } catch {
2751          // Fail-open
2752        }
2753  
2754        ctx.ui.notify(
2755          `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` +
2756            (boundWorkspaceName ? ` (${boundWorkspaceName})` : ""),
2757          "success"
2758        );
2759      },
2760    });
2761  
2762  
2763    pi.registerCommand("rpcli-readcache-status", {
2764      description: "Show repoprompt-cli read_file cache status",
2765      handler: async (_args, ctx) => {
2766        config = loadConfig();
2767  
2768        let msg = "repoprompt-cli read_file cache\n";
2769        msg += "──────────────────────────\n";
2770        msg += `Enabled: ${config.readcacheReadFile === true ? "✓" : "✗"}\n`;
2771        msg += `Auto-select reads: ${config.autoSelectReadSlices === true ? "✓" : "✗"}\n`;
2772  
2773        if (config.readcacheReadFile !== true) {
2774          msg += "\nEnable by creating ~/.pi/agent/extensions/repoprompt-cli/config.json\n";
2775          msg += "\nwith:\n  { \"readcacheReadFile\": true }\n";
2776          ctx.ui.notify(msg, "info");
2777          return;
2778        }
2779  
2780        try {
2781          const stats = await getStoreStats(ctx.cwd);
2782          msg += `\nObject store (under ${ctx.cwd}/.pi/readcache):\n`;
2783          msg += `  Objects: ${stats.objects}\n`;
2784          msg += `  Bytes: ${stats.bytes}\n`;
2785        } catch {
2786          msg += "\nObject store: unavailable\n";
2787        }
2788  
2789        msg += "\nNotes:\n";
2790        msg += "- Cache applies only to simple rp_exec reads (read/cat/read_file)\n";
2791        msg += "- Use bypass_cache=true in the read command to force baseline output\n";
2792  
2793        ctx.ui.notify(msg, "info");
2794      },
2795    });
2796  
2797    pi.registerCommand("rpcli-readcache-refresh", {
2798      description: "Invalidate repoprompt-cli read_file cache trust for a path and optional line range",
2799      handler: async (args, ctx) => {
2800        config = loadConfig();
2801  
2802        if (config.readcacheReadFile !== true) {
2803          ctx.ui.notify("readcacheReadFile is disabled in config", "error");
2804          return;
2805        }
2806  
2807        const trimmed = args.trim();
2808        if (!trimmed) {
2809          ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
2810          return;
2811        }
2812  
2813        const parts = trimmed.split(/\s+/);
2814        const pathInput = parts[0];
2815        const rangeInput = parts[1];
2816  
2817        if (!pathInput) {
2818          ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
2819          return;
2820        }
2821  
2822        const binding = await ensureBindingTargetsLiveWindow(ctx);
2823        if (!binding) {
2824          ctx.ui.notify("rp_exec is not bound. Bind first via /rpbind or rp_bind", "error");
2825          return;
2826        }
2827  
2828        let scopeKey: ScopeKey = SCOPE_FULL;
2829        if (rangeInput) {
2830          const match = rangeInput.match(/^(\d+)-(\d+)$/);
2831          if (!match) {
2832            ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
2833            return;
2834          }
2835  
2836          const start = parseInt(match[1] ?? "", 10);
2837          const end = parseInt(match[2] ?? "", 10);
2838          if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end < start) {
2839            ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
2840            return;
2841          }
2842  
2843          scopeKey = scopeRange(start, end);
2844        }
2845  
2846        const resolved = await resolveReadFilePath(pi, pathInput, ctx.cwd, binding.windowId, binding.tab);
2847        if (!resolved.absolutePath) {
2848          ctx.ui.notify(`Could not resolve path: ${pathInput}`, "error");
2849          return;
2850        }
2851  
2852        pi.appendEntry(RP_READCACHE_CUSTOM_TYPE, buildInvalidationV1(resolved.absolutePath, scopeKey));
2853        clearReadcacheCaches();
2854  
2855        ctx.ui.notify(
2856          `Invalidated readcache for ${resolved.absolutePath}` + (scopeKey === SCOPE_FULL ? "" : ` (${scopeKey})`),
2857          "info"
2858        );
2859      },
2860    });
2861  
2862    pi.registerTool({
2863      name: "rp_bind",
2864      label: "RepoPrompt Bind",
2865      description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
2866      parameters: BindParams,
2867  
2868      async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2869        await ensureJustBashLoaded();
2870        maybeWarnAstUnavailable(ctx);
2871  
2872        const binding = await enrichBinding(params.windowId, params.tab);
2873        persistBinding(binding);
2874  
2875        try {
2876          await syncAutoSelectionToCurrentBranch(ctx);
2877        } catch {
2878          // Fail-open
2879        }
2880  
2881        return {
2882          content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
2883          details: {
2884            windowId: boundWindowId,
2885            tab: boundTab,
2886            workspaceId: boundWorkspaceId,
2887            workspaceName: boundWorkspaceName,
2888            workspaceRoots: boundWorkspaceRoots,
2889          },
2890        };
2891      },
2892    });
2893  
2894    pi.registerTool({
2895      name: "rp_exec",
2896      label: "RepoPrompt Exec",
2897      description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
2898      parameters: ExecParams,
2899  
2900      async execute(_toolCallId, params, signal, onUpdate, ctx) {
2901        // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
2902        await ensureJustBashLoaded();
2903        maybeWarnAstUnavailable(ctx);
2904  
2905        if (params.windowId === undefined && params.tab === undefined) {
2906          try {
2907            await maybeEnsureBindingTargetsLiveWindow(ctx);
2908          } catch {
2909            // Fail-open
2910          }
2911        }
2912  
2913        const windowId = params.windowId ?? boundWindowId;
2914        const tab = params.tab ?? boundTab;
2915        const rawJson = params.rawJson ?? false;
2916        const quiet = params.quiet ?? true;
2917        const failFast = params.failFast ?? true;
2918        const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2919        const maxOutputChars = params.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
2920        const allowDelete = params.allowDelete ?? false;
2921        const allowWorkspaceSwitchInPlace = params.allowWorkspaceSwitchInPlace ?? false;
2922        const failOnNoopEdits = params.failOnNoopEdits ?? true;
2923  
2924        if (!allowDelete && looksLikeDeleteCommand(params.cmd)) {
2925          return {
2926            isError: true,
2927            content: [
2928              {
2929                type: "text",
2930                text: "Blocked potential delete command. If deletion is explicitly requested, rerun with allowDelete=true",
2931              },
2932            ],
2933            details: { blocked: true, reason: "delete", cmd: params.cmd, windowId, tab },
2934          };
2935        }
2936  
2937        if (!allowWorkspaceSwitchInPlace && looksLikeWorkspaceSwitchInPlace(params.cmd)) {
2938          return {
2939            isError: true,
2940            content: [
2941              {
2942                type: "text",
2943                text:
2944                  "Blocked in-place workspace change (it can clobber selection/prompt/context and disrupt other sessions). " +
2945                  "Add `--new-window`, or rerun with allowWorkspaceSwitchInPlace=true if explicitly safe",
2946              },
2947            ],
2948            details: { blocked: true, reason: "workspace_switch_in_place", cmd: params.cmd, windowId, tab },
2949          };
2950        }
2951  
2952        const isBound = windowId !== undefined && tab !== undefined;
2953        if (!isBound && !isSafeToRunUnbound(params.cmd)) {
2954          return {
2955            content: [
2956              {
2957                type: "text",
2958                text:
2959                  "Blocked rp_exec because it is not bound to a window+tab. " +
2960                  "Do not fall back to native Pi tools—bind first. " +
2961                  "Run `windows` (it now reports tabs/context IDs) or `tabs list`, then bind with rp_bind(windowId, tab). " +
2962                  "If RepoPrompt is in single-window mode, windowId is usually 1",
2963              },
2964            ],
2965            details: { blocked: true, reason: "unbound", cmd: params.cmd, windowId, tab },
2966          };
2967        }
2968  
2969        // Parse read-like commands to:
2970        // - detect cacheable reads (when enabled)
2971        // - strip wrapper-only args like bypass_cache=true even when caching is disabled
2972        //   (so agents can safely use bypass_cache in instructions regardless of config)
2973        const readRequest = parseReadFileRequest(params.cmd);
2974  
2975        const cmdToRun = readRequest ? readRequest.cmdToRun : params.cmd;
2976  
2977        const rpArgs: string[] = [];
2978        if (windowId !== undefined) rpArgs.push("-w", String(windowId));
2979        if (tab !== undefined) rpArgs.push("-t", tab);
2980        if (quiet) rpArgs.push("-q");
2981        if (rawJson) rpArgs.push("--raw-json");
2982        if (failFast) rpArgs.push("--fail-fast");
2983        rpArgs.push("-e", cmdToRun);
2984  
2985        if (windowId === undefined || tab === undefined) {
2986          onUpdate({
2987            status:
2988              "Running rp-cli without a bound window/tab (non-deterministic). Bind first with rp_bind(windowId, tab)",
2989          });
2990        } else {
2991          onUpdate({ status: `Running rp-cli in window ${windowId}, tab "${tab}"…` });
2992        }
2993  
2994        let stdout = "";
2995        let stderr = "";
2996        let exitCode = -1;
2997        let execError: string | undefined;
2998  
2999        try {
3000          const result = await pi.exec("rp-cli", rpArgs, { signal, timeout: timeoutMs });
3001          stdout = result.stdout ?? "";
3002          stderr = result.stderr ?? "";
3003          exitCode = result.code ?? 0;
3004        } catch (error) {
3005          execError = error instanceof Error ? error.message : String(error);
3006        }
3007  
3008        const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
3009  
3010        let rawOutput = execError ? `rp-cli execution failed: ${execError}` : combinedOutput;
3011  
3012        let rpReadcache: RpReadcacheMetaV1 | null = null;
3013  
3014        if (
3015          config.readcacheReadFile === true &&
3016          readRequest !== null &&
3017          readRequest.cacheable === true &&
3018          !execError &&
3019          exitCode === 0 &&
3020          windowId !== undefined &&
3021          tab !== undefined
3022        ) {
3023          try {
3024            const cached = await readFileWithCache(
3025              pi,
3026              {
3027                path: readRequest.path,
3028                ...(typeof readRequest.startLine === "number" ? { start_line: readRequest.startLine } : {}),
3029                ...(typeof readRequest.limit === "number" ? { limit: readRequest.limit } : {}),
3030                ...(readRequest.bypassCache ? { bypass_cache: true } : {}),
3031              },
3032              ctx,
3033              readcacheRuntimeState,
3034              windowId,
3035              tab,
3036              signal
3037            );
3038  
3039            rpReadcache = cached.meta;
3040  
3041            if (typeof cached.outputText === "string" && cached.outputText.length > 0) {
3042              rawOutput = cached.outputText;
3043            }
3044          } catch {
3045            // Fail-open: caching must never break the baseline command output
3046          }
3047        }
3048  
3049        const shouldAutoSelectRead =
3050          config.autoSelectReadSlices === true &&
3051          readRequest !== null &&
3052          !execError &&
3053          exitCode === 0 &&
3054          windowId !== undefined &&
3055          tab !== undefined &&
3056          params.windowId === undefined &&
3057          params.tab === undefined;
3058  
3059        if (shouldAutoSelectRead) {
3060          try {
3061            await autoSelectReadFileInRepoPromptSelection(
3062              ctx,
3063              readRequest.path,
3064              readRequest.startLine,
3065              readRequest.limit,
3066              combinedOutput,
3067            );
3068          } catch {
3069            // Fail-open
3070          }
3071        }
3072  
3073        const editNoop =
3074          !execError &&
3075          exitCode === 0 &&
3076          looksLikeEditCommand(params.cmd) &&
3077          looksLikeNoopEditOutput(rawOutput);
3078  
3079        const shouldFailNoopEdit = editNoop && failOnNoopEdits;
3080        const commandFailed = Boolean(execError) || exitCode !== 0;
3081        const shouldError = commandFailed || shouldFailNoopEdit;
3082  
3083        let outputForUser = rawOutput;
3084        if (editNoop) {
3085          const rpCliOutput = rawOutput.length > 0 ? `\n--- rp-cli output ---\n${rawOutput}` : "";
3086  
3087          if (shouldFailNoopEdit) {
3088            outputForUser =
3089              "RepoPrompt edit made no changes (0 edits applied). This usually means the search string was not found.\n" +
3090              "If this was expected, rerun with failOnNoopEdits=false. Otherwise, verify the search text or rerun with rawJson=true / quiet=false.\n" +
3091              "Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" +
3092              rpCliOutput;
3093          } else {
3094            outputForUser =
3095              "RepoPrompt edit made no changes (0 edits applied).\n" +
3096              "RepoPrompt may report this as an error (e.g. 'search block not found'), but failOnNoopEdits=false is treating it as non-fatal.\n" +
3097              "Tip: for tricky edits with multiline content, use rp-cli directly: rp-cli -c apply_edits -j '{...}'" +
3098              rpCliOutput;
3099          }
3100        }
3101  
3102        const outputWithBindingWarning =
3103          windowId === undefined || tab === undefined
3104            ? `WARNING: rp_exec is not bound to a RepoPrompt window/tab. Bind with rp_bind(windowId, tab).\n\n${outputForUser}`
3105            : outputForUser;
3106  
3107        const { text: truncatedOutput, truncated } = truncateText(outputWithBindingWarning.trim(), maxOutputChars);
3108        const finalText = truncatedOutput.length > 0 ? truncatedOutput : "(no output)";
3109  
3110        return {
3111          isError: shouldError,
3112          content: [{ type: "text", text: finalText }],
3113          details: {
3114            cmd: params.cmd,
3115            windowId,
3116            tab,
3117            rawJson,
3118            quiet,
3119            failOnNoopEdits,
3120            failFast,
3121            timeoutMs,
3122            maxOutputChars,
3123            exitCode,
3124            truncated,
3125            stderrIncluded: stderr.trim().length > 0,
3126            execError,
3127            editNoop,
3128            shouldFailNoopEdit,
3129            rpReadcache: rpReadcache ?? undefined,
3130          },
3131        };
3132      },
3133  
3134      renderCall(args: Record<string, unknown>, theme: Theme) {
3135        const cmd = (args.cmd as string) || "...";
3136        const windowId = args.windowId ?? boundWindowId;
3137        const tab = args.tab ?? boundTab;
3138  
3139        let text = theme.fg("toolTitle", theme.bold("rp_exec"));
3140        text += " " + theme.fg("accent", cmd);
3141  
3142        if (windowId !== undefined && tab !== undefined) {
3143          text += theme.fg("muted", ` (window ${windowId}, tab "${tab}")`);
3144        } else {
3145          text += theme.fg("warning", " (unbound)");
3146        }
3147  
3148        return new Text(text, 0, 0);
3149      },
3150  
3151      renderResult(
3152        result: { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean },
3153        options: ToolRenderResultOptions,
3154        theme: Theme
3155      ) {
3156        const details = result.details || {};
3157        const exitCode = details.exitCode as number | undefined;
3158        const truncated = details.truncated as boolean | undefined;
3159        const blocked = details.blocked as boolean | undefined;
3160  
3161        // Get text content
3162        const textContent = result.content
3163          .filter((c) => c.type === "text")
3164          .map((c) => c.text || "")
3165          .join("\n");
3166  
3167        // Handle partial/streaming state
3168        if (options.isPartial) {
3169          return new Text(theme.fg("warning", "Running…"), 0, 0);
3170        }
3171  
3172        // Handle blocked commands
3173        if (blocked) {
3174          return new Text(theme.fg("error", "✗ " + textContent), 0, 0);
3175        }
3176  
3177        // Handle errors
3178        if (result.isError || (exitCode !== undefined && exitCode !== 0)) {
3179          const exitInfo = exitCode !== undefined ? ` (exit ${exitCode})` : "";
3180          return new Text(theme.fg("error", `✗${exitInfo}\n${textContent}`), 0, 0);
3181        }
3182  
3183        // Success case
3184        const truncatedNote = truncated ? theme.fg("warning", " (truncated)") : "";
3185        const successPrefix = theme.fg("success", "✓ ");
3186        const prefixFirstLine = (value: string, prefix: string): string => {
3187          if (!value) {
3188            return prefix.trimEnd();
3189          }
3190          const idx = value.indexOf("\n");
3191          if (idx < 0) {
3192            return `${prefix}${value}`;
3193          }
3194          return `${prefix}${value.slice(0, idx)}${value.slice(idx)}`;
3195        };
3196  
3197        if (!options.expanded) {
3198          const collapsedMaxLines = config.collapsedMaxLines;
3199          const maxLines = collapsedMaxLines ?? DEFAULT_COLLAPSED_MAX_LINES;
3200          const { content, truncated: collapsedTruncated, totalLines } = prepareCollapsedView(
3201            textContent,
3202            theme,
3203            collapsedMaxLines
3204          );
3205  
3206          if (maxLines === 0) {
3207            const hidden = theme.fg("muted", "(output hidden)");
3208            const moreText = totalLines > 0 ? theme.fg("muted", `\n… (${totalLines} more lines)`) : "";
3209            return new Text(`${successPrefix}${truncatedNote}${hidden}${moreText}`, 0, 0);
3210          }
3211  
3212          if (collapsedTruncated) {
3213            const remaining = totalLines - maxLines;
3214            const moreText = remaining > 0 ? theme.fg("muted", `\n… (${remaining} more lines)`) : "";
3215            return new Text(`${prefixFirstLine(content, `${successPrefix}${truncatedNote}`)}${moreText}`, 0, 0);
3216          }
3217  
3218          return new Text(prefixFirstLine(content, `${successPrefix}${truncatedNote}`), 0, 0);
3219        }
3220  
3221        // Expanded view or short output: render with syntax highlighting
3222        const highlighted = renderRpExecOutput(textContent, theme);
3223        return new Text(prefixFirstLine(highlighted, `${successPrefix}${truncatedNote}`), 0, 0);
3224      },
3225    });
3226  }