@oh-my-pi/pi-coding-agent 17.1.8 → 17.2.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.
Files changed (246) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/{CHANGELOG-vt8ene9g.md → CHANGELOG-dj46zzrm.md} +110 -0
  3. package/dist/cli.js +10009 -9654
  4. package/dist/{template-dys3vk5b.js → template-8vdv6xb4.js} +1 -1
  5. package/dist/types/advisor/runtime.d.ts +8 -2
  6. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  7. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  8. package/dist/types/cli/args.d.ts +2 -0
  9. package/dist/types/cli/session-picker.d.ts +14 -7
  10. package/dist/types/collab/guest.d.ts +21 -2
  11. package/dist/types/commands/launch.d.ts +6 -0
  12. package/dist/types/config/model-discovery.d.ts +12 -0
  13. package/dist/types/config/settings-schema.d.ts +73 -8
  14. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  15. package/dist/types/cursor.d.ts +193 -8
  16. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  17. package/dist/types/edit/hashline/diff.d.ts +9 -1
  18. package/dist/types/edit/index.d.ts +8 -1
  19. package/dist/types/edit/streaming.d.ts +8 -1
  20. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  21. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +25 -0
  23. package/dist/types/internal-urls/index.d.ts +1 -0
  24. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  25. package/dist/types/internal-urls/security-protocol.d.ts +15 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  28. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  29. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  30. package/dist/types/lsp/client.d.ts +2 -0
  31. package/dist/types/main.d.ts +2 -0
  32. package/dist/types/mcp/manager.d.ts +33 -4
  33. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  35. package/dist/types/modes/components/session-selector.d.ts +6 -0
  36. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  38. package/dist/types/modes/components/usage-row.d.ts +2 -0
  39. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  40. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  41. package/dist/types/modes/interactive-mode.d.ts +4 -2
  42. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  44. package/dist/types/modes/theme/theme.d.ts +12 -1
  45. package/dist/types/modes/types.d.ts +2 -1
  46. package/dist/types/sdk.d.ts +18 -0
  47. package/dist/types/security/auth.d.ts +30 -0
  48. package/dist/types/security/cloud.d.ts +79 -0
  49. package/dist/types/security/comparison.d.ts +49 -0
  50. package/dist/types/security/contracts/ids.d.ts +15 -0
  51. package/dist/types/security/contracts/index.d.ts +4 -0
  52. package/dist/types/security/contracts/schemas.d.ts +660 -0
  53. package/dist/types/security/contracts/types.d.ts +234 -0
  54. package/dist/types/security/contracts/validation.d.ts +5 -0
  55. package/dist/types/security/coordinator.d.ts +100 -0
  56. package/dist/types/security/importers/codex-security.d.ts +7 -0
  57. package/dist/types/security/importers/index.d.ts +2 -0
  58. package/dist/types/security/importers/sarif.d.ts +9 -0
  59. package/dist/types/security/index.d.ts +13 -0
  60. package/dist/types/security/preflight.d.ts +61 -0
  61. package/dist/types/security/provenance.d.ts +24 -0
  62. package/dist/types/security/publication.d.ts +78 -0
  63. package/dist/types/security/remediation.d.ts +27 -0
  64. package/dist/types/security/resource-output.d.ts +8 -0
  65. package/dist/types/security/sarif.d.ts +2 -0
  66. package/dist/types/security/store.d.ts +40 -0
  67. package/dist/types/session/agent-session-events.d.ts +2 -0
  68. package/dist/types/session/agent-session-types.d.ts +36 -1
  69. package/dist/types/session/agent-session.d.ts +4 -2
  70. package/dist/types/session/claude-session-store.d.ts +13 -0
  71. package/dist/types/session/codex-session-store.d.ts +14 -0
  72. package/dist/types/session/credential-pin.d.ts +58 -0
  73. package/dist/types/session/foreign-session-import.d.ts +15 -0
  74. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  75. package/dist/types/session/foreign-session-store.d.ts +23 -0
  76. package/dist/types/session/session-advisors.d.ts +47 -2
  77. package/dist/types/session/session-entries.d.ts +19 -1
  78. package/dist/types/session/session-handoff.d.ts +4 -1
  79. package/dist/types/session/session-loader.d.ts +2 -0
  80. package/dist/types/session/session-maintenance.d.ts +1 -1
  81. package/dist/types/session/session-manager.d.ts +21 -0
  82. package/dist/types/session/session-tools.d.ts +25 -1
  83. package/dist/types/session/turn-recovery.d.ts +10 -4
  84. package/dist/types/slash-commands/helpers/security.d.ts +2 -0
  85. package/dist/types/system-prompt.d.ts +4 -1
  86. package/dist/types/task/executor.d.ts +14 -2
  87. package/dist/types/task/render.d.ts +7 -0
  88. package/dist/types/thinking.d.ts +18 -6
  89. package/dist/types/tools/acp-bridge.d.ts +28 -5
  90. package/dist/types/tools/builtin-names.d.ts +1 -1
  91. package/dist/types/tools/grep.d.ts +17 -1
  92. package/dist/types/tools/hub/launch.d.ts +5 -1
  93. package/dist/types/tools/index.d.ts +10 -2
  94. package/dist/types/tools/path-utils.d.ts +19 -0
  95. package/dist/types/tools/read.d.ts +2 -0
  96. package/dist/types/tools/security-scan.d.ts +96 -0
  97. package/dist/types/tools/xdev.d.ts +2 -1
  98. package/dist/types/utils/changelog.d.ts +19 -0
  99. package/dist/types/utils/git.d.ts +10 -0
  100. package/dist/types/utils/jj.d.ts +11 -4
  101. package/dist/types/web/search/types.d.ts +2 -2
  102. package/package.json +12 -12
  103. package/scripts/security-compare.ts +40 -0
  104. package/src/advisor/runtime.ts +138 -12
  105. package/src/advisor/transcript-recorder.ts +56 -0
  106. package/src/auto-thinking/classifier.ts +48 -10
  107. package/src/cli/args.ts +6 -0
  108. package/src/cli/flag-tables.ts +2 -0
  109. package/src/cli/gallery-fixtures/fs.ts +25 -0
  110. package/src/cli/session-picker.ts +34 -17
  111. package/src/cli/ttsr-cli.ts +19 -1
  112. package/src/cli.ts +9 -0
  113. package/src/collab/guest.ts +37 -4
  114. package/src/collab/host.ts +1 -0
  115. package/src/commands/launch.ts +6 -0
  116. package/src/config/model-discovery.ts +32 -5
  117. package/src/config/model-registry.ts +107 -25
  118. package/src/config/settings-schema.ts +77 -6
  119. package/src/config/settings.ts +25 -0
  120. package/src/cursor-bridge-tools.ts +81 -0
  121. package/src/cursor.ts +463 -12
  122. package/src/edit/edit-clipboard.ts +23 -0
  123. package/src/edit/hashline/diff.ts +49 -10
  124. package/src/edit/hashline/execute.ts +38 -6
  125. package/src/edit/hashline/filesystem.ts +27 -3
  126. package/src/edit/index.ts +12 -2
  127. package/src/edit/renderer.ts +4 -4
  128. package/src/edit/streaming.ts +17 -2
  129. package/src/eval/py/prelude.py +7 -3
  130. package/src/export/html/template.js +1 -1
  131. package/src/extensibility/extensions/runner.ts +70 -0
  132. package/src/extensibility/extensions/types.ts +30 -0
  133. package/src/extensibility/legacy-pi-coding-agent-shim.ts +65 -13
  134. package/src/internal-urls/index.ts +1 -0
  135. package/src/internal-urls/local-protocol.ts +3 -2
  136. package/src/internal-urls/mcp-protocol.ts +4 -1
  137. package/src/internal-urls/router.ts +4 -1
  138. package/src/internal-urls/security-protocol.ts +261 -0
  139. package/src/internal-urls/types.ts +1 -1
  140. package/src/launch/terminal-output-worker-client.ts +53 -0
  141. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  142. package/src/launch/terminal-output-worker.ts +23 -0
  143. package/src/lsp/client.ts +18 -3
  144. package/src/lsp/index.ts +7 -0
  145. package/src/lsp/render.ts +1 -1
  146. package/src/main.ts +127 -56
  147. package/src/mcp/manager.ts +120 -18
  148. package/src/modes/acp/acp-agent.ts +15 -9
  149. package/src/modes/components/chat-transcript-builder.ts +30 -11
  150. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  151. package/src/modes/components/read-tool-group.ts +159 -17
  152. package/src/modes/components/session-selector.ts +19 -3
  153. package/src/modes/components/status-line/component.ts +381 -54
  154. package/src/modes/components/tool-execution.ts +78 -31
  155. package/src/modes/components/usage-row.ts +10 -5
  156. package/src/modes/controllers/event-controller.ts +26 -4
  157. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  158. package/src/modes/controllers/selector-controller.ts +94 -36
  159. package/src/modes/controllers/tan-command-controller.ts +14 -0
  160. package/src/modes/interactive-mode.ts +31 -17
  161. package/src/modes/rpc/host-uris.ts +6 -0
  162. package/src/modes/rpc/rpc-client.ts +18 -0
  163. package/src/modes/rpc/rpc-mode.ts +16 -1
  164. package/src/modes/rpc/rpc-types.ts +11 -0
  165. package/src/modes/theme/theme.ts +151 -6
  166. package/src/modes/types.ts +2 -1
  167. package/src/modes/utils/ui-helpers.ts +38 -21
  168. package/src/prompts/agents/security-reviewer.md +75 -0
  169. package/src/prompts/security/scan-coordinator.md +7 -0
  170. package/src/prompts/security/scan-request.md +21 -0
  171. package/src/prompts/security/validate-request.md +8 -0
  172. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  173. package/src/prompts/system/plan-mode-active.md +2 -2
  174. package/src/prompts/system/system-prompt.md +6 -0
  175. package/src/prompts/system/xdev-mount-notice.md +1 -1
  176. package/src/prompts/tools/bash.md +15 -17
  177. package/src/prompts/tools/checkpoint.md +1 -1
  178. package/src/prompts/tools/glob.md +7 -6
  179. package/src/prompts/tools/grep.md +7 -6
  180. package/src/prompts/tools/security-publish.md +1 -0
  181. package/src/prompts/tools/security-scan.md +1 -0
  182. package/src/sdk.ts +212 -21
  183. package/src/security/auth.ts +98 -0
  184. package/src/security/cloud.ts +686 -0
  185. package/src/security/comparison.ts +255 -0
  186. package/src/security/contracts/ids.ts +111 -0
  187. package/src/security/contracts/index.ts +4 -0
  188. package/src/security/contracts/schemas.ts +201 -0
  189. package/src/security/contracts/types.ts +254 -0
  190. package/src/security/contracts/validation.ts +65 -0
  191. package/src/security/coordinator.ts +708 -0
  192. package/src/security/importers/codex-security.ts +387 -0
  193. package/src/security/importers/index.ts +2 -0
  194. package/src/security/importers/sarif.ts +357 -0
  195. package/src/security/index.ts +13 -0
  196. package/src/security/preflight.ts +405 -0
  197. package/src/security/provenance.ts +106 -0
  198. package/src/security/publication.ts +326 -0
  199. package/src/security/remediation.ts +93 -0
  200. package/src/security/resource-output.ts +50 -0
  201. package/src/security/sarif.ts +78 -0
  202. package/src/security/store.ts +430 -0
  203. package/src/session/agent-session-events.ts +1 -0
  204. package/src/session/agent-session-types.ts +40 -1
  205. package/src/session/agent-session.ts +302 -175
  206. package/src/session/claude-session-store.ts +426 -0
  207. package/src/session/codex-session-store.ts +673 -0
  208. package/src/session/credential-pin.ts +93 -0
  209. package/src/session/foreign-session-import.ts +52 -0
  210. package/src/session/foreign-session-jsonl.ts +29 -0
  211. package/src/session/foreign-session-store.ts +26 -0
  212. package/src/session/model-controls.ts +11 -3
  213. package/src/session/session-advisors.ts +140 -16
  214. package/src/session/session-entries.ts +21 -1
  215. package/src/session/session-handoff.ts +20 -3
  216. package/src/session/session-loader.ts +25 -9
  217. package/src/session/session-maintenance.ts +59 -11
  218. package/src/session/session-manager.ts +61 -0
  219. package/src/session/session-tools.ts +107 -5
  220. package/src/session/settings-stream-fn.ts +1 -0
  221. package/src/session/turn-recovery.ts +97 -51
  222. package/src/slash-commands/builtin-registry.ts +152 -3
  223. package/src/slash-commands/helpers/security.ts +451 -0
  224. package/src/system-prompt.ts +7 -2
  225. package/src/task/agents.ts +2 -0
  226. package/src/task/executor.ts +20 -4
  227. package/src/task/render.ts +20 -4
  228. package/src/task/structured-subagent.ts +6 -4
  229. package/src/thinking.ts +29 -10
  230. package/src/tools/acp-bridge.ts +52 -8
  231. package/src/tools/browser.ts +5 -0
  232. package/src/tools/builtin-names.ts +1 -0
  233. package/src/tools/checkpoint.ts +0 -13
  234. package/src/tools/grep.ts +64 -8
  235. package/src/tools/hub/launch.ts +10 -15
  236. package/src/tools/index.ts +37 -5
  237. package/src/tools/path-utils.ts +91 -0
  238. package/src/tools/read.ts +3 -0
  239. package/src/tools/security-scan.ts +287 -0
  240. package/src/tools/write.ts +9 -3
  241. package/src/tools/xdev.ts +39 -14
  242. package/src/utils/changelog.ts +144 -4
  243. package/src/utils/git.ts +61 -14
  244. package/src/utils/jj.ts +19 -13
  245. package/src/web/search/providers/codex.ts +34 -0
  246. package/src/web/search/types.ts +1 -1
