@oh-my-pi/pi-coding-agent 15.10.11 → 15.10.12

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 (121) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli.js +5349 -5328
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli-commands.d.ts +12 -0
  5. package/dist/types/commands/launch.d.ts +4 -0
  6. package/dist/types/config/api-key-resolver.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +18 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  12. package/dist/types/eval/py/executor.d.ts +5 -0
  13. package/dist/types/eval/py/kernel.d.ts +6 -1
  14. package/dist/types/eval/py/runtime.d.ts +9 -0
  15. package/dist/types/exec/bash-executor.d.ts +2 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/memory-backend/index.d.ts +1 -0
  19. package/dist/types/memory-backend/runtime.d.ts +4 -0
  20. package/dist/types/memory-backend/types.d.ts +66 -1
  21. package/dist/types/modes/index.d.ts +3 -3
  22. package/dist/types/modes/interactive-mode.d.ts +7 -2
  23. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  24. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  26. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  27. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  28. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  29. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  30. package/dist/types/modes/types.d.ts +2 -0
  31. package/dist/types/secrets/index.d.ts +1 -1
  32. package/dist/types/secrets/obfuscator.d.ts +8 -2
  33. package/dist/types/session/agent-session.d.ts +14 -2
  34. package/dist/types/session/streaming-output.d.ts +23 -0
  35. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  36. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  37. package/dist/types/slash-commands/types.d.ts +1 -1
  38. package/dist/types/system-prompt.d.ts +2 -0
  39. package/dist/types/task/executor.d.ts +1 -0
  40. package/dist/types/task/index.d.ts +2 -2
  41. package/dist/types/task/types.d.ts +8 -0
  42. package/dist/types/thinking.d.ts +4 -0
  43. package/dist/types/tiny/title-client.d.ts +11 -0
  44. package/dist/types/tiny/title-protocol.d.ts +1 -0
  45. package/dist/types/tools/index.d.ts +6 -0
  46. package/dist/types/utils/git.d.ts +15 -2
  47. package/dist/types/utils/title-generator.d.ts +3 -2
  48. package/package.json +10 -10
  49. package/src/auto-thinking/classifier.ts +1 -0
  50. package/src/cli/args.ts +3 -0
  51. package/src/cli-commands.ts +29 -0
  52. package/src/cli.ts +8 -9
  53. package/src/commands/launch.ts +4 -0
  54. package/src/commit/model-selection.ts +3 -2
  55. package/src/config/api-key-resolver.ts +8 -6
  56. package/src/config/model-registry.ts +97 -30
  57. package/src/config/model-resolver.ts +60 -0
  58. package/src/config/settings-schema.ts +43 -15
  59. package/src/config/settings.ts +61 -3
  60. package/src/edit/hashline/execute.ts +39 -2
  61. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  62. package/src/eval/completion-bridge.ts +1 -0
  63. package/src/eval/py/executor.ts +29 -7
  64. package/src/eval/py/index.ts +6 -1
  65. package/src/eval/py/kernel.ts +31 -11
  66. package/src/eval/py/runtime.ts +37 -0
  67. package/src/exec/bash-executor.ts +82 -3
  68. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  69. package/src/extensibility/extensions/runner.ts +6 -1
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/hindsight/bank.ts +17 -2
  72. package/src/internal-urls/docs-index.generated.ts +3 -3
  73. package/src/main.ts +18 -6
  74. package/src/memories/index.ts +2 -0
  75. package/src/memory-backend/index.ts +1 -0
  76. package/src/memory-backend/local-backend.ts +9 -0
  77. package/src/memory-backend/off-backend.ts +9 -0
  78. package/src/memory-backend/runtime.ts +66 -0
  79. package/src/memory-backend/types.ts +81 -1
  80. package/src/mnemopi/backend.ts +151 -4
  81. package/src/modes/acp/acp-agent.ts +119 -11
  82. package/src/modes/components/assistant-message.ts +19 -21
  83. package/src/modes/components/footer.ts +3 -1
  84. package/src/modes/components/status-line/component.ts +118 -34
  85. package/src/modes/controllers/command-controller.ts +1 -1
  86. package/src/modes/controllers/input-controller.ts +1 -0
  87. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  88. package/src/modes/index.ts +3 -21
  89. package/src/modes/interactive-mode.ts +39 -9
  90. package/src/modes/oauth-manual-input.ts +30 -3
  91. package/src/modes/rpc/rpc-client.ts +154 -3
  92. package/src/modes/rpc/rpc-mode.ts +97 -12
  93. package/src/modes/rpc/rpc-subagents.ts +265 -0
  94. package/src/modes/rpc/rpc-types.ts +81 -1
  95. package/src/modes/setup-wizard/index.ts +12 -2
  96. package/src/modes/setup-wizard/lazy.ts +16 -0
  97. package/src/modes/types.ts +2 -0
  98. package/src/sdk.ts +8 -1
  99. package/src/secrets/index.ts +8 -1
  100. package/src/secrets/obfuscator.ts +39 -18
  101. package/src/session/agent-session.ts +179 -54
  102. package/src/session/streaming-output.ts +166 -10
  103. package/src/slash-commands/acp-builtins.ts +24 -0
  104. package/src/slash-commands/builtin-registry.ts +20 -0
  105. package/src/slash-commands/types.ts +1 -1
  106. package/src/system-prompt.ts +14 -0
  107. package/src/task/executor.ts +13 -12
  108. package/src/task/index.ts +9 -8
  109. package/src/task/render.ts +18 -3
  110. package/src/task/types.ts +9 -0
  111. package/src/thinking.ts +7 -0
  112. package/src/tiny/title-client.ts +34 -5
  113. package/src/tiny/title-protocol.ts +1 -1
  114. package/src/tiny/worker.ts +6 -4
  115. package/src/tools/bash.ts +46 -5
  116. package/src/tools/image-gen.ts +11 -4
  117. package/src/tools/index.ts +13 -1
  118. package/src/tools/inspect-image.ts +1 -0
  119. package/src/utils/commit-message-generator.ts +1 -0
  120. package/src/utils/git.ts +267 -13
  121. package/src/utils/title-generator.ts +24 -5
