opencode-repos 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,22 +1,54 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { tool } from "@opencode-ai/plugin"
3
3
  import { $ } from "bun"
4
- import { parseRepoSpec, buildGitUrl, cloneRepo, updateRepo, getRepoInfo } from "./src/git"
4
+ import { parseRepoSpec, buildGitUrl, cloneRepo, updateRepo, switchBranch, getRepoInfo } from "./src/git"
5
5
  import { createRepoExplorerAgent } from "./src/agents/repo-explorer"
6
6
  import {
7
7
  loadManifest,
8
8
  saveManifest,
9
9
  withManifestLock,
10
+ setCacheDir,
10
11
  type RepoEntry,
11
12
  } from "./src/manifest"
12
13
  import { scanLocalRepos, matchRemoteToSpec, findLocalRepoByName } from "./src/scanner"
13
- import { homedir } from "node:os"
14
- import { join } from "node:path"
14
+ import { homedir, tmpdir } from "node:os"
15
+ import { dirname, isAbsolute, join } from "node:path"
15
16
  import { existsSync } from "node:fs"
16
- import { rm, readFile } from "node:fs/promises"
17
+ import { appendFile, mkdir, rm, readFile } from "node:fs/promises"
17
18
 
18
19
  interface Config {
19
- localSearchPaths: string[]
20
+ localSearchPaths?: string[]
21
+ cleanupMaxAgeDays?: number
22
+ cacheDir?: string
23
+ useHttps?: boolean
24
+ autoSyncOnExplore?: boolean
25
+ autoSyncIntervalHours?: number
26
+ defaultBranch?: string
27
+ includeProjectParent?: boolean
28
+ debug?: boolean
29
+ repoExplorerModel?: string
30
+ debugLogPath?: string
31
+ }
32
+
33
+ const DEFAULTS = {
34
+ cleanupMaxAgeDays: 30,
35
+ cacheDir: join(tmpdir(), "opencode-repos"),
36
+ useHttps: false,
37
+ autoSyncOnExplore: true,
38
+ autoSyncIntervalHours: 24,
39
+ defaultBranch: "main",
40
+ includeProjectParent: true,
41
+ debug: false,
42
+ repoExplorerModel: "opencode/grok-code",
43
+ debugLogPath: join(homedir(), ".cache", "opencode-repos", "debug.log"),
44
+ } as const
45
+
46
+ function parseModelString(modelString: string): { providerID: string; modelID: string } | undefined {
47
+ const parts = modelString.split("/")
48
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
49
+ return undefined
50
+ }
51
+ return { providerID: parts[0], modelID: parts[1] }
20
52
  }
21
53
 
22
54
  async function loadConfig(): Promise<Config | null> {
@@ -34,12 +66,865 @@ async function loadConfig(): Promise<Config | null> {
34
66
  }
35
67
  }
36
68
 
37
- const CACHE_DIR = join(homedir(), ".cache", "opencode-repos")
38
69
 