@@ -15,6 +15,11 @@ import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/sessio
15
15
  import { calculateTokensPerSecond } from "../../../utils/token-rate";
16
16
  import { sanitizeStatusText } from "../../shared";
17
17
  import { theme } from "../../theme/theme";
18
+ import {
19
+ type CodexResetFireworksEvent,
20
+ type CodexResetUsageSnapshot,
21
+ detectCodexResetFireworks,
22
+ } from "../codex-reset-fireworks";
18
23
  import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils";
19
24
  import { getPreset } from "./presets";
20
25
  import { renderSegment, type SegmentContext } from "./segments";
@@ -28,6 +33,37 @@ import type {
28
33
  } from "./types";
29
34
 
30
35
  const JJ_REFRESH_TTL_MS = 5000;
36
+ const WATCHER_FAILURE_POLL_TTL_MS = 5000;
37
+
38
+ function normalizeCodexIdentityValue(value: unknown): string | undefined {
39
+ return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
40
+ }
41
+
42
+ /**
43
+ * Fireworks are stateful, so their report match must be stricter than the
44
+ * status display's fallback matching: every known credential identifier must
45
+ * be present and equal or a workspace sibling can mutate this account's
46
+ * baseline.
47
+ */
48
+ function codexReportMatchesExactIdentity(report: UsageReport, identity: OAuthAccountIdentity | undefined): boolean {
49
+ if (!identity) return false;
50
+ const accountId = normalizeCodexIdentityValue(identity.accountId);
51
+ const email = normalizeCodexIdentityValue(identity.email);
52
+ const projectId = normalizeCodexIdentityValue(identity.projectId);
53
+ const orgId = normalizeCodexIdentityValue(identity.orgId);
54
+ if (!accountId && !email && !projectId && !orgId) return false;
55
+
56
+ const metadata = report.metadata ?? {};
57
+ const reportAccountId =
58
+ normalizeCodexIdentityValue(metadata.accountId) ?? normalizeCodexIdentityValue(metadata.account_id);
59
+ const reportProjectId =
60
+ normalizeCodexIdentityValue(metadata.projectId) ?? normalizeCodexIdentityValue(metadata.project_id);
61
+ if (accountId && reportAccountId !== accountId) return false;
62
+ if (email && normalizeCodexIdentityValue(metadata.email) !== email) return false;
63
+ if (projectId && reportProjectId !== projectId) return false;
64
+ if (orgId && normalizeCodexIdentityValue(metadata.orgId) !== orgId) return false;
65
+ return true;
66
+ }
31
67
 
