@oh-my-pi/pi-coding-agent 17.0.3 → 17.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +88 -35
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/mnemopi/state.d.ts +32 -9
  17. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  18. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  20. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  21. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  22. package/dist/types/modes/interactive-mode.d.ts +2 -0
  23. package/dist/types/modes/print-mode.d.ts +4 -0
  24. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -0
  27. package/dist/types/sdk.d.ts +2 -0
  28. package/dist/types/session/agent-session.d.ts +19 -1
  29. package/dist/types/session/messages.d.ts +6 -0
  30. package/dist/types/task/types.d.ts +6 -6
  31. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  32. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  33. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  35. package/dist/types/tools/gh.d.ts +5 -3
  36. package/dist/types/tools/hub/index.d.ts +1 -1
  37. package/dist/types/tools/hub/jobs.d.ts +1 -0
  38. package/dist/types/tools/hub/types.d.ts +2 -0
  39. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  40. package/dist/types/utils/git.d.ts +33 -0
  41. package/dist/types/web/search/providers/codex.d.ts +1 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +150 -0
  44. package/src/advisor/advise-tool.ts +4 -0
  45. package/src/advisor/runtime.ts +38 -14
  46. package/src/async/job-manager.ts +3 -0
  47. package/src/cli/bench-cli.ts +8 -2
  48. package/src/cli/dry-balance-cli.ts +1 -0
  49. package/src/config/model-registry.ts +89 -8
  50. package/src/config/model-resolver.ts +78 -14
  51. package/src/config/models-config-schema.ts +1 -0
  52. package/src/config/settings.ts +3 -1
  53. package/src/dap/client.ts +168 -1
  54. package/src/dap/config.ts +51 -1
  55. package/src/dap/session.ts +575 -234
  56. package/src/dap/types.ts +6 -5
  57. package/src/discovery/agents.ts +2 -2
  58. package/src/extensibility/extensions/runner.ts +6 -4
  59. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  60. package/src/launch/broker.ts +34 -31
  61. package/src/launch/client.ts +7 -1
  62. package/src/launch/spawn-options.test.ts +31 -0
  63. package/src/launch/spawn-options.ts +17 -0
  64. package/src/lsp/types.ts +5 -1
  65. package/src/main.ts +17 -4
  66. package/src/mcp/transports/stdio.test.ts +9 -1
  67. package/src/mnemopi/backend.ts +1 -1
  68. package/src/mnemopi/embed-worker.ts +8 -7
  69. package/src/mnemopi/state.ts +45 -18
  70. package/src/modes/components/ask-dialog.ts +137 -73
  71. package/src/modes/components/status-line/component.ts +2 -2
  72. package/src/modes/components/status-line/segments.ts +20 -3
  73. package/src/modes/components/status-line/types.ts +3 -1
  74. package/src/modes/components/tool-execution.ts +5 -0
  75. package/src/modes/components/transcript-container.ts +18 -111
  76. package/src/modes/components/tree-selector.ts +10 -4
  77. package/src/modes/controllers/event-controller.ts +1 -2
  78. package/src/modes/controllers/input-controller.ts +1 -1
  79. package/src/modes/controllers/selector-controller.ts +29 -8
  80. package/src/modes/interactive-mode.ts +40 -8
  81. package/src/modes/noninteractive-dispose.test.ts +12 -1
  82. package/src/modes/print-mode.ts +21 -9
  83. package/src/modes/rpc/rpc-input.ts +38 -0
  84. package/src/modes/rpc/rpc-mode.ts +7 -2
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  86. package/src/modes/types.ts +2 -0
  87. package/src/modes/utils/ui-helpers.ts +3 -2
  88. package/src/prompts/tools/browser.md +3 -2
  89. package/src/prompts/tools/debug.md +2 -7
  90. package/src/prompts/tools/github.md +6 -1
  91. package/src/prompts/tools/hub.md +4 -2
  92. package/src/prompts/tools/task-async-contract.md +1 -0
  93. package/src/prompts/tools/task.md +9 -2
  94. package/src/sdk.ts +38 -6
  95. package/src/session/agent-session.ts +395 -162
  96. package/src/session/messages.test.ts +91 -0
  97. package/src/session/messages.ts +248 -110
  98. package/src/session/session-loader.ts +31 -3
  99. package/src/slash-commands/builtin-registry.ts +1 -0
  100. package/src/stt/recorder.ts +68 -55
  101. package/src/task/executor.ts +59 -33
  102. package/src/task/index.ts +46 -9
  103. package/src/task/types.ts +11 -8
  104. package/src/task/worktree.ts +10 -0
  105. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  106. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  107. package/src/tools/browser/cmux/socket-client.ts +139 -3
  108. package/src/tools/browser/run-cancellation.ts +4 -0
  109. package/src/tools/browser/tab-protocol.ts +2 -0
  110. package/src/tools/browser/tab-supervisor.ts +21 -11
  111. package/src/tools/browser/tab-worker.ts +199 -33
  112. package/src/tools/debug.ts +3 -0
  113. package/src/tools/gh.ts +40 -2
  114. package/src/tools/hub/index.ts +4 -1
  115. package/src/tools/hub/jobs.ts +42 -1
  116. package/src/tools/hub/types.ts +2 -0
  117. package/src/tools/tool-timeouts.ts +1 -1
  118. package/src/utils/git.ts +237 -0
  119. package/src/web/search/index.ts +9 -5
  120. package/src/web/search/providers/codex.ts +195 -99