39
- export const OpencodeRepos: Plugin = async ({ client }) => {
70
+
71
+ async function runCleanup(maxAgeDays: number): Promise<void> {
72
+ const cutoffMs = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000)
73
+
74
+ try {
75
+ const manifest = await loadManifest()
76
+ const staleKeys: string[] = []
77
+
78
+ for (const [repoKey, entry] of Object.entries(manifest.repos)) {
79
+ if (entry.type !== "cached") continue
80
+
81
+ const lastAccessedMs = new Date(entry.lastAccessed).getTime()
82
+ if (lastAccessedMs < cutoffMs) {
83
+ staleKeys.push(repoKey)
84
+ }
85
+ }
86
+
87
+ if (staleKeys.length === 0) return
88
+
89
+ await withManifestLock(async () => {
90
+ const updatedManifest = await loadManifest()
91
+
92
+ for (const key of staleKeys) {
93
+ const entry = updatedManifest.repos[key]
94
+ if (!entry || entry.type !== "cached") continue
95
+
96
+ try {
97
+ await rm(entry.path, { recursive: true, force: true })
98
+ delete updatedManifest.repos[key]
99
+ } catch {}
100
+ }
101
+
102
+ await saveManifest(updatedManifest)
103
+ })
104
+ } catch {}
105
+ }
106
+
107
+ function resolveSearchPaths(
108
+ configuredPaths: string[],
109
+ includeProjectParent: boolean,
110
+ projectDirectory?: string
111
+ ): string[] {
112
+ const resolved = [...configuredPaths]
113
+
114
+ if (includeProjectParent && projectDirectory) {
115
+ const parentDir = dirname(projectDirectory)
116
+ if (!resolved.includes(parentDir)) {
117
+ resolved.push(parentDir)
118
+ }
119
+ }
120
+
121
+ return resolved
122
+ }
123
+
124
+ function shouldSyncRepo(lastUpdated: string | undefined, intervalHours: number): boolean {
125
+ if (!lastUpdated) return true
126
+
127
+ const lastUpdatedMs = new Date(lastUpdated).getTime()
128
+ if (Number.isNaN(lastUpdatedMs)) return true
129
+
130
+ const intervalMs = intervalHours * 60 * 60 * 1000
131
+ return Date.now() - lastUpdatedMs >= intervalMs
132
+ }
133
+
134
+ function expandHomePath(input: string): string {
135
+ if (input.startsWith("~/")) {
136
+ return join(homedir(), input.slice(2))
137
+ }
138
+
139
+ return input
140
+ }
141
+
142
+ async function resolveLocalPathQuery(
143
+ query: string,
144
+ defaultBranch: string
145
+ ): Promise<{ candidate: RepoCandidate | null; error?: string }> {
146
+ const expanded = expandHomePath(query.trim())
147
+
148
+ if (!isAbsolute(expanded)) {
149
+ return { candidate: null }
150
+ }
151
+
152
+ if (!existsSync(expanded)) {
153
+ return { candidate: null, error: `Path does not exist: ${expanded}` }
154
+ }
155
+
156
+ if (!existsSync(join(expanded, ".git"))) {
157
+ return { candidate: null, error: `No .git directory found at: ${expanded}` }
158
+ }
159
+
160
+ try {
161
+ const [remote, branch] = await Promise.all([
162
+ $`git -C ${expanded} remote get-url origin`.text(),
163
+ $`git -C ${expanded} branch --show-current`.text(),
164
+ ])
165
+
166
+ if (!remote.trim()) {
167
+ return {
168
+ candidate: null,
169
+ error: "Local repository has no origin remote. Add one to use repo_query.",
170
+ }
171
+ }
172
+
173
+ const spec = matchRemoteToSpec(remote.trim())
174
+ if (!spec) {
175
+ return {
176
+ candidate: null,
177
+ error: "Origin remote is not a supported GitHub URL.",
178
+ }
179
+ }
180
+
181
+ return {
182
+ candidate: {
183
+ key: spec,
184
+ source: "local",
185
+ path: expanded,
186
+ branch: branch.trim() || defaultBranch,
187
+ remote: remote.trim(),
188
+ },
189
+ }
190
+ } catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error)
192
+ return {
193
+ candidate: null,
194
+ error: `Failed to read local repository metadata: ${message}`,
195
+ }
196
+ }
197
+ }
198
+
199
+ async function registerLocalRepo(
200
+ repoKey: string,
201
+ path: string,
202
+ branch: string,
203
+ remote: string
204
+ ): Promise<void> {
205
+ await withManifestLock(async () => {
206
+ const manifest = await loadManifest()
207
+ if (manifest.repos[repoKey]) return
208
+
209
+ const now = new Date().toISOString()
210
+ manifest.repos[repoKey] = {
211
+ type: "local",
212
+ path,
213
+ lastAccessed: now,
214
+ currentBranch: branch,
215
+ shallow: false,
216
+ }
217
+
218
+ manifest.localIndex[remote] = path
219
+ await saveManifest(manifest)
220
+ })
221
+ }
222
+
223
+ async function resolveCandidates(
224
+ query: string,
225
+ searchPaths: string[],
226
+ manifest: Awaited<ReturnType<typeof loadManifest>>,
227
+ defaultBranch: string,
228
+ allowGithub: boolean
229
+ ): Promise<{
230
+ candidates: RepoCandidate[]
231
+ exactRepoKey: string | null
232
+ branchOverride: string | null
233
+ }> {
234
+ const trimmed = query.trim()
235
+ let exactRepoKey: string | null = null
236
+ let branchOverride: string | null = null
237
+
238
+ if (trimmed.includes("/")) {
239
+ try {
240
+ const spec = parseRepoSpec(trimmed)
241
+ exactRepoKey = `${spec.owner}/${spec.repo}`
242
+ branchOverride = spec.branch ?? null
243
+ } catch {
244
+ exactRepoKey = null
245
+ branchOverride = null
246
+ }
247
+ }
248
+
249
+ const queryLower = trimmed.toLowerCase()
250
+ const candidates: RepoCandidate[] = []
251
+
252
+ for (const [repoKey, entry] of Object.entries(manifest.repos)) {
253
+ if (exactRepoKey) {
254
+ if (repoKey.toLowerCase() !== exactRepoKey.toLowerCase()) continue
255
+ candidates.push({
256
+ key: repoKey,
257
+ source: "registered",
258
+ path: entry.path,
259
+ branch: entry.currentBranch,
260
+ })
261
+ continue
262
+ }
263
+
264
+ if (repoKey.toLowerCase().includes(queryLower)) {
265
+ candidates.push({
266
+ key: repoKey,
267
+ source: "registered",
268
+ path: entry.path,
269
+ branch: entry.currentBranch,
270
+ })
271
+ }
272
+ }
273
+
274
+ if (searchPaths.length > 0) {
275
+ try {
276
+ const localResults = await findLocalRepoByName(searchPaths, trimmed)
277
+ for (const local of localResults) {
278
+ if (exactRepoKey && local.spec.toLowerCase() !== exactRepoKey.toLowerCase()) {
279
+ continue
280
+ }
281
+
282
+ candidates.push({
283
+ key: local.spec,
284
+ source: "local",
285
+ path: local.path,
286
+ branch: local.branch || defaultBranch,
287
+ remote: local.remote,
288
+ })
289
+ }
290
+ } catch {}
291
+ }
292
+
293
+ if (allowGithub) {
294
+ try {
295
+ if (exactRepoKey) {
296
+ const repoCheck =
297
+ await $`gh repo view ${exactRepoKey} --json nameWithOwner,description,url 2>/dev/null`.text()
298
+ const repo = JSON.parse(repoCheck)
299
+ candidates.push({
300
+ key: repo.nameWithOwner,
301
+ source: "github",
302
+ description: repo.description || "",
303
+ url: repo.url,
304
+ branch: branchOverride ?? defaultBranch,
305
+ })
306
+ } else {
307
+ const searchResult =
308
+ await $`gh search repos ${trimmed} --limit 5 --json fullName,description,url 2>/dev/null`.text()
309
+ const repos = JSON.parse(searchResult)
310
+ for (const repo of repos) {
311
+ candidates.push({
312
+ key: repo.fullName,
313
+ source: "github",
314
+ description: repo.description || "",
315
+ url: repo.url,
316
+ branch: defaultBranch,
317
+ })
318
+ }
319
+ }
320
+ } catch {}
321
+ }
322
+
323
+ return {
324
+ candidates: uniqueCandidates(candidates),
325
+ exactRepoKey,
326
+ branchOverride,
327
+ }
328
+ }
329
+
330
+ async function ensureRepoAvailable(
331
+ repoKey: string,
332
+ branch: string,
333
+ cacheDir: string,
334
+ useHttps: boolean,
335
+ autoSyncOnExplore: boolean,
336
+ autoSyncIntervalHours: number
337
+ ): Promise<{
338
+ repoPath: string
339
+ branch: string
340
+ type: "cached" | "local"
341
+ }> {
342
+ let manifest = await loadManifest()
343
+ const entry = manifest.repos[repoKey]
344
+
345
+ if (!entry) {
346
+ const [owner, repo] = repoKey.split("/")
347
+ const repoPath = join(cacheDir, owner, repo)
348
+ const url = buildGitUrl(owner, repo, useHttps)
349
+
350
+ let actualBranch = branch
351
+ try {
352
+ await withManifestLock(async () => {
353
+ const cloneResult = await cloneRepo(url, repoPath, { branch })
354
+ actualBranch = cloneResult.branch
355
+
356
+ const now = new Date().toISOString()
357
+ const updatedManifest = await loadManifest()
358
+ updatedManifest.repos[repoKey] = {
359
+ type: "cached",
360
+ path: repoPath,
361
+ clonedAt: now,
362
+ lastAccessed: now,
363
+ lastUpdated: now,
364
+ currentBranch: actualBranch,
365
+ shallow: true,
366
+ }
367
+ await saveManifest(updatedManifest)
368
+ })
369
+ } catch (error) {
370
+ const message = error instanceof Error ? error.message : String(error)
371
+ throw new Error(`Clone failed for ${repoKey}@${branch}: ${message}`)
372
+ }
373
+
374
+ return {
375
+ repoPath,
376
+ branch: actualBranch,
377
+ type: "cached",
378
+ }
379
+ }
380
+
381
+ const repoPath = entry.path
382
+
383
+ if (entry.type === "cached") {
384
+ try {
385
+ if (entry.currentBranch !== branch) {
386
+ await switchBranch(repoPath, branch)
387
+ await withManifestLock(async () => {
388
+ const updatedManifest = await loadManifest()
389
+ if (updatedManifest.repos[repoKey]) {
390
+ updatedManifest.repos[repoKey].currentBranch = branch
391
+ updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
392
+ await saveManifest(updatedManifest)
393
+ }
394
+ })
395
+ } else if (autoSyncOnExplore && shouldSyncRepo(entry.lastUpdated, autoSyncIntervalHours)) {
396
+ await updateRepo(repoPath)
397
+ await withManifestLock(async () => {
398
+ const updatedManifest = await loadManifest()
399
+ if (updatedManifest.repos[repoKey]) {
400
+ updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
401
+ await saveManifest(updatedManifest)
402
+ }
403
+ })
404
+ }
405
+ } catch (error) {
406
+ const message = error instanceof Error ? error.message : String(error)
407
+ throw new Error(`Update failed for ${repoKey}@${branch}: ${message}`)
408
+ }
409
+ }
410
+
411
+ return {
412
+ repoPath,
413
+ branch,
414
+ type: entry.type,
415
+ }
416
+ }
417
+
418
+ async function runRepoExplorer(
419
+ client: RepoClient,
420
+ ctx: RepoToolContext,
421
+ repoKey: string,
422
+ repoPath: string,
423
+ question: string,
424
+ model?: string,
425
+ parentDirectory?: string
426
+ ): Promise<string> {
427
+ let sessionID: string | undefined
428
+
429
+ // Validate that repo-explorer agent is available
430
+ try {
431
+ const agentsResult = await client.app.agents()
432
+ const agents = agentsResult.data ?? []
433
+ const agentNames = agents.map((a) => a.name)
434
+
435
+ if (debugLogger) {
436
+ debugLogger("repo_explore", [
437
+ `repoKey: ${repoKey}`,
438
+ `availableAgents: ${agentNames.join(", ") || "none"}`,
439
+ `repoExplorerExists: ${agentNames.includes("repo-explorer")}`,
440
+ ])
441
+ }
442
+
443
+ if (!agentNames.includes("repo-explorer")) {
444
+ return `## Exploration failed
445
+
446
+ Repository: ${repoKey}
447
+ Path: ${repoPath}
448
+
449
+ The repo-explorer agent is not available. Available agents: ${agentNames.join(", ") || "none"}
450
+
451
+ This may indicate the opencode-repos plugin is not properly loaded.`
452
+ }
453
+ } catch (error) {
454
+ if (debugLogger) {
455
+ const message = error instanceof Error ? error.message : String(error)
456
+ debugLogger("repo_explore", [
457
+ `repoKey: ${repoKey}`,
458
+ `agentValidationError: ${message}`,
459
+ ])
460
+ }
461
+ // Continue anyway - the session.prompt will fail with a clearer error if agent doesn't exist
462
+ }
463
+
464
+ // Use parent directory for session creation (like oh-my-opencode does)
465
+ // The repoPath will be passed in the prompt context instead
466
+ const sessionDirectory = parentDirectory ?? repoPath
467
+
468
+ try {
469
+ const createResult = await client.session.create({
470
+ body: {
471
+ parentID: ctx.sessionID,
472
+ title: `Repo explorer: ${repoKey}`,
473
+ },
474
+ query: {
475
+ directory: sessionDirectory,
476
+ },
477
+ })
478
+
479
+ sessionID = createResult.data?.id
480
+ } catch (error) {
481
+ const message = error instanceof Error ? error.message : String(error)
482
+ return `## Exploration failed
483
+
484
+ Repository: ${repoKey}
485
+ Path: ${repoPath}
486
+
487
+ Failed to create a subagent session: ${message}`
488
+ }
489
+
490
+ if (!sessionID) {
491
+ return `## Exploration failed
492
+
493
+ Repository: ${repoKey}
494
+ Path: ${repoPath}
495
+
496
+ Failed to create a subagent session (no session ID returned).`
497
+ }
498
+
499
+ const explorationPrompt = `Index the codebase and answer the following question:
500
+
501
+ ${question}
502
+
503
+ Working directory: ${repoPath}
504
+
505
+ You have access to all standard code exploration tools:
506
+ - read: Read files
507
+ - glob: Find files by pattern
508
+ - grep: Search for patterns
509
+ - bash: Run git commands if needed
510
+
511
+ Remember to:
512
+ - Start with high-level structure (README, package.json, main files)
513
+ - Cite specific files and line numbers
514
+ - Include relevant code snippets
515
+ - Explain how components interact
516
+ `
517
+
518
+ await Bun.sleep(150)
519
+
520
+ let promptAttempt = 0
521
+ const promptMaxAttempts = 3
522
+
523
+ const parsedModel = model ? parseModelString(model) : undefined
524
+
525
+ while (promptAttempt < promptMaxAttempts) {
526
+ try {
527
+ if (debugLogger) {
528
+ debugLogger("repo_explore", [
529
+ `repoKey: ${repoKey}`,
530
+ `sessionID: ${sessionID}`,
531
+ `promptSending: attempt ${promptAttempt + 1}`,
532
+ `model: ${parsedModel ? `${parsedModel.providerID}/${parsedModel.modelID}` : "default"}`,
533
+ ])
534
+ }
535
+ await client.session.prompt({
536
+ path: { id: sessionID },
537
+ body: {
538
+ agent: "repo-explorer",
539
+ tools: {
540
+ task: false,
541
+ delegate_task: false,
542
+ },
543
+ parts: [{ type: "text", text: explorationPrompt }],
544
+ ...(parsedModel ? { model: parsedModel } : {}),
545
+ },
546
+ })
547
+ if (debugLogger) {
548
+ debugLogger("repo_explore", [
549
+ `repoKey: ${repoKey}`,
550
+ `sessionID: ${sessionID}`,
551
+ `promptSent: success`,
552
+ ])
553
+ }
554
+ break
555
+ } catch (error) {
556
+ promptAttempt += 1
557
+ const message = error instanceof Error ? error.message : String(error)
558
+ if (debugLogger) {
559
+ debugLogger("repo_explore", [
560
+ `repoKey: ${repoKey}`,
561
+ `sessionID: ${sessionID}`,
562
+ `promptError: ${message}`,
563
+ `promptAttempt: ${promptAttempt}`,
564
+ ])
565
+ }
566
+
567
+ if (promptAttempt >= promptMaxAttempts) {
568
+ return `## Exploration failed
569
+
570
+ Repository: ${repoKey}
571
+ Session: ${sessionID}
572
+ Path: ${repoPath}
573
+
574
+ Failed to run exploration agent: ${message}`
575
+ }
576
+
577
+ await Bun.sleep(300 * promptAttempt)
578
+ }
579
+ }
580
+
581
+ const pollStart = Date.now()
582
+ const maxPollMs = 5 * 60 * 1000
583
+ const pollIntervalMs = 500
584
+ const minStabilityTimeMs = 10000
585
+ const stabilityPollsRequired = 3
586
+ let lastMsgCount = 0
587
+ let stablePolls = 0
588
+ let pollCount = 0
589
+
590
+ if (debugLogger) {
591
+ debugLogger("repo_explore", [
592
+ `repoKey: ${repoKey}`,
593
+ `sessionID: ${sessionID}`,
594
+ `pollStart: starting poll loop`,
595
+ ])
596
+ }
597
+
598
+ while (Date.now() - pollStart < maxPollMs) {
599
+ await Bun.sleep(pollIntervalMs)
600
+ pollCount++
601
+
602
+ try {
603
+ const statusResult = await client.session.status()
604
+ const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
605
+ const sessionStatus = allStatuses[sessionID]
606
+
607
+ if (pollCount % 10 === 0 && debugLogger) {
608
+ debugLogger("repo_explore", [
609
+ `repoKey: ${repoKey}`,
610
+ `sessionID: ${sessionID}`,
611
+ `pollCount: ${pollCount}`,
612
+ `elapsed: ${Math.floor((Date.now() - pollStart) / 1000)}s`,
613
+ `sessionStatus: ${sessionStatus?.type ?? "not_in_status"}`,
614
+ `stablePolls: ${stablePolls}`,
615
+ `lastMsgCount: ${lastMsgCount}`,
616
+ ])
617
+ }
618
+
619
+ if (sessionStatus && sessionStatus.type !== "idle") {
620
+ stablePolls = 0
621
+ lastMsgCount = 0
622
+ continue
623
+ }
624
+
625
+ const elapsed = Date.now() - pollStart
626
+ if (elapsed < minStabilityTimeMs) {
627
+ continue
628
+ }
629
+
630
+ const messagesResult = await client.session.messages({ path: { id: sessionID } })
631
+ const messages = messagesResult.data ?? []
632
+ const currentMsgCount = messages.length
633
+
634
+ if (currentMsgCount === lastMsgCount) {
635
+ stablePolls += 1
636
+ if (stablePolls >= stabilityPollsRequired) {
637
+ if (debugLogger) {
638
+ debugLogger("repo_explore", [
639
+ `repoKey: ${repoKey}`,
640
+ `sessionID: ${sessionID}`,
641
+ `pollComplete: messages stable`,
642
+ `currentMsgCount: ${currentMsgCount}`,
643
+ ])
644
+ }
645
+ break
646
+ }
647
+ } else {
648
+ stablePolls = 0
649
+ lastMsgCount = currentMsgCount
650
+ }
651
+ } catch (error) {
652
+ const message = error instanceof Error ? error.message : String(error)
653
+ if (debugLogger) {
654
+ debugLogger("repo_explore", [
655
+ `repoKey: ${repoKey}`,
656
+ `sessionID: ${sessionID}`,
657
+ `pollError: ${message}`,
658
+ ])
659
+ }
660
+ }
661
+ }
662
+
663
+ if (Date.now() - pollStart >= maxPollMs) {
664
+ return `## Exploration failed
665
+
666
+ Repository: ${repoKey}
667
+ Session: ${sessionID}
668
+ Path: ${repoPath}
669
+
670
+ Timed out waiting for subagent output.`
671
+ }
672
+
673
+ const messagesResult = await client.session.messages({ path: { id: sessionID } })
674
+ const messages = messagesResult.data ?? []
675
+ const extracted: string[] = []
676
+
677
+ for (const message of messages) {
678
+ const parts = message.parts ?? []
679
+ for (const part of parts) {
680
+ if (
681
+ typeof part === "object" &&
682
+ part &&
683
+ "type" in part &&
684
+ (part as { type: string }).type === "text" &&
685
+ "text" in part
686
+ ) {
687
+ const textValue = (part as { text?: string }).text
688
+ if (textValue) extracted.push(textValue)
689
+ } else if (
690
+ typeof part === "object" &&
691
+ part &&
692
+ "type" in part &&
693
+ (part as { type: string }).type === "tool_result"
694
+ ) {
695
+ const toolResult = part as { content?: unknown }
696
+ if (typeof toolResult.content === "string") {
697
+ extracted.push(toolResult.content)
698
+ } else if (Array.isArray(toolResult.content)) {
699
+ for (const block of toolResult.content) {
700
+ if (block && typeof block === "object" && "text" in block) {
701
+ const blockText = (block as { text?: string }).text
702
+ if (blockText) extracted.push(blockText)
703
+ }
704
+ }
705
+ }
706
+ }
707
+ }
708
+ }
709
+
710
+ const responseText = extracted.filter(Boolean).join("\n\n")
711
+ return responseText || "No response from exploration agent."
712
+ }
713
+
714
+ interface RepoToolContext {
715
+ sessionID: string
716
+ }
717
+
718
+ interface PermissionContext {
719
+ ask: (input: {
720
+ permission: "external_directory"
721
+ patterns: string[]
722
+ always: string[]
723
+ metadata: Record<string, string>
724
+ }) => Promise<void>
725
+ }
726
+
727
+ interface RepoClient {
728
+ session: {
729
+ create: (input: {
730
+ body: { parentID?: string; title?: string }
731
+ query?: { directory?: string }
732
+ }) => Promise<{ data?: { id?: string } }>
733
+ get: (input: { path: { id: string } }) => Promise<{ data?: { id?: string; directory?: string } }>
734
+ status: () => Promise<{ data?: Record<string, { type: string }> }>
735
+ messages: (input: { path: { id: string } }) => Promise<{ data?: Array<{ parts?: unknown[] }> }>
736
+ prompt: (input: {
737
+ path: { id: string }
738
+ body: {
739
+ agent: string
740
+ parts: Array<{ type: "text"; text: string }>
741
+ tools?: Record<string, boolean>
742
+ model?: { providerID: string; modelID: string }
743
+ }
744
+ }) => Promise<{
745
+ error?: unknown
746
+ data?: { parts?: Array<{ type: string; text?: string }> }
747
+ }>
748
+ }
749
+ app: {
750
+ agents: () => Promise<{ data?: Array<{ name: string; mode?: string }> }>
751
+ }
752
+ }
753
+
754
+ type RepoSource = "registered" | "local" | "github"
755
+
756
+ interface RepoCandidate {
757
+ key: string
758
+ source: RepoSource
759
+ path?: string
760
+ branch?: string
761
+ description?: string
762
+ url?: string
763
+ remote?: string
764
+ }
765
+
766
+ interface RepoQueryDebugInfo {
767
+ query: string
768
+ allowGithub: boolean
769
+ localSearchPaths: string[]
770
+ candidates: RepoCandidate[]
771
+ selectedTargets: Array<{ repoKey: string; branch: string }>
772
+ }
773
+
774
+ function uniqueCandidates(candidates: RepoCandidate[]): RepoCandidate[] {
775
+ const map = new Map<string, RepoCandidate>()
776
+ for (const candidate of candidates) {
777
+ if (!map.has(candidate.key)) {
778
+ map.set(candidate.key, candidate)
779
+ }
780
+ }
781
+ return Array.from(map.values())
782
+ }
783
+
784
+ async function touchRepoAccess(repoKey: string): Promise<void> {
785
+ await withManifestLock(async () => {
786
+ const updatedManifest = await loadManifest()
787
+ if (updatedManifest.repos[repoKey]) {
788
+ updatedManifest.repos[repoKey].lastAccessed = new Date().toISOString()
789
+ await saveManifest(updatedManifest)
790
+ }
791
+ })
792
+ }
793
+
794
+ function formatRepoQueryDebug(info: RepoQueryDebugInfo): string {
795
+ let output = "## Debug\n\n"
796
+ output += `Query: ${info.query}\n`
797
+ output += `GitHub search enabled: ${info.allowGithub}\n`
798
+ output += `Local search paths: ${info.localSearchPaths.length}\n`
799
+ for (const path of info.localSearchPaths) {
800
+ output += `- ${path}\n`
801
+ }
802
+ output += "\nCandidates:\n"
803
+ if (info.candidates.length === 0) {
804
+ output += "- none\n"
805
+ } else {
806
+ for (const candidate of info.candidates) {
807
+ output += `- ${candidate.key} (${candidate.source})\n`
808
+ }
809
+ }
810
+ output += "\nSelected targets:\n"
811
+ if (info.selectedTargets.length === 0) {
812
+ output += "- none\n"
813
+ } else {
814
+ for (const target of info.selectedTargets) {
815
+ output += `- ${target.repoKey} @ ${target.branch}\n`
816
+ }
817
+ }
818
+ return output
819
+ }
820
+
821
+ type DebugLogger = (toolName: string, lines: string[]) => void
822
+
823
+ let debugLogger: DebugLogger | null = null
824
+
825
+ function appendDebug(
826
+ output: string,
827
+ toolName: string,
828
+ lines: string[],
829
+ enabled: boolean
830
+ ): string {
831
+ if (!enabled) return output
832
+
833
+ if (debugLogger) {
834
+ debugLogger(toolName, lines)
835
+ }
836
+
837
+ let section = `\n\n## Debug\n\nTool: ${toolName}\n`
838
+ for (const line of lines) {
839
+ section += `- ${line}\n`
840
+ }
841
+
842
+ return `${output}${section}`
843
+ }
844
+
845
+ function createDebugLogger(path: string, enabled: boolean): DebugLogger | null {
846
+ if (!enabled) return null
847
+
848
+ return (toolName, lines) => {
849
+ const timestamp = new Date().toISOString()
850
+ const payload = [
851
+ `[${timestamp}] ${toolName}`,
852
+ ...lines.map((line) => `- ${line}`),
853
+ "",
854
+ ].join("\n")
855
+
856
+ void appendFile(path, payload)
857
+ }
858
+ }
859
+
860
+ function logRepoQueryDebug(info: RepoQueryDebugInfo): void {
861
+ if (!debugLogger) return
862
+
863
+ const lines: string[] = [
864
+ `query: ${info.query}`,
865
+ `allowGithub: ${info.allowGithub}`,
866
+ `localSearchPaths: ${info.localSearchPaths.length}`,
867
+ `candidates: ${info.candidates.map((c) => c.key).join(", ") || "none"}`,
868
+ `selected: ${info.selectedTargets.map((t) => t.repoKey).join(", ") || "none"}`,
869
+ ]
870
+
871
+ debugLogger("repo_query", lines)
872
+ }
873
+
874
+ async function requestExternalDirectoryAccess(
875
+ ctx: PermissionContext,
876
+ targetPath: string
877
+ ): Promise<void> {
878
+ const parentDir = dirname(targetPath)
879
+ const glob = join(parentDir, "*")
880
+ await ctx.ask({
881
+ permission: "external_directory",
882
+ patterns: [glob],
883
+ always: [glob],
884
+ metadata: {
885
+ filepath: targetPath,
886
+ parentDir,
887
+ },
888
+ })
889
+ }
890
+
891
+ export const OpencodeRepos: Plugin = async ({ client, directory }) => {
892
+ const userConfig = await loadConfig()
893
+
894
+ const cacheDir = userConfig?.cacheDir ?? DEFAULTS.cacheDir
895
+ const useHttps = userConfig?.useHttps ?? DEFAULTS.useHttps
896
+ const autoSyncOnExplore = userConfig?.autoSyncOnExplore ?? DEFAULTS.autoSyncOnExplore
897
+ const autoSyncIntervalHours =
898
+ userConfig?.autoSyncIntervalHours ?? DEFAULTS.autoSyncIntervalHours
899
+ const defaultBranch = userConfig?.defaultBranch ?? DEFAULTS.defaultBranch
900
+ const cleanupMaxAgeDays = userConfig?.cleanupMaxAgeDays ?? DEFAULTS.cleanupMaxAgeDays
901
+ const includeProjectParent =
902
+ userConfig?.includeProjectParent ?? DEFAULTS.includeProjectParent
903
+ const debugEnabled = userConfig?.debug ?? DEFAULTS.debug
904
+ const repoExplorerModel =
905
+ userConfig?.repoExplorerModel ?? DEFAULTS.repoExplorerModel
906
+ const debugLogPath = expandHomePath(
907
+ userConfig?.debugLogPath ?? DEFAULTS.debugLogPath
908
+ )
909
+ const localSearchPaths = resolveSearchPaths(
910
+ userConfig?.localSearchPaths ?? [],
911
+ includeProjectParent,
912
+ directory
913
+ )
914
+
915
+ setCacheDir(cacheDir)
916
+
917
+ if (debugEnabled) {
918
+ await mkdir(dirname(debugLogPath), { recursive: true })
919
+ }
920
+
921
+ debugLogger = createDebugLogger(debugLogPath, debugEnabled)
922
+
923
+ runCleanup(cleanupMaxAgeDays)
924
+
40
925
  return {
41
926
  config: async (config) => {
42
- const explorerAgent = createRepoExplorerAgent()
927
+ const explorerAgent = createRepoExplorerAgent(repoExplorerModel)
43
928
  config.agent = {
44
929
  ...config.agent,
45
930
  "repo-explorer": explorerAgent,
@@ -57,7 +942,7 @@ When user mentions another project or asks about external code:
57
942
  tool: {
58
943
  repo_clone: tool({
59
944
  description:
60
- "Clone a repository to local cache or return path if already cached. Supports public and private (SSH) repos. Example: repo_clone({ repo: 'vercel/next.js' }) or repo_clone({ repo: 'vercel/next.js@canary', force: true })",
945
+ "Clone a repository to local cache or return path if already cached. Supports public and private repos. Example: repo_clone({ repo: 'vercel/next.js' }) or repo_clone({ repo: 'vercel/next.js@canary', force: true })",
61
946
  args: {
62
947
  repo: tool.schema
63
948
  .string()
@@ -72,30 +957,32 @@ When user mentions another project or asks about external code:
72
957
  },
73
958
  async execute(args) {
74
959
  const spec = parseRepoSpec(args.repo)
75
- const branch = spec.branch || "main"
76
- const repoKey = `${spec.owner}/${spec.repo}@${branch}`
960
+ const branch = spec.branch || defaultBranch
961
+ const repoKey = `${spec.owner}/${spec.repo}`
77
962
 
78
963
  const result = await withManifestLock(async () => {
79
964
  const manifest = await loadManifest()
80
-
81
965
  const existingEntry = manifest.repos[repoKey]
966
+ const destPath = join(cacheDir, spec.owner, spec.repo)
967
+
82
968
  if (existingEntry && !args.force) {
969
+ if (existingEntry.currentBranch !== branch) {
970
+ await switchBranch(existingEntry.path, branch)
971
+ existingEntry.currentBranch = branch
972
+ existingEntry.lastUpdated = new Date().toISOString()
973
+ }
83
974
  existingEntry.lastAccessed = new Date().toISOString()
84
975
  await saveManifest(manifest)
85
976
 
86
977
  return {
87
978
  path: existingEntry.path,
979
+ branch,
88
980
  status: "cached" as const,
89
981
  alreadyExists: true,
90
982
  }
91
983
  }
92
984
 
93
- const destPath = join(
94
- CACHE_DIR,
95
- spec.owner,
96
- `${spec.repo}@${branch}`
97
- )
98
- const url = buildGitUrl(spec.owner, spec.repo)
985
+ const url = buildGitUrl(spec.owner, spec.repo, useHttps)
99
986
 
100
987
  if (args.force && existingEntry) {
101
988
  try {
@@ -103,8 +990,10 @@ When user mentions another project or asks about external code:
103
990
  } catch {}
104
991
  }
105
992
 
993
+ let actualBranch = branch
106
994
  try {
107
- await cloneRepo(url, destPath, { branch })
995
+ const cloneResult = await cloneRepo(url, destPath, { branch })
996
+ actualBranch = cloneResult.branch
108
997
  } catch (error) {
109
998
  const message =
110
999
  error instanceof Error ? error.message : String(error)
@@ -118,7 +1007,7 @@ When user mentions another project or asks about external code:
118
1007
  clonedAt: now,
119
1008
  lastAccessed: now,
120
1009
  lastUpdated: now,
121
- defaultBranch: branch,
1010
+ currentBranch: actualBranch,
122
1011
  shallow: true,
123
1012
  }
124
1013
  manifest.repos[repoKey] = entry
@@ -127,6 +1016,7 @@ When user mentions another project or asks about external code:
127
1016
 
128
1017
  return {
129
1018
  path: destPath,
1019
+ branch: actualBranch,
130
1020
  status: "cloned" as const,
131
1021
  alreadyExists: false,
132
1022
  }
@@ -136,13 +1026,30 @@ When user mentions another project or asks about external code:
136
1026
  ? "Repository already cached"
137
1027
  : "Successfully cloned repository"
138
1028
 
139
- return `## ${statusText}
1029
+ let output = `## ${statusText}
140
1030
 
141
- **Repository**: ${args.repo}
1031
+ **Repository**: ${repoKey}
1032
+ **Branch**: ${result.branch}
142
1033
  **Path**: ${result.path}
143
1034
  **Status**: ${result.status}
144
1035
 
145
1036
  You can now use \`repo_read\` to access files from this repository.`
1037
+
1038
+ output = appendDebug(
1039
+ output,
1040
+ "repo_clone",
1041
+ [
1042
+ `repoKey: ${repoKey}`,
1043
+ `branch: ${result.branch}`,
1044
+ `path: ${result.path}`,
1045
+ `cacheDir: ${cacheDir}`,
1046
+ `useHttps: ${useHttps}`,
1047
+ `force: ${args.force}`,
1048
+ ],
1049
+ debugEnabled
1050
+ )
1051
+
1052
+ return output
146
1053
  },
147
1054
  }),
148
1055
 
@@ -166,37 +1073,62 @@ You can now use \`repo_read\` to access files from this repository.`
166
1073
  },
167
1074
  async execute(args) {
168
1075
  const spec = parseRepoSpec(args.repo)
169
- const branch = spec.branch || "main"
170
- const repoKey = `${spec.owner}/${spec.repo}@${branch}`
1076
+ const branch = spec.branch || defaultBranch
1077
+ const repoKey = `${spec.owner}/${spec.repo}`
171
1078
 
172
1079
  const manifest = await loadManifest()
173
1080
  const entry = manifest.repos[repoKey]
174
1081
 
175
1082
  if (!entry) {
176
- return `## Repository not found
1083
+ let output = `## Repository not found
177
1084
 
178
- Repository \`${args.repo}\` is not registered.
1085
+ Repository \`${spec.owner}/${spec.repo}\` is not registered.
179
1086
 
180
1087
  Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
1088
+ output = appendDebug(
1089
+ output,
1090
+ "repo_read",
1091
+ [`repoKey: ${repoKey}`, `branch: ${branch}`],
1092
+ debugEnabled
1093
+ )
1094
+ return output
1095
+ }
1096
+
1097
+ if (entry.type === "cached" && entry.currentBranch !== branch) {
1098
+ await switchBranch(entry.path, branch)
1099
+ await withManifestLock(async () => {
1100
+ const updatedManifest = await loadManifest()
1101
+ if (updatedManifest.repos[repoKey]) {
1102
+ updatedManifest.repos[repoKey].currentBranch = branch
1103
+ updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
1104
+ await saveManifest(updatedManifest)
1105
+ }
1106
+ })
181
1107
  }
182
1108
 
183
1109
  const repoPath = entry.path
184
1110
  const fullPath = join(repoPath, args.path)
185
1111
 
186
- let filePaths: string[] = []
1112
+ let filePaths: string[] = []
187
1113
 
188
- if (args.path.includes("*") || args.path.includes("?")) {
189
- const fdResult = await $`fd -t f -g ${args.path} ${repoPath}`.text()
190
- filePaths = fdResult.split("\n").filter(Boolean)
191
- } else {
192
- filePaths = [fullPath]
193
- }
1114
+ if (args.path.includes("*") || args.path.includes("?")) {
1115
+ const fdResult = await $`fd -t f -g ${args.path} ${repoPath}`.text()
1116
+ filePaths = fdResult.split("\n").filter(Boolean)
1117
+ } else {
1118
+ filePaths = [fullPath]
1119
+ }
194
1120
 
195
1121
  if (filePaths.length === 0) {
196
- return `No files found matching path: ${args.path}`
1122
+ const output = appendDebug(
1123
+ `No files found matching path: ${args.path}`,
1124
+ "repo_read",
1125
+ [`repoKey: ${repoKey}`, `branch: ${branch}`],
1126
+ debugEnabled
1127
+ )
1128
+ return output
197
1129
  }
198
1130
 
199
- let output = `## Files from ${args.repo}\n\n`
1131
+ let output = `## Files from ${repoKey} @ ${branch}\n\n`
200
1132
  const maxLines = args.maxLines ?? 500
201
1133
 
202
1134
  for (const filePath of filePaths) {
@@ -231,13 +1163,24 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
231
1163
  }
232
1164
  })
233
1165
 
1166
+ output = appendDebug(
1167
+ output,
1168
+ "repo_read",
1169
+ [
1170
+ `repoKey: ${repoKey}`,
1171
+ `branch: ${branch}`,
1172
+ `files: ${filePaths.length}`,
1173
+ ],
1174
+ debugEnabled
1175
+ )
1176
+
234
1177
  return output
235
1178
  },
236
1179
  }),
237
1180
 
238
1181
  repo_list: tool({
239
1182
  description:
240
- "List all registered repositories (cached and local). Shows metadata like type, branch, last accessed, and size.",
1183
+ "List all registered repositories (cached and local). Shows metadata like type, current branch, freshness (for cached), and size.",
241
1184
  args: {
242
1185
  type: tool.schema
243
1186
  .enum(["all", "cached", "local"])
@@ -255,21 +1198,32 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
255
1198
  })
256
1199
 
257
1200
  if (filteredRepos.length === 0) {
258
- return "No repositories registered."
1201
+ return appendDebug(
1202
+ "No repositories registered.",
1203
+ "repo_list",
1204
+ [`type: ${args.type}`],
1205
+ debugEnabled
1206
+ )
259
1207
  }
260
1208
 
261
1209
  let output = "## Registered Repositories\n\n"
262
- output += "| Repo | Type | Branch | Last Accessed | Size |\n"
263
- output += "|------|------|--------|---------------|------|\n"
1210
+ output += "| Repo | Type | Branch | Last Updated | Size |\n"
1211
+ output += "|------|------|--------|--------------|------|\n"
264
1212
 
265
1213
  for (const [repoKey, entry] of filteredRepos) {
266
- const repoName = repoKey.substring(0, repoKey.lastIndexOf("@"))
267
- const lastAccessed = new Date(entry.lastAccessed).toLocaleDateString()
268
1214
  const size = entry.sizeBytes
269
1215
  ? `${Math.round(entry.sizeBytes / 1024 / 1024)}MB`
270
1216
  : "-"
271
1217
 
272
- output += `| ${repoName} | ${entry.type} | ${entry.defaultBranch} | ${lastAccessed} | ${size} |\n`
1218
+ let freshness = "-"
1219
+ if (entry.type === "cached" && entry.lastUpdated) {
1220
+ const daysSinceUpdate = Math.floor(
1221
+ (Date.now() - new Date(entry.lastUpdated).getTime()) / (1000 * 60 * 60 * 24)
1222
+ )
1223
+ freshness = daysSinceUpdate === 0 ? "today" : `${daysSinceUpdate}d ago`
1224
+ }
1225
+
1226
+ output += `| ${repoKey} | ${entry.type} | ${entry.currentBranch} | ${freshness} | ${size} |\n`
273
1227
  }
274
1228
 
275
1229
  const cachedCount = filteredRepos.filter(
@@ -280,7 +1234,12 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
280
1234
  ).length
281
1235
  output += `\nTotal: ${filteredRepos.length} repos (${cachedCount} cached, ${localCount} local)`
282
1236
 
283
- return output
1237
+ return appendDebug(
1238
+ output,
1239
+ "repo_list",
1240
+ [`type: ${args.type}`, `total: ${filteredRepos.length}`],
1241
+ debugEnabled
1242
+ )
284
1243
  },
285
1244
  }),
286
1245
 
@@ -294,15 +1253,10 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
294
1253
  .describe("Override search paths (default: from config)"),
295
1254
  },