@@ -29,7 +29,7 @@ export interface TinyTitleProgressEvent {
29
29
 
30
30
  export type TinyTitleWorkerInbound =
31
31
  | { type: "ping"; id: string }
32
- | { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
32
+ | { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string; systemPrompt?: string }
33
33
  | { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
34
34
  | { type: "download"; id: string; modelKey: TinyLocalModelKey };
35
35
 
@@ -436,9 +436,10 @@ async function loadPipeline(
436
436
  return loaded;
437
437
  }
438
438
 
439
- function buildPrompt(generator: TextGenerationPipeline, message: string): string {
439
+ function buildPrompt(generator: TextGenerationPipeline, message: string, systemPrompt?: string): string {
440
+ const selectedSystemPrompt = systemPrompt?.trim() || TINY_TITLE_SYSTEM_PROMPT;
440
441
  const chat = [
441
- { role: "system", content: TINY_TITLE_SYSTEM_PROMPT },
442
+ { role: "system", content: selectedSystemPrompt },
442
443
  { role: "user", content: formatTitleUserMessage(message) },
443
444
  ];
444
445
  const chatTemplateOptions = {
@@ -464,9 +465,10 @@ async function generateTitle(
464
465
  requestId: string,
465
466
  modelKey: TinyTitleLocalModelKey,
466
467
  message: string,
468
+ systemPrompt?: string,
467
469
  ): Promise<string | null> {
468
470
  const generator = await loadPipeline(modelKey, transport, requestId);
469
- const promptText = buildPrompt(generator, message);
471
+ const promptText = buildPrompt(generator, message, systemPrompt);
470
472
  const transformers = await loadTransformers(transport, requestId, modelKey);
471
473
  const output = (await generator(promptText, {
472
474
  max_new_tokens: TITLE_MAX_NEW_TOKENS,
@@ -548,7 +550,7 @@ async function handleQueuedRequest(
548
550
  transport.send({ type: "completion", id: request.id, text });
549
551
  return;
550
552
  }
551
- const title = await generateTitle(transport, request.id, request.modelKey, request.message);
553
+ const title = await generateTitle(transport, request.id, request.modelKey, request.message, request.systemPrompt);
552
554
  transport.send({ type: "title", id: request.id, title });
553
555
  } catch (error) {
554
556
  transport.send({ type: "error", id: request.id, error: errorText(error) });
package/src/tools/bash.ts CHANGED
@@ -1212,6 +1212,22 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1212
1212
  const details = result.details;
1213
1213
  const outputBlock = new CachedOutputBlock();
1214
1214
 
1215
+ // Per-instance cache for the expensive inner lines computation. Mirrors
1216
+ // the eval-renderer pattern (`eval-render.ts:709-752`): without this,
1217
+ // every TUI repaint (one per keystroke when a long transcript is on
1218
+ // screen) re-runs `split` / `replaceTabs` / `truncateToVisualLines` over
1219
+ // the whole stored output for every bash row in scrollback. With a
1220
+ // 50KB-tail bash result times hundreds of rows, that re-rendering is
1221
+ // what pinned the main thread in issue #2081 and made keystrokes feel
1222
+ // like the CPU was at 100%. The cache key includes every render input
1223
+ // that materially affects the produced lines.
1224
+ let cachedWidth: number | undefined;
1225
+ let cachedPreviewLines: number | undefined;
1226
+ let cachedExpanded: boolean | undefined;
1227
+ let cachedRawOutput: string | undefined;
1228
+ let cachedIsPartial: boolean | undefined;
1229
+ let cachedLines: readonly string[] | undefined;
1230
+
1215
1231
  return markFramedBlockComponent({
1216
1232
  render: (width: number): readonly string[] => {
1217
1233
  // REACTIVE: read mutable options at render time
@@ -1223,6 +1239,19 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1223
1239
  // Strip the LLM-facing notice appended by wrappedExecute so we don't
1224
1240
  // double-print it alongside the styled warning line below.
1225
1241
  const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
1242
+
1243
+ const isPartial = options.isPartial === true;
1244
+
1245
+ if (
1246
+ cachedLines !== undefined &&
1247
+ cachedWidth === width &&
1248
+ cachedPreviewLines === previewLines &&
1249
+ cachedExpanded === expanded &&
1250
+ cachedRawOutput === rawOutput &&
1251
+ cachedIsPartial === isPartial
1252
+ ) {
1253
+ return cachedLines;
1254
+ }
1226
1255
  const strippedOutput = stripOutputNotice(rawOutput, details?.meta);
1227
1256
  const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode);
1228
1257
  const withoutWall = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
@@ -1299,15 +1328,13 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1299
1328
  if (timeoutLine) outputLines.push(timeoutLine);
1300
1329
  if (warningLine) outputLines.push(warningLine);
1301
1330
 
1302
- return outputBlock.render(
1331
+ const framed = outputBlock.render(
1303
1332
  {
1304
1333
  header,
1305
- state: options.isPartial ? "pending" : isError ? "error" : "success",
1334
+ state: isPartial ? "pending" : isError ? "error" : "success",
1306
1335
  sections: [
1307
1336
  {
1308
- lines: options.isPartial
1309
- ? capPreviewLines(cmdLines ?? [], uiTheme, { expanded })
1310
- : (cmdLines ?? []),
1337
+ lines: isPartial ? capPreviewLines(cmdLines ?? [], uiTheme, { expanded }) : (cmdLines ?? []),
1311
1338
  },
1312
1339
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
1313
1340
  ],
@@ -1315,9 +1342,23 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1315
1342
  },
1316
1343
  uiTheme,
1317
1344
  );
1345
+
1346
+ cachedWidth = width;
1347
+ cachedPreviewLines = previewLines;
1348
+ cachedExpanded = expanded;
1349
+ cachedRawOutput = rawOutput;
1350
+ cachedIsPartial = isPartial;
1351
+ cachedLines = framed;
1352
+ return framed;
1318
1353
  },
1319
1354
  invalidate: () => {
1320
1355
  outputBlock.invalidate();
1356
+ cachedLines = undefined;
1357
+ cachedWidth = undefined;
1358
+ cachedPreviewLines = undefined;
1359
+ cachedExpanded = undefined;
1360
+ cachedRawOutput = undefined;
1361
+ cachedIsPartial = undefined;
1321
1362
  },
1322
1363
  });
1323
1364
  },
@@ -472,8 +472,13 @@ function parseAntigravityCredentials(raw: string): ParsedAntigravityCredentials
472
472
  return null;
473
473
  }
474
474
 
475
- async function findAntigravityCredentials(modelRegistry: ModelRegistry): Promise<ImageApiKey | null> {
476
- const apiKey = await modelRegistry.getApiKeyForProvider("google-antigravity");
475
+ async function findAntigravityCredentials(
476
+ modelRegistry: ModelRegistry,
477
+ sessionId?: string,
478
+ ): Promise<ImageApiKey | null> {
479
+ const apiKey = await modelRegistry.getApiKeyForProvider("google-antigravity", sessionId, {
480
+ modelId: DEFAULT_ANTIGRAVITY_MODEL,
481
+ });
477
482
  if (!apiKey) return null;
478
483
 
479
484
  const parsed = parseAntigravityCredentials(apiKey);
@@ -523,7 +528,7 @@ async function findImageApiKey(
523
528
  if (openAI) return openAI;
524
529
  // Fall through to auto-detect if preferred provider key not found.
525
530
  } else if (preferredImageProvider === "antigravity" && modelRegistry) {
526
- const antigravity = await findAntigravityCredentials(modelRegistry);
531
+ const antigravity = await findAntigravityCredentials(modelRegistry, sessionId);
527
532
  if (antigravity) return antigravity;
528
533
  // Fall through to auto-detect if preferred provider key not found.
529
534
  } else if (preferredImageProvider === "gemini") {
@@ -547,7 +552,7 @@ async function findImageApiKey(
547
552
  if (openAI) return openAI;
548
553
 
549
554
  if (modelRegistry) {
550
- const antigravity = await findAntigravityCredentials(modelRegistry);
555
+ const antigravity = await findAntigravityCredentials(modelRegistry, sessionId);
551
556
  if (antigravity) return antigravity;
552
557
  }
553
558
 
@@ -1052,6 +1057,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1052
1057
  const hostedKey: ApiKey = ctx.modelRegistry.resolver(hostedModel.provider, {
1053
1058
  sessionId,
1054
1059
  baseUrl: hostedModel.baseUrl,
1060
+ modelId: hostedModel.id,
1055
1061
  });
1056
1062
 
1057
1063
  const parsed = await withAuth(
@@ -1113,6 +1119,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1113
1119
  const prompt = assemblePrompt(params);
1114
1120
  const antigravityKey: ApiKey = ctx.modelRegistry.resolver("google-antigravity", {
1115
1121
  sessionId,
1122
+ modelId: DEFAULT_ANTIGRAVITY_MODEL,
1116
1123
  });
1117
1124
 
1118
1125
  const response = await withAuth(
@@ -320,6 +320,13 @@ export interface ToolSession {
320
320
  * model for each file. Lazily initialized by `getDiagnosticsLedger`. */
321
321
  diagnosticsLedger?: import("../lsp/diagnostics-ledger").DiagnosticsLedger;
322
322
 
323
+ /** Per-session ledger of consecutive byte-identical no-op edits, keyed by
324
+ * canonical file path. The hashline executor escalates a soft no-op hint
325
+ * to a thrown error once the same payload no-ops `NOOP_HARD_LIMIT` times,
326
+ * breaking subagent loops that ignore the textual hint (issue #2081).
327
+ * Lazily initialized by `getNoopLoopGuard`. */
328
+ noopLoopGuard?: import("../edit/hashline/noop-loop-guard").NoopLoopGuard;
329
+
323
330
  /** Queue a hidden message to be injected at the next agent turn. */
324
331
  queueDeferredMessage?(message: CustomMessage): void;
325
332
  /** Queue late LSP diagnostics (arrived after an edit/write returned) to be shown
@@ -463,7 +470,12 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
463
470
  !allowJs &&
464
471
  (requestedTools === undefined || requestedTools.includes("eval"))
465
472
  ) {
466
- const availability = await logger.time("createTools:pythonCheck", checkPythonKernelAvailability, session.cwd);
473
+ const availability = await logger.time(
474
+ "createTools:pythonCheck",
475
+ checkPythonKernelAvailability,
476
+ session.cwd,
477
+ session.settings.get("python.interpreter")?.trim() || undefined,
478
+ );
467
479
  pythonAvailable = availability.ok;
468
480
  if (!availability.ok) {
469
481
  logger.warn("Python kernel unavailable and JS backend disabled; eval will be unavailable", {
@@ -141,6 +141,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
141
141
  apiKey: modelRegistry.resolver(model.provider, {
142
142
  sessionId: this.session.getSessionId?.() ?? undefined,
143
143
  baseUrl: model.baseUrl,
144
+ modelId: model.id,
144
145
  }),
145
146
  signal,
146
147
  },
@@ -115,6 +115,7 @@ export async function generateCommitMessage(
115
115
  apiKey: registry.resolver(candidate.model.provider, {
116
116
  sessionId,
117
117
  baseUrl: candidate.model.baseUrl,
118
+ modelId: candidate.model.id,
118
119
  }),
119
120
  maxTokens,
120
121
  reasoning: toReasoningEffort(candidate.thinkingLevel),
package/src/utils/git.ts CHANGED
@@ -27,6 +27,7 @@ export interface GitRepository {
27
27
  gitEntryPath: string;
28
28
  headPath: string;
29
29
  repoRoot: string;
30
+ isReftable?: boolean;
30
31
  }
31
32
 
32
33
  export interface GitStatusSummary {
@@ -476,6 +477,31 @@ async function resolveCommonDir(gitDir: string): Promise<string> {
476
477
  if (!relative) return gitDir;
477
478
  return path.resolve(gitDir, relative);
478
479
  }
480
+ function isLinkedWorktree(repository: GitRepository): boolean {
481
+ return (
482
+ repository.gitDir !== repository.commonDir &&
483
+ getEntryTypeSync(path.join(repository.gitDir, "commondir")) === "file"
484
+ );
485
+ }
486
+
487
+ async function isLinkedWorktreeAsync(repository: GitRepository): Promise<boolean> {
488
+ return (
489
+ repository.gitDir !== repository.commonDir &&
490
+ (await getEntryType(path.join(repository.gitDir, "commondir"))) === "file"
491
+ );
492
+ }
493
+
494
+ function primaryRootFromRepositorySync(repository: GitRepository): string {
495
+ if (path.basename(repository.commonDir) === ".git") return path.dirname(repository.commonDir);
496
+ if (isLinkedWorktree(repository)) return repository.commonDir;
497
+ return repository.repoRoot;
498
+ }
499
+
500
+ async function primaryRootFromRepository(repository: GitRepository): Promise<string> {
501
+ if (path.basename(repository.commonDir) === ".git") return path.dirname(repository.commonDir);
502
+ if (await isLinkedWorktreeAsync(repository)) return repository.commonDir;
503
+ return repository.repoRoot;
504
+ }
479
505
 
480
506
  function resolveRepoFromEntrySync(repoRoot: string, gitEntryPath: string, entryType: EntryType): GitRepository | null {
481
507
  const gitDir = resolveGitDirSync(gitEntryPath, entryType);
@@ -560,7 +586,174 @@ function parsePackedRefs(content: string | null, targetRef: string): string | nu
560
586
  return null;
561
587
  }
562
588
 
589
+ function stripGitConfigComments(line: string): string {
590
+ let clean = "";
591
+ let inQuotes = false;
592
+ for (let i = 0; i < line.length; i++) {
593
+ const char = line[i];
594
+ if (char === '"') {
595
+ inQuotes = !inQuotes;
596
+ clean += char;
597
+ } else if (!inQuotes && (char === ";" || char === "#")) {
598
+ break;
599
+ } else {
600
+ clean += char;
601
+ }
602
+ }
603
+ return clean.trim();
604
+ }
605
+
606
+ function parseGitConfigHasReftable(content: string): boolean {
607
+ let inExtensions = false;
608
+ for (const line of content.split("\n")) {
609
+ const trimmed = stripGitConfigComments(line);
610
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
611
+ const section = trimmed.slice(1, -1).trim().toLowerCase();
612
+ inExtensions = section === "extensions";
613
+ } else if (inExtensions) {
614
+ const eqIndex = trimmed.indexOf("=");
615
+ if (eqIndex !== -1) {
616
+ const key = trimmed.slice(0, eqIndex).trim().toLowerCase();
617
+ let value = trimmed.slice(eqIndex + 1).trim();
618
+ if (key === "refstorage") {
619
+ if (value.startsWith('"') && value.endsWith('"')) {
620
+ value = value.slice(1, -1).trim();
621
+ }
622
+ const lowerValue = value.toLowerCase();
623
+ if (lowerValue === "reftable" || lowerValue.startsWith("reftable:")) {
624
+ return true;
625
+ }
626
+ }
627
+ }
628
+ }
629
+ }
630
+ return false;
631
+ }
632
+
633
+ function isReftableRepoSync(repository: GitRepository): boolean {
634
+ if (repository.isReftable !== undefined) return repository.isReftable;
635
+ const configPath = path.join(repository.commonDir, "config");
636
+ const content = readOptionalTextSync(configPath);
637
+ repository.isReftable = content ? parseGitConfigHasReftable(content) : false;
638
+ return repository.isReftable;
639
+ }
640
+
641
+ async function isReftableRepo(repository: GitRepository): Promise<boolean> {
642
+ if (repository.isReftable !== undefined) return repository.isReftable;
643
+ const configPath = path.join(repository.commonDir, "config");
644
+ const content = await readOptionalText(configPath);
645
+ repository.isReftable = content ? parseGitConfigHasReftable(content) : false;
646
+ return repository.isReftable;
647
+ }
648
+
649
+ async function resolveHeadStateReftable(repository: GitRepository, signal?: AbortSignal): Promise<GitHeadState | null> {
650
+ throwIfAborted(signal);
651
+ const symResult = await git(repository.repoRoot, ["symbolic-ref", "HEAD"], { readOnly: true, signal }).catch(err => {
652
+ if (signal?.aborted || (err instanceof Error && (err.name === "AbortError" || err.name === "ToolAbortError"))) {
653
+ throw err;
654
+ }
655
+ return null;
656
+ });
657
+ throwIfAborted(signal);
658
+ const revResult = await git(repository.repoRoot, ["rev-parse", "--verify", "HEAD"], {
659
+ readOnly: true,
660
+ signal,
661
+ }).catch(err => {
662
+ if (signal?.aborted || (err instanceof Error && (err.name === "AbortError" || err.name === "ToolAbortError"))) {
663
+ throw err;
664
+ }
665
+ return null;
666
+ });
667
+ const commit = revResult && revResult.exitCode === 0 ? revResult.stdout.trim() || null : null;
668
+
669
+ if (symResult && symResult.exitCode === 0) {
670
+ const ref = symResult.stdout.trim();
671
+ const branchName = ref.startsWith(LOCAL_BRANCH_PREFIX) ? ref.slice(LOCAL_BRANCH_PREFIX.length) : null;
672
+ return {
673
+ ...repository,
674
+ kind: "ref",
675
+ ref,
676
+ branchName,
677
+ commit,
678
+ headContent: `${HEAD_REF_PREFIX} ${ref}`,
679
+ };
680
+ }
681
+
682
+ return {
683
+ ...repository,
684
+ kind: "detached",
685
+ commit,
686
+ headContent: commit || "",
687
+ };
688
+ }
689
+
690
+ function resolveHeadStateReftableSync(repository: GitRepository): GitHeadState | null {
691
+ ensureAvailable();
692
+ const symArgs = withShortLivedGitConfig(withNoOptionalLocks(["symbolic-ref", "HEAD"]));
693
+ const symResult = Bun.spawnSync(["git", ...symArgs], {
694
+ cwd: repository.repoRoot,
695
+ stdout: "pipe",
696
+ stderr: "pipe",
697
+ windowsHide: true,
698
+ });
699
+
700
+ const revArgs = withShortLivedGitConfig(withNoOptionalLocks(["rev-parse", "--verify", "HEAD"]));
701
+ const revResult = Bun.spawnSync(["git", ...revArgs], {
702
+ cwd: repository.repoRoot,
703
+ stdout: "pipe",
704
+ stderr: "pipe",
705
+ windowsHide: true,
706
+ });
707
+ const commit = revResult.exitCode === 0 ? new TextDecoder().decode(revResult.stdout).trim() || null : null;
708
+
709
+ if (symResult.exitCode === 0) {
710
+ const ref = new TextDecoder().decode(symResult.stdout).trim();
711
+ const branchName = ref.startsWith(LOCAL_BRANCH_PREFIX) ? ref.slice(LOCAL_BRANCH_PREFIX.length) : null;
712
+ return {
713
+ ...repository,
714
+ kind: "ref",
715
+ ref,
716
+ branchName,
717
+ commit,
718
+ headContent: `${HEAD_REF_PREFIX} ${ref}`,
719
+ };
720
+ }
721
+
722
+ return {
723
+ ...repository,
724
+ kind: "detached",
725
+ commit,
726
+ headContent: commit || "",
727
+ };
728
+ }
729
+
563
730
  function readRefSync(repository: GitRepository, targetRef: string): string | null {
731
+ if (isReftableRepoSync(repository)) {
732
+ ensureAvailable();
733
+ const symArgs = withShortLivedGitConfig(withNoOptionalLocks(["symbolic-ref", targetRef]));
734
+ const symResult = Bun.spawnSync(["git", ...symArgs], {
735
+ cwd: repository.repoRoot,
736
+ stdout: "pipe",
737
+ stderr: "pipe",
738
+ windowsHide: true,
739
+ });
740
+ if (symResult.exitCode === 0) {
741
+ const stdoutText = new TextDecoder().decode(symResult.stdout).trim();
742
+ return `${HEAD_REF_PREFIX} ${stdoutText}`;
743
+ }
744
+ const revArgs = withShortLivedGitConfig(withNoOptionalLocks(["rev-parse", "--verify", targetRef]));
745
+ const revResult = Bun.spawnSync(["git", ...revArgs], {
746
+ cwd: repository.repoRoot,
747
+ stdout: "pipe",
748
+ stderr: "pipe",
749
+ windowsHide: true,
750
+ });
751
+ if (revResult.exitCode === 0) {
752
+ return new TextDecoder().decode(revResult.stdout).trim() || null;
753
+ }
754
+ return null;
755
+ }
756
+
564
757
  for (const dir of getRefLookupDirs(repository)) {
565
758
  const value = normalizeRefValue(readOptionalTextSync(path.join(dir, targetRef)));
566
759
  if (value) return value;
@@ -572,7 +765,42 @@ function readRefSync(repository: GitRepository, targetRef: string): string | nul
572
765
  return null;
573
766
  }
574
767
 
575
- async function readRef(repository: GitRepository, targetRef: string): Promise<string | null> {
768
+ async function readRef(repository: GitRepository, targetRef: string, signal?: AbortSignal): Promise<string | null> {
769
+ if (await isReftableRepo(repository)) {
770
+ throwIfAborted(signal);
771
+ const symResult = await git(repository.repoRoot, ["symbolic-ref", targetRef], { readOnly: true, signal }).catch(
772
+ err => {
773
+ if (
774
+ signal?.aborted ||
775
+ (err instanceof Error && (err.name === "AbortError" || err.name === "ToolAbortError"))
776
+ ) {
777
+ throw err;
778
+ }
779
+ return null;
780
+ },
781
+ );
782
+ if (symResult && symResult.exitCode === 0) {
783
+ return `${HEAD_REF_PREFIX} ${symResult.stdout.trim()}`;
784
+ }
785
+ throwIfAborted(signal);
786
+ const revResult = await git(repository.repoRoot, ["rev-parse", "--verify", targetRef], {
787
+ readOnly: true,
788
+ signal,
789
+ }).catch(err => {
790
+ if (
791
+ signal?.aborted ||
792
+ (err instanceof Error && (err.name === "AbortError" || err.name === "ToolAbortError"))
793
+ ) {
794
+ throw err;
795
+ }
796
+ return null;
797
+ });
798
+ if (revResult && revResult.exitCode === 0) {
799
+ return revResult.stdout.trim() || null;
800
+ }
801
+ return null;
802
+ }
803
+
576
804
  for (const dir of getRefLookupDirs(repository)) {
577
805
  const value = normalizeRefValue(await readOptionalText(path.join(dir, targetRef)));
578
806
  if (value) return value;
@@ -997,7 +1225,7 @@ export const branch = {
997
1225
  const repository = await resolveRepository(cwd);
998
1226
  if (repository) {
999
1227
  for (const refPath of DEFAULT_BRANCH_REFS) {
1000
- const target = await readRef(repository, refPath);
1228
+ const target = await readRef(repository, refPath, signal);
1001
1229
  const branchName = parseDefaultBranchRef(refPath, target);
1002
1230
  if (branchName) return branchName;
1003
1231
  }
@@ -1095,7 +1323,7 @@ export const ref = {
1095
1323
  async exists(cwd: string, refName: string, signal?: AbortSignal): Promise<boolean> {
1096
1324
  if (refName === "HEAD") return (await head.sha(cwd, signal)) !== null;
1097
1325
  const repository = await resolveRepository(cwd);
1098
- if (repository && refName.startsWith("refs/")) return (await readRef(repository, refName)) !== null;
1326
+ if (repository && refName.startsWith("refs/")) return (await readRef(repository, refName, signal)) !== null;
1099
1327
  const result = await git(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal });
1100
1328
  return result.exitCode === 0;
1101
1329
  },
@@ -1104,7 +1332,7 @@ export const ref = {
1104
1332
  async resolve(cwd: string, refName: string, signal?: AbortSignal): Promise<string | null> {
1105
1333
  if (refName === "HEAD") return head.sha(cwd, signal);
1106
1334
  const repository = await resolveRepository(cwd);
1107
- if (repository && refName.startsWith("refs/")) return readRef(repository, refName);
1335
+ if (repository && refName.startsWith("refs/")) return readRef(repository, refName, signal);
1108
1336
  const result = await git(cwd, ["rev-parse", refName], { readOnly: true, signal });
1109
1337
  if (result.exitCode !== 0) return null;
1110
1338
  return result.stdout.trim() || null;
@@ -1397,9 +1625,12 @@ export const ls = {
1397
1625
 
1398
1626
  export const head = {
1399
1627
  /** Full HEAD state (branch, commit, repo info). */
1400
- async resolve(cwd: string): Promise<GitHeadState | null> {
1628
+ async resolve(cwd: string, signal?: AbortSignal): Promise<GitHeadState | null> {
1401
1629
  const repository = await resolveRepository(cwd);
1402
1630
  if (!repository) return null;
1631
+ if (await isReftableRepo(repository)) {
1632
+ return resolveHeadStateReftable(repository, signal);
1633
+ }
1403
1634
  const content = await readOptionalText(repository.headPath);
1404
1635
  if (content === null) return null;
1405
1636
  return parseHeadState(repository, content);
@@ -1409,6 +1640,9 @@ export const head = {
1409
1640
  resolveSync(cwd: string): GitHeadState | null {
1410
1641
  const repository = resolveRepositorySync(cwd);
1411
1642
  if (!repository) return null;
1643
+ if (isReftableRepoSync(repository)) {
1644
+ return resolveHeadStateReftableSync(repository);
1645
+ }
1412
1646
  const content = readOptionalTextSync(repository.headPath);
1413
1647
  if (content === null) return null;
1414
1648
  return parseHeadStateSync(repository, content);
@@ -1416,7 +1650,7 @@ export const head = {
1416
1650
 
1417
1651
  /** Current HEAD commit SHA. */
1418
1652
  async sha(cwd: string, signal?: AbortSignal): Promise<string | null> {
1419
- const headState = await head.resolve(cwd);
1653
+ const headState = await head.resolve(cwd, signal);
1420
1654
  if (headState?.commit) return headState.commit;
1421
1655
  const result = await git(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal });
1422
1656
  if (result.exitCode !== 0) return null;
@@ -1445,13 +1679,10 @@ export const repo = {
1445
1679
  return result.stdout.trim() || null;
1446
1680
  },
1447
1681
 
1448
- /** Resolve the primary repository root (not a worktree the main checkout). */
1682
+ /** Resolve the primary checkout root, or the shared common dir for bare-repo worktrees. */
1449
1683
  async primaryRoot(cwd: string, signal?: AbortSignal): Promise<string | null> {
1450
1684
  const repository = await resolveRepository(cwd);
1451
- if (repository) {
1452
- if (path.basename(repository.commonDir) === ".git") return path.dirname(repository.commonDir);
1453
- return repository.repoRoot;
1454
- }
1685
+ if (repository) return primaryRootFromRepository(repository);
1455
1686
  const repoRoot = await repo.root(cwd, signal);
1456
1687
  if (!repoRoot) return null;
1457
1688
  const commonDir = await runText(repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
@@ -1462,6 +1693,19 @@ export const repo = {
1462
1693
  return repoRoot;
1463
1694
  },
1464
1695
 
1696
+ /**
1697
+ * Sync sibling of {@link primaryRoot}. Resolves only via on-disk `.git`/
1698
+ * `commondir` walking — no subprocess fallback — so it stays usable from
1699
+ * paths where async I/O is impractical (e.g. `computeBankScope`). Returns
1700
+ * `null` when `cwd` is outside a repository. Bare-repo worktrees resolve to
1701
+ * the shared common dir (`foo.git`) because they have no primary checkout.
1702
+ */
1703
+ primaryRootSync(cwd: string): string | null {
1704
+ const repository = resolveRepositorySync(cwd);
1705
+ if (!repository) return null;
1706
+ return primaryRootFromRepositorySync(repository);
1707
+ },
1708
+
1465
1709
  /** Full GitRepository metadata (sync). */
1466
1710
  resolveSync(cwd: string): GitRepository | null {
1467
1711
  return resolveRepositorySync(cwd);
@@ -1471,11 +1715,21 @@ export const repo = {
1471
1715
  resolve(cwd: string): Promise<GitRepository | null> {
1472
1716
  return resolveRepository(cwd);
1473
1717
  },
1718
+
1719
+ /** Check if the repository uses the reftable reference storage format (sync). */
1720
+ isReftableSync(repository: GitRepository): boolean {
1721
+ return isReftableRepoSync(repository);
1722
+ },
1723
+
1724
+ /** Check if the repository uses the reftable reference storage format. */
1725
+ isReftable(repository: GitRepository): Promise<boolean> {
1726
+ return isReftableRepo(repository);
1727
+ },
1474
1728
  };
1475
1729
 
1476
1730
  // Helper used during head resolution — defined here to reference `head` namespace.
1477
- async function resolveHead(cwd: string): Promise<GitHeadState | null> {
1478
- return head.resolve(cwd);
1731
+ async function resolveHead(cwd: string, signal?: AbortSignal): Promise<GitHeadState | null> {
1732
+ return head.resolve(cwd, signal);
1479
1733
  }
1480
1734
 
1481
1735
  // ════════════════════════════════════════════════════════════════════════════