package/src/tools/gh.ts CHANGED
@@ -249,6 +249,7 @@ const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "a
249
249
  const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]);
250
250
  const GITHUB_READONLY_OPS: ReadonlySet<string> = new Set([
251
251
  "repo_view",
252
+ "file_read",
252
253
  "search_issues",
253
254
  "search_prs",
254
255
  "search_code",
@@ -259,10 +260,11 @@ const GITHUB_READONLY_OPS: ReadonlySet<string> = new Set([
259
260
 
260
261
  const githubSchema = type({
261
262
  op: type(
262
- "'repo_view' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'",
263
+ "'repo_view' | 'file_read' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'",
263
264
  ).describe("github operation"),
264
265
  "repo?": type("string").describe("owner/repo"),
265
266
  "branch?": type("string").describe("branch"),
267
+ "path?": type("string").describe("repository-relative file path"),
266
268
  "pr?": type("string | string[]").describe("pr number, url, or branch"),
267
269
  "force?": type("boolean").describe("reset existing local branch"),
268
270
  "forceWithLease?": type("boolean").describe("force-with-lease push"),
@@ -2455,7 +2457,7 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
2455
2457
  const op = typeof rawOp === "string" ? rawOp : "";
2456
2458
  return GITHUB_READONLY_OPS.has(op) ? "read" : "exec";
2457
2459
  };
2458
- readonly summary = "Interact with GitHub issues, pull requests, and repositories";
2460
+ readonly summary = "Interact with GitHub repositories, files, pull requests, and Actions";
2459
2461
  readonly loadMode = "discoverable";
2460
2462
  readonly label = "GitHub";
2461
2463
  readonly description = prompt.render(githubDescription);
@@ -2480,6 +2482,8 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
2480
2482
  switch (params.op) {
2481
2483
  case "repo_view":
2482
2484
  return executeRepoView(this.session, params, signal);
2485
+ case "file_read":
2486
+ return executeFileRead(this.session, params, signal);
2483
2487
  case "pr_create":
2484
2488
  return executePrCreate(this.session, params, signal);
2485
2489
  case "pr_checkout":
@@ -2525,6 +2529,40 @@ async function executeRepoView(
2525
2529
  return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
2526
2530
  }
2527
2531
 
2532
+ async function executeFileRead(
2533
+ session: ToolSession,
2534
+ params: GithubInput,
2535
+ signal: AbortSignal | undefined,
2536
+ ): Promise<AgentToolResult<GhToolDetails>> {
2537
+ const repo = await resolveGitHubRepo(session.cwd, normalizeOptionalString(params.repo), undefined, signal);
2538
+ const filePath = requireNonEmpty(normalizeOptionalString(params.path), "path");
2539
+ if (filePath.startsWith("/")) {
2540
+ throw new ToolError("path must be repository-relative");
2541
+ }
2542
+ const branch = normalizeOptionalString(params.branch);
2543
+ const endpointPath = filePath
2544
+ .split("/")
2545
+ .map(segment => encodeURIComponent(segment))
2546
+ .join("/");
2547
+ const args = [
2548
+ "api",
2549
+ `/repos/${repo}/contents/${endpointPath}`,
2550
+ "--method",
2551
+ "GET",
2552
+ "-H",
2553
+ "Accept: application/vnd.github.raw+json",
2554
+ ];
2555
+ if (branch) {
2556
+ args.push("-f", `ref=${branch}`);
2557
+ }
2558
+ const text = await git.github.text(session.cwd, args, signal, {
2559
+ repoProvided: true,
2560
+ trimOutput: false,
2561
+ });
2562
+ const sourceUrl = `https://github.com/${repo}/blob/${encodeURIComponent(branch ?? "HEAD")}/${endpointPath}`;
2563
+ return buildTextResult(text, sourceUrl, { repo, branch });
2564
+ }
2565
+
2528
2566
  // ────────────────────────────────────────────────────────────────────────────
2529
2567
  // Cached issue/PR view fetchers
2530
2568
  //
@@ -159,7 +159,10 @@ export class HubTool implements AgentTool<typeof hubSchema, HubDetails> {
159
159
  readonly description: string;
160
160
  readonly parameters = hubSchema;
161
161
  readonly strict = true;
162
- readonly interruptible = true;
162
+ readonly interruptible = (params: Partial<HubParams>): boolean => {
163
+ if (params.op === "wait") return true;
164
+ return params.op === "logs" && params.follow === true;
165
+ };
163
166
  readonly loadMode = "essential";
164
167
 
165
168
  readonly examples: readonly ToolExample<typeof hubSchema.infer>[] = [
@@ -8,6 +8,7 @@ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
8
8
  import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import type { AsyncJob, AsyncJobManager } from "../../async";
11
+ import { settings } from "../../config/settings";
11
12
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
12
13
  import { shimmerEnabled, shimmerText } from "../../modes/theme/shimmer";
13
14
  import type { Theme } from "../../modes/theme/theme";
@@ -134,6 +135,7 @@ interface TrackedJobLike {
134
135
  status: string;
135
136
  label: string;
136
137
  startTime: number;
138
+ latestDetails?: Record<string, unknown>;
137
139
  resultText?: string;
138
140
  errorText?: string;
139
141
  }
@@ -143,12 +145,34 @@ export function snapshotJobs(session: ToolSession, jobs: TrackedJobLike[]): JobS
143
145
  return jobs.map(j => {
144
146
  const current = session.asyncJobManager?.getJob(j.id);
145
147
  const latest = current ?? j;
148
+ let resolvedModel: string | undefined;
149
+ if (latest.type === "task") {
150
+ const progressValue = latest.latestDetails?.progress;
151
+ if (Array.isArray(progressValue)) {
152
+ let progressRecord: Record<string, unknown> | undefined;
153
+ for (const item of progressValue) {
154
+ if (!item || typeof item !== "object") continue;
155
+ const candidate = item as Record<string, unknown>;
156
+ if (!progressRecord) progressRecord = candidate;
157
+ if (candidate.id === latest.id) {
158
+ progressRecord = candidate;
159
+ break;
160
+ }
161
+ }
162
+ const modelValue = progressRecord?.resolvedModel;
163
+ if (typeof modelValue === "string") {
164
+ const trimmed = modelValue.trim();
165
+ if (trimmed) resolvedModel = trimmed;
166
+ }
167
+ }
168
+ }
146
169
  return {
147
170
  id: latest.id,
148
171
  type: latest.type,
149
172
  status: latest.status as JobSnapshot["status"],
150
173
  label: latest.label,
151
174
  durationMs: Math.max(0, now - latest.startTime),
175
+ ...(resolvedModel ? { resolvedModel } : {}),
152
176
  ...(latest.resultText ? { resultText: latest.resultText } : {}),
153
177
  ...(latest.errorText ? { errorText: latest.errorText } : {}),
154
178
  };
@@ -354,6 +378,7 @@ const PREVIEW_LINES_EXPANDED = 4;
354
378
  const LABEL_LINES_COLLAPSED = 1;
355
379
  const LABEL_LINES_EXPANDED = 3;
356
380
  const PREVIEW_LINE_WIDTH = 80;
381
+ const MODEL_BADGE_MAX_WIDTH = 48;
357
382
 
358
383
  function statusToIcon(status: JobSnapshot["status"]): ToolUIStatus {
359
384
  switch (status) {
@@ -545,6 +570,20 @@ export function jobsRenderResult(
545
570
  visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
546
571
  }
547
572
  const durationText = uiTheme.fg("dim", formatDuration(job.durationMs));
573
+ const modelText =
574
+ job.type === "task" &&
575
+ typeof job.resolvedModel === "string" &&
576
+ job.resolvedModel.trim() &&
577
+ settings.get("task.showResolvedModelBadge")
578
+ ? `${uiTheme.sep.dot}${uiTheme.fg(
579
+ "dim",
580
+ truncateToWidth(
581
+ replaceTabs(job.resolvedModel.trim()),
582
+ MODEL_BADGE_MAX_WIDTH,
583
+ Ellipsis.Unicode,
584
+ ),
585
+ )}`
586
+ : "";
548
587
  // Running rows in a live block shimmer their label; once the block
549
588
  // stops animating (sealed, or a settled snapshot — spinnerFrame
550
589
  // cleared) they render static so scrollback never keeps a mid-sweep
@@ -556,7 +595,9 @@ export function jobsRenderResult(
556
595
  ? shimmerText(headRaw, uiTheme)
557
596
  : uiTheme.fg("accent", headRaw)
558
597
  : uiTheme.fg("toolOutput", headRaw);
559
- lines.push(`${icon}${idPart} ${typeBadge} ${headLabel} ${durationText}`);
598
+ lines.push(
599
+ `${icon}${idPart} ${typeBadge} ${headLabel}${modelText}${modelText ? uiTheme.sep.dot : " "}${durationText}`,
600
+ );
560
601
  for (let i = 1; i < visibleLabelLines.length; i++) {
561
602
  lines.push(` ${uiTheme.fg("toolOutput", visibleLabelLines[i]!)}`);
562
603
  }
@@ -46,6 +46,8 @@ export interface JobSnapshot {
46
46
  status: "running" | "completed" | "failed" | "cancelled";
47
47
  label: string;
48
48
  durationMs: number;
49
+ /** Effective task model selector, including an explicit reasoning suffix when configured. */
50
+ resolvedModel?: string;
49
51
  resultText?: string;
50
52
  errorText?: string;
51
53
  }
@@ -13,7 +13,7 @@ export const TOOL_TIMEOUTS = {
13
13
  browser: { default: 30, min: 1, max: 300 },
14
14
  ssh: { default: 60, min: 1, max: 3600 },
15
15
  fetch: { default: 20, min: 1, max: 45 },
16
- lsp: { default: 20, min: 5, max: 60 },
16
+ lsp: { default: 20, min: 5, max: 300 },
17
17
  debug: { default: 30, min: 5, max: 300 },
18
18
  } as const satisfies Record<string, ToolTimeoutConfig>;
19
19
 
package/src/utils/git.ts CHANGED
@@ -637,6 +637,10 @@ async function readOptionalText(filePath: string): Promise<string | null> {
637
637
  return retryOnEintr(async () => await Bun.file(filePath).text());
638
638
  }
639
639
 
640
+ async function readOptionalBytes(filePath: string): Promise<Uint8Array | null> {
641
+ return retryOnEintr(async () => await Bun.file(filePath).bytes());
642
+ }
643
+
640
644
  function parseGitDirPointer(content: string): string | null {
641
645
  const match = /^gitdir:\s*(.+)\s*$/iu.exec(content.trim());
642
646
  return match?.[1] ?? null;
@@ -1379,6 +1383,239 @@ export async function writeTree(cwd: string, options: Pick<CommandOptions, "env"
1379
1383
  return (await runText(cwd, ["write-tree"], options)).trim();
1380
1384
  }
1381
1385
 
1386
+ // ════════════════════════════════════════════════════════════════════════════
1387
+ // API: worktree isolation
1388
+ // ════════════════════════════════════════════════════════════════════════════
1389
+
1390
+ /** Outcome of {@link detachGitDir}. */
1391
+ export type DetachGitDirResult =
1392
+ /** `worktreeRoot` had no `.git`; nothing to detach. */
1393
+ | "no-git"
1394
+ /** `.git` already resolves to an independent object DB — left untouched. */
1395
+ | "independent"
1396
+ /** Detached into a standalone repo borrowing `sourceCommonDir`'s objects. */
1397
+ | "detached";
1398
+
1399
+ /**
1400
+ * Sever a copied/mounted working tree from the git metadata it shares with a
1401
+ * source checkout, turning it into a standalone repository that borrows the
1402
+ * source object database through `objects/info/alternates`.
1403
+ *
1404
+ * Isolation backends (reflink/apfs/btrfs/rcopy…) materialise `merged` by
1405
+ * copying `worktreeRoot` byte-for-byte. When `worktreeRoot` is a **linked git
1406
+ * worktree** its `.git` is a pointer file (`gitdir: …/worktrees/<name>`), so
1407
+ * the copy still resolves HEAD/index/refs through the source repo — a task's
1408
+ * `git checkout`/`commit` inside the isolation then mutates the *parent*
1409
+ * checkout. The rcopy `git worktree add` path leaks the other way: task
1410
+ * branches land in the shared ref namespace and stack on each other.
1411
+ *
1412
+ * After detaching, the working tree keeps its files verbatim while:
1413
+ * - HEAD, refs, and the index are frozen to the snapshot at call time;
1414
+ * - all commits/branches the task creates stay private to the isolation;
1415
+ * - objects resolve against `sourceCommonDir` via alternates, so history reads
1416
+ * and later `git fetch <merged>` object transfer keep working;
1417
+ * - the source checkout's HEAD, branch, index, and working tree are untouched.
1418
+ *
1419
+ * A full-copy `.git` (non-worktree source) already owns its object DB and is
1420
+ * returned as `"independent"` without modification. `worktreeRoot` without a
1421
+ * `.git` yields `"no-git"`.
1422
+ */
1423
+ export async function detachGitDir(worktreeRoot: string, sourceCommonDir: string): Promise<DetachGitDirResult> {
1424
+ ensureAvailable();
1425
+ const gitEntry = path.join(worktreeRoot, ".git");
1426
+ let entryStat: fs.Stats;
1427
+ try {
1428
+ entryStat = await fs.promises.lstat(gitEntry);
1429
+ } catch (err) {
1430
+ if (isEnoent(err)) return "no-git";
1431
+ throw err;
1432
+ }
1433
+ // Canonicalize both sides before comparing: `rev-parse` resolves symlinks
1434
+ // (macOS `/tmp` → `/private/tmp`) while callers derive `sourceCommonDir`
1435
+ // lexically from the session cwd. A lexical mismatch here would silently
1436
+ // classify a shared linked-worktree copy as "independent" and skip the
1437
+ // detach entirely — leaving the parent-mutation leak in place.
1438
+ const parentCommon = await fs.promises.realpath(sourceCommonDir).catch(() => path.resolve(sourceCommonDir));
1439
+ const isoCommonRaw = (
1440
+ await runText(worktreeRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
1441
+ readOnly: true,
1442
+ })
1443
+ ).trim();
1444
+ const isoCommon = await fs.promises.realpath(isoCommonRaw).catch(() => path.resolve(isoCommonRaw));
1445
+ // A full-copy `.git` already resolves to its own object DB — leave it alone.
1446
+ if (isoCommon !== parentCommon) return "independent";
1447
+
1448
+ // Snapshot the state the standalone repo must preserve. HEAD may be a branch
1449
+ // ref (normal checkout), detached, or unborn (a fresh/orphan branch with no
1450
+ // commits — a linked worktree still shares the parent ref namespace, so it
1451
+ // must be severed too). Refs are frozen so `baseSha..branch` ranges and
1452
+ // history reads keep resolving after the source moves on.
1453
+ const headSha = (await tryText(worktreeRoot, ["rev-parse", "HEAD"], { readOnly: true }))?.trim() ?? "";
1454
+ const headRef = (await tryText(worktreeRoot, ["symbolic-ref", "-q", "HEAD"], { readOnly: true }))?.trim() ?? "";
1455
+ const refDump = headSha
1456
+ ? (
1457
+ await runText(worktreeRoot, ["for-each-ref", "--format=%(objectname) %(refname)"], {
1458
+ readOnly: true,
1459
+ })
1460
+ ).trim()
1461
+ : "";
1462
+ const objectFormat =
1463
+ (await tryText(worktreeRoot, ["rev-parse", "--show-object-format"], { readOnly: true }))?.trim() || "sha1";
1464
+ const userName = await config.get(worktreeRoot, "user.name");
1465
+ const userEmail = await config.get(worktreeRoot, "user.email");
1466
+
1467
+ // Preserve the index verbatim rather than round-tripping through
1468
+ // write-tree/read-tree: the raw index carries skip-worktree bits (sparse
1469
+ // checkout), assume-unchanged flags, and exact stage entries. A rebuilt
1470
+ // index drops skip-worktree, so files intentionally absent from a sparse
1471
+ // working tree would read as deletions and delta capture would apply those
1472
+ // deletions back to the parent. Sparse config + patterns are carried too so
1473
+ // later git operations in the isolation keep honouring the sparse view.
1474
+ const indexPath = (
1475
+ await runText(worktreeRoot, ["rev-parse", "--path-format=absolute", "--git-path", "index"], {
1476
+ readOnly: true,
1477
+ })
1478
+ ).trim();
1479
+ const indexBytes = await readOptionalBytes(indexPath);
1480
+ const sparseCheckout = await config.get(worktreeRoot, "core.sparseCheckout");
1481
+ const sparseCone = await config.get(worktreeRoot, "core.sparseCheckoutCone");
1482
+ const sparsePatternPath = (
1483
+ await runText(worktreeRoot, ["rev-parse", "--path-format=absolute", "--git-path", "info/sparse-checkout"], {
1484
+ readOnly: true,
1485
+ })
1486
+ ).trim();
1487
+ const sparsePatterns = await readOptionalText(sparsePatternPath);
1488
+ // Status parity with the source: an explicit core.filemode (e.g. false on
1489
+ // mounts ignoring the executable bit) must carry over, or the re-inited
1490
+ // repo's platform default makes clean files read as mode-changed and delta
1491
+ // capture would apply bogus chmod diffs back to the parent.
1492
+ const fileMode = await config.get(worktreeRoot, "core.fileMode");
1493
+ // A split index references sharedindex.* files beside the source index;
1494
+ // restoring the raw index without them makes every git read fail. Carry the
1495
+ // shared files (and the config) alongside the verbatim index bytes.
1496
+ const splitIndex = await config.get(worktreeRoot, "core.splitIndex");
1497
+ const sharedIndexFiles: Array<{ name: string; bytes: Uint8Array }> = [];
1498
+ if (indexBytes) {
1499
+ const indexDir = path.dirname(indexPath);
1500
+ let entries: string[] = [];
1501
+ try {
1502
+ entries = await fs.promises.readdir(indexDir);
1503
+ } catch {}
1504
+ for (const name of entries) {
1505
+ if (!name.startsWith("sharedindex.")) continue;
1506
+ const bytes = await readOptionalBytes(path.join(indexDir, name));
1507
+ if (bytes) sharedIndexFiles.push({ name, bytes });
1508
+ }
1509
+ }
1510
+ // A shallow source deliberately lacks parents beyond its `shallow` boundary
1511
+ // file; without it, history traversal over the borrowed objects treats the
1512
+ // boundary commit's missing parent as corruption.
1513
+ const shallowBoundary = await readOptionalText(path.join(parentCommon, "shallow"));
1514
+
1515
+ // A pointer `.git` file whose worktree-admin dir back-references this exact
1516
+ // tree is the rcopy `git worktree add` registration. Remove that admin entry
1517
+ // so the source repo's worktree list stops tracking the isolation. A pointer
1518
+ // referencing the *source's* admin (a copied linked-worktree `.git`) is not
1519
+ // ours to delete — only the local pointer file is discarded. Compare via
1520
+ // realpath: git canonicalizes the back-reference (e.g. macOS `/var` →
1521
+ // `/private/var`), so a lexical path comparison would miss the match and
1522
+ // leave a stale registration in the source repo's worktree list.
1523
+ let ownWorktreeAdmin: string | undefined;
1524
+ if (entryStat.isFile()) {
1525
+ const pointer = parseGitDirPointer((await readOptionalText(gitEntry)) ?? "");
1526
+ if (pointer) {
1527
+ const adminDir = path.resolve(path.dirname(gitEntry), pointer);
1528
+ const backRef = (await readOptionalText(path.join(adminDir, "gitdir")))?.trim();
1529
+ if (backRef) {
1530
+ const [realBackRef, realGitEntry] = await Promise.all([
1531
+ fs.promises.realpath(backRef).catch(() => path.resolve(backRef)),
1532
+ fs.promises.realpath(gitEntry).catch(() => path.resolve(gitEntry)),
1533
+ ]);
1534
+ if (realBackRef === realGitEntry) ownWorktreeAdmin = adminDir;
1535
+ }
1536
+ }
1537
+ }
1538
+
1539
+ await fs.promises.rm(gitEntry, { recursive: true, force: true });
1540
+ if (ownWorktreeAdmin) await fs.promises.rm(ownWorktreeAdmin, { recursive: true, force: true });
1541
+
1542
+ // Preserve the checked-out branch name so an unborn HEAD (fresh/orphan
1543
+ // branch with no commits) keeps its symbolic ref after `init` rather than
1544
+ // snapping to the init default; born HEADs get the ref rewritten below anyway.
1545
+ const initArgs = ["init", "--object-format", objectFormat, "-q"];
1546
+ const initialBranch = headRef.startsWith(LOCAL_BRANCH_PREFIX) ? headRef.slice(LOCAL_BRANCH_PREFIX.length) : "";
1547
+ if (initialBranch) initArgs.push("-b", initialBranch);
1548
+ await runEffect(worktreeRoot, initArgs);
1549
+ const objectsInfo = path.join(gitEntry, "objects", "info");
1550
+ await fs.promises.mkdir(objectsInfo, { recursive: true });
1551
+ const alternates = [path.join(parentCommon, "objects")];
1552
+ const chained = await readOptionalText(path.join(parentCommon, "objects", "info", "alternates"));
1553
+ if (chained) {
1554
+ for (const line of chained.split("\n")) {
1555
+ const entry = line.trim();
1556
+ if (!entry) continue;
1557
+ alternates.push(path.isAbsolute(entry) ? entry : path.resolve(parentCommon, "objects", entry));
1558
+ }
1559
+ }
1560
+ await Bun.write(path.join(objectsInfo, "alternates"), `${alternates.join("\n")}\n`);
1561
+
1562
+ // Freeze refs when HEAD is born. Point HEAD at the raw SHA first so
1563
+ // `update-ref` writes land even for the branch HEAD currently names, then
1564
+ // restore the symbolic HEAD. An unborn HEAD has no refs to freeze; `init -b`
1565
+ // above already set the symbolic HEAD to the unborn branch.
1566
+ if (headSha) {
1567
+ await Bun.write(path.join(gitEntry, "HEAD"), `${headSha}\n`);
1568
+ if (refDump) {
1569
+ const commands = refDump
1570
+ .split("\n")
1571
+ .filter(Boolean)
1572
+ .map(line => {
1573
+ const sep = line.indexOf(" ");
1574
+ return `create ${line.slice(sep + 1)} ${line.slice(0, sep)}`;
1575
+ })
1576
+ .join("\n");
1577
+ await runEffect(worktreeRoot, ["update-ref", "--stdin"], { stdin: `${commands}\n` });
1578
+ }
1579
+ if (headRef) await Bun.write(path.join(gitEntry, "HEAD"), `ref: ${headRef}\n`);
1580
+ } else if (headRef && !initialBranch) {
1581
+ // Unborn detached HEAD (no branch, no commit) — restore the raw ref target.
1582
+ await Bun.write(path.join(gitEntry, "HEAD"), `ref: ${headRef}\n`);
1583
+ }
1584
+
1585
+ // Carry the source identity so isolated commits have an author.
1586
+ if (userName) await config.set(worktreeRoot, "user.name", userName);
1587
+ if (userEmail) await config.set(worktreeRoot, "user.email", userEmail);
1588
+ if (fileMode !== undefined) await config.set(worktreeRoot, "core.fileMode", fileMode);
1589
+ if (splitIndex !== undefined) await config.set(worktreeRoot, "core.splitIndex", splitIndex);
1590
+ // Preserve the shallow boundary so history traversal over the borrowed
1591
+ // object DB stops at the boundary instead of failing on missing parents.
1592
+ if (shallowBoundary !== null) await Bun.write(path.join(gitEntry, "shallow"), shallowBoundary);
1593
+
1594
+ // Restore sparse-checkout state before the index so skip-worktree entries
1595
+ // keep resolving against the carried patterns.
1596
+ if (sparseCheckout) await config.set(worktreeRoot, "core.sparseCheckout", sparseCheckout);
1597
+ if (sparseCone) await config.set(worktreeRoot, "core.sparseCheckoutCone", sparseCone);
1598
+ if (sparsePatterns !== null) {
1599
+ const infoDir = path.join(gitEntry, "info");
1600
+ await fs.promises.mkdir(infoDir, { recursive: true });
1601
+ await Bun.write(path.join(infoDir, "sparse-checkout"), sparsePatterns);
1602
+ }
1603
+
1604
+ // Restore the index verbatim (skip-worktree, assume-unchanged, exact stage
1605
+ // entries) so the working tree's dirty set — including sparse-excluded files
1606
+ // — matches the source. Fall back to rebuilding from HEAD only when the
1607
+ // source had no index (a bare-ish/never-staged checkout).
1608
+ if (indexBytes) {
1609
+ for (const shared of sharedIndexFiles) {
1610
+ await Bun.write(path.join(gitEntry, shared.name), shared.bytes);
1611
+ }
1612
+ await Bun.write(path.join(gitEntry, "index"), indexBytes);
1613
+ } else if (headSha) {
1614
+ await readTree(worktreeRoot, headSha);
1615
+ }
1616
+ return "detached";
1617
+ }
1618
+
1382
1619
  // ════════════════════════════════════════════════════════════════════════════
1383
1620
  // API: show
1384
1621
  // ════════════════════════════════════════════════════════════════════════════
@@ -134,10 +134,7 @@ async function executeSearch(
134
134
  const explicitProvider = params.provider;
135
135
  let candidates: SearchProviderCandidate[];
136
136
  if (explicitProvider && explicitProvider !== "auto") {
137
- const provider = await getSearchProvider(explicitProvider);
138
- candidates = (await provider.isExplicitlyAvailable(authStorage))
139
- ? [{ id: explicitProvider, explicit: true }]
140
- : resolveProviderCandidates("auto");
137
+ candidates = [{ id: explicitProvider, explicit: true }];
141
138
  } else if (explicitProvider === "auto") {
142
139
  // Explicit `--provider auto` bypasses the configured preferred provider
143
140
  // for this invocation; exclusions still apply.
@@ -175,7 +172,13 @@ async function executeSearch(
175
172
  const available = candidate.explicit
176
173
  ? await provider.isExplicitlyAvailable(authStorage)
177
174
  : await provider.isAvailable(authStorage);
178
- if (!available) continue;
175
+ if (!available && !candidate.explicit) continue;
176
+ if (!available && candidate.explicit) {
177
+ throw new SearchProviderError(
178
+ provider.id,
179
+ `${provider.label} web search is unavailable. Configure its credentials or select the automatic provider chain.`,
180
+ );
181
+ }
179
182
  availableProviderCount++;
180
183
  lastProvider = provider;
181
184
 
@@ -213,6 +216,7 @@ async function executeSearch(
213
216
  // summary error), masking the cancellation.
214
217
  throwIfAborted(signal);
215
218
  failures.push({ provider: provider ?? providerMeta, error });
219
+ if (candidate.explicit) break;
216
220
  }
217
221
  }
218
222