296
1255
  async execute(args) {
297
- let searchPaths: string[] | null = args.paths || null
298
-
299
- if (!searchPaths) {
300
- const config = await loadConfig()
301
- searchPaths = config?.localSearchPaths || null
302
- }
1256
+ const searchPaths = args.paths ?? (localSearchPaths.length > 0 ? localSearchPaths : null)
303
1257
 
304
1258
  if (!searchPaths || searchPaths.length === 0) {
305
- return `## No search paths configured
1259
+ let output = `## No search paths configured
306
1260
 
307
1261
  Create a config file at \`~/.config/opencode/opencode-repos.json\`:
308
1262
 
@@ -317,17 +1271,31 @@ Create a config file at \`~/.config/opencode/opencode-repos.json\`:
317
1271
  \`\`\`
318
1272
 
319
1273
  Or provide paths directly: \`repo_scan({ paths: ["~/projects"] })\``
1274
+ output = appendDebug(
1275
+ output,
1276
+ "repo_scan",
1277
+ ["searchPaths: none"],
1278
+ debugEnabled
1279
+ )
1280
+ return output
320
1281
  }
321
1282
 
322
1283
  const foundRepos = await scanLocalRepos(searchPaths)
323
1284
 
324
1285
  if (foundRepos.length === 0) {
325
- return `## No repositories found
1286
+ let output = `## No repositories found
326
1287
 
327
1288
  Searched ${searchPaths.length} path(s):
328
1289
  ${searchPaths.map((p) => `- ${p}`).join("\n")}
329
1290
 
330
1291
  No git repositories with remotes were found.`
1292
+ output = appendDebug(
1293
+ output,
1294
+ "repo_scan",
1295
+ [`searchPaths: ${searchPaths.length}`],
1296
+ debugEnabled
1297
+ )
1298
+ return output
331
1299
  }
332
1300
 
333
1301
  let newCount = 0
@@ -340,8 +1308,8 @@ No git repositories with remotes were found.`
340
1308
  const spec = matchRemoteToSpec(repo.remote)
341
1309
  if (!spec) continue
342
1310
 
343
- const branch = repo.branch || "main"
344
- const repoKey = `${spec}@${branch}`
1311
+ const branch = repo.branch || defaultBranch
1312
+ const repoKey = spec
345
1313
 
346
1314
  if (manifest.repos[repoKey]) {
347
1315
  existingCount++
@@ -353,7 +1321,7 @@ No git repositories with remotes were found.`
353
1321
  type: "local",
354
1322
  path: repo.path,
355
1323
  lastAccessed: now,
356
- defaultBranch: branch,
1324
+ currentBranch: branch,
357
1325
  shallow: false,
358
1326
  }
359
1327
 
@@ -365,19 +1333,30 @@ No git repositories with remotes were found.`
365
1333
  await saveManifest(manifest)
366
1334
  })
367
1335
 
368
- return `## Local Repository Scan Complete
1336
+ let output = `## Local Repository Scan Complete
369
1337
 
370
1338
  **Found**: ${foundRepos.length} repositories in ${searchPaths.length} path(s)
371
1339
  **New**: ${newCount} repos registered
372
1340
  **Existing**: ${existingCount} repos already registered
373
1341
 
374
1342
  ${newCount > 0 ? "Use `repo_list()` to see all registered repositories." : ""}`
1343
+ output = appendDebug(
1344
+ output,
1345
+ "repo_scan",
1346
+ [
1347
+ `searchPaths: ${searchPaths.length}`,
1348
+ `found: ${foundRepos.length}`,
1349
+ `new: ${newCount}`,
1350
+ ],
1351
+ debugEnabled
1352
+ )
1353
+ return output
375
1354
  },
376
1355
  }),
377
1356
 
378
1357
  repo_update: tool({
379
1358
  description:
380
- "Update a cached repository to latest. For local repos, shows git status without modifying. Only cached repos (cloned via repo_clone) are updated.",
1359
+ "Update a cached repository to latest. Optionally switch to a different branch first. For local repos, shows git status without modifying.",
381
1360
  args: {
382
1361
  repo: tool.schema
383
1362
  .string()
@@ -387,18 +1366,25 @@ ${newCount > 0 ? "Use `repo_list()` to see all registered repositories." : ""}`
387
1366
  },