32
68
  // ═══════════════════════════════════════════════════════════════════════════
33
69
  // Context-usage memo
@@ -174,6 +210,17 @@ interface ActiveRepoCache {
174
210
  worktree: WorktreeContext | null;
175
211
  }
176
212
 
213
+ interface BranchResolveRequest {
214
+ id: number;
215
+ cwd: string;
216
+ controller: AbortController;
217
+ }
218
+
219
+ interface JjResolveRequest {
220
+ id: number;
221
+ controller: AbortController;
222
+ }
223
+
177
224
  interface WorktreeContext {
178
225
  /** Primary-checkout (project) name shown by the path segment. */
179
226
  projectName: string;
@@ -250,7 +297,31 @@ export class StatusLineComponent implements Component {
250
297
  #cachedBranch: string | null | undefined = undefined;
251
298
  #cachedBranchRepoId: string | null | undefined = undefined;
252
299
  #cachedBranchCwd: string | undefined = undefined;
300
+ #cachedBranchHasGitRepository = false;
301
+ // In-flight reftable resolve slot. Ownership is the launch id, not the cwd:
302
+ // two live resolves can share a cwd string across an invalidation, and a
303
+ // stale one must never free (or poison) a slot it no longer owns.
304
+ #branchResolveSeq = 0;
305
+ #branchResolveActive: BranchResolveRequest | undefined = undefined;
306
+ // Bumped on every branch-cache reset (#invalidateGitCaches — a HEAD move or
307
+ // repo-context change). An in-flight reftable resolve captures this at
308
+ // launch; a mismatch on resolve means the cache was invalidated underneath
309
+ // it (a newer resolve superseded it), so its result is stale and must be
310
+ // dropped rather than overwrite the value the newer resolve committed.
311
+ // Mirrors #jjCacheGeneration / #getJjBranch in this file.
312
+ // Timestamp of the latest branch read; only bounds cache freshness when the
313
+ // HEAD watcher could not be installed.
314
+ #branchLastFetch: number | undefined = undefined;
315
+ // Bumped on every branch-cache reset (invalidateGitCaches — a HEAD move or
316
+ // repo-context change). An in-flight reftable resolve captures this at
317
+ // launch; a mismatch on resolve means the cache was invalidated underneath
318
+ // it (a newer resolve superseded it), so its result is stale and must be
319
+ // dropped rather than overwrite the value the newer resolve committed.
320
+ // Mirrors #jjCacheGeneration / #getJjBranch in this file.
321
+ #branchCacheGeneration = 0;
253
322
  #gitWatcher: fs.FSWatcher | null = null;
323
+ #gitWatcherErrorListener: (() => void) | undefined = undefined;
324
+ #gitWatcherUnavailable = false;
254
325
  #onBranchChange: (() => void) | null = null;
255
326
  #disposed = false;
256
327
  #autoCompactEnabled: boolean = true;
@@ -299,10 +370,11 @@ export class StatusLineComponent implements Component {
299
370
  #jjRootCwd: string | undefined = undefined;
300
371
  #cachedJjBranch: string | null = null;
301
372
  #jjBranchLastFetch = 0;
302
- #jjBranchInFlight = false;
373
+ #jjResolveSeq = 0;
374
+ #jjBranchActive: JjResolveRequest | undefined = undefined;
303
375
  #cachedJjStatus: { staged: number; unstaged: number; untracked: number } | null = null;
304
376
  #jjStatusLastFetch = 0;
305
- #jjStatusInFlight = false;
377
+ #jjStatusActive: JjResolveRequest | undefined = undefined;
306
378
  // Bumped on every jj-cache reset — a cwd switch (#jjRootFor) or a HEAD /
307
379
  // bookmark move (#invalidateGitCaches). An in-flight jj query captures this
308
380
  // at launch; a mismatch on resolve means the caches were reset underneath it
@@ -330,6 +402,12 @@ export class StatusLineComponent implements Component {
330
402
  #usageFetchedAt = 0;
331
403
  #usageInFlight = false;
332
404
  #usageStartTimer: Timer | null = null;
405
+ // A timed-out request may still resolve. Its result remains eligible only
406
+ // until a newer request has applied.
407
+ #usageRefreshSequence = 0;
408
+ #latestAppliedUsageRefreshSequence = 0;
409
+ #codexResetSnapshots = new Map<string, CodexResetUsageSnapshot>();
410
+ #onCodexResetFireworks: ((event: CodexResetFireworksEvent) => void) | undefined;
333
411
  // Context-usage memo. The status line redraws on every agent event, so the
334
412
  // hot path must not recompute context tokens unless an input changed.
335
413
  // `getContextUsage()` anchors on the last assistant's real prompt-token
@@ -548,6 +626,11 @@ export class StatusLineComponent implements Component {
548
626
  this.#collabStatus = status;
549
627
  }
550
628
 
629
+ /** Set the callback that presents detected Codex reset celebrations, or clear it with `undefined`. */
630
+ setCodexResetFireworksHandler(handler: ((event: CodexResetFireworksEvent) => void) | undefined): void {
631
+ this.#onCodexResetFireworks = handler;
632
+ }
633
+
551
634
  setHookStatus(key: string, text: string | undefined): void {
552
635
  if (text === undefined) {
553
636
  this.#hookStatuses.delete(key);
@@ -562,45 +645,70 @@ export class StatusLineComponent implements Component {
562
645
  }
563
646
 
564
647
  #setupGitWatcher(): void {
565
- if (this.#gitWatcher) {
566
- this.#gitWatcher.close();
567
- this.#gitWatcher = null;
568
- }
648
+ this.#retireGitWatcher();
649
+ this.#gitWatcherUnavailable = false;
569
650
 
570
651
  if (!this.#gitEnabled() || !this.#hasGitBackedSegment()) {
571
- this.#invalidateGitCaches();
652
+ this.invalidateGitCaches();
572
653
  return;
573
654
  }
574
655
 
575
656
  const { effectiveGitCwd } = this.#resolveActiveRepoCache();
576
657
  const repository = git.repo.resolveSync(effectiveGitCwd);
577
- if (!repository) return;
658
+ if (!repository) {
659
+ // There is no path to watch yet. Cache the negative result only for the
660
+ // fallback poll interval so a later `git init` becomes visible without
661
+ // generic invalidations or a render-path probe on every paint.
662
+ this.#gitWatcherUnavailable = true;
663
+ return;
664
+ }
578
665
 
579
666
  const watchPath = git.repo.isReftableSync(repository)
580
667
  ? path.join(repository.gitDir, "reftable")
581
668
  : repository.headPath;
582
669
 
583
670
  try {
584
- this.#gitWatcher = fs.watch(watchPath, () => {
585
- if (this.#disposed) return;
586
- this.#invalidateGitCaches();
587
- if (this.#onBranchChange) {
588
- this.#onBranchChange();
589
- }
671
+ const watcher = fs.watch(watchPath, () => {
672
+ if (this.#disposed || this.#gitWatcher !== watcher) return;
673
+ this.invalidateGitCaches();
674
+ this.#onBranchChange?.();
590
675
  });
676
+ const onError = () => {
677
+ if (this.#gitWatcher !== watcher) return;
678
+ this.#retireGitWatcher();
679
+ this.#gitWatcherUnavailable = true;
680
+ if (this.#disposed) return;
681
+ this.invalidateGitCaches();
682
+ this.#onBranchChange?.();
683
+ };
684
+ this.#gitWatcher = watcher;
685
+ this.#gitWatcherErrorListener = onError;
686
+ watcher.on("error", onError);
591
687
  } catch {
592
- this.#invalidateGitCaches();
688
+ this.#gitWatcherUnavailable = true;
593
689
  }
594
690
  }
595
691
 
692
+ #retireGitWatcher(): void {
693
+ const watcher = this.#gitWatcher;
694
+ const onError = this.#gitWatcherErrorListener;
695
+ this.#gitWatcher = null;
696
+ this.#gitWatcherErrorListener = undefined;
697
+ if (!watcher) return;
698
+ if (onError) watcher.off("error", onError);
699
+ watcher.close();
700
+ }
701
+
596
702
  dispose(): void {
597
703
  this.#disposed = true;
704
+ this.#branchResolveActive?.controller.abort();
705
+ this.#branchResolveActive = undefined;
706
+ this.#resetJjRequests();
598
707
  this.#onBranchChange = null;
599
708
  this.#clearUsageStartTimer();
600
- if (this.#gitWatcher) {
601
- this.#gitWatcher.close();
602
- this.#gitWatcher = null;
603
- }
709
+ this.#onCodexResetFireworks = undefined;
710
+ this.#codexResetSnapshots.clear();
711
+ this.#retireGitWatcher();
604
712
  }
605
713
 
606
714
  #clearUsageStartTimer(): void {
@@ -610,7 +718,17 @@ export class StatusLineComponent implements Component {
610
718
  }
611
719
 
612
720
  invalidate(): void {
613
- this.#invalidateGitCaches();
721
+ // Generic repaint invalidation (theme change, message event, model
722
+ // switch, …). Must NOT abort or restart a live reftable HEAD/PR resolve:
723
+ // the render path self-invalidates via cwd/context cache-miss checks, so
724
+ // a generic paint only needs to re-render — not tear down in-flight VCS
725
+ // work. Aborting here would fan out a new git subprocess on every agent
726
+ // event, re-introducing the render-path spawn churn the async resolve
727
+ // was designed to avoid. Explicit Git/repository invalidation (watcher
728
+ // HEAD-move, cwd/repo switch) goes through {@link invalidateGitCaches}.
729
+ // A tool may open, close, or merge a PR without moving HEAD. Expire the
730
+ // settled PR context on ordinary activity while leaving HEAD work intact.
731
+ this.#cachedPrContext = undefined;
614
732
  }
615
733
  #invalidateSessionCaches(): void {
616
734
  this.#clearUsageStartTimer();
@@ -622,14 +740,30 @@ export class StatusLineComponent implements Component {
622
740
  this.#lastTokensPerSecondTimestamp = null;
623
741
  }
624
742
 
625
- #invalidateGitCaches(): void {
743
+ /**
744
+ * Explicit Git/repository cache invalidation. Aborts any in-flight
745
+ * reftable HEAD/PR resolve, bumps the stale-result generation, and drops
746
+ * the branch/PR/jj caches so the next render refetches from disk. Called
747
+ * by the git watcher on a HEAD move and by {@link applyCwdChange} on a
748
+ * repo/cwd switch. Generic repaints use {@link invalidate} instead and
749
+ * must never reach this path.
750
+ */
751
+ invalidateGitCaches(): void {
626
752
  this.#cachedBranch = undefined;
627
753
  this.#cachedBranchRepoId = undefined;
628
754
  this.#cachedBranchCwd = undefined;
755
+ this.#cachedBranchHasGitRepository = false;
756
+ // Abort before releasing the in-flight slot. Releasing alone would allow
757
+ // repeated invalidations to fan out still-running git subprocesses.
758
+ this.#branchResolveActive?.controller.abort();
759
+ this.#branchResolveActive = undefined;
760
+ this.#branchLastFetch = undefined;
761
+ this.#branchCacheGeneration++;
629
762
  this.#cachedPrContext = undefined;
630
763
  // jj label/status share the git segment's lifecycle: a HEAD move (e.g. a
631
764
  // colocated `jj new`/bookmark move) must drop the throttled jj caches too,
632
765
  // mirroring #jjRootFor's per-cwd reset so the next render refetches.
766
+ this.#resetJjRequests();
633
767
  this.#jjRoot = undefined;
634
768
  this.#jjRootCwd = undefined;
635
769
  this.#cachedJjBranch = null;
@@ -638,25 +772,111 @@ export class StatusLineComponent implements Component {
638
772
  this.#jjStatusLastFetch = 0;
639
773
  this.#jjCacheGeneration++;
640
774
  }
775
+
776
+ /**
777
+ * Re-point the status line's VCS watcher and caches at a new cwd/repository.
778
+ * Atomically retires the old watcher/listeners, invalidates VCS caches and
779
+ * in-flight controllers, then runs watcher setup for the new cwd and requests
780
+ * a repaint. Called by {@link InteractiveMode.applyCwdChange} after the
781
+ * SessionManager's cwd has moved — the watcher ownership always follows the
782
+ * effective cwd/repo, so a stale watcher for the previous repo can never
783
+ * invalidate the new one. Generic repaints use {@link invalidate} and must
784
+ * never retire the watcher or abort a live resolve.
785
+ */
786
+ applyCwdChange(): void {
787
+ this.#retireGitWatcher();
788
+ this.invalidateGitCaches();
789
+ this.#setupGitWatcher();
790
+ this.#onBranchChange?.();
791
+ }
792
+
793
+ #resetJjRequests(): void {
794
+ this.#jjBranchActive?.controller.abort();
795
+ this.#jjBranchActive = undefined;
796
+ this.#jjStatusActive?.controller.abort();
797
+ this.#jjStatusActive = undefined;
798
+ }
641
799
  #getCurrentBranch(effectiveGitCwd?: string): string | null {
642
800
  if (!this.#gitEnabled()) return null;
643
801
 
644
802
  const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
645
- if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === gitCwd) {
803
+ const fallbackCacheExpired =
804
+ this.#gitWatcherUnavailable &&
805
+ (this.#branchLastFetch === undefined || Date.now() - this.#branchLastFetch >= WATCHER_FAILURE_POLL_TTL_MS);
806
+ if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === gitCwd && !fallbackCacheExpired) {
646
807
  return this.#cachedBranch;
647
808
  }
648
809
 
810
+ // A reftable repo resolves HEAD by spawning `git symbolic-ref` +
811
+ // `git rev-parse` — the unbounded spawn that froze the render path (F7).
812
+ // A non-reftable repo resolves HEAD with cheap sync filesystem reads, so
813
+ // only the reftable branch moves off the render path, mirroring
814
+ // #getGitStatus and #getJjBranch in this file.
815
+ const repository = git.repo.resolveSync(gitCwd);
816
+ if (repository && git.repo.isReftableSync(repository)) {
817
+ if (this.#branchResolveActive !== undefined) {
818
+ return this.#branchResolveActive.cwd === gitCwd && this.#cachedBranchCwd === gitCwd
819
+ ? (this.#cachedBranch ?? null)
820
+ : null;
821
+ }
822
+ const request: BranchResolveRequest = {
823
+ id: ++this.#branchResolveSeq,
824
+ cwd: gitCwd,
825
+ controller: new AbortController(),
826
+ };
827
+ this.#branchResolveActive = request;
828
+ // Capture the cache generation at launch. invalidateGitCaches bumps it
829
+ // on a HEAD move and clears the in-flight slot, so a fresher resolve can
830
+ // start while this one is still pending. Without a generation check the
831
+ // older resolve would finish later, install its stale HEAD, and clear the
832
+ // slot — dropping the fresh result and freezing the status line on the
833
+ // pre-change branch. Mirrors #jjCacheGeneration / #getJjBranch.
834
+ const generation = this.#branchCacheGeneration;
835
+ (async () => {
836
+ let next: string | null = null;
837
+ let repoId: string | null = null;
838
+ try {
839
+ const headState = await git.head.resolve(gitCwd, request.controller.signal);
840
+ repoId = headState?.headPath ?? null;
841
+ next = !headState
842
+ ? null
843
+ : headState.kind === "ref"
844
+ ? (headState.branchName ?? headState.ref)
845
+ : "detached";
846
+ } catch {
847
+ next = null;
848
+ } finally {
849
+ // Release the slot only if this resolve still owns it: after an
850
+ // invalidation a fresher resolve may hold it, and freeing that
851
+ // slot here would let a third same-generation resolve launch and
852
+ // race the fresh one to the cache commit.
853
+ if (this.#branchResolveActive?.id === request.id) this.#branchResolveActive = undefined;
854
+ }
855
+ // Only the latest generation may update the cache; a mismatch means a
856
+ // newer resolve superseded this one (or the component disposed).
857
+ if (this.#branchCacheGeneration !== generation || this.#disposed) return;
858
+ const prev = this.#cachedBranchCwd === gitCwd ? this.#cachedBranch : undefined;
859
+ this.#cachedBranchCwd = gitCwd;
860
+ this.#cachedBranchRepoId = repoId;
861
+ this.#cachedBranchHasGitRepository = next === null;
862
+ this.#cachedBranch = next;
863
+ this.#branchLastFetch = Date.now();
864
+ if (prev !== next && this.#onBranchChange) this.#onBranchChange();
865
+ })();
866
+ return this.#cachedBranchCwd === gitCwd ? (this.#cachedBranch ?? null) : null;
867
+ }
868
+
869
+ // Non-reftable: cheap sync filesystem read, safe on the render path.
649
870
  const head = git.head.resolveSync(gitCwd);
650
871
  const gitHeadPath = head?.headPath ?? null;
651
872
  this.#cachedBranchCwd = gitCwd;
652
873
  this.#cachedBranchRepoId = gitHeadPath;
874
+ this.#branchLastFetch = Date.now();
653
875
  if (!head) {
654
876
  this.#cachedBranch = null;
655
877
  return null;
656
878
  }
657
-
658
879
  this.#cachedBranch = head.kind === "ref" ? (head.branchName ?? head.ref) : "detached";
659
-
660
880
  return this.#cachedBranch ?? null;
661
881
  }
662
882
 
@@ -741,17 +961,24 @@ export class StatusLineComponent implements Component {
741
961
  const cwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
742
962
  const root = this.#jjRootFor(cwd);
743
963
  if (!root) return null;
744
- if (this.#jjBranchInFlight || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) {
964
+ if (this.#jjBranchActive || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) {
745
965
  return this.#cachedJjBranch;
746
966
  }
747
- this.#jjBranchInFlight = true;
967
+ const request: JjResolveRequest = {
968
+ id: ++this.#jjResolveSeq,
969
+ controller: new AbortController(),
970
+ };
971
+ this.#jjBranchActive = request;
748
972
  const generation = this.#jjCacheGeneration;
749
973
  (async () => {
750
974
  let next: string | null = null;
751
975
  try {
752
- next = await jj.workingCopy.label(root);
976
+ next = await jj.workingCopy.label(root, {
977
+ signal: request.controller.signal,
978
+ timeoutMs: jj.JJ_COMMAND_TIMEOUT_MS,
979
+ });
753
980
  } finally {
754
- this.#jjBranchInFlight = false;
981
+ if (this.#jjBranchActive?.id === request.id) this.#jjBranchActive = undefined;
755
982
  // Advance the throttle only if no reset raced this query; a reset
756
983
  // leaves LastFetch at 0 so the current root refetches instead of
757
984
  // being throttled on a superseded result.
@@ -763,7 +990,7 @@ export class StatusLineComponent implements Component {
763
990
  if (this.#jjCacheGeneration !== generation || this.#disposed) return;
764
991
  const changed = next !== this.#cachedJjBranch;
765
992
  this.#cachedJjBranch = next;
766
- if (changed && this.#onBranchChange) this.#onBranchChange();
993
+ if (changed) this.#onBranchChange?.();
767
994
  })();
768
995
  return this.#cachedJjBranch;
769
996
  }
@@ -775,23 +1002,30 @@ export class StatusLineComponent implements Component {
775
1002
  const cwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
776
1003
  const root = this.#jjRootFor(cwd);
777
1004
  if (!root) return null;
778
- if (this.#jjStatusInFlight || Date.now() - this.#jjStatusLastFetch < JJ_REFRESH_TTL_MS) {
1005
+ if (this.#jjStatusActive || Date.now() - this.#jjStatusLastFetch < JJ_REFRESH_TTL_MS) {
779
1006
  return this.#cachedJjStatus;
780
1007
  }
781
- this.#jjStatusInFlight = true;
1008
+ const request: JjResolveRequest = {
1009
+ id: ++this.#jjResolveSeq,
1010
+ controller: new AbortController(),
1011
+ };
1012
+ this.#jjStatusActive = request;
782
1013
  const generation = this.#jjCacheGeneration;
783
1014
  (async () => {
784
1015
  let next: { staged: number; unstaged: number; untracked: number } | null = null;
785
1016
  try {
786
- next = await jj.status.summary(root);
1017
+ next = await jj.status.summary(root, {
1018
+ signal: request.controller.signal,
1019
+ timeoutMs: jj.JJ_COMMAND_TIMEOUT_MS,
1020
+ });
787
1021
  } finally {
788
- this.#jjStatusInFlight = false;
1022
+ if (this.#jjStatusActive?.id === request.id) this.#jjStatusActive = undefined;
789
1023
  if (this.#jjCacheGeneration === generation) this.#jjStatusLastFetch = Date.now();
790
1024
  }
791
1025
  if (this.#jjCacheGeneration !== generation || this.#disposed) return;
792
1026
  const prev = this.#cachedJjStatus;
793
1027
  this.#cachedJjStatus = next;
794
- if (this.#onBranchChange && JSON.stringify(prev) !== JSON.stringify(next)) this.#onBranchChange();
1028
+ if (JSON.stringify(prev) !== JSON.stringify(next)) this.#onBranchChange?.();
795
1029
  })();
796
1030
  return this.#cachedJjStatus;
797
1031
  }
@@ -939,10 +1173,8 @@ export class StatusLineComponent implements Component {
939
1173
  return this.#vibeWorkerTokenRate?.() ?? null;
940
1174
  }
941
1175
 
942
- #getUsageContextKey(session: AgentSession): string {
943
- const activeProvider = session.state.model?.provider ?? session.model?.provider ?? "";
1176
+ #formatUsageContextKey(activeProvider: string | undefined, identity: OAuthAccountIdentity | undefined): string {
944
1177
  if (!activeProvider) return "";
945
- const identity = session.modelRegistry?.authStorage?.getOAuthAccountIdentity(activeProvider, session.sessionId);
946
1178
  // orgId is part of the key: rotating between two same-email Anthropic
947
1179
  // subscriptions must invalidate the cached usage immediately instead of
948
1180
  // showing the previous org's quota for the rest of the cache TTL.
@@ -955,6 +1187,14 @@ export class StatusLineComponent implements Component {
955
1187
  ].join("\0");
956
1188
  }
957
1189
 
1190
+ #getUsageContextKey(session: AgentSession): string {
1191
+ const activeProvider = session.state.model?.provider ?? session.model?.provider;
1192
+ const identity = activeProvider
1193
+ ? session.modelRegistry?.authStorage?.getOAuthAccountIdentity(activeProvider, session.sessionId)
1194
+ : undefined;
1195
+ return this.#formatUsageContextKey(activeProvider, identity);
1196
+ }
1197
+
958
1198
  /**
959
1199
  * Startup redraws only arm a short-delayed task; timeout releases the render
960
1200
  * cadence while a late successful fetch can still refresh the cached segment.
@@ -984,40 +1224,60 @@ export class StatusLineComponent implements Component {
984
1224
  this.#usageInFlight = false;
985
1225
  return;
986
1226
  }
1227
+ const sequence = ++this.#usageRefreshSequence;
987
1228
  const signal = AbortSignal.timeout(STATUS_USAGE_REFRESH_TIMEOUT_MS);
988
1229
  let reportsPromise: Promise<unknown> | undefined;
989
1230
  try {
990
1231
  reportsPromise = fetcher.call(session, signal);
991
- this.#applyUsageRefreshReports(session, await this.#raceUsageRefreshWithSignal(reportsPromise, signal));
1232
+ this.#applyUsageRefreshReports(
1233
+ session,
1234
+ await this.#raceUsageRefreshWithSignal(reportsPromise, signal),
1235
+ sequence,
1236
+ );
992
1237
  } catch {
993
1238
  if (this.session !== session) return;
994
1239
  this.#usageFetchedAt = Date.now();
995
1240
  if (signal.aborted && reportsPromise) {
996
- this.#observeLateUsageRefresh(session, reportsPromise);
1241
+ this.#observeLateUsageRefresh(session, reportsPromise, sequence);
997
1242
  }
998
1243
  } finally {
999
1244
  if (this.session === session) this.#usageInFlight = false;
1000
1245
  }
1001
1246
  }
1002
1247
 
1003
- #applyUsageRefreshReports(session: AgentSession, reports: unknown): void {
1004
- if (this.#disposed || this.session !== session) return;
1248
+ #applyUsageRefreshReports(session: AgentSession, reports: unknown, sequence: number): void {
1249
+ if (this.#disposed || this.session !== session || sequence < this.#latestAppliedUsageRefreshSequence) {
1250
+ return;
1251
+ }
1252
+ this.#latestAppliedUsageRefreshSequence = sequence;
1005
1253
  const activeProvider = session.state.model?.provider ?? session.model?.provider;
1006
1254
  const activeIdentity =
1007
1255
  activeProvider && session.modelRegistry?.authStorage
1008
1256
  ? session.modelRegistry.authStorage.getOAuthAccountIdentity(activeProvider, session.sessionId)
1009
1257
  : undefined;
1010
- this.#cachedUsage = this.#normalizeUsageReports(reports, activeProvider, activeIdentity);
1258
+ const normalized = this.#normalizeUsageReports(reports, activeProvider, activeIdentity);
1259
+ const resetSnapshot =
1260
+ activeProvider === "openai-codex" ? this.#normalizeCodexResetSnapshot(reports, activeIdentity) : null;
1261
+ this.#cachedUsage = normalized;
1011
1262
  this.#usageFetchedAt = Date.now();
1263
+ if (!resetSnapshot) return;
1264
+ const contextKey = this.#formatUsageContextKey(activeProvider, activeIdentity);
1265
+ const previous = this.#codexResetSnapshots.get(contextKey);
1266
+ this.#codexResetSnapshots.set(contextKey, resetSnapshot);
1267
+ if (!previous || !settings.get("tui.codexResetFireworks")) return;
1268
+ const event = detectCodexResetFireworks(previous, resetSnapshot);
1269
+ if (event) this.#onCodexResetFireworks?.(event);
1012
1270
  }
1013
1271
 
1014
- #observeLateUsageRefresh(session: AgentSession, reportsPromise: Promise<unknown>): void {
1272
+ #observeLateUsageRefresh(session: AgentSession, reportsPromise: Promise<unknown>, sequence: number): void {
1015
1273
  void reportsPromise
1016
1274
  .then(reports => {
1017
- this.#applyUsageRefreshReports(session, reports);
1275
+ this.#applyUsageRefreshReports(session, reports, sequence);
1018
1276
  })
1019
1277
  .catch(() => {
1020
- if (this.#disposed || this.session !== session) return;
1278
+ if (this.#disposed || this.session !== session || sequence < this.#latestAppliedUsageRefreshSequence) {
1279
+ return;
1280
+ }
1021
1281
  this.#usageFetchedAt = Date.now();
1022
1282
  });
1023
1283
  }
@@ -1034,6 +1294,72 @@ export class StatusLineComponent implements Component {
1034
1294
  }
1035
1295
  }
1036
1296
 
1297
+ #normalizeCodexResetSnapshot(
1298
+ reports: unknown,
1299
+ activeIdentity: OAuthAccountIdentity | undefined,
1300
+ ): CodexResetUsageSnapshot | null {
1301
+ if (!Array.isArray(reports)) return null;
1302
+ let matchingReport: UsageReport | undefined;
1303
+ for (const report of reports) {
1304
+ if (!report || typeof report !== "object") continue;
1305
+ if (
1306
+ !("provider" in report) ||
1307
+ report.provider !== "openai-codex" ||
1308
+ !("limits" in report) ||
1309
+ !Array.isArray(report.limits)
1310
+ ) {
1311
+ continue;
1312
+ }
1313
+ // The report boundary above validates the fields this extractor iterates;
1314
+ // optional metadata and credit fields are narrowed again before use.
1315
+ const usageReport = report as UsageReport;
1316
+ if (!codexReportMatchesExactIdentity(usageReport, activeIdentity)) continue;
1317
+ matchingReport = usageReport;
1318
+ break;
1319
+ }
1320
+ if (!matchingReport) return null;
1321
+
1322
+ const plan =
1323
+ typeof matchingReport.metadata?.planType === "string" && matchingReport.metadata.planType
1324
+ ? matchingReport.metadata.planType
1325
+ : undefined;
1326
+ let sevenDay: CodexResetUsageSnapshot["sevenDay"];
1327
+ let sevenDayTier: string | undefined;
1328
+ for (const limit of matchingReport.limits) {
1329
+ if (!limit || typeof limit !== "object") continue;
1330
+ const candidate = limit as {
1331
+ scope?: { windowId?: string; tier?: string };
1332
+ window?: { resetsAt?: number };
1333
+ amount?: { usedFraction?: number };
1334
+ };
1335
+ const fraction = candidate.amount?.usedFraction;
1336
+ if (candidate.scope?.windowId !== "7d" || typeof fraction !== "number" || !Number.isFinite(fraction)) {
1337
+ continue;
1338
+ }
1339
+ const tier =
1340
+ typeof candidate.scope?.tier === "string" && candidate.scope.tier ? candidate.scope.tier : undefined;
1341
+ if (sevenDay && (sevenDayTier === undefined || tier)) continue;
1342
+ const resetsAt = candidate.window?.resetsAt;
1343
+ sevenDay = {
1344
+ percent: fraction * 100,
1345
+ resetsAt: typeof resetsAt === "number" && Number.isFinite(resetsAt) ? resetsAt : undefined,
1346
+ tier,
1347
+ plan,
1348
+ };
1349
+ sevenDayTier = tier;
1350
+ }
1351
+
1352
+ const fetchedAt = matchingReport.fetchedAt;
1353
+ const availableCount = matchingReport.resetCredits?.availableCount;
1354
+ const observedAt = typeof fetchedAt === "number" && Number.isFinite(fetchedAt) ? fetchedAt : undefined;
1355
+ const savedResets =
1356
+ typeof availableCount === "number" && Number.isFinite(availableCount)
1357
+ ? Math.max(0, Math.trunc(availableCount))
1358
+ : undefined;
1359
+ if (!sevenDay && savedResets === undefined) return null;
1360
+ return { observedAt, sevenDay, savedResets };
1361
+ }
1362
+
1037
1363
  #normalizeUsageReports(
1038
1364
  reports: unknown,
1039
1365
  activeProvider?: string,
@@ -1055,12 +1381,10 @@ export class StatusLineComponent implements Component {
1055
1381
  if (activeProvider && provider !== activeProvider) continue;
1056
1382
  const limits = (report as { limits?: unknown }).limits;
1057
1383
  if (!Array.isArray(limits)) continue;
1384
+ const usageReport = report as UsageReport;
1058
1385
  for (const limit of limits) {
1059
1386
  if (!limit || typeof limit !== "object") continue;
1060
- if (
1061
- activeIdentity &&
1062
- !limitMatchesActiveAccount(report as UsageReport, limit as UsageLimit, activeIdentity)
1063
- ) {
1387
+ if (activeIdentity && !limitMatchesActiveAccount(usageReport, limit as UsageLimit, activeIdentity)) {
1064
1388
  continue;
1065
1389
  }
1066
1390
  const l = limit as {
@@ -1213,11 +1537,14 @@ export class StatusLineComponent implements Component {
1213
1537
  : { projectDir, activeRepo: null, effectiveGitCwd: projectDir, worktree: null };
1214
1538
  let gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null;
1215
1539
  // A jj repo has no git branch to read: git HEAD is detached (colocated) or
1216
- // absent. Gate BOTH the jj branch label and the jj status counts on that
1217
- // same condition, captured before the label overlay rewrites gitBranch, so
1218
- // a nested ordinary git checkout under a parent jj workspace keeps its own
1219
- // git branch AND its own git status instead of the ancestor jj status.
1220
- const gitHeadIsJjLike = gitBranch === "detached" || gitBranch === null;
1540
+ // absent. A pending reftable resolve owns this cwd as an explicit Git repo,
1541
+ // so it must not be mistaken for an absent Git checkout and fall through to
1542
+ // an ancestor jj workspace.
1543
+ const gitHeadResolvePending = this.#branchResolveActive?.cwd === activeRepoCache.effectiveGitCwd;
1544
+ const gitHeadIsJjLike =
1545
+ !this.#cachedBranchHasGitRepository &&
1546
+ !gitHeadResolvePending &&
1547
+ (gitBranch === "detached" || gitBranch === null);
1221
1548
  if (includeGit && gitHeadIsJjLike) {
1222
1549
  gitBranch = this.#getJjBranch(activeRepoCache.effectiveGitCwd) ?? gitBranch;
1223
1550
  }