pi-repoprompt-cli 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import * as path from "node:path";
2
3
 
3
4
  import type { ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
4
5
  import { highlightCode, Theme } from "@mariozechner/pi-coding-agent";
@@ -7,13 +8,25 @@ import { Type } from "@sinclair/typebox";
7
8
  import * as Diff from "diff";
8
9
 
9
10
  import { loadConfig } from "./config.js";
11
+ import {
12
+ computeSliceRangeFromReadArgs,
13
+ countFileLines,
14
+ inferSelectionStatus,
15
+ toPosixPath,
16
+ } from "./auto-select.js";
10
17
  import { RP_READCACHE_CUSTOM_TYPE, SCOPE_FULL, scopeRange } from "./readcache/constants.js";
11
18
  import { buildInvalidationV1 } from "./readcache/meta.js";
12
19
  import { getStoreStats, pruneObjectsOlderThan } from "./readcache/object-store.js";
13
20
  import { readFileWithCache } from "./readcache/read-file.js";
14
21
  import { clearReplayRuntimeState, createReplayRuntimeState } from "./readcache/replay.js";
15
- import { resolveReadFilePath } from "./readcache/resolve.js";
22
+ import { clearRootsCache, resolveReadFilePath } from "./readcache/resolve.js";
16
23
  import type { RpReadcacheMetaV1, ScopeKey } from "./readcache/types.js";
24
+ import type {
25
+ AutoSelectionEntryData,
26
+ AutoSelectionEntryRangeData,
27
+ AutoSelectionEntrySliceData,
28
+ RpCliBindingEntryData,
29
+ } from "./types.js";
17
30
 
18
31
  let parseBash: ((input: string) => any) | null = null;
19
32
  let justBashLoadPromise: Promise<void> | null = null;
@@ -247,6 +260,16 @@ function hasPipeOutsideQuotes(script: string): boolean {
247
260
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
248
261
  const DEFAULT_MAX_OUTPUT_CHARS = 12000;
249
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
+ }
250
273
 
251
274
  const BindParams = Type.Object({
252
275
  windowId: Type.Number({ description: "RepoPrompt window id (from `rp-cli -e windows`)" }),
@@ -1250,278 +1273,1606 @@ export default function (pi: ExtensionAPI) {
1250
1273
  clearReplayRuntimeState(readcacheRuntimeState);
1251
1274
  };
1252
1275
 
1276
+ let activeAutoSelectionState: AutoSelectionEntryData | null = null;
1277
+
1253
1278
  let boundWindowId: number | undefined;
1254
1279
  let boundTab: string | undefined;
1280
+ let boundWorkspaceId: string | undefined;
1281
+ let boundWorkspaceName: string | undefined;
1282
+ let boundWorkspaceRoots: string[] | undefined;
1255
1283
 
1256
- const setBinding = (windowId: number, tab: string) => {
1257
- boundWindowId = windowId;
1258
- boundTab = tab;
1259
- };
1284
+ let windowsCache: { windows: RpCliWindow[]; fetchedAtMs: number } | null = null;
1285
+ let lastBindingValidationAtMs = 0;
1260
1286
 
1261
- const persistBinding = (windowId: number, tab: string) => {
1262
- // Persist binding across session reloads without injecting extra text into the model context
1263
- if (boundWindowId === windowId && boundTab === tab) return;
1287
+ function sameOptionalText(a?: string, b?: string): boolean {
1288
+ return (a ?? undefined) === (b ?? undefined);
1289
+ }
1264
1290
 
1265
- setBinding(windowId, tab);
1266
- pi.appendEntry(BINDING_CUSTOM_TYPE, { windowId, tab });
1267
- };
1291
+ function clearWindowsCache(): void {
1292
+ windowsCache = null;
1293
+ }
1268
1294
 
1269
- const reconstructBinding = (ctx: ExtensionContext) => {
1270
- // Prefer persisted binding (appendEntry) from the *current branch*, then fall back to prior rp_bind tool results
1271
- // Branch semantics: if the current branch has no binding state, stay unbound
1272
- boundWindowId = undefined;
1273
- boundTab = undefined;
1295
+ function markBindingValidationStale(): void {
1296
+ lastBindingValidationAtMs = 0;
1297
+ }
1274
1298
 
1275
- let reconstructedWindowId: number | undefined;
1276
- let reconstructedTab: string | undefined;
1299
+ function shouldRevalidateBinding(): boolean {
1300
+ const now = Date.now();
1301
+ return (now - lastBindingValidationAtMs) >= BINDING_VALIDATION_TTL_MS;
1302
+ }
1277
1303
 
1278
- for (const entry of ctx.sessionManager.getBranch()) {
1279
- if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) continue;
1304
+ function markBindingValidatedNow(): void {
1305
+ lastBindingValidationAtMs = Date.now();
1306
+ }
1280
1307
 
1281
- const data = entry.data as { windowId?: unknown; tab?: unknown } | undefined;
1282
- const windowId = typeof data?.windowId === "number" ? data.windowId : undefined;
1283
- const tab = typeof data?.tab === "string" ? data.tab : undefined;
1284
- if (windowId !== undefined && tab) {
1285
- reconstructedWindowId = windowId;
1286
- reconstructedTab = tab;
1287
- }
1308
+ function normalizeWorkspaceRoots(roots: string[] | undefined): string[] {
1309
+ if (!Array.isArray(roots)) {
1310
+ return [];
1288
1311
  }
1289
1312
 
1290
- if (reconstructedWindowId !== undefined && reconstructedTab !== undefined) {
1291
- setBinding(reconstructedWindowId, reconstructedTab);
1292
- return;
1313
+ return [...new Set(roots.map((root) => toPosixPath(String(root).trim())).filter(Boolean))].sort();
1314
+ }
1315
+
1316
+ function workspaceRootsEqual(left: string[] | undefined, right: string[] | undefined): boolean {
1317
+ const leftNormalized = normalizeWorkspaceRoots(left);
1318
+ const rightNormalized = normalizeWorkspaceRoots(right);
1319
+ return JSON.stringify(leftNormalized) === JSON.stringify(rightNormalized);
1320
+ }
1321
+
1322
+ function workspaceIdentityMatches(
1323
+ left: { workspaceId?: string; workspaceName?: string; workspaceRoots?: string[] },
1324
+ right: { workspaceId?: string; workspaceName?: string; workspaceRoots?: string[] }
1325
+ ): boolean {
1326
+ if (left.workspaceId && right.workspaceId) {
1327
+ return left.workspaceId === right.workspaceId;
1293
1328
  }
1294
1329
 
1295
- for (const entry of ctx.sessionManager.getBranch()) {
1296
- if (entry.type !== "message") continue;
1297
- const msg = entry.message;
1298
- if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") continue;
1330
+ const leftRoots = normalizeWorkspaceRoots(left.workspaceRoots);
1331
+ const rightRoots = normalizeWorkspaceRoots(right.workspaceRoots);
1299
1332
 
1300
- const details = msg.details as { windowId?: number; tab?: string } | undefined;
1301
- if (details?.windowId !== undefined && details?.tab) {
1302
- persistBinding(details.windowId, details.tab);
1303
- }
1333
+ if (leftRoots.length > 0 && rightRoots.length > 0) {
1334
+ return JSON.stringify(leftRoots) === JSON.stringify(rightRoots);
1304
1335
  }
1305
- };
1306
1336
 
1307
- pi.on("session_start", async (_event, ctx) => {
1308
- config = loadConfig();
1309
- clearReadcacheCaches();
1310
- if (config.readcacheReadFile === true) {
1311
- void pruneObjectsOlderThan(ctx.cwd).catch(() => {
1312
- // Fail-open
1313
- });
1337
+ if (left.workspaceName && right.workspaceName) {
1338
+ return left.workspaceName === right.workspaceName;
1314
1339
  }
1315
- reconstructBinding(ctx);
1316
- });
1317
1340
 
1318
- pi.on("session_switch", async (_event, ctx) => {
1319
- config = loadConfig();
1320
- clearReadcacheCaches();
1321
- reconstructBinding(ctx);
1322
- });
1341
+ return false;
1342
+ }
1323
1343
 
1324
- // session_fork is the current event name; keep session_branch for backwards compatibility
1325
- pi.on("session_fork", async (_event, ctx) => {
1326
- config = loadConfig();
1327
- clearReadcacheCaches();
1328
- reconstructBinding(ctx);
1329
- });
1344
+ function getCurrentBinding(): RpCliBindingEntryData | null {
1345
+ if (boundWindowId === undefined || !boundTab) {
1346
+ return null;
1347
+ }
1330
1348
 
1331
- pi.on("session_branch", async (_event, ctx) => {
1332
- config = loadConfig();
1333
- clearReadcacheCaches();
1334
- reconstructBinding(ctx);
1335
- });
1349
+ return {
1350
+ windowId: boundWindowId,
1351
+ tab: boundTab,
1352
+ workspaceId: boundWorkspaceId,
1353
+ workspaceName: boundWorkspaceName,
1354
+ workspaceRoots: boundWorkspaceRoots,
1355
+ };
1356
+ }
1336
1357
 
1337
- pi.on("session_tree", async (_event, ctx) => {
1338
- config = loadConfig();
1339
- clearReadcacheCaches();
1340
- reconstructBinding(ctx);
1341
- });
1358
+ function normalizeBindingEntry(binding: RpCliBindingEntryData): RpCliBindingEntryData {
1359
+ return {
1360
+ windowId: binding.windowId,
1361
+ tab: binding.tab,
1362
+ workspaceId: typeof binding.workspaceId === "string" ? binding.workspaceId : undefined,
1363
+ workspaceName: typeof binding.workspaceName === "string" ? binding.workspaceName : undefined,
1364
+ workspaceRoots: normalizeWorkspaceRoots(binding.workspaceRoots),
1365
+ };
1366
+ }
1342
1367
 
1343
- pi.on("session_compact", async () => {
1344
- clearReadcacheCaches();
1345
- });
1368
+ function bindingEntriesEqual(a: RpCliBindingEntryData | null, b: RpCliBindingEntryData | null): boolean {
1369
+ if (!a && !b) {
1370
+ return true;
1371
+ }
1346
1372
 
1347
- pi.on("session_shutdown", async () => {
1348
- clearReadcacheCaches();
1349
- });
1373
+ if (!a || !b) {
1374
+ return false;
1375
+ }
1350
1376
 
1351
- pi.registerCommand("rpbind", {
1352
- description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>",
1353
- handler: async (args, ctx) => {
1354
- const parsed = parseRpbindArgs(args);
1355
- if ("error" in parsed) {
1356
- ctx.ui.notify(parsed.error, "error");
1357
- return;
1358
- }
1377
+ const left = normalizeBindingEntry(a);
1378
+ const right = normalizeBindingEntry(b);
1359
1379
 
1360
- persistBinding(parsed.windowId, parsed.tab);
1361
- ctx.ui.notify(`Bound rp_exec window ${boundWindowId}, tab "${boundTab}"`, "success");
1362
- },
1363
- });
1380
+ return (
1381
+ left.windowId === right.windowId &&
1382
+ left.tab === right.tab &&
1383
+ sameOptionalText(left.workspaceId, right.workspaceId) &&
1384
+ sameOptionalText(left.workspaceName, right.workspaceName) &&
1385
+ workspaceRootsEqual(left.workspaceRoots, right.workspaceRoots)
1386
+ );
1387
+ }
1364
1388
 
1365
- pi.registerCommand("rpcli-readcache-status", {
1366
- description: "Show repoprompt-cli read_file cache status",
1367
- handler: async (_args, ctx) => {
1368
- config = loadConfig();
1389
+ function setBinding(binding: RpCliBindingEntryData | null): void {
1390
+ const previousWindowId = boundWindowId;
1369
1391
 
1370
- let msg = "repoprompt-cli read_file cache\n";
1371
- msg += "──────────────────────────\n";
1372
- msg += `Enabled: ${config.readcacheReadFile === true ? "✓" : "✗"}\n`;
1392
+ if (!binding) {
1393
+ boundWindowId = undefined;
1394
+ boundTab = undefined;
1395
+ boundWorkspaceId = undefined;
1396
+ boundWorkspaceName = undefined;
1397
+ boundWorkspaceRoots = undefined;
1398
+ } else {
1399
+ const normalized = normalizeBindingEntry(binding);
1400
+ boundWindowId = normalized.windowId;
1401
+ boundTab = normalized.tab;
1402
+ boundWorkspaceId = normalized.workspaceId;
1403
+ boundWorkspaceName = normalized.workspaceName;
1404
+ boundWorkspaceRoots = normalized.workspaceRoots;
1405
+ }
1373
1406
 
1374
- if (config.readcacheReadFile !== true) {
1375
- msg += "\nEnable by creating ~/.pi/agent/extensions/repoprompt-cli/config.json\n";
1376
- msg += "\nwith:\n { \"readcacheReadFile\": true }\n";
1377
- ctx.ui.notify(msg, "info");
1378
- return;
1407
+ if (previousWindowId !== boundWindowId) {
1408
+ if (previousWindowId !== undefined) {
1409
+ clearRootsCache(previousWindowId);
1379
1410
  }
1380
1411
 
1381
- try {
1382
- const stats = await getStoreStats(ctx.cwd);
1383
- msg += `\nObject store (under ${ctx.cwd}/.pi/readcache):\n`;
1384
- msg += ` Objects: ${stats.objects}\n`;
1385
- msg += ` Bytes: ${stats.bytes}\n`;
1386
- } catch {
1387
- msg += "\nObject store: unavailable\n";
1412
+ if (boundWindowId !== undefined) {
1413
+ clearRootsCache(boundWindowId);
1388
1414
  }
1389
1415
 
1390
- msg += "\nNotes:\n";
1391
- msg += "- Cache applies only to simple rp_exec reads (read/cat/read_file)\n";
1392
- msg += "- Use bypass_cache=true in the read command to force baseline output\n";
1416
+ clearWindowsCache();
1417
+ }
1393
1418
 
1394
- ctx.ui.notify(msg, "info");
1395
- },
1396
- });
1419
+ markBindingValidationStale();
1420
+ }
1397
1421
 
1398
- pi.registerCommand("rpcli-readcache-refresh", {
1399
- description: "Invalidate repoprompt-cli read_file cache trust for a path and optional line range",
1400
- handler: async (args, ctx) => {
1401
- config = loadConfig();
1422
+ function parseBindingEntryData(value: unknown): RpCliBindingEntryData | null {
1423
+ if (!value || typeof value !== "object") {
1424
+ return null;
1425
+ }
1402
1426
 
1403
- if (config.readcacheReadFile !== true) {
1404
- ctx.ui.notify("readcacheReadFile is disabled in config", "error");
1405
- return;
1427
+ const obj = value as Record<string, unknown>;
1428
+
1429
+ const windowId = typeof obj.windowId === "number" ? obj.windowId : undefined;
1430
+ const tab = typeof obj.tab === "string" ? obj.tab : undefined;
1431
+
1432
+ if (windowId === undefined || !tab) {
1433
+ return null;
1434
+ }
1435
+
1436
+ const workspaceId = typeof obj.workspaceId === "string"
1437
+ ? obj.workspaceId
1438
+ : (typeof obj.workspaceID === "string" ? obj.workspaceID : undefined);
1439
+
1440
+ const workspaceName = typeof obj.workspaceName === "string"
1441
+ ? obj.workspaceName
1442
+ : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1443
+
1444
+ const workspaceRoots = Array.isArray(obj.workspaceRoots)
1445
+ ? obj.workspaceRoots.filter((root): root is string => typeof root === "string")
1446
+ : (Array.isArray(obj.rootFolderPaths)
1447
+ ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1448
+ : undefined);
1449
+
1450
+ return normalizeBindingEntry({
1451
+ windowId,
1452
+ tab,
1453
+ workspaceId,
1454
+ workspaceName,
1455
+ workspaceRoots,
1456
+ });
1457
+ }
1458
+
1459
+ function persistBinding(binding: RpCliBindingEntryData): void {
1460
+ const normalized = normalizeBindingEntry(binding);
1461
+ const current = getCurrentBinding();
1462
+
1463
+ if (bindingEntriesEqual(current, normalized)) {
1464
+ return;
1465
+ }
1466
+
1467
+ setBinding(normalized);
1468
+ pi.appendEntry(BINDING_CUSTOM_TYPE, normalized);
1469
+ }
1470
+
1471
+ function reconstructBinding(ctx: ExtensionContext): void {
1472
+ // Prefer persisted binding (appendEntry) from the *current branch*, then fall back to prior rp_bind tool results
1473
+ // Branch semantics: if the current branch has no binding state, stay unbound
1474
+ setBinding(null);
1475
+
1476
+ let reconstructed: RpCliBindingEntryData | null = null;
1477
+
1478
+ for (const entry of ctx.sessionManager.getBranch()) {
1479
+ if (entry.type !== "custom" || entry.customType !== BINDING_CUSTOM_TYPE) {
1480
+ continue;
1406
1481
  }
1407
1482
 
1408
- const trimmed = args.trim();
1409
- if (!trimmed) {
1410
- ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
1411
- return;
1483
+ const parsed = parseBindingEntryData(entry.data);
1484
+ if (parsed) {
1485
+ reconstructed = parsed;
1412
1486
  }
1487
+ }
1413
1488
 
1414
- const parts = trimmed.split(/\s+/);
1415
- const pathInput = parts[0];
1416
- const rangeInput = parts[1];
1489
+ if (reconstructed) {
1490
+ setBinding(reconstructed);
1491
+ return;
1492
+ }
1417
1493
 
1418
- if (!pathInput) {
1419
- ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
1420
- return;
1494
+ for (const entry of ctx.sessionManager.getBranch()) {
1495
+ if (entry.type !== "message") {
1496
+ continue;
1421
1497
  }
1422
1498
 
1423
- const windowId = boundWindowId;
1424
- const tab = boundTab;
1499
+ const msg = entry.message;
1500
+ if (msg.role !== "toolResult" || msg.toolName !== "rp_bind") {
1501
+ continue;
1502
+ }
1425
1503
 
1426
- if (windowId === undefined) {
1427
- ctx.ui.notify("rp_exec is not bound. Bind first via /rpbind or rp_bind", "error");
1428
- return;
1504
+ const parsed = parseBindingEntryData(msg.details);
1505
+ if (parsed) {
1506
+ persistBinding(parsed);
1429
1507
  }
1508
+ }
1509
+ }
1430
1510
 
1431
- let scopeKey: ScopeKey = SCOPE_FULL;
1432
- if (rangeInput) {
1433
- const match = rangeInput.match(/^(\d+)-(\d+)$/);
1434
- if (!match) {
1435
- ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
1436
- return;
1437
- }
1511
+ function parseWindowsRawJson(raw: string): RpCliWindow[] {
1512
+ const trimmed = raw.trim();
1513
+ if (!trimmed) {
1514
+ return [];
1515
+ }
1438
1516
 
1439
- const start = parseInt(match[1] ?? "", 10);
1440
- const end = parseInt(match[2] ?? "", 10);
1441
- if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end < start) {
1442
- ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
1443
- return;
1444
- }
1517
+ let parsed: unknown;
1518
+ try {
1519
+ parsed = JSON.parse(trimmed);
1520
+ } catch {
1521
+ return [];
1522
+ }
1445
1523
 
1446
- scopeKey = scopeRange(start, end);
1524
+ const pickRows = (value: unknown): unknown[] => {
1525
+ if (Array.isArray(value)) {
1526
+ return value;
1447
1527
  }
1448
1528
 
1449
- const resolved = await resolveReadFilePath(pi, pathInput, ctx.cwd, windowId, tab);
1450
- if (!resolved.absolutePath) {
1451
- ctx.ui.notify(`Could not resolve path: ${pathInput}`, "error");
1452
- return;
1529
+ if (!value || typeof value !== "object") {
1530
+ return [];
1453
1531
  }
1454
1532
 
1455
- pi.appendEntry(RP_READCACHE_CUSTOM_TYPE, buildInvalidationV1(resolved.absolutePath, scopeKey));
1456
- clearReadcacheCaches();
1533
+ const obj = value as Record<string, unknown>;
1457
1534
 
1458
- ctx.ui.notify(
1459
- `Invalidated readcache for ${resolved.absolutePath}` + (scopeKey === SCOPE_FULL ? "" : ` (${scopeKey})`),
1460
- "info"
1461
- );
1462
- },
1463
- });
1535
+ const directArrayKeys = ["windows", "items", "data", "result"];
1536
+ for (const key of directArrayKeys) {
1537
+ const candidate = obj[key];
1538
+ if (Array.isArray(candidate)) {
1539
+ return candidate;
1540
+ }
1541
+ }
1464
1542
 
1465
- pi.registerTool({
1466
- name: "rp_bind",
1467
- label: "RepoPrompt Bind",
1468
- description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
1469
- parameters: BindParams,
1543
+ const nested = obj.data;
1544
+ if (nested && typeof nested === "object") {
1545
+ const nestedObj = nested as Record<string, unknown>;
1546
+ if (Array.isArray(nestedObj.windows)) {
1547
+ return nestedObj.windows;
1548
+ }
1549
+ }
1470
1550
 
1471
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1472
- await ensureJustBashLoaded();
1473
- maybeWarnAstUnavailable(ctx);
1474
- persistBinding(params.windowId, params.tab);
1551
+ return [];
1552
+ };
1475
1553
 
1476
- return {
1477
- content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
1478
- details: { windowId: boundWindowId, tab: boundTab },
1479
- };
1480
- },
1481
- });
1554
+ const rows = pickRows(parsed);
1482
1555
 
1483
- pi.registerTool({
1484
- name: "rp_exec",
1485
- label: "RepoPrompt Exec",
1486
- description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
1487
- parameters: ExecParams,
1556
+ return rows
1557
+ .map((row) => {
1558
+ if (!row || typeof row !== "object") {
1559
+ return null;
1560
+ }
1488
1561
 
1489
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1490
- // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
1491
- await ensureJustBashLoaded();
1492
- maybeWarnAstUnavailable(ctx);
1562
+ const obj = row as Record<string, unknown>;
1563
+ const windowId = typeof obj.windowID === "number"
1564
+ ? obj.windowID
1565
+ : (typeof obj.windowId === "number" ? obj.windowId : undefined);
1493
1566
 
1494
- const windowId = params.windowId ?? boundWindowId;
1495
- const tab = params.tab ?? boundTab;
1496
- const rawJson = params.rawJson ?? false;
1497
- const quiet = params.quiet ?? true;
1498
- const failFast = params.failFast ?? true;
1499
- const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1500
- const maxOutputChars = params.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
1501
- const allowDelete = params.allowDelete ?? false;
1502
- const allowWorkspaceSwitchInPlace = params.allowWorkspaceSwitchInPlace ?? false;
1503
- const failOnNoopEdits = params.failOnNoopEdits ?? true;
1567
+ if (windowId === undefined) {
1568
+ return null;
1569
+ }
1570
+
1571
+ const workspaceId = typeof obj.workspaceID === "string"
1572
+ ? obj.workspaceID
1573
+ : (typeof obj.workspaceId === "string" ? obj.workspaceId : undefined);
1574
+
1575
+ const workspaceName = typeof obj.workspaceName === "string"
1576
+ ? obj.workspaceName
1577
+ : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1578
+
1579
+ const rootFolderPaths = Array.isArray(obj.rootFolderPaths)
1580
+ ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1581
+ : (Array.isArray(obj.roots)
1582
+ ? obj.roots.filter((root): root is string => typeof root === "string")
1583
+ : undefined);
1504
1584
 
1505
- if (!allowDelete && looksLikeDeleteCommand(params.cmd)) {
1506
1585
  return {
1507
- isError: true,
1508
- content: [
1509
- {
1510
- type: "text",
1511
- text: "Blocked potential delete command. If deletion is explicitly requested, rerun with allowDelete=true",
1512
- },
1513
- ],
1514
- details: { blocked: true, reason: "delete", cmd: params.cmd, windowId, tab },
1515
- };
1586
+ windowId,
1587
+ workspaceId,
1588
+ workspaceName,
1589
+ rootFolderPaths,
1590
+ } as RpCliWindow;
1591
+ })
1592
+ .filter((window): window is RpCliWindow => window !== null);
1593
+ }
1594
+
1595
+ async function fetchWindowsFromCli(forceRefresh = false): Promise<RpCliWindow[]> {
1596
+ const now = Date.now();
1597
+
1598
+ if (!forceRefresh && windowsCache && (now - windowsCache.fetchedAtMs) < WINDOWS_CACHE_TTL_MS) {
1599
+ return windowsCache.windows;
1600
+ }
1601
+
1602
+ try {
1603
+ const result = await pi.exec("rp-cli", ["--raw-json", "-q", "-e", "windows"], { timeout: 10_000 });
1604
+
1605
+ if ((result.code ?? 0) !== 0) {
1606
+ return windowsCache?.windows ?? [];
1516
1607
  }
1517
1608
 
1518
- if (!allowWorkspaceSwitchInPlace && looksLikeWorkspaceSwitchInPlace(params.cmd)) {
1519
- return {
1520
- isError: true,
1521
- content: [
1522
- {
1523
- type: "text",
1524
- text:
1609
+ const stdout = result.stdout ?? "";
1610
+ const stderr = result.stderr ?? "";
1611
+
1612
+ const fromStdout = parseWindowsRawJson(stdout);
1613
+ const parsed = fromStdout.length > 0 ? fromStdout : parseWindowsRawJson(stderr);
1614
+
1615
+ windowsCache = {
1616
+ windows: parsed,
1617
+ fetchedAtMs: now,
1618
+ };
1619
+
1620
+ return parsed;
1621
+ } catch {
1622
+ return windowsCache?.windows ?? [];
1623
+ }
1624
+ }
1625
+
1626
+ function windowToBinding(window: RpCliWindow, tab: string): RpCliBindingEntryData {
1627
+ return normalizeBindingEntry({
1628
+ windowId: window.windowId,
1629
+ tab,
1630
+ workspaceId: window.workspaceId,
1631
+ workspaceName: window.workspaceName,
1632
+ workspaceRoots: window.rootFolderPaths,
1633
+ });
1634
+ }
1635
+
1636
+ async function enrichBinding(windowId: number, tab: string): Promise<RpCliBindingEntryData> {
1637
+ const windows = await fetchWindowsFromCli();
1638
+ const match = windows.find((window) => window.windowId === windowId);
1639
+
1640
+ if (!match) {
1641
+ return normalizeBindingEntry({ windowId, tab });
1642
+ }
1643
+
1644
+ return windowToBinding(match, tab);
1645
+ }
1646
+
1647
+ async function ensureBindingTargetsLiveWindow(
1648
+ ctx: ExtensionContext,
1649
+ forceRefresh = false
1650
+ ): Promise<RpCliBindingEntryData | null> {
1651
+ const binding = getCurrentBinding();
1652
+ if (!binding) {
1653
+ return null;
1654
+ }
1655
+
1656
+ const windows = await fetchWindowsFromCli(forceRefresh);
1657
+ if (windows.length === 0) {
1658
+ return binding;
1659
+ }
1660
+
1661
+ const sameWindow = windows.find((window) => window.windowId === binding.windowId);
1662
+ if (sameWindow) {
1663
+ const hydrated = windowToBinding(sameWindow, binding.tab);
1664
+
1665
+ if (binding.workspaceId && hydrated.workspaceId && binding.workspaceId !== hydrated.workspaceId) {
1666
+ // Window IDs were recycled to a different workspace. Fall through to workspace-based remap
1667
+ } else {
1668
+ if (!bindingEntriesEqual(binding, hydrated)) {
1669
+ persistBinding(hydrated);
1670
+ return hydrated;
1671
+ }
1672
+
1673
+ setBinding(hydrated);
1674
+ return hydrated;
1675
+ }
1676
+ }
1677
+
1678
+ const workspaceCandidates = windows.filter((window) => workspaceIdentityMatches(
1679
+ {
1680
+ workspaceId: binding.workspaceId,
1681
+ workspaceName: binding.workspaceName,
1682
+ workspaceRoots: binding.workspaceRoots,
1683
+ },
1684
+ {
1685
+ workspaceId: window.workspaceId,
1686
+ workspaceName: window.workspaceName,
1687
+ workspaceRoots: window.rootFolderPaths,
1688
+ }
1689
+ ));
1690
+
1691
+ if (workspaceCandidates.length === 1) {
1692
+ const rebound = windowToBinding(workspaceCandidates[0], binding.tab);
1693
+ persistBinding(rebound);
1694
+ return rebound;
1695
+ }
1696
+
1697
+ setBinding(null);
1698
+
1699
+ if (ctx.hasUI) {
1700
+ const workspaceLabel = binding.workspaceName ?? binding.workspaceId ?? `window ${binding.windowId}`;
1701
+
1702
+ if (workspaceCandidates.length > 1) {
1703
+ ctx.ui.notify(
1704
+ `repoprompt-cli: binding for ${workspaceLabel} is ambiguous after restart. Re-bind with /rpbind`,
1705
+ "warning"
1706
+ );
1707
+ } else {
1708
+ ctx.ui.notify(
1709
+ `repoprompt-cli: ${workspaceLabel} not found after restart. Re-bind with /rpbind`,
1710
+ "warning"
1711
+ );
1712
+ }
1713
+ }
1714
+
1715
+ return null;
1716
+ }
1717
+
1718
+ async function maybeEnsureBindingTargetsLiveWindow(ctx: ExtensionContext): Promise<RpCliBindingEntryData | null> {
1719
+ const binding = getCurrentBinding();
1720
+ if (!binding) {
1721
+ return null;
1722
+ }
1723
+
1724
+ if (!shouldRevalidateBinding()) {
1725
+ return binding;
1726
+ }
1727
+
1728
+ const validated = await ensureBindingTargetsLiveWindow(ctx, true);
1729
+
1730
+ if (validated) {
1731
+ markBindingValidatedNow();
1732
+ }
1733
+
1734
+ return validated;
1735
+ }
1736
+
1737
+ function sameBindingForAutoSelection(
1738
+ binding: RpCliBindingEntryData | null,
1739
+ state: AutoSelectionEntryData | null
1740
+ ): boolean {
1741
+ if (!binding || !state) {
1742
+ return false;
1743
+ }
1744
+
1745
+ if (!sameOptionalText(binding.tab, state.tab)) {
1746
+ return false;
1747
+ }
1748
+
1749
+ if (binding.windowId === state.windowId) {
1750
+ return true;
1751
+ }
1752
+
1753
+ return workspaceIdentityMatches(
1754
+ {
1755
+ workspaceId: binding.workspaceId,
1756
+ workspaceName: binding.workspaceName,
1757
+ workspaceRoots: binding.workspaceRoots,
1758
+ },
1759
+ {
1760
+ workspaceId: state.workspaceId,
1761
+ workspaceName: state.workspaceName,
1762
+ workspaceRoots: state.workspaceRoots,
1763
+ }
1764
+ );
1765
+ }
1766
+
1767
+ function makeEmptyAutoSelectionState(binding: RpCliBindingEntryData): AutoSelectionEntryData {
1768
+ return {
1769
+ windowId: binding.windowId,
1770
+ tab: binding.tab,
1771
+ workspaceId: binding.workspaceId,
1772
+ workspaceName: binding.workspaceName,
1773
+ workspaceRoots: normalizeWorkspaceRoots(binding.workspaceRoots),
1774
+ fullPaths: [],
1775
+ slicePaths: [],
1776
+ };
1777
+ }
1778
+
1779
+ function normalizeAutoSelectionRanges(ranges: AutoSelectionEntryRangeData[]): AutoSelectionEntryRangeData[] {
1780
+ const normalized = ranges
1781
+ .map((range) => ({
1782
+ start_line: Number(range.start_line),
1783
+ end_line: Number(range.end_line),
1784
+ }))
1785
+ .filter((range) => Number.isFinite(range.start_line) && Number.isFinite(range.end_line))
1786
+ .filter((range) => range.start_line > 0 && range.end_line >= range.start_line)
1787
+ .sort((a, b) => {
1788
+ if (a.start_line !== b.start_line) {
1789
+ return a.start_line - b.start_line;
1790
+ }
1791
+
1792
+ return a.end_line - b.end_line;
1793
+ });
1794
+
1795
+ const merged: AutoSelectionEntryRangeData[] = [];
1796
+ for (const range of normalized) {
1797
+ const last = merged[merged.length - 1];
1798
+ if (!last) {
1799
+ merged.push(range);
1800
+ continue;
1801
+ }
1802
+
1803
+ if (range.start_line <= last.end_line + 1) {
1804
+ last.end_line = Math.max(last.end_line, range.end_line);
1805
+ continue;
1806
+ }
1807
+
1808
+ merged.push(range);
1809
+ }
1810
+
1811
+ return merged;
1812
+ }
1813
+
1814
+ function normalizeAutoSelectionState(state: AutoSelectionEntryData): AutoSelectionEntryData {
1815
+ const fullPaths = [...new Set(state.fullPaths.map((p) => toPosixPath(String(p).trim())).filter(Boolean))].sort();
1816
+
1817
+ const fullSet = new Set(fullPaths);
1818
+
1819
+ const sliceMap = new Map<string, AutoSelectionEntryRangeData[]>();
1820
+ for (const item of state.slicePaths) {
1821
+ const pathKey = toPosixPath(String(item.path ?? "").trim());
1822
+ if (!pathKey || fullSet.has(pathKey)) {
1823
+ continue;
1824
+ }
1825
+
1826
+ const existing = sliceMap.get(pathKey) ?? [];
1827
+ existing.push(...normalizeAutoSelectionRanges(item.ranges ?? []));
1828
+ sliceMap.set(pathKey, existing);
1829
+ }
1830
+
1831
+ const slicePaths: AutoSelectionEntrySliceData[] = [...sliceMap.entries()]
1832
+ .map(([pathKey, ranges]) => ({
1833
+ path: pathKey,
1834
+ ranges: normalizeAutoSelectionRanges(ranges),
1835
+ }))
1836
+ .filter((item) => item.ranges.length > 0)
1837
+ .sort((a, b) => a.path.localeCompare(b.path));
1838
+
1839
+ return {
1840
+ windowId: state.windowId,
1841
+ tab: state.tab,
1842
+ workspaceId: typeof state.workspaceId === "string" ? state.workspaceId : undefined,
1843
+ workspaceName: typeof state.workspaceName === "string" ? state.workspaceName : undefined,
1844
+ workspaceRoots: normalizeWorkspaceRoots(state.workspaceRoots),
1845
+ fullPaths,
1846
+ slicePaths,
1847
+ };
1848
+ }
1849
+
1850
+ function autoSelectionStatesEqual(a: AutoSelectionEntryData | null, b: AutoSelectionEntryData | null): boolean {
1851
+ if (!a && !b) {
1852
+ return true;
1853
+ }
1854
+
1855
+ if (!a || !b) {
1856
+ return false;
1857
+ }
1858
+
1859
+ const left = normalizeAutoSelectionState(a);
1860
+ const right = normalizeAutoSelectionState(b);
1861
+
1862
+ return JSON.stringify(left) === JSON.stringify(right);
1863
+ }
1864
+
1865
+ function parseAutoSelectionEntryData(
1866
+ value: unknown,
1867
+ binding: RpCliBindingEntryData
1868
+ ): AutoSelectionEntryData | null {
1869
+ if (!value || typeof value !== "object") {
1870
+ return null;
1871
+ }
1872
+
1873
+ const obj = value as Record<string, unknown>;
1874
+
1875
+ const windowId = typeof obj.windowId === "number" ? obj.windowId : undefined;
1876
+ const tab = typeof obj.tab === "string" ? obj.tab : undefined;
1877
+
1878
+ const workspaceId = typeof obj.workspaceId === "string"
1879
+ ? obj.workspaceId
1880
+ : (typeof obj.workspaceID === "string" ? obj.workspaceID : undefined);
1881
+
1882
+ const workspaceName = typeof obj.workspaceName === "string"
1883
+ ? obj.workspaceName
1884
+ : (typeof obj.workspace === "string" ? obj.workspace : undefined);
1885
+
1886
+ const workspaceRoots = Array.isArray(obj.workspaceRoots)
1887
+ ? obj.workspaceRoots.filter((root): root is string => typeof root === "string")
1888
+ : (Array.isArray(obj.rootFolderPaths)
1889
+ ? obj.rootFolderPaths.filter((root): root is string => typeof root === "string")
1890
+ : undefined);
1891
+
1892
+ const tabMatches = sameOptionalText(tab, binding.tab);
1893
+ const windowMatches = windowId === binding.windowId;
1894
+ const workspaceMatches = workspaceIdentityMatches(
1895
+ {
1896
+ workspaceId: binding.workspaceId,
1897
+ workspaceName: binding.workspaceName,
1898
+ workspaceRoots: binding.workspaceRoots,
1899
+ },
1900
+ {
1901
+ workspaceId,
1902
+ workspaceName,
1903
+ workspaceRoots,
1904
+ }
1905
+ );
1906
+
1907
+ if (!tabMatches || (!windowMatches && !workspaceMatches)) {
1908
+ return null;
1909
+ }
1910
+
1911
+ const fullPaths = Array.isArray(obj.fullPaths)
1912
+ ? obj.fullPaths.filter((item): item is string => typeof item === "string")
1913
+ : [];
1914
+
1915
+ const slicePathsRaw = Array.isArray(obj.slicePaths) ? obj.slicePaths : [];
1916
+ const slicePaths: AutoSelectionEntrySliceData[] = slicePathsRaw
1917
+ .map((raw) => {
1918
+ if (!raw || typeof raw !== "object") {
1919
+ return null;
1920
+ }
1921
+
1922
+ const row = raw as Record<string, unknown>;
1923
+ const pathValue = typeof row.path === "string" ? row.path : null;
1924
+ const rangesRaw = Array.isArray(row.ranges) ? row.ranges : [];
1925
+
1926
+ if (!pathValue) {
1927
+ return null;
1928
+ }
1929
+
1930
+ const ranges: AutoSelectionEntryRangeData[] = rangesRaw
1931
+ .map((rangeRaw) => {
1932
+ if (!rangeRaw || typeof rangeRaw !== "object") {
1933
+ return null;
1934
+ }
1935
+
1936
+ const rangeObj = rangeRaw as Record<string, unknown>;
1937
+ const start = typeof rangeObj.start_line === "number" ? rangeObj.start_line : NaN;
1938
+ const end = typeof rangeObj.end_line === "number" ? rangeObj.end_line : NaN;
1939
+
1940
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
1941
+ return null;
1942
+ }
1943
+
1944
+ return {
1945
+ start_line: start,
1946
+ end_line: end,
1947
+ };
1948
+ })
1949
+ .filter((range): range is AutoSelectionEntryRangeData => range !== null);
1950
+
1951
+ return {
1952
+ path: pathValue,
1953
+ ranges,
1954
+ };
1955
+ })
1956
+ .filter((item): item is AutoSelectionEntrySliceData => item !== null);
1957
+
1958
+ return normalizeAutoSelectionState({
1959
+ windowId: binding.windowId,
1960
+ tab: binding.tab,
1961
+ workspaceId: binding.workspaceId ?? workspaceId,
1962
+ workspaceName: binding.workspaceName ?? workspaceName,
1963
+ workspaceRoots: binding.workspaceRoots ?? workspaceRoots,
1964
+ fullPaths,
1965
+ slicePaths,
1966
+ });
1967
+ }
1968
+
1969
+ function getAutoSelectionStateFromBranch(
1970
+ ctx: ExtensionContext,
1971
+ binding: RpCliBindingEntryData
1972
+ ): AutoSelectionEntryData {
1973
+ const entries = ctx.sessionManager.getBranch();
1974
+
1975
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1976
+ const entry = entries[i];
1977
+ if (entry.type !== "custom" || entry.customType !== AUTO_SELECTION_CUSTOM_TYPE) {
1978
+ continue;
1979
+ }
1980
+
1981
+ const parsed = parseAutoSelectionEntryData(entry.data, binding);
1982
+ if (parsed) {
1983
+ return parsed;
1984
+ }
1985
+ }
1986
+
1987
+ return makeEmptyAutoSelectionState(binding);
1988
+ }
1989
+
1990
+ function persistAutoSelectionState(state: AutoSelectionEntryData): void {
1991
+ const normalized = normalizeAutoSelectionState(state);
1992
+ activeAutoSelectionState = normalized;
1993
+ pi.appendEntry(AUTO_SELECTION_CUSTOM_TYPE, normalized);
1994
+ }
1995
+
1996
+ function autoSelectionManagedPaths(state: AutoSelectionEntryData): string[] {
1997
+ const fromSlices = state.slicePaths.map((item) => item.path);
1998
+ return [...new Set([...state.fullPaths, ...fromSlices])];
1999
+ }
2000
+
2001
+ function autoSelectionSliceKey(item: AutoSelectionEntrySliceData): string {
2002
+ return JSON.stringify(normalizeAutoSelectionRanges(item.ranges));
2003
+ }
2004
+
2005
+ function bindingForAutoSelectionState(state: AutoSelectionEntryData): RpCliBindingEntryData {
2006
+ return {
2007
+ windowId: state.windowId,
2008
+ tab: state.tab ?? "Compose",
2009
+ workspaceId: state.workspaceId,
2010
+ workspaceName: state.workspaceName,
2011
+ workspaceRoots: state.workspaceRoots,
2012
+ };
2013
+ }
2014
+
2015
+ async function runRpCliForBinding(
2016
+ binding: RpCliBindingEntryData,
2017
+ cmd: string,
2018
+ rawJson = false,
2019
+ timeout = 10_000
2020
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; output: string }> {
2021
+ const args: string[] = ["-w", String(binding.windowId)];
2022
+
2023
+ if (binding.tab) {
2024
+ args.push("-t", binding.tab);
2025
+ }
2026
+
2027
+ args.push("-q", "--fail-fast");
2028
+
2029
+ if (rawJson) {
2030
+ args.push("--raw-json");
2031
+ }
2032
+
2033
+ args.push("-e", cmd);
2034
+
2035
+ const result = await pi.exec("rp-cli", args, { timeout });
2036
+
2037
+ const stdout = result.stdout ?? "";
2038
+ const stderr = result.stderr ?? "";
2039
+ const exitCode = result.code ?? 0;
2040
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
2041
+
2042
+ if (exitCode !== 0) {
2043
+ throw new Error(output || `rp-cli exited with status ${exitCode}`);
2044
+ }
2045
+
2046
+ return { stdout, stderr, exitCode, output };
2047
+ }
2048
+
2049
+ async function callManageSelection(binding: RpCliBindingEntryData, args: Record<string, unknown>): Promise<string> {
2050
+ const cmd = `call manage_selection ${JSON.stringify(args)}`;
2051
+ const result = await runRpCliForBinding(binding, cmd, false);
2052
+ return result.output;
2053
+ }
2054
+
2055
+ async function getSelectionFilesText(binding: RpCliBindingEntryData): Promise<string | null> {
2056
+ try {
2057
+ const text = await callManageSelection(binding, {
2058
+ op: "get",
2059
+ view: "files",
2060
+ });
2061
+
2062
+ return text.length > 0 ? text : null;
2063
+ } catch {
2064
+ return null;
2065
+ }
2066
+ }
2067
+
2068
+ function buildSelectionPathFromResolved(
2069
+ inputPath: string,
2070
+ resolved: { absolutePath: string | null; repoRoot: string | null }
2071
+ ): string {
2072
+ if (!resolved.absolutePath || !resolved.repoRoot) {
2073
+ return inputPath;
2074
+ }
2075
+
2076
+ const rel = path.relative(resolved.repoRoot, resolved.absolutePath);
2077
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
2078
+ return inputPath;
2079
+ }
2080
+
2081
+ const rootHint = path.basename(resolved.repoRoot);
2082
+ const relPosix = rel.split(path.sep).join("/");
2083
+
2084
+ return `${rootHint}/${relPosix}`;
2085
+ }
2086
+
2087
+ function updateAutoSelectionStateAfterFullRead(binding: RpCliBindingEntryData, selectionPath: string): void {
2088
+ const normalizedPath = toPosixPath(selectionPath);
2089
+
2090
+ const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2091
+ ? (activeAutoSelectionState as AutoSelectionEntryData)
2092
+ : makeEmptyAutoSelectionState(binding);
2093
+
2094
+ const nextState: AutoSelectionEntryData = {
2095
+ ...baseState,
2096
+ fullPaths: [...baseState.fullPaths, normalizedPath],
2097
+ slicePaths: baseState.slicePaths.filter((entry) => entry.path !== normalizedPath),
2098
+ };
2099
+
2100
+ const normalizedNext = normalizeAutoSelectionState(nextState);
2101
+ if (autoSelectionStatesEqual(baseState, normalizedNext)) {
2102
+ activeAutoSelectionState = normalizedNext;
2103
+ return;
2104
+ }
2105
+
2106
+ persistAutoSelectionState(normalizedNext);
2107
+ }
2108
+
2109
+ function existingSliceRangesForRead(
2110
+ binding: RpCliBindingEntryData,
2111
+ selectionPath: string
2112
+ ): AutoSelectionEntryRangeData[] | null {
2113
+ const normalizedPath = toPosixPath(selectionPath);
2114
+
2115
+ const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2116
+ ? (activeAutoSelectionState as AutoSelectionEntryData)
2117
+ : makeEmptyAutoSelectionState(binding);
2118
+
2119
+ if (baseState.fullPaths.includes(normalizedPath)) {
2120
+ return null;
2121
+ }
2122
+
2123
+ const existing = baseState.slicePaths.find((entry) => entry.path === normalizedPath);
2124
+ return normalizeAutoSelectionRanges(existing?.ranges ?? []);
2125
+ }
2126
+
2127
+ function subtractCoveredRanges(
2128
+ incomingRange: AutoSelectionEntryRangeData,
2129
+ existingRanges: AutoSelectionEntryRangeData[]
2130
+ ): AutoSelectionEntryRangeData[] {
2131
+ let pending: AutoSelectionEntryRangeData[] = [incomingRange];
2132
+
2133
+ for (const existing of normalizeAutoSelectionRanges(existingRanges)) {
2134
+ const nextPending: AutoSelectionEntryRangeData[] = [];
2135
+
2136
+ for (const candidate of pending) {
2137
+ const overlapStart = Math.max(candidate.start_line, existing.start_line);
2138
+ const overlapEnd = Math.min(candidate.end_line, existing.end_line);
2139
+
2140
+ if (overlapStart > overlapEnd) {
2141
+ nextPending.push(candidate);
2142
+ continue;
2143
+ }
2144
+
2145
+ if (candidate.start_line < overlapStart) {
2146
+ nextPending.push({
2147
+ start_line: candidate.start_line,
2148
+ end_line: overlapStart - 1,
2149
+ });
2150
+ }
2151
+
2152
+ if (candidate.end_line > overlapEnd) {
2153
+ nextPending.push({
2154
+ start_line: overlapEnd + 1,
2155
+ end_line: candidate.end_line,
2156
+ });
2157
+ }
2158
+ }
2159
+
2160
+ pending = nextPending;
2161
+ if (pending.length === 0) {
2162
+ return [];
2163
+ }
2164
+ }
2165
+
2166
+ return normalizeAutoSelectionRanges(pending);
2167
+ }
2168
+
2169
+ function updateAutoSelectionStateAfterSliceRead(
2170
+ binding: RpCliBindingEntryData,
2171
+ selectionPath: string,
2172
+ range: AutoSelectionEntryRangeData
2173
+ ): void {
2174
+ const normalizedPath = toPosixPath(selectionPath);
2175
+
2176
+ const baseState = sameBindingForAutoSelection(binding, activeAutoSelectionState)
2177
+ ? (activeAutoSelectionState as AutoSelectionEntryData)
2178
+ : makeEmptyAutoSelectionState(binding);
2179
+
2180
+ if (baseState.fullPaths.includes(normalizedPath)) {
2181
+ return;
2182
+ }
2183
+
2184
+ const existing = baseState.slicePaths.find((entry) => entry.path === normalizedPath);
2185
+
2186
+ const nextSlicePaths = baseState.slicePaths.filter((entry) => entry.path !== normalizedPath);
2187
+ nextSlicePaths.push({
2188
+ path: normalizedPath,
2189
+ ranges: [...(existing?.ranges ?? []), range],
2190
+ });
2191
+
2192
+ const nextState: AutoSelectionEntryData = {
2193
+ ...baseState,
2194
+ fullPaths: [...baseState.fullPaths],
2195
+ slicePaths: nextSlicePaths,
2196
+ };
2197
+
2198
+ const normalizedNext = normalizeAutoSelectionState(nextState);
2199
+ if (autoSelectionStatesEqual(baseState, normalizedNext)) {
2200
+ activeAutoSelectionState = normalizedNext;
2201
+ return;
2202
+ }
2203
+
2204
+ persistAutoSelectionState(normalizedNext);
2205
+ }
2206
+
2207
+ async function removeAutoSelectionPaths(state: AutoSelectionEntryData, paths: string[]): Promise<void> {
2208
+ if (paths.length === 0) {
2209
+ return;
2210
+ }
2211
+
2212
+ const binding = bindingForAutoSelectionState(state);
2213
+ await callManageSelection(binding, { op: "remove", paths });
2214
+ }
2215
+
2216
+ async function addAutoSelectionFullPaths(state: AutoSelectionEntryData, paths: string[]): Promise<void> {
2217
+ if (paths.length === 0) {
2218
+ return;
2219
+ }
2220
+
2221
+ const binding = bindingForAutoSelectionState(state);
2222
+ await callManageSelection(binding, {
2223
+ op: "add",
2224
+ mode: "full",
2225
+ paths,
2226
+ });
2227
+ }
2228
+
2229
+ async function addAutoSelectionSlices(
2230
+ state: AutoSelectionEntryData,
2231
+ slices: AutoSelectionEntrySliceData[]
2232
+ ): Promise<void> {
2233
+ if (slices.length === 0) {
2234
+ return;
2235
+ }
2236
+
2237
+ const binding = bindingForAutoSelectionState(state);
2238
+ await callManageSelection(binding, {
2239
+ op: "add",
2240
+ slices,
2241
+ });
2242
+ }
2243
+
2244
+ async function reconcileAutoSelectionWithinBinding(
2245
+ currentState: AutoSelectionEntryData,
2246
+ desiredState: AutoSelectionEntryData
2247
+ ): Promise<void> {
2248
+ const currentModeByPath = new Map<string, "full" | "slices">();
2249
+ for (const p of currentState.fullPaths) {
2250
+ currentModeByPath.set(p, "full");
2251
+ }
2252
+
2253
+ for (const s of currentState.slicePaths) {
2254
+ if (!currentModeByPath.has(s.path)) {
2255
+ currentModeByPath.set(s.path, "slices");
2256
+ }
2257
+ }
2258
+
2259
+ const desiredModeByPath = new Map<string, "full" | "slices">();
2260
+ for (const p of desiredState.fullPaths) {
2261
+ desiredModeByPath.set(p, "full");
2262
+ }
2263
+
2264
+ for (const s of desiredState.slicePaths) {
2265
+ if (!desiredModeByPath.has(s.path)) {
2266
+ desiredModeByPath.set(s.path, "slices");
2267
+ }
2268
+ }
2269
+
2270
+ const desiredSliceByPath = new Map<string, AutoSelectionEntrySliceData>();
2271
+ for (const s of desiredState.slicePaths) {
2272
+ desiredSliceByPath.set(s.path, s);
2273
+ }
2274
+
2275
+ const currentSliceByPath = new Map<string, AutoSelectionEntrySliceData>();
2276
+ for (const s of currentState.slicePaths) {
2277
+ currentSliceByPath.set(s.path, s);
2278
+ }
2279
+
2280
+ const removePaths = new Set<string>();
2281
+ const addFullPaths: string[] = [];
2282
+ const addSlices: AutoSelectionEntrySliceData[] = [];
2283
+
2284
+ for (const [pathKey] of currentModeByPath) {
2285
+ if (!desiredModeByPath.has(pathKey)) {
2286
+ removePaths.add(pathKey);
2287
+ }
2288
+ }
2289
+
2290
+ for (const [pathKey, mode] of desiredModeByPath) {
2291
+ const currentMode = currentModeByPath.get(pathKey);
2292
+
2293
+ if (mode === "full") {
2294
+ if (currentMode === "full") {
2295
+ continue;
2296
+ }
2297
+
2298
+ if (currentMode === "slices") {
2299
+ removePaths.add(pathKey);
2300
+ }
2301
+
2302
+ addFullPaths.push(pathKey);
2303
+ continue;
2304
+ }
2305
+
2306
+ const desiredSlice = desiredSliceByPath.get(pathKey);
2307
+ if (!desiredSlice) {
2308
+ continue;
2309
+ }
2310
+
2311
+ if (currentMode === "full") {
2312
+ removePaths.add(pathKey);
2313
+ addSlices.push(desiredSlice);
2314
+ continue;
2315
+ }
2316
+
2317
+ if (currentMode === "slices") {
2318
+ const currentSlice = currentSliceByPath.get(pathKey);
2319
+ if (currentSlice && autoSelectionSliceKey(currentSlice) === autoSelectionSliceKey(desiredSlice)) {
2320
+ continue;
2321
+ }
2322
+
2323
+ removePaths.add(pathKey);
2324
+ addSlices.push(desiredSlice);
2325
+ continue;
2326
+ }
2327
+
2328
+ addSlices.push(desiredSlice);
2329
+ }
2330
+
2331
+ await removeAutoSelectionPaths(currentState, [...removePaths]);
2332
+ await addAutoSelectionFullPaths(desiredState, addFullPaths);
2333
+ await addAutoSelectionSlices(desiredState, addSlices);
2334
+ }
2335
+
2336
+ async function reconcileAutoSelectionStates(
2337
+ currentState: AutoSelectionEntryData | null,
2338
+ desiredState: AutoSelectionEntryData | null
2339
+ ): Promise<void> {
2340
+ if (autoSelectionStatesEqual(currentState, desiredState)) {
2341
+ return;
2342
+ }
2343
+
2344
+ if (currentState && desiredState) {
2345
+ const sameBinding =
2346
+ currentState.windowId === desiredState.windowId &&
2347
+ sameOptionalText(currentState.tab, desiredState.tab);
2348
+
2349
+ if (sameBinding) {
2350
+ await reconcileAutoSelectionWithinBinding(currentState, desiredState);
2351
+ return;
2352
+ }
2353
+
2354
+ try {
2355
+ await removeAutoSelectionPaths(currentState, autoSelectionManagedPaths(currentState));
2356
+ } catch {
2357
+ // Old binding/window may no longer exist after RepoPrompt app restart
2358
+ }
2359
+
2360
+ await addAutoSelectionFullPaths(desiredState, desiredState.fullPaths);
2361
+ await addAutoSelectionSlices(desiredState, desiredState.slicePaths);
2362
+ return;
2363
+ }
2364
+
2365
+ if (currentState && !desiredState) {
2366
+ try {
2367
+ await removeAutoSelectionPaths(currentState, autoSelectionManagedPaths(currentState));
2368
+ } catch {
2369
+ // Old binding/window may no longer exist after RepoPrompt app restart
2370
+ }
2371
+ return;
2372
+ }
2373
+
2374
+ if (!currentState && desiredState) {
2375
+ await addAutoSelectionFullPaths(desiredState, desiredState.fullPaths);
2376
+ await addAutoSelectionSlices(desiredState, desiredState.slicePaths);
2377
+ }
2378
+ }
2379
+
2380
+ async function syncAutoSelectionToCurrentBranch(ctx: ExtensionContext): Promise<void> {
2381
+ if (config.autoSelectReadSlices !== true) {
2382
+ activeAutoSelectionState = null;
2383
+ return;
2384
+ }
2385
+
2386
+ const binding = await ensureBindingTargetsLiveWindow(ctx);
2387
+ const desiredState = binding ? getAutoSelectionStateFromBranch(ctx, binding) : null;
2388
+
2389
+ try {
2390
+ await reconcileAutoSelectionStates(activeAutoSelectionState, desiredState);
2391
+ } catch {
2392
+ // Fail-open
2393
+ }
2394
+
2395
+ activeAutoSelectionState = desiredState;
2396
+ }
2397
+
2398
+ function parseReadOutputHints(readOutputText: string | undefined): {
2399
+ selectionPath: string | null;
2400
+ totalLines: number | null;
2401
+ } {
2402
+ if (!readOutputText) {
2403
+ return { selectionPath: null, totalLines: null };
2404
+ }
2405
+
2406
+ const pathMatch =
2407
+ /\*\*Path\*\*:\s*`([^`]+)`/i.exec(readOutputText) ??
2408
+ /\*\*Path\*\*:\s*([^\n]+)$/im.exec(readOutputText);
2409
+
2410
+ const selectionPath = pathMatch?.[1]?.trim() ?? null;
2411
+
2412
+ const linesRegexes = [
2413
+ /\*\*Lines\*\*:\s*(\d+)\s*[–—-]\s*(\d+)\s+of\s+(\d+)/i,
2414
+ /Lines(?:\s*:)?\s*(\d+)\s*[–—-]\s*(\d+)\s+of\s+(\d+)/i,
2415
+ ];
2416
+
2417
+ let totalLines: number | null = null;
2418
+
2419
+ for (const rx of linesRegexes) {
2420
+ const match = rx.exec(readOutputText);
2421
+ if (!match) {
2422
+ continue;
2423
+ }
2424
+
2425
+ const parsed = Number.parseInt(match[3] ?? "", 10);
2426
+ if (Number.isFinite(parsed)) {
2427
+ totalLines = parsed;
2428
+ break;
2429
+ }
2430
+ }
2431
+
2432
+ return { selectionPath, totalLines };
2433
+ }
2434
+
2435
+ async function autoSelectReadFileInRepoPromptSelection(
2436
+ ctx: ExtensionContext,
2437
+ inputPath: string,
2438
+ startLine: number | undefined,
2439
+ limit: number | undefined,
2440
+ readOutputText: string | undefined
2441
+ ): Promise<void> {
2442
+ if (config.autoSelectReadSlices !== true) {
2443
+ return;
2444
+ }
2445
+
2446
+ const outputHints = parseReadOutputHints(readOutputText);
2447
+
2448
+ const binding = await maybeEnsureBindingTargetsLiveWindow(ctx);
2449
+ if (!binding) {
2450
+ return;
2451
+ }
2452
+
2453
+ const selectionText = await getSelectionFilesText(binding);
2454
+ if (selectionText === null) {
2455
+ return;
2456
+ }
2457
+
2458
+ const resolved = await resolveReadFilePath(pi, inputPath, ctx.cwd, binding.windowId, binding.tab);
2459
+
2460
+ const resolvedSelectionPath = buildSelectionPathFromResolved(inputPath, resolved);
2461
+ const selectionPath =
2462
+ outputHints.selectionPath && outputHints.selectionPath.trim().length > 0
2463
+ ? outputHints.selectionPath
2464
+ : resolvedSelectionPath;
2465
+
2466
+ const candidatePaths = new Set<string>();
2467
+ candidatePaths.add(toPosixPath(selectionPath));
2468
+ candidatePaths.add(toPosixPath(resolvedSelectionPath));
2469
+ candidatePaths.add(toPosixPath(inputPath));
2470
+
2471
+ if (outputHints.selectionPath) {
2472
+ candidatePaths.add(toPosixPath(outputHints.selectionPath));
2473
+ }
2474
+
2475
+ if (resolved.absolutePath) {
2476
+ candidatePaths.add(toPosixPath(resolved.absolutePath));
2477
+ }
2478
+
2479
+ if (resolved.absolutePath && resolved.repoRoot) {
2480
+ const rel = path.relative(resolved.repoRoot, resolved.absolutePath);
2481
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
2482
+ candidatePaths.add(toPosixPath(rel.split(path.sep).join("/")));
2483
+ }
2484
+ }
2485
+
2486
+ let selectionStatus: ReturnType<typeof inferSelectionStatus> = null;
2487
+
2488
+ for (const candidate of candidatePaths) {
2489
+ const status = inferSelectionStatus(selectionText, candidate);
2490
+ if (!status) {
2491
+ continue;
2492
+ }
2493
+
2494
+ if (status.mode === "full") {
2495
+ selectionStatus = status;
2496
+ break;
2497
+ }
2498
+
2499
+ if (status.mode === "codemap_only" && status.codemapManual === true) {
2500
+ selectionStatus = status;
2501
+ break;
2502
+ }
2503
+
2504
+ if (selectionStatus === null) {
2505
+ selectionStatus = status;
2506
+ continue;
2507
+ }
2508
+
2509
+ if (selectionStatus.mode === "codemap_only" && status.mode === "slices") {
2510
+ selectionStatus = status;
2511
+ }
2512
+ }
2513
+
2514
+ if (selectionStatus?.mode === "full") {
2515
+ return;
2516
+ }
2517
+
2518
+ if (selectionStatus?.mode === "codemap_only" && selectionStatus.codemapManual === true) {
2519
+ return;
2520
+ }
2521
+
2522
+ let totalLines: number | undefined;
2523
+
2524
+ if (typeof startLine === "number" && startLine < 0) {
2525
+ if (resolved.absolutePath) {
2526
+ try {
2527
+ totalLines = await countFileLines(resolved.absolutePath);
2528
+ } catch {
2529
+ totalLines = undefined;
2530
+ }
2531
+ }
2532
+
2533
+ if (totalLines === undefined && typeof outputHints.totalLines === "number") {
2534
+ totalLines = outputHints.totalLines;
2535
+ }
2536
+ }
2537
+
2538
+ const sliceRange = computeSliceRangeFromReadArgs(startLine, limit, totalLines);
2539
+
2540
+
2541
+ if (sliceRange) {
2542
+ const existingRanges = existingSliceRangesForRead(binding, selectionPath);
2543
+ if (!existingRanges) {
2544
+ return;
2545
+ }
2546
+
2547
+ const uncoveredRanges = subtractCoveredRanges(sliceRange, existingRanges);
2548
+
2549
+ if (uncoveredRanges.length === 0) {
2550
+ updateAutoSelectionStateAfterSliceRead(binding, selectionPath, sliceRange);
2551
+ return;
2552
+ }
2553
+
2554
+ // Add only uncovered ranges to avoid touching unrelated selection state
2555
+ // (and to avoid global set semantics)
2556
+ const payload = {
2557
+ op: "add",
2558
+ slices: [
2559
+ {
2560
+ path: toPosixPath(selectionPath),
2561
+ ranges: uncoveredRanges,
2562
+ },
2563
+ ],
2564
+ };
2565
+
2566
+ try {
2567
+ await callManageSelection(binding, payload);
2568
+ } catch {
2569
+ // Fail-open
2570
+ return;
2571
+ }
2572
+
2573
+ updateAutoSelectionStateAfterSliceRead(binding, selectionPath, sliceRange);
2574
+ return;
2575
+ }
2576
+
2577
+ const payload = {
2578
+ op: "add",
2579
+ mode: "full",
2580
+ paths: [toPosixPath(selectionPath)],
2581
+ };
2582
+
2583
+ try {
2584
+ await callManageSelection(binding, payload);
2585
+ } catch {
2586
+ // Fail-open
2587
+ return;
2588
+ }
2589
+
2590
+ updateAutoSelectionStateAfterFullRead(binding, selectionPath);
2591
+ }
2592
+
2593
+
2594
+ pi.on("session_start", async (_event, ctx) => {
2595
+ config = loadConfig();
2596
+ clearReadcacheCaches();
2597
+ clearWindowsCache();
2598
+ markBindingValidationStale();
2599
+ reconstructBinding(ctx);
2600
+
2601
+ const binding = getCurrentBinding();
2602
+ activeAutoSelectionState =
2603
+ config.autoSelectReadSlices === true && binding
2604
+ ? getAutoSelectionStateFromBranch(ctx, binding)
2605
+ : null;
2606
+
2607
+ if (config.readcacheReadFile === true) {
2608
+ void pruneObjectsOlderThan(ctx.cwd).catch(() => {
2609
+ // Fail-open
2610
+ });
2611
+ }
2612
+
2613
+ try {
2614
+ await syncAutoSelectionToCurrentBranch(ctx);
2615
+ } catch {
2616
+ // Fail-open
2617
+ }
2618
+ });
2619
+
2620
+ pi.on("session_switch", async (_event, ctx) => {
2621
+ config = loadConfig();
2622
+ clearReadcacheCaches();
2623
+ clearWindowsCache();
2624
+ markBindingValidationStale();
2625
+ reconstructBinding(ctx);
2626
+ await syncAutoSelectionToCurrentBranch(ctx);
2627
+ });
2628
+
2629
+ // session_fork is the current event name; keep session_branch for backwards compatibility
2630
+ pi.on("session_fork", async (_event, ctx) => {
2631
+ config = loadConfig();
2632
+ clearReadcacheCaches();
2633
+ clearWindowsCache();
2634
+ markBindingValidationStale();
2635
+ reconstructBinding(ctx);
2636
+ await syncAutoSelectionToCurrentBranch(ctx);
2637
+ });
2638
+
2639
+ pi.on("session_branch", async (_event, ctx) => {
2640
+ config = loadConfig();
2641
+ clearReadcacheCaches();
2642
+ clearWindowsCache();
2643
+ markBindingValidationStale();
2644
+ reconstructBinding(ctx);
2645
+ await syncAutoSelectionToCurrentBranch(ctx);
2646
+ });
2647
+
2648
+ pi.on("session_tree", async (_event, ctx) => {
2649
+ config = loadConfig();
2650
+ clearReadcacheCaches();
2651
+ clearWindowsCache();
2652
+ markBindingValidationStale();
2653
+ reconstructBinding(ctx);
2654
+ await syncAutoSelectionToCurrentBranch(ctx);
2655
+ });
2656
+
2657
+ pi.on("session_compact", async () => {
2658
+ clearReadcacheCaches();
2659
+ });
2660
+
2661
+ pi.on("session_shutdown", async () => {
2662
+ clearReadcacheCaches();
2663
+ clearWindowsCache();
2664
+ activeAutoSelectionState = null;
2665
+ setBinding(null);
2666
+ });
2667
+
2668
+ pi.registerCommand("rpbind", {
2669
+ description: "Bind rp_exec to RepoPrompt: /rpbind <window_id> <tab>",
2670
+ handler: async (args, ctx) => {
2671
+ const parsed = parseRpbindArgs(args);
2672
+ if ("error" in parsed) {
2673
+ ctx.ui.notify(parsed.error, "error");
2674
+ return;
2675
+ }
2676
+
2677
+ const binding = await enrichBinding(parsed.windowId, parsed.tab);
2678
+ persistBinding(binding);
2679
+
2680
+ try {
2681
+ await syncAutoSelectionToCurrentBranch(ctx);
2682
+ } catch {
2683
+ // Fail-open
2684
+ }
2685
+
2686
+ ctx.ui.notify(
2687
+ `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` +
2688
+ (boundWorkspaceName ? ` (${boundWorkspaceName})` : ""),
2689
+ "success"
2690
+ );
2691
+ },
2692
+ });
2693
+
2694
+
2695
+ pi.registerCommand("rpcli-readcache-status", {
2696
+ description: "Show repoprompt-cli read_file cache status",
2697
+ handler: async (_args, ctx) => {
2698
+ config = loadConfig();
2699
+
2700
+ let msg = "repoprompt-cli read_file cache\n";
2701
+ msg += "──────────────────────────\n";
2702
+ msg += `Enabled: ${config.readcacheReadFile === true ? "✓" : "✗"}\n`;
2703
+ msg += `Auto-select reads: ${config.autoSelectReadSlices === true ? "✓" : "✗"}\n`;
2704
+
2705
+ if (config.readcacheReadFile !== true) {
2706
+ msg += "\nEnable by creating ~/.pi/agent/extensions/repoprompt-cli/config.json\n";
2707
+ msg += "\nwith:\n { \"readcacheReadFile\": true }\n";
2708
+ ctx.ui.notify(msg, "info");
2709
+ return;
2710
+ }
2711
+
2712
+ try {
2713
+ const stats = await getStoreStats(ctx.cwd);
2714
+ msg += `\nObject store (under ${ctx.cwd}/.pi/readcache):\n`;
2715
+ msg += ` Objects: ${stats.objects}\n`;
2716
+ msg += ` Bytes: ${stats.bytes}\n`;
2717
+ } catch {
2718
+ msg += "\nObject store: unavailable\n";
2719
+ }
2720
+
2721
+ msg += "\nNotes:\n";
2722
+ msg += "- Cache applies only to simple rp_exec reads (read/cat/read_file)\n";
2723
+ msg += "- Use bypass_cache=true in the read command to force baseline output\n";
2724
+
2725
+ ctx.ui.notify(msg, "info");
2726
+ },
2727
+ });
2728
+
2729
+ pi.registerCommand("rpcli-readcache-refresh", {
2730
+ description: "Invalidate repoprompt-cli read_file cache trust for a path and optional line range",
2731
+ handler: async (args, ctx) => {
2732
+ config = loadConfig();
2733
+
2734
+ if (config.readcacheReadFile !== true) {
2735
+ ctx.ui.notify("readcacheReadFile is disabled in config", "error");
2736
+ return;
2737
+ }
2738
+
2739
+ const trimmed = args.trim();
2740
+ if (!trimmed) {
2741
+ ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
2742
+ return;
2743
+ }
2744
+
2745
+ const parts = trimmed.split(/\s+/);
2746
+ const pathInput = parts[0];
2747
+ const rangeInput = parts[1];
2748
+
2749
+ if (!pathInput) {
2750
+ ctx.ui.notify("Usage: /rpcli-readcache-refresh <path> [start-end]", "error");
2751
+ return;
2752
+ }
2753
+
2754
+ const binding = await ensureBindingTargetsLiveWindow(ctx);
2755
+ if (!binding) {
2756
+ ctx.ui.notify("rp_exec is not bound. Bind first via /rpbind or rp_bind", "error");
2757
+ return;
2758
+ }
2759
+
2760
+ let scopeKey: ScopeKey = SCOPE_FULL;
2761
+ if (rangeInput) {
2762
+ const match = rangeInput.match(/^(\d+)-(\d+)$/);
2763
+ if (!match) {
2764
+ ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
2765
+ return;
2766
+ }
2767
+
2768
+ const start = parseInt(match[1] ?? "", 10);
2769
+ const end = parseInt(match[2] ?? "", 10);
2770
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end < start) {
2771
+ ctx.ui.notify("Invalid range. Use <start-end> like 1-120", "error");
2772
+ return;
2773
+ }
2774
+
2775
+ scopeKey = scopeRange(start, end);
2776
+ }
2777
+
2778
+ const resolved = await resolveReadFilePath(pi, pathInput, ctx.cwd, binding.windowId, binding.tab);
2779
+ if (!resolved.absolutePath) {
2780
+ ctx.ui.notify(`Could not resolve path: ${pathInput}`, "error");
2781
+ return;
2782
+ }
2783
+
2784
+ pi.appendEntry(RP_READCACHE_CUSTOM_TYPE, buildInvalidationV1(resolved.absolutePath, scopeKey));
2785
+ clearReadcacheCaches();
2786
+
2787
+ ctx.ui.notify(
2788
+ `Invalidated readcache for ${resolved.absolutePath}` + (scopeKey === SCOPE_FULL ? "" : ` (${scopeKey})`),
2789
+ "info"
2790
+ );
2791
+ },
2792
+ });
2793
+
2794
+ pi.registerTool({
2795
+ name: "rp_bind",
2796
+ label: "RepoPrompt Bind",
2797
+ description: "Bind rp_exec to a specific RepoPrompt window and compose tab",
2798
+ parameters: BindParams,
2799
+
2800
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2801
+ await ensureJustBashLoaded();
2802
+ maybeWarnAstUnavailable(ctx);
2803
+
2804
+ const binding = await enrichBinding(params.windowId, params.tab);
2805
+ persistBinding(binding);
2806
+
2807
+ try {
2808
+ await syncAutoSelectionToCurrentBranch(ctx);
2809
+ } catch {
2810
+ // Fail-open
2811
+ }
2812
+
2813
+ return {
2814
+ content: [{ type: "text", text: `Bound rp_exec → window ${boundWindowId}, tab "${boundTab}"` }],
2815
+ details: {
2816
+ windowId: boundWindowId,
2817
+ tab: boundTab,
2818
+ workspaceId: boundWorkspaceId,
2819
+ workspaceName: boundWorkspaceName,
2820
+ workspaceRoots: boundWorkspaceRoots,
2821
+ },
2822
+ };
2823
+ },
2824
+ });
2825
+
2826
+ pi.registerTool({
2827
+ name: "rp_exec",
2828
+ label: "RepoPrompt Exec",
2829
+ description: "Run rp-cli in the bound RepoPrompt window/tab, with quiet defaults and output truncation",
2830
+ parameters: ExecParams,
2831
+
2832
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
2833
+ // Routing: prefer call-time overrides, otherwise fall back to the last persisted binding
2834
+ await ensureJustBashLoaded();
2835
+ maybeWarnAstUnavailable(ctx);
2836
+
2837
+ if (params.windowId === undefined && params.tab === undefined) {
2838
+ try {
2839
+ await maybeEnsureBindingTargetsLiveWindow(ctx);
2840
+ } catch {
2841
+ // Fail-open
2842
+ }
2843
+ }
2844
+
2845
+ const windowId = params.windowId ?? boundWindowId;
2846
+ const tab = params.tab ?? boundTab;
2847
+ const rawJson = params.rawJson ?? false;
2848
+ const quiet = params.quiet ?? true;
2849
+ const failFast = params.failFast ?? true;
2850
+ const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
2851
+ const maxOutputChars = params.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
2852
+ const allowDelete = params.allowDelete ?? false;
2853
+ const allowWorkspaceSwitchInPlace = params.allowWorkspaceSwitchInPlace ?? false;
2854
+ const failOnNoopEdits = params.failOnNoopEdits ?? true;
2855
+
2856
+ if (!allowDelete && looksLikeDeleteCommand(params.cmd)) {
2857
+ return {
2858
+ isError: true,
2859
+ content: [
2860
+ {
2861
+ type: "text",
2862
+ text: "Blocked potential delete command. If deletion is explicitly requested, rerun with allowDelete=true",
2863
+ },
2864
+ ],
2865
+ details: { blocked: true, reason: "delete", cmd: params.cmd, windowId, tab },
2866
+ };
2867
+ }
2868
+
2869
+ if (!allowWorkspaceSwitchInPlace && looksLikeWorkspaceSwitchInPlace(params.cmd)) {
2870
+ return {
2871
+ isError: true,
2872
+ content: [
2873
+ {
2874
+ type: "text",
2875
+ text:
1525
2876
  "Blocked in-place workspace change (it can clobber selection/prompt/context and disrupt other sessions). " +
1526
2877
  "Add `--new-window`, or rerun with allowWorkspaceSwitchInPlace=true if explicitly safe",
1527
2878
  },
@@ -1593,10 +2944,10 @@ export default function (pi: ExtensionAPI) {
1593
2944
  let rpReadcache: RpReadcacheMetaV1 | null = null;
1594
2945
 
1595
2946
  if (
1596
- config.readcacheReadFile === true &&
1597
- readRequest !== null &&
1598
- readRequest.cacheable === true &&
1599
- !execError &&
2947
+ config.readcacheReadFile === true &&
2948
+ readRequest !== null &&
2949
+ readRequest.cacheable === true &&
2950
+ !execError &&
1600
2951
  exitCode === 0 &&
1601
2952
  windowId !== undefined &&
1602
2953
  tab !== undefined
@@ -1627,6 +2978,30 @@ export default function (pi: ExtensionAPI) {
1627
2978
  }
1628
2979
  }
1629
2980
 
2981
+ const shouldAutoSelectRead =
2982
+ config.autoSelectReadSlices === true &&
2983
+ readRequest !== null &&
2984
+ !execError &&
2985
+ exitCode === 0 &&
2986
+ windowId !== undefined &&
2987
+ tab !== undefined &&
2988
+ params.windowId === undefined &&
2989
+ params.tab === undefined;
2990
+
2991
+ if (shouldAutoSelectRead) {
2992
+ try {
2993
+ await autoSelectReadFileInRepoPromptSelection(
2994
+ ctx,
2995
+ readRequest.path,
2996
+ readRequest.startLine,
2997
+ readRequest.limit,
2998
+ combinedOutput,
2999
+ );
3000
+ } catch {
3001
+ // Fail-open
3002
+ }
3003
+ }
3004
+
1630
3005
  const editNoop =
1631
3006
  !execError &&
1632
3007
  exitCode === 0 &&