388
1367
  async execute(args) {
389
1368
  const spec = parseRepoSpec(args.repo)
390
- const branch = spec.branch || "main"
391
- const repoKey = `${spec.owner}/${spec.repo}@${branch}`
1369
+ const requestedBranch = spec.branch
1370
+ const repoKey = `${spec.owner}/${spec.repo}`
392
1371
 
393
1372
  const manifest = await loadManifest()
394
1373
  const entry = manifest.repos[repoKey]
395
1374
 
396
1375
  if (!entry) {
397
- return `## Repository not found
1376
+ let output = `## Repository not found
398
1377
 
399
- Repository \`${args.repo}\` is not registered.
1378
+ Repository \`${repoKey}\` is not registered.
400
1379
 
401
1380
  Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
1381
+ output = appendDebug(
1382
+ output,
1383
+ "repo_update",
1384
+ [`repoKey: ${repoKey}`],
1385
+ debugEnabled
1386
+ )
1387
+ return output
402
1388
  }
403
1389
 
404
1390
  if (entry.type === "local") {
@@ -407,8 +1393,9 @@ Use \`repo_clone({ repo: "${args.repo}" })\` to clone it first.`
407
1393
 
408
1394
  return `## Local Repository Status
409
1395
 
410
- **Repository**: ${args.repo}
1396
+ **Repository**: ${repoKey}
411
1397
  **Path**: ${entry.path}
1398
+ **Branch**: ${entry.currentBranch}
412
1399
  **Type**: Local (not modified by plugin)
413
1400
 
414
1401
  \`\`\`
@@ -416,41 +1403,69 @@ ${status || "Working tree clean"}
416
1403
  \`\`\``
417
1404
  } catch (error) {
418
1405
  const message = error instanceof Error ? error.message : String(error)
419
- return `## Error getting status
420
-
421
- Failed to get git status for ${args.repo}: ${message}`
1406
+ let output = `## Error getting status
1407
+
1408
+ Failed to get git status for ${repoKey}: ${message}`
1409
+ output = appendDebug(
1410
+ output,
1411
+ "repo_update",
1412
+ [`repoKey: ${repoKey}`, `path: ${entry.path}`],
1413
+ debugEnabled
1414
+ )
1415
+ return output
422
1416
  }
423
1417
  }
424
1418
 
425
1419
  try {
426
- await updateRepo(entry.path, branch)
1420
+ const targetBranch = requestedBranch || entry.currentBranch
1421
+
1422
+ if (targetBranch !== entry.currentBranch) {
1423
+ await switchBranch(entry.path, targetBranch)
1424
+ } else {
1425
+ await updateRepo(entry.path)
1426
+ }
427
1427
 
428
1428
  const info = await getRepoInfo(entry.path)
429
1429
 
430
1430
  await withManifestLock(async () => {
431
1431
  const updatedManifest = await loadManifest()
432
1432
  if (updatedManifest.repos[repoKey]) {
1433
+ updatedManifest.repos[repoKey].currentBranch = targetBranch
433
1434
  updatedManifest.repos[repoKey].lastUpdated = new Date().toISOString()
434
1435
  updatedManifest.repos[repoKey].lastAccessed = new Date().toISOString()
435
1436
  await saveManifest(updatedManifest)
436
1437
  }
437
1438
  })
438
1439
 
439
- return `## Repository Updated
1440
+ let output = `## Repository Updated
440
1441
 
441
- **Repository**: ${args.repo}
1442
+ **Repository**: ${repoKey}
442
1443
  **Path**: ${entry.path}
443
- **Branch**: ${branch}
1444
+ **Branch**: ${targetBranch}
444
1445
  **Latest Commit**: ${info.commit.substring(0, 7)}
445
1446
 
446
1447
  Repository has been updated to the latest commit.`
1448
+ output = appendDebug(
1449
+ output,
1450
+ "repo_update",
1451
+ [`repoKey: ${repoKey}`, `branch: ${targetBranch}`],
1452
+ debugEnabled
1453
+ )
1454
+ return output
447
1455
  } catch (error) {
448
1456
  const message = error instanceof Error ? error.message : String(error)
449
- return `## Update Failed
1457
+ let output = `## Update Failed
450
1458
 
451
- Failed to update ${args.repo}: ${message}
1459
+ Failed to update ${repoKey}: ${message}
452
1460
 
453
1461
  The repository may be corrupted. Try \`repo_clone({ repo: "${args.repo}", force: true })\` to re-clone.`
1462
+ output = appendDebug(
1463
+ output,
1464
+ "repo_update",
1465
+ [`repoKey: ${repoKey}`],
1466
+ debugEnabled
1467
+ )
1468
+ return output
454
1469
  }
455
1470
  },
456
1471
  }),
@@ -462,7 +1477,7 @@ The repository may be corrupted. Try \`repo_clone({ repo: "${args.repo}", force:
462
1477
  repo: tool.schema
463
1478
  .string()
464
1479
  .describe(
465
- "Repository in format 'owner/repo' or 'owner/repo@branch'"
1480
+ "Repository in format 'owner/repo'"
466
1481
  ),
467
1482
  confirm: tool.schema
468
1483
  .boolean()
@@ -472,18 +1487,24 @@ The repository may be corrupted. Try \`repo_clone({ repo: "${args.repo}", force:
472
1487
  },
473
1488
  async execute(args) {
474
1489
  const spec = parseRepoSpec(args.repo)
475
- const branch = spec.branch || "main"
476
- const repoKey = `${spec.owner}/${spec.repo}@${branch}`
1490
+ const repoKey = `${spec.owner}/${spec.repo}`
477
1491
 
478
1492
  const manifest = await loadManifest()
479
1493
  const entry = manifest.repos[repoKey]
480
1494
 
481
1495
  if (!entry) {
482
- return `## Repository not found
1496
+ let output = `## Repository not found
483
1497
 
484
- Repository \`${args.repo}\` is not registered.
1498
+ Repository \`${repoKey}\` is not registered.
485
1499
 
486
1500
  Use \`repo_list()\` to see all registered repositories.`
1501
+ output = appendDebug(
1502
+ output,
1503
+ "repo_remove",
1504
+ [`repoKey: ${repoKey}`],
1505
+ debugEnabled
1506
+ )
1507
+ return output
487
1508
  }
488
1509
 
489
1510
  if (entry.type === "local") {
@@ -501,28 +1522,42 @@ Use \`repo_list()\` to see all registered repositories.`
501
1522
  await saveManifest(updatedManifest)
502
1523
  })
503
1524
 
504
- return `## Local Repository Unregistered
1525
+ let output = `## Local Repository Unregistered
505
1526
 
506
- **Repository**: ${args.repo}
1527
+ **Repository**: ${repoKey}
507
1528
  **Path**: ${entry.path}
508
1529
 
509
1530
  The repository has been unregistered. Files are preserved at the path above.
510
1531
 
511
1532
  To re-register, run \`repo_scan()\`.`
1533
+ output = appendDebug(
1534
+ output,
1535
+ "repo_remove",
1536
+ [`repoKey: ${repoKey}`, `path: ${entry.path}`],
1537
+ debugEnabled
1538
+ )
1539
+ return output
512
1540
  }
513
1541
 
514
1542
  if (!args.confirm) {
515
- return `## Confirmation Required
1543
+ let output = `## Confirmation Required
516
1544
 
517
- **Repository**: ${args.repo}
1545
+ **Repository**: ${repoKey}
518
1546
  **Path**: ${entry.path}
519
1547
  **Type**: Cached (cloned by plugin)
520
1548
 
521
1549
  This will **permanently delete** the cached repository from disk.
522
1550
 
523
- To proceed: \`repo_remove({ repo: "${args.repo}", confirm: true })\`
1551
+ To proceed: \`repo_remove({ repo: "${repoKey}", confirm: true })\`
524
1552
 
525
1553
  To keep the repo but unregister it, manually delete it from \`~/.cache/opencode-repos/manifest.json\`.`
1554
+ output = appendDebug(
1555
+ output,
1556
+ "repo_remove",
1557
+ [`repoKey: ${repoKey}`, `path: ${entry.path}`],
1558
+ debugEnabled
1559
+ )
1560
+ return output
526
1561
  }
527
1562
 
528
1563
  try {
@@ -534,14 +1569,21 @@ To keep the repo but unregister it, manually delete it from \`~/.cache/opencode-
534
1569
  await saveManifest(updatedManifest)
535
1570
  })
536
1571
 
537
- return `## Cached Repository Deleted
1572
+ let output = `## Cached Repository Deleted
538
1573
 
539
- **Repository**: ${args.repo}
1574
+ **Repository**: ${repoKey}
540
1575
  **Path**: ${entry.path}
541
1576
 
542
1577
  The repository has been permanently deleted from disk and unregistered from the cache.
543
1578
 
544
- To re-clone: \`repo_clone({ repo: "${args.repo}" })\``
1579
+ To re-clone: \`repo_clone({ repo: "${repoKey}" })\``
1580
+ output = appendDebug(
1581
+ output,
1582
+ "repo_remove",
1583
+ [`repoKey: ${repoKey}`, `path: ${entry.path}`],
1584
+ debugEnabled
1585
+ )
1586
+ return output
545
1587
  } catch (error) {
546
1588
  const message = error instanceof Error ? error.message : String(error)
547
1589
 
@@ -553,11 +1595,18 @@ To re-clone: \`repo_clone({ repo: "${args.repo}" })\``
553
1595
  })
554
1596
  } catch {}
555
1597
 
556
- return `## Deletion Failed
1598
+ let output = `## Deletion Failed
557
1599
 
558
- Failed to delete ${args.repo}: ${message}
1600
+ Failed to delete ${repoKey}: ${message}
559
1601
 
560
1602
  The repository has been unregistered from the manifest. You may need to manually delete the directory at: ${entry.path}`
1603
+ output = appendDebug(
1604
+ output,
1605
+ "repo_remove",
1606
+ [`repoKey: ${repoKey}`, `path: ${entry.path}`],
1607
+ debugEnabled
1608
+ )
1609
+ return output
561
1610
  }
562
1611
  },
563
1612
  }),
@@ -597,11 +1646,10 @@ The repository has been unregistered from the manifest. You may need to manually
597
1646
  }
598
1647
  }
599
1648
 
600
- const config = await loadConfig()
601
- if (config?.localSearchPaths?.length) {
1649
+ if (localSearchPaths.length > 0) {
602
1650
  try {
603
1651
  const localResults = await findLocalRepoByName(
604
- config.localSearchPaths,
1652
+ localSearchPaths,
605
1653
  query
606
1654
  )
607
1655
  for (const local of localResults) {
@@ -682,120 +1730,508 @@ The repository has been unregistered from the manifest. You may need to manually
682
1730
  output += `- Check if gh CLI is authenticated\n`
683
1731
  }
684
1732
 
1733
+ output = appendDebug(
1734
+ output,
1735
+ "repo_find",
1736
+ [`query: ${query}`, `localSearchPaths: ${localSearchPaths.length}`],
1737
+ debugEnabled
1738
+ )
685
1739
  return output
686
1740
  },
687
1741
  }),
688
1742
 
689
- repo_explore: tool({
1743
+ repo_pick_dir: tool({
690
1744
  description:
691
- "Explore a repository to understand its codebase. Spawns a specialized exploration agent that analyzes the repo and answers your question. The agent will read source files, trace code paths, and explain architecture.",
1745
+ "Open a native folder picker and return the selected path. Call this immediately after the user asks for a local repo (avoid delayed popups if the user is away).",
692
1746
  args: {
693
- repo: tool.schema
1747
+ prompt: tool.schema
694
1748
  .string()
1749
+ .optional()
1750
+ .describe("Prompt text shown in the picker dialog"),
1751
+ },
1752
+ async execute(args) {
1753
+ const promptText = args.prompt ?? "Select a repository folder"
1754
+ const platform = process.platform
1755
+
1756
+ if (platform === "darwin") {
1757
+ const safePrompt = promptText.replace(/"/g, "\\\"")
1758
+ const script = `POSIX path of (choose folder with prompt "${safePrompt}")`
1759
+ const result = await $`osascript -e ${script}`.text()
1760
+ const selected = result.trim()
1761
+
1762
+ if (!selected) {
1763
+ return appendDebug(
1764
+ "No folder selected.",
1765
+ "repo_pick_dir",
1766
+ [`platform: ${platform}`],
1767
+ debugEnabled
1768
+ )
1769
+ }
1770
+
1771
+ return appendDebug(
1772
+ `## Folder selected\n\n${selected}`,
1773
+ "repo_pick_dir",
1774
+ [`platform: ${platform}`],
1775
+ debugEnabled
1776
+ )
1777
+ }
1778
+
1779
+ if (platform === "win32") {
1780
+ const safePrompt = promptText.replace(/'/g, "''")
1781
+ const command = `[void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');` +
1782
+ `$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;` +
1783
+ `$dialog.Description='${safePrompt}';` +
1784
+ `if ($dialog.ShowDialog() -eq 'OK') { $dialog.SelectedPath }`
1785
+
1786
+ const result = await $`powershell -NoProfile -Command ${command}`.text()
1787
+ const selected = result.trim()
1788
+
1789
+ if (!selected) {
1790
+ return appendDebug(
1791
+ "No folder selected.",
1792
+ "repo_pick_dir",
1793
+ [`platform: ${platform}`],
1794
+ debugEnabled
1795
+ )
1796
+ }
1797
+
1798
+ return appendDebug(
1799
+ `## Folder selected\n\n${selected}`,
1800
+ "repo_pick_dir",
1801
+ [`platform: ${platform}`],
1802
+ debugEnabled
1803
+ )
1804
+ }
1805
+
1806
+ return appendDebug(
1807
+ "Folder picker is not supported on this platform.",
1808
+ "repo_pick_dir",
1809
+ [`platform: ${platform}`],
1810
+ debugEnabled
1811
+ )
1812
+ },
1813
+ }),
1814
+
1815
+ repo_query: tool({
1816
+ description:
1817
+ "Resolve a repository automatically and explore it with a subagent. Accepts local paths or owner/repo. Picks an exact match when possible, otherwise asks you to disambiguate. Can run multiple repos when specified.",
1818
+ args: {
1819
+ query: tool.schema
1820
+ .string()
1821
+ .optional()
695
1822
  .describe(
696
- "Repository in format 'owner/repo' or 'owner/repo@branch'"
1823
+ "Repository name, owner/repo, or absolute local path. Examples: 'next.js', 'vercel/next.js', '/Users/me/projects/app'"
697
1824
  ),
1825
+ repos: tool.schema
1826
+ .array(tool.schema.string())
1827
+ .optional()
1828
+ .describe("Explicit repositories to explore (owner/repo or owner/repo@branch)"),
698
1829
  question: tool.schema
699
1830
  .string()
700
1831
  .describe("What you want to understand about the codebase"),
701
1832
  },
702
1833
  async execute(args, ctx) {
703
- const spec = parseRepoSpec(args.repo)
704
- const branch = spec.branch || "main"
705
- const repoKey = `${spec.owner}/${spec.repo}@${branch}`
1834
+ const explicitRepos = args.repos?.filter(Boolean) ?? []
706
1835
 
707
- let manifest = await loadManifest()
708
- let repoPath: string
1836
+ if (explicitRepos.length === 0 && !args.query) {
1837
+ return `## Missing repository query
709
1838
 
710
- if (!manifest.repos[repoKey]) {
711
- try {
712
- repoPath = join(CACHE_DIR, spec.owner, `${spec.repo}@${branch}`)
713
- const url = buildGitUrl(spec.owner, spec.repo)
1839
+ Provide either \`query\` or a non-empty \`repos\` list.`
1840
+ }
714
1841
 
715
- await withManifestLock(async () => {
716
- await cloneRepo(url, repoPath, { branch })
1842
+ const targets: Array<{
1843
+ repoKey: string
1844
+ branch: string
1845
+ source?: RepoSource
1846
+ remote?: string
1847
+ path?: string
1848
+ }> = []
1849
+
1850
+ const debugInfo: RepoQueryDebugInfo = {
1851
+ query: args.query ?? explicitRepos.join(", "),
1852
+ allowGithub: false,
1853
+ localSearchPaths,
1854
+ candidates: [],
1855
+ selectedTargets: [],
1856
+ }
717
1857
 
718
- const now = new Date().toISOString()
719
- const updatedManifest = await loadManifest()
720
- updatedManifest.repos[repoKey] = {
721
- type: "cached",
722
- path: repoPath,
723
- clonedAt: now,
724
- lastAccessed: now,
725
- lastUpdated: now,
726
- defaultBranch: branch,
727
- shallow: true,
728
- }
729
- await saveManifest(updatedManifest)
1858
+ if (explicitRepos.length > 0) {
1859
+ for (const repo of explicitRepos) {
1860
+ try {
1861
+ const spec = parseRepoSpec(repo)
1862
+ targets.push({
1863
+ repoKey: `${spec.owner}/${spec.repo}`,
1864
+ branch: spec.branch || defaultBranch,
1865
+ })
1866
+ debugInfo.selectedTargets.push({
1867
+ repoKey: `${spec.owner}/${spec.repo}`,
1868
+ branch: spec.branch || defaultBranch,
1869
+ })
1870
+ } catch (error) {
1871
+ const message =
1872
+ error instanceof Error ? error.message : String(error)
1873
+ return `## Invalid repository
1874
+
1875
+ Failed to parse \`${repo}\`: ${message}`
1876
+ }
1877
+ }
1878
+ } else {
1879
+ const localPathResult = await resolveLocalPathQuery(
1880
+ args.query!,
1881
+ defaultBranch
1882
+ )
1883
+
1884
+ if (localPathResult.error) {
1885
+ let output = `## Local path error\n\n${localPathResult.error}`
1886
+ if (debugEnabled) {
1887
+ logRepoQueryDebug(debugInfo)
1888
+ output += `\n\n${formatRepoQueryDebug(debugInfo)}`
1889
+ }
1890
+ return output
1891
+ }
1892
+
1893
+ if (localPathResult.candidate) {
1894
+ const candidate = localPathResult.candidate
1895
+ targets.push({
1896
+ repoKey: candidate.key,
1897
+ branch: candidate.branch ?? defaultBranch,
1898
+ source: candidate.source,
1899
+ remote: candidate.remote,
1900
+ path: candidate.path,
730
1901
  })
731
- } catch (error) {
732
- const message =
733
- error instanceof Error ? error.message : String(error)
734
- return `## Failed to clone repository
1902
+ debugInfo.candidates = [candidate]
1903
+ debugInfo.selectedTargets.push({
1904
+ repoKey: candidate.key,
1905
+ branch: candidate.branch ?? defaultBranch,
1906
+ })
1907
+ }
735
1908
 
736
- Failed to clone ${args.repo}: ${message}
1909
+ if (targets.length > 0) {
1910
+ for (const target of targets) {
1911
+ if (target.source === "local" && target.remote && target.path) {
1912
+ await registerLocalRepo(
1913
+ target.repoKey,
1914
+ target.path,
1915
+ target.branch,
1916
+ target.remote
1917
+ )
1918
+ }
1919
+ }
1920
+ }
737
1921
 
738
- Please check that the repository exists and you have access to it.`
1922
+ if (targets.length > 0) {
1923
+ const results: Array<{ repoKey: string; response: string }> = []
1924
+
1925
+ for (const target of targets) {
1926
+ try {
1927
+ const resolved = await ensureRepoAvailable(
1928
+ target.repoKey,
1929
+ target.branch,
1930
+ cacheDir,
1931
+ useHttps,
1932
+ autoSyncOnExplore,
1933
+ autoSyncIntervalHours
1934
+ )
1935
+
1936
+ await requestExternalDirectoryAccess(
1937
+ ctx as PermissionContext,
1938
+ resolved.repoPath
1939
+ )
1940
+
1941
+ const response = await runRepoExplorer(
1942
+ client as RepoClient,
1943
+ ctx,
1944
+ target.repoKey,
1945
+ resolved.repoPath,
1946
+ args.question,
1947
+ repoExplorerModel,
1948
+ directory
1949
+ )
1950
+
1951
+ await touchRepoAccess(target.repoKey)
1952
+
1953
+ results.push({
1954
+ repoKey: target.repoKey,
1955
+ response,
1956
+ })
1957
+ } catch (error) {
1958
+ const message =
1959
+ error instanceof Error ? error.message : String(error)
1960
+ results.push({
1961
+ repoKey: target.repoKey,
1962
+ response: `## Exploration failed\n\n${message}`,
1963
+ })
1964
+ }
1965
+ }
1966
+
1967
+ if (results.length === 1) {
1968
+ if (debugEnabled) {
1969
+ logRepoQueryDebug(debugInfo)
1970
+ return `${results[0].response}\n\n${formatRepoQueryDebug(debugInfo)}`
1971
+ }
1972
+ return results[0].response
1973
+ }
1974
+
1975
+ let output = "## Repository Exploration Results\n\n"
1976
+ for (const result of results) {
1977
+ output += `### ${result.repoKey}\n\n${result.response}\n\n`
1978
+ }
1979
+
1980
+ if (debugEnabled) {
1981
+ logRepoQueryDebug(debugInfo)
1982
+ output += `${formatRepoQueryDebug(debugInfo)}\n`
1983
+ }
1984
+
1985
+ return output
739
1986
  }
740
- } else {
741
- repoPath = manifest.repos[repoKey].path
742
- }
743
1987
 
744
- const explorationPrompt = `Explore the codebase at ${repoPath} and answer the following question:
1988
+ const manifest = await loadManifest()
1989
+ let allowGithub = false
745
1990
 
746
- ${args.question}
1991
+ if (args.query?.includes("/")) {
1992
+ try {
1993
+ parseRepoSpec(args.query)
1994
+ allowGithub = true
1995
+ } catch {
1996
+ allowGithub = false
1997
+ }
1998
+ }
747
1999
 
748
- Working directory: ${repoPath}
2000
+ debugInfo.allowGithub = allowGithub
749
2001
 
750
- You have access to all standard code exploration tools:
751
- - read: Read files
752
- - glob: Find files by pattern
753
- - grep: Search for patterns
754
- - bash: Run git commands if needed
2002
+ const { candidates, exactRepoKey, branchOverride } = await resolveCandidates(
2003
+ args.query!,
2004
+ localSearchPaths,
2005
+ manifest,
2006
+ defaultBranch,
2007
+ allowGithub
2008
+ )
755
2009
 
756
- Remember to:
757
- - Start with high-level structure (README, package.json, main files)
758
- - Cite specific files and line numbers
759
- - Include relevant code snippets
760
- - Explain how components interact
2010
+ debugInfo.candidates = candidates
2011
+
2012
+ if (candidates.length === 0) {
2013
+ if (!allowGithub) {
2014
+ let output = `## No local repositories matched
2015
+
2016
+ No repositories matched \`${args.query}\` in the local registry.
2017
+
2018
+ Provide a local path or configure search paths:
2019
+
2020
+ - Use \`repo_pick_dir()\` to select a folder (GUI required)
2021
+ - Or set \`localSearchPaths\` in ~/.config/opencode/opencode-repos.json
2022
+ - Or pass an explicit repo in \`repos\` (owner/repo)
761
2023
  `
2024
+ if (debugEnabled) {
2025
+ logRepoQueryDebug(debugInfo)
2026
+ output += `\n${formatRepoQueryDebug(debugInfo)}`
2027
+ }
2028
+ return output
2029
+ }
2030
+ let output = `## No repositories found
762
2031
 
763
- try {
764
- const response = await client.session.prompt({
765
- path: { id: ctx.sessionID },
766
- body: {
767
- agent: "repo-explorer",
768
- parts: [{ type: "text", text: explorationPrompt }],
769
- },
770
- })
2032
+ No repositories matched \`${args.query}\`.
771
2033
 
772
- await withManifestLock(async () => {
773
- const updatedManifest = await loadManifest()
774
- if (updatedManifest.repos[repoKey]) {
775
- updatedManifest.repos[repoKey].lastAccessed =
776
- new Date().toISOString()
777
- await saveManifest(updatedManifest)
2034
+ Try:
2035
+ - Using owner/repo format for exact matches
2036
+ - Running \`repo_find({ query: "${args.query}" })\` to see available options`
2037
+ if (debugEnabled) {
2038
+ logRepoQueryDebug(debugInfo)
2039
+ output += `\n${formatRepoQueryDebug(debugInfo)}`
778
2040
  }
2041
+ return output
2042
+ }
2043
+
2044
+ if (candidates.length > 1 && !exactRepoKey) {
2045
+ let output = `## Multiple repositories matched "${args.query}"
2046
+
2047
+ Be more specific or pass an explicit list with \`repos\`.
2048
+
2049
+ `
2050
+ for (const candidate of candidates) {
2051
+ const sourceLabel = candidate.source === "registered" ? "registered" : candidate.source
2052
+ const description = candidate.description ? ` - ${candidate.description.slice(0, 80)}` : ""
2053
+ output += `- ${candidate.key} (${sourceLabel})${description}\n`
2054
+ }
2055
+
2056
+ if (debugEnabled) {
2057
+ logRepoQueryDebug(debugInfo)
2058
+ output += `\n${formatRepoQueryDebug(debugInfo)}`
2059
+ }
2060
+
2061
+ return output
2062
+ }
2063
+
2064
+ const selected = candidates[0]
2065
+ const branch = branchOverride ?? selected.branch ?? defaultBranch
2066
+
2067
+ targets.push({
2068
+ repoKey: selected.key,
2069
+ branch,
2070
+ source: selected.source,
2071
+ remote: selected.remote,
2072
+ path: selected.path,
2073
+ })
2074
+
2075
+ debugInfo.selectedTargets.push({
2076
+ repoKey: selected.key,
2077
+ branch,
779
2078
  })
2079
+ }
2080
+
2081
+ for (const target of targets) {
2082
+ if (target.source === "local" && target.remote && target.path) {
2083
+ await registerLocalRepo(
2084
+ target.repoKey,
2085
+ target.path,
2086
+ target.branch,
2087
+ target.remote
2088
+ )
2089
+ }
2090
+ }
2091
+
2092
+ const results: Array<{ repoKey: string; response: string }> = []
2093
+
2094
+ for (const target of targets) {
2095
+ try {
2096
+ const resolved = await ensureRepoAvailable(
2097
+ target.repoKey,
2098
+ target.branch,
2099
+ cacheDir,
2100
+ useHttps,
2101
+ autoSyncOnExplore,
2102
+ autoSyncIntervalHours
2103
+ )
2104
+
2105
+ await requestExternalDirectoryAccess(
2106
+ ctx as PermissionContext,
2107
+ resolved.repoPath
2108
+ )
2109
+
2110
+ const response = await runRepoExplorer(
2111
+ client as RepoClient,
2112
+ ctx,
2113
+ target.repoKey,
2114
+ resolved.repoPath,
2115
+ args.question,
2116
+ repoExplorerModel,
2117
+ directory
2118
+ )
780
2119
 
781
- if (response.error) {
782
- return `## Exploration failed
2120
+ await touchRepoAccess(target.repoKey)
783
2121
 
784
- Error from API: ${JSON.stringify(response.error)}`
2122
+ results.push({
2123
+ repoKey: target.repoKey,
2124
+ response,
2125
+ })
2126
+ } catch (error) {
2127
+ const message =
2128
+ error instanceof Error ? error.message : String(error)
2129
+ results.push({
2130
+ repoKey: target.repoKey,
2131
+ response: `## Exploration failed\n\n${message}`,
2132
+ })
2133
+ }
2134
+ }
2135
+
2136
+ if (results.length === 1) {
2137
+ if (debugEnabled) {
2138
+ logRepoQueryDebug(debugInfo)
2139
+ return `${results[0].response}\n\n${formatRepoQueryDebug(debugInfo)}`
785
2140
  }
2141
+ return results[0].response
2142
+ }
2143
+
2144
+ let output = "## Repository Exploration Results\n\n"
2145
+ for (const result of results) {
2146
+ output += `### ${result.repoKey}\n\n${result.response}\n\n`
2147
+ }
2148
+
2149
+ if (debugEnabled) {
2150
+ logRepoQueryDebug(debugInfo)
2151
+ output += `${formatRepoQueryDebug(debugInfo)}\n`
2152
+ }
2153
+
2154
+ return output
2155
+ },
2156
+ }),
2157
+
2158
+ repo_explore: tool({
2159
+ description:
2160
+ "Explore a repository to understand its codebase. Spawns a specialized exploration agent that analyzes the repo and answers your question. The agent will read source files, trace code paths, and explain architecture.",
2161
+ args: {
2162
+ repo: tool.schema
2163
+ .string()
2164
+ .describe(
2165
+ "Repository in format 'owner/repo' or 'owner/repo@branch'"
2166
+ ),
2167
+ question: tool.schema
2168
+ .string()
2169
+ .describe("What you want to understand about the codebase"),
2170
+ },
2171
+ async execute(args, ctx) {
2172
+ const spec = parseRepoSpec(args.repo)
2173
+ const branch = spec.branch || defaultBranch
2174
+ const repoKey = `${spec.owner}/${spec.repo}`
2175
+ let repoPath: string
2176
+
2177
+ try {
2178
+ const resolved = await ensureRepoAvailable(
2179
+ repoKey,
2180
+ branch,
2181
+ cacheDir,
2182
+ useHttps,
2183
+ autoSyncOnExplore,
2184
+ autoSyncIntervalHours
2185
+ )
2186
+ repoPath = resolved.repoPath
2187
+ await requestExternalDirectoryAccess(ctx as PermissionContext, repoPath)
2188
+ } catch (error) {
2189
+ const message =
2190
+ error instanceof Error ? error.message : String(error)
2191
+ const output = `## Failed to prepare repository
786
2192
 
787
- const parts = response.data?.parts || []
788
- const textParts = parts.filter(p => p.type === "text")
789
- const texts = textParts.map(p => "text" in p ? p.text : "").filter(Boolean)
790
- return texts.join("\n\n") || "No response from exploration agent."
2193
+ Failed to prepare ${args.repo}: ${message}
2194
+
2195
+ Please check that the repository exists and you have access to it.`
2196
+ return appendDebug(
2197
+ output,
2198
+ "repo_explore",
2199
+ [`repoKey: ${repoKey}`, `branch: ${branch}`],
2200
+ debugEnabled
2201
+ )
2202
+ }
2203
+
2204
+ try {
2205
+ const response = await runRepoExplorer(
2206
+ client as RepoClient,
2207
+ ctx,
2208
+ repoKey,
2209
+ repoPath,
2210
+ args.question,
2211
+ repoExplorerModel,
2212
+ directory
2213
+ )
2214
+ await touchRepoAccess(repoKey)
2215
+ return appendDebug(
2216
+ response,
2217
+ "repo_explore",
2218
+ [`repoKey: ${repoKey}`, `branch: ${branch}`, `path: ${repoPath}`],
2219
+ debugEnabled
2220
+ )
791
2221
  } catch (error) {
792
2222
  const message =
793
2223
  error instanceof Error ? error.message : String(error)
794
- return `## Exploration failed
2224
+ const output = `## Exploration failed
795
2225
 
796
2226
  Failed to spawn exploration agent: ${message}
797
2227
 
798
2228
  This may indicate an issue with the OpenCode session or agent registration.`
2229
+ return appendDebug(
2230
+ output,
2231
+ "repo_explore",
2232
+ [`repoKey: ${repoKey}`, `branch: ${branch}`],
2233
+ debugEnabled
2234
+ )
799
2235
  }
800
2236
  },
801
2237
  }),