Date: Thu, 2 Jul 2026 17:06:01 -0700
Subject: [PATCH 32/70] docs: update Android APK link to 0.0.21 (#7163)
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index e5f11ec0b90..f5cace99b1e 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
+- **Android:** [Download APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
---
From 8e8a08ac7cef393677f7ec578de57ffebf94f7c9 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 2 Jul 2026 17:45:15 -0700
Subject: [PATCH 33/70] fix: supplement Claude Fable usage from CLI (#7167)
---
src/main/rate-limits/claude-fetcher.test.ts | 105 ++++++++++++++++++++
src/main/rate-limits/claude-fetcher.ts | 72 +++++++++++++-
src/main/rate-limits/service.test.ts | 17 ++--
src/main/rate-limits/service.ts | 12 ++-
4 files changed, 196 insertions(+), 10 deletions(-)
diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts
index 0790374ce0b..24ee44f7cec 100644
--- a/src/main/rate-limits/claude-fetcher.test.ts
+++ b/src/main/rate-limits/claude-fetcher.test.ts
@@ -205,6 +205,111 @@ describe('fetchClaudeRateLimits', () => {
})
})
+ it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => {
+ const configDir = '/Users/test/.claude'
+ const authPreparation: ClaudeRuntimeAuthPreparation = {
+ configDir,
+ envPatch: { CLAUDE_CONFIG_DIR: configDir },
+ stripAuthEnv: false,
+ provenance: 'managed:account-1'
+ }
+ vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
+ JSON.stringify({
+ claudeAiOauth: {
+ accessToken: 'oauth-token',
+ expiresAt: Date.now() + 60_000
+ }
+ })
+ )
+ netFetchMock.mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ five_hour: { used_percentage: 23.5, resets_at: 1770000000 },
+ seven_day: { used_percentage: 41.2, resets_at: 1770604800 }
+ }),
+ { status: 200 }
+ )
+ )
+ vi.mocked(fetchViaPty).mockResolvedValueOnce({
+ provider: 'claude',
+ session: { usedPercent: 91, windowMinutes: 300, resetsAt: null, resetDescription: null },
+ weekly: null,
+ fableWeekly: {
+ usedPercent: 12.3,
+ windowMinutes: 10080,
+ resetsAt: null,
+ resetDescription: '3d 2h'
+ },
+ updatedAt: 1,
+ error: null,
+ status: 'ok'
+ })
+
+ await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
+ provider: 'claude',
+ status: 'ok',
+ session: { usedPercent: 23.5, resetsAt: 1770000000000 },
+ weekly: { usedPercent: 41.2, resetsAt: 1770604800000 },
+ fableWeekly: { usedPercent: 12.3, resetDescription: '3d 2h' },
+ usageMetadata: {
+ source: 'oauth',
+ attemptedSources: ['oauth', 'cli']
+ }
+ })
+ expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation })
+ })
+
+ it('supplements system OAuth usage when the service explicitly allows usage-panel reads', async () => {
+ const configDir = '/Users/test/.claude'
+ const authPreparation: ClaudeRuntimeAuthPreparation = {
+ configDir,
+ envPatch: { CLAUDE_CONFIG_DIR: configDir },
+ stripAuthEnv: false,
+ provenance: 'system'
+ }
+ vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
+ JSON.stringify({
+ claudeAiOauth: {
+ accessToken: 'oauth-token',
+ expiresAt: Date.now() + 60_000
+ }
+ })
+ )
+ vi.mocked(fetchViaPty).mockResolvedValueOnce({
+ provider: 'claude',
+ session: null,
+ weekly: null,
+ fableWeekly: {
+ usedPercent: 58,
+ windowMinutes: 10080,
+ resetsAt: null,
+ resetDescription: '4d'
+ },
+ updatedAt: 1,
+ error: null,
+ status: 'ok'
+ })
+
+ await expect(
+ fetchClaudeRateLimits({
+ authPreparation,
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true
+ })
+ ).resolves.toMatchObject({
+ provider: 'claude',
+ status: 'ok',
+ session: { usedPercent: 12 },
+ weekly: { usedPercent: 34 },
+ fableWeekly: { usedPercent: 58, resetDescription: '4d' },
+ usageMetadata: {
+ source: 'oauth',
+ attemptedSources: ['oauth', 'cli']
+ }
+ })
+ expect(fetchViaPty).toHaveBeenCalledWith({ authPreparation })
+ })
+
it('ignores bare Fable OAuth usage because the window length is ambiguous', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts
index c89aef0473c..5f283e58ec8 100644
--- a/src/main/rate-limits/claude-fetcher.ts
+++ b/src/main/rate-limits/claude-fetcher.ts
@@ -518,6 +518,64 @@ async function fetchClaudeUsageViaCli(input: {
)
}
+function isManagedClaudeAuth(authPreparation: ClaudeRuntimeAuthPreparation | undefined): boolean {
+ return authPreparation?.provenance.startsWith('managed:') === true
+}
+
+function canSupplementOAuthUsageFromCli(input: {
+ oauthLimits: ProviderRateLimits
+ authPreparation?: ClaudeRuntimeAuthPreparation
+ allowUsagePanelSupplement: boolean
+}): boolean {
+ // Why: Fable is visible in Claude's interactive /usage panel even when the
+ // OAuth usage endpoint only reports documented 5h/7d windows. This runs only
+ // after OAuth succeeds, so it must not become a broad auth-recovery fallback.
+ return Boolean(
+ input.allowUsagePanelSupplement &&
+ !input.authPreparation?.managedRefreshDeferredByLivePty &&
+ !input.oauthLimits.fableWeekly &&
+ (input.oauthLimits.session || input.oauthLimits.weekly)
+ )
+}
+
+function mergeClaudeUsageWindows(
+ primary: ProviderRateLimits,
+ supplement: ProviderRateLimits | null
+): ProviderRateLimits {
+ if (!supplement) {
+ return primary
+ }
+ return {
+ ...primary,
+ session: primary.session ?? supplement.session,
+ weekly: primary.weekly ?? supplement.weekly,
+ fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null
+ }
+}
+
+async function supplementOAuthUsageFromCli(input: {
+ oauthLimits: ProviderRateLimits
+ authPreparation?: ClaudeRuntimeAuthPreparation
+ oauthCredentials: OAuthCredentialReadResult
+ attempts: ClaudeUsageAttemptState
+ allowUsagePanelSupplement: boolean
+}): Promise {
+ if (!canSupplementOAuthUsageFromCli(input)) {
+ return input.oauthLimits
+ }
+ try {
+ const cliLimits = await fetchClaudeUsageViaCli({
+ authPreparation: input.authPreparation,
+ oauthCredentials: input.oauthCredentials,
+ attempts: input.attempts
+ })
+ return mergeClaudeUsageWindows(input.oauthLimits, cliLimits)
+ } catch (err) {
+ warnClaudeUsageFetchFailure(input.authPreparation, input.oauthCredentials, err)
+ return input.oauthLimits
+ }
+}
+
function shouldDeferForLiveClaude(
authPreparation: ClaudeRuntimeAuthPreparation | undefined,
classification: ClaudeUsageErrorClassification
@@ -588,8 +646,9 @@ async function attemptCliRepairThenRetryOAuth(input: {
recordAttempt(input.attempts, 'oauth')
try {
const oauthRetry = await fetchViaOAuth(refreshedCredentials.token)
+ const supplemented = mergeClaudeUsageWindows(oauthRetry, cliResult)
return withClaudeUsageMetadata(
- oauthRetry,
+ supplemented,
metadataForAttempt({
attemptedSources: input.attempts.attemptedSources,
oauthCredentials: refreshedCredentials,
@@ -612,6 +671,7 @@ async function attemptCliRepairThenRetryOAuth(input: {
export type FetchClaudeRateLimitsOptions = {
authPreparation?: ClaudeRuntimeAuthPreparation
allowPtyFallback?: boolean
+ allowUsagePanelSupplement?: boolean
}
export async function fetchClaudeRateLimits(
@@ -643,7 +703,15 @@ export async function fetchClaudeRateLimits(
if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) {
recordAttempt(attempts, 'oauth')
try {
- const limits = await fetchViaOAuth(oauthCredentials.token)
+ const oauthLimits = await fetchViaOAuth(oauthCredentials.token)
+ const limits = await supplementOAuthUsageFromCli({
+ oauthLimits,
+ authPreparation: options?.authPreparation,
+ oauthCredentials,
+ attempts,
+ allowUsagePanelSupplement:
+ options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation)
+ })
return withClaudeUsageMetadata(
limits,
metadataForAttempt({
diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts
index 30394246253..c5c389bd0e3 100644
--- a/src/main/rate-limits/service.test.ts
+++ b/src/main/rate-limits/service.test.ts
@@ -353,7 +353,8 @@ describe('RateLimitService', () => {
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: undefined,
- allowPtyFallback: false
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true
})
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
@@ -459,7 +460,8 @@ describe('RateLimitService', () => {
wslLinuxConfigDir: '/home/jin/.claude',
stripAuthEnv: true
}),
- allowPtyFallback: true
+ allowPtyFallback: true,
+ allowUsagePanelSupplement: true
})
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
})
@@ -483,7 +485,8 @@ describe('RateLimitService', () => {
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: expect.objectContaining({ provenance: 'system' }),
- allowPtyFallback: false
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true
})
})
@@ -497,7 +500,8 @@ describe('RateLimitService', () => {
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: undefined,
- allowPtyFallback: false
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true
})
})
@@ -521,7 +525,8 @@ describe('RateLimitService', () => {
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
- allowPtyFallback: false
+ allowPtyFallback: false,
+ allowUsagePanelSupplement: true
})
})
@@ -580,7 +585,7 @@ describe('RateLimitService', () => {
})
expect(fetchClaudeRateLimits).toHaveBeenLastCalledWith(
- expect.objectContaining({ allowPtyFallback: true })
+ expect.objectContaining({ allowPtyFallback: true, allowUsagePanelSupplement: true })
)
expect(service.getState().inactiveClaudeAccounts).not.toEqual(
diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts
index 42d8f8c3580..af7431bcad6 100644
--- a/src/main/rate-limits/service.ts
+++ b/src/main/rate-limits/service.ts
@@ -818,6 +818,12 @@ export class RateLimitService {
return !isSystemDefaultClaudeAuth(authPreparation)
}
+ private shouldAllowClaudeUsagePanelSupplement(): boolean {
+ // Why: this supplement runs only after OAuth has already returned usage
+ // data. Keep it off on Windows where hidden PTYs are still less reliable.
+ return process.platform !== 'win32'
+ }
+
private withFetchingStatus(
current: ProviderRateLimits | null,
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
@@ -881,7 +887,8 @@ export class RateLimitService {
await Promise.allSettled([
fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
- allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation)
+ allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
+ allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement()
}),
missingWslCodexHome ??
fetchCodexRateLimits({
@@ -1056,7 +1063,8 @@ export class RateLimitService {
const claude = await fetchClaudeRateLimits({
authPreparation: claudeAuthPreparation,
- allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation)
+ allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
+ allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement()
}).catch(
(err): ProviderRateLimits => ({
provider: 'claude',
From 78d3a37b90df85e0421ddc82a51204b74ca75c59 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Thu, 2 Jul 2026 17:55:37 -0700
Subject: [PATCH 34/70] Revert WebGL context retention (#7169)
---
notes/windows-perf-progress.md | 11 +-
.../terminal-visibility-resume.ts | 5 +-
.../src/lib/pane-manager/pane-manager.ts | 5 -
.../pane-manager/pane-rendering-control.ts | 33 +--
.../pane-webgl-context-retention.test.ts | 235 ------------------
.../pane-webgl-context-retention.ts | 56 -----
.../pane-webgl-refresh-lifecycle.test.ts | 12 +
.../lib/pane-manager/pane-webgl-renderer.ts | 11 +-
8 files changed, 19 insertions(+), 349 deletions(-)
delete mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts
delete mode 100644 src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts
diff --git a/notes/windows-perf-progress.md b/notes/windows-perf-progress.md
index c31c10d243d..51bee65e4f6 100644
--- a/notes/windows-perf-progress.md
+++ b/notes/windows-perf-progress.md
@@ -30,15 +30,10 @@ Findings (baseline, this machine):
ConPTY corruption + perf differences on degraded-mode/fresh-local spawns).
Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis):
-- A: WebGL context retention across hide/show (pane-webgl-context-retention.ts, LRU cap 32 —
- sized for ~10-16 worktrees × ~2 terminals hot sets; suspend keeps live contexts; resume
- repaints instead of recreating). Gated to Windows (getRendererAppPlatform() === 'win32'):
- macOS/Linux context creation is cheap, so they keep dispose-on-hide and pay no retention
- GPU-memory cost. Peer note: at least one comparable product raises the context budget to
- 256 and retains unbounded with a session-wide DOM latch on loss; we bound + recover instead.
+- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping
+ were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the
+ previous dispose-on-hide behavior.
- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon.
-- D: atlas recovery bursts skip suspended panes (scoped to visible); retained panes repaint
- on resume (shared-atlas invariant preserved).
- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the
first user terminal doesn't pay the ~2.7s first-ConPTY cost.
diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
index fd9ff1260f4..fdf597c5587 100644
--- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts
@@ -97,9 +97,8 @@ export function hideTerminalVisibility({
captureViewportPositions(false)
}
if (!isWorktreeActive && (wasVisible || surfaceBecameHidden)) {
- // Suspend WebGL when going hidden. xterm.write() continues to land in
- // the (now DOM-renderer-fallback or paused-canvas) terminal; the
- // suspend is purely a GPU resource decision.
+ // Suspend WebGL when going hidden. xterm.write() continues to land in the
+ // DOM-renderer fallback terminal; the suspend is purely a GPU resource decision.
manager.suspendRendering()
return { hiddenReason: 'surface', renderingSuspended: true }
}
diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts
index 0a30f50f118..61ff5a7d42d 100644
--- a/src/renderer/src/lib/pane-manager/pane-manager.ts
+++ b/src/renderer/src/lib/pane-manager/pane-manager.ts
@@ -168,11 +168,6 @@ export class PaneManager {
refreshAllPanes(): void {
for (const pane of this.panes.values()) {
- // Why: suspended panes are invisible and repaint on rendering resume;
- // recovery repaints must not scale with hidden-workspace pane count.
- if (pane.webglAttachmentDeferred) {
- continue
- }
try {
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts
index c79598ff0eb..8ad22f07eca 100644
--- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts
+++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts
@@ -7,11 +7,6 @@ import {
markComplexScriptOutput,
resetWebglTextureAtlas
} from './pane-webgl-renderer'
-import {
- retainSuspendedWebglPane,
- shouldRetainSuspendedWebglContexts,
- unretainWebglPane
-} from './pane-webgl-context-retention'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
export function setPaneGpuRenderingState(
@@ -48,20 +43,9 @@ export function markPaneComplexScriptOutput(
}
export function suspendPaneRendering(panes: Iterable): void {
- const retainContexts = shouldRetainSuspendedWebglContexts()
for (const pane of panes) {
- // Why: deferred blocks NEW context creation while hidden; on Windows,
- // live contexts are retained (not disposed) so the return switch repaints
- // instantly instead of paying ANGLE context re-creation. The LRU cap
- // bounds how many hidden panes may keep one.
pane.webglAttachmentDeferred = true
- if (!retainContexts) {
- disposeWebgl(pane)
- continue
- }
- for (const evicted of retainSuspendedWebglPane(pane)) {
- disposeWebgl(evicted)
- }
+ disposeWebgl(pane)
}
}
@@ -71,23 +55,8 @@ export function resumePaneRendering(panes: Iterable): void
// loss, and bounding retries to resume events cannot loop on live loss.
clearTerminalWebglAttachBackoff()
for (const pane of panes) {
- unretainWebglPane(pane)
pane.webglAttachmentDeferred = false
pane.webglDisabledAfterContextLoss = false
- if (pane.webglAddon) {
- // Why: recovery bursts skip suspended panes, so the shared glyph atlas
- // may have been cleared/rebuilt while this pane sat hidden with its
- // retained context. Repaint from the buffer so stale glyph coordinates
- // never reach the screen.
- try {
- if (pane.terminal.rows > 0) {
- pane.terminal.refresh(0, pane.terminal.rows - 1)
- }
- } catch {
- /* ignore — pane may be tearing down during resume */
- }
- continue
- }
reattachWebglIfNeeded(pane)
}
}
diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts
deleted file mode 100644
index eb4c14bc6d5..00000000000
--- a/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.test.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { ManagedPaneInternal } from './pane-manager-types'
-import {
- clearRetainedWebglPanes,
- RETAINED_WEBGL_PANE_CAP,
- retainedWebglPaneCount
-} from './pane-webgl-context-retention'
-import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control'
-import {
- attachWebgl,
- disposeWebgl,
- resetTerminalWebglSuggestion,
- resetWebglTextureAtlas
-} from './pane-webgl-renderer'
-
-let nextPaneId = 1
-
-function createPane(): ManagedPaneInternal {
- const leafId = '11111111-1111-4111-8111-111111111111' as never
- return {
- id: nextPaneId++,
- leafId,
- stablePaneId: leafId,
- terminal: {
- cols: 80,
- rows: 24,
- refresh: vi.fn(),
- loadAddon: vi.fn()
- } as never,
- container: {} as never,
- xtermContainer: {} as never,
- linkTooltip: {} as never,
- terminalGpuAcceleration: 'on',
- gpuRenderingEnabled: true,
- webglAttachmentDeferred: false,
- webglDisabledAfterContextLoss: false,
- hasComplexScriptOutput: false,
- webglAddon: null,
- ligaturesAddon: null,
- fitResizeObserver: null,
- pendingObservedFitRafId: null,
- pendingWebglRefreshRafId: null,
- fitAddon: {
- proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })),
- fit: vi.fn()
- } as never,
- searchAddon: {} as never,
- serializeAddon: {} as never,
- unicode11Addon: {} as never,
- webLinksAddon: {} as never,
- compositionHandler: null,
- pendingSplitScrollState: null,
- debugLabel: null
- }
-}
-
-function createAttachedPane(): ManagedPaneInternal {
- const pane = createPane()
- attachWebgl(pane)
- expect(pane.webglAddon).not.toBeNull()
- return pane
-}
-
-function fireContextLoss(pane: ManagedPaneInternal): void {
- const addon = pane.webglAddon as unknown as { _onContextLoss: { fire: () => void } }
- addon._onContextLoss.fire()
-}
-
-function stubCommonGlobals(userAgent: string): void {
- vi.stubGlobal('navigator', { userAgent })
- vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
- callback(16)
- return 1
- })
- vi.stubGlobal('cancelAnimationFrame', vi.fn())
-}
-
-describe('terminal WebGL context retention across hide/show (Windows)', () => {
- beforeEach(() => {
- resetTerminalWebglSuggestion()
- clearRetainedWebglPanes()
- vi.spyOn(console, 'warn').mockImplementation(() => {})
- stubCommonGlobals('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
- })
-
- afterEach(() => {
- clearRetainedWebglPanes()
- vi.unstubAllGlobals()
- vi.restoreAllMocks()
- })
-
- it('keeps the live WebGL addon when rendering is suspended', () => {
- const pane = createAttachedPane()
- const addon = pane.webglAddon
-
- suspendPaneRendering([pane])
-
- expect(pane.webglAttachmentDeferred).toBe(true)
- expect(pane.webglAddon).toBe(addon)
- expect(retainedWebglPaneCount()).toBe(1)
- })
-
- it('does not create a new context when resuming a retained pane', () => {
- const pane = createAttachedPane()
- const addon = pane.webglAddon
- expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
-
- suspendPaneRendering([pane])
- resumePaneRendering([pane])
-
- expect(pane.webglAddon).toBe(addon)
- expect(pane.webglAttachmentDeferred).toBe(false)
- expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1)
- expect(retainedWebglPaneCount()).toBe(0)
- })
-
- it('still blocks new context creation while suspended', () => {
- const pane = createPane()
-
- suspendPaneRendering([pane])
- attachWebgl(pane)
-
- expect(pane.webglAddon).toBeNull()
- expect(pane.terminal.loadAddon).not.toHaveBeenCalled()
- })
-
- it('evicts and disposes the least-recently-suspended pane past the cap', () => {
- const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 2 }, () => createAttachedPane())
-
- suspendPaneRendering(panes)
-
- expect(retainedWebglPaneCount()).toBe(RETAINED_WEBGL_PANE_CAP)
- expect(panes[0].webglAddon).toBeNull()
- expect(panes[1].webglAddon).toBeNull()
- expect(panes[2].webglAddon).not.toBeNull()
- expect(panes.at(-1)?.webglAddon).not.toBeNull()
- })
-
- it('re-attaches an evicted pane on resume', () => {
- const panes = Array.from({ length: RETAINED_WEBGL_PANE_CAP + 1 }, () => createAttachedPane())
- suspendPaneRendering(panes)
- expect(panes[0].webglAddon).toBeNull()
-
- resumePaneRendering([panes[0]])
-
- expect(panes[0].webglAddon).not.toBeNull()
- expect(panes[0].terminal.loadAddon).toHaveBeenCalledTimes(2)
- })
-
- it('drops the retention entry when a suspended pane is disposed', () => {
- const pane = createAttachedPane()
- suspendPaneRendering([pane])
- expect(retainedWebglPaneCount()).toBe(1)
-
- disposeWebgl(pane)
-
- expect(retainedWebglPaneCount()).toBe(0)
- expect(pane.webglAddon).toBeNull()
- })
-
- it('repaints a retained pane on resume so a cleared shared atlas cannot leave stale glyphs', () => {
- const pane = createAttachedPane()
- suspendPaneRendering([pane])
- vi.mocked(pane.terminal.refresh).mockClear()
-
- resumePaneRendering([pane])
-
- expect(pane.terminal.refresh).toHaveBeenCalledWith(0, pane.terminal.rows - 1)
- })
-
- it('skips suspended panes during atlas recovery resets', () => {
- const pane = createAttachedPane()
- const clearTextureAtlas = vi.fn()
- ;(pane.webglAddon as unknown as { clearTextureAtlas: () => void }).clearTextureAtlas =
- clearTextureAtlas
- suspendPaneRendering([pane])
- vi.mocked(pane.terminal.refresh).mockClear()
-
- resetWebglTextureAtlas(pane)
-
- expect(clearTextureAtlas).not.toHaveBeenCalled()
- expect(pane.terminal.refresh).not.toHaveBeenCalled()
- })
-
- it('drops the retention entry on context loss while hidden and recovers on resume', () => {
- const pane = createAttachedPane()
- suspendPaneRendering([pane])
-
- fireContextLoss(pane)
-
- expect(pane.webglAddon).toBeNull()
- expect(pane.webglDisabledAfterContextLoss).toBe(true)
- expect(retainedWebglPaneCount()).toBe(0)
-
- resumePaneRendering([pane])
-
- expect(pane.webglDisabledAfterContextLoss).toBe(false)
- expect(pane.webglAddon).not.toBeNull()
- })
-})
-
-describe('terminal WebGL context retention is Windows-only', () => {
- beforeEach(() => {
- resetTerminalWebglSuggestion()
- clearRetainedWebglPanes()
- vi.spyOn(console, 'warn').mockImplementation(() => {})
- stubCommonGlobals('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
- })
-
- afterEach(() => {
- clearRetainedWebglPanes()
- vi.unstubAllGlobals()
- vi.restoreAllMocks()
- })
-
- it('keeps the dispose-on-hide behavior off Windows', () => {
- const pane = createAttachedPane()
-
- suspendPaneRendering([pane])
-
- expect(pane.webglAttachmentDeferred).toBe(true)
- expect(pane.webglAddon).toBeNull()
- expect(retainedWebglPaneCount()).toBe(0)
- })
-
- it('re-attaches on resume off Windows as before', () => {
- const pane = createAttachedPane()
- suspendPaneRendering([pane])
-
- resumePaneRendering([pane])
-
- expect(pane.webglAddon).not.toBeNull()
- expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(2)
- })
-})
diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts
deleted file mode 100644
index 282d57fc5e7..00000000000
--- a/src/renderer/src/lib/pane-manager/pane-webgl-context-retention.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
-import type { ManagedPaneInternal } from './pane-manager-types'
-
-// Why: workspace switches used to dispose every hidden pane's WebGL context
-// and recreate it on return — ~5ms on macOS but 100-500ms per pane on Windows
-// (ANGLE → D3D11), paid synchronously on the switch path. With the app-level
-// context budget raised to 128 (#7064), hidden panes can keep their contexts;
-// this LRU cap only bounds hidden-pane GPU memory. Sized for the reported hot
-// set (~10-16 worktrees × ~2 terminals) while staying far under the budget.
-export const RETAINED_WEBGL_PANE_CAP = 32
-
-/** Windows-only: context re-creation is cheap on macOS/Linux GL, so hidden
- * panes there keep the long-proven dispose-on-hide behavior instead of
- * paying retention's GPU-memory cost for no perceptible switch latency win. */
-export function shouldRetainSuspendedWebglContexts(): boolean {
- return getRendererAppPlatform() === 'win32'
-}
-
-// Insertion order doubles as LRU order: re-retaining deletes + re-adds.
-const retainedPanes = new Set()
-
-/**
- * Registers a suspended pane's live WebGL context for retention across
- * hide/show. Returns panes evicted by the cap — the caller disposes them
- * (dependency points that way to avoid a cycle with pane-webgl-renderer).
- */
-export function retainSuspendedWebglPane(pane: ManagedPaneInternal): ManagedPaneInternal[] {
- if (!pane.webglAddon) {
- return []
- }
- retainedPanes.delete(pane)
- retainedPanes.add(pane)
- const evicted: ManagedPaneInternal[] = []
- while (retainedPanes.size > RETAINED_WEBGL_PANE_CAP) {
- const oldest = retainedPanes.values().next().value
- if (!oldest) {
- break
- }
- retainedPanes.delete(oldest)
- evicted.push(oldest)
- }
- return evicted
-}
-
-export function unretainWebglPane(pane: ManagedPaneInternal): void {
- retainedPanes.delete(pane)
-}
-
-export function retainedWebglPaneCount(): number {
- return retainedPanes.size
-}
-
-/** Drops all retention entries without disposing anything (test isolation). */
-export function clearRetainedWebglPanes(): void {
- retainedPanes.clear()
-}
diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts
index 846b2779abc..91b95f2caf2 100644
--- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts
+++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ManagedPaneInternal } from './pane-manager-types'
import { disposePane } from './pane-lifecycle'
+import { suspendPaneRendering } from './pane-rendering-control'
import { disposeWebgl } from './pane-webgl-renderer'
function createPane(
@@ -89,6 +90,17 @@ describe('pane WebGL refresh lifecycle', () => {
expect(pane.webglAddon).toBeNull()
})
+ it('disposes WebGL when rendering is suspended', () => {
+ const dispose = vi.fn()
+ const pane = createPane({ webglAddon: { dispose } as never })
+
+ suspendPaneRendering([pane])
+
+ expect(pane.webglAttachmentDeferred).toBe(true)
+ expect(dispose).toHaveBeenCalledTimes(1)
+ expect(pane.webglAddon).toBeNull()
+ })
+
it('cancels a pending WebGL refresh when the pane is disposed', () => {
const cancelAnimationFrame = vi.fn()
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts
index 1b129c2ae9a..d7c80e76220 100644
--- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts
+++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts
@@ -1,6 +1,5 @@
import { WebglAddon } from '@xterm/addon-webgl'
import type { ManagedPaneInternal } from './pane-manager-types'
-import { unretainWebglPane } from './pane-webgl-context-retention'
import {
getTerminalWebglAutoDecision,
resetTerminalWebglAutoDecision
@@ -73,10 +72,6 @@ export function disposeWebgl(
options?: { refreshDimensions?: boolean }
): void {
cancelPendingWebglRefresh(pane)
- // Why: every dispose path (pane close, context loss, GPU-setting off) must
- // drop the retention entry, or the LRU would later evict-dispose a pane
- // whose addon was already gone.
- unretainWebglPane(pane)
if (!pane.webglAddon) {
return
}
@@ -123,11 +118,7 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void {
}
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
- // Why: suspended panes keep retained contexts but are invisible; their
- // rebuild is deferred to resumePaneRendering. Clearing + repainting them
- // here would make every recovery burst scale with total pane count across
- // all workspaces instead of visible panes.
- if (pane.webglDisabledAfterContextLoss || pane.webglAttachmentDeferred) {
+ if (pane.webglDisabledAfterContextLoss) {
return
}
try {
From d6fb8e04f991759dc9519c5fbdec3b8ae3b06ac9 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 2 Jul 2026 18:05:50 -0700
Subject: [PATCH 35/70] Revert "Notarize serve-sim camera dylibs in macOS
releases" (#7165)
---
config/electron-builder.config.cjs | 197 +----------
.../scripts/electron-builder-config.test.mjs | 328 +-----------------
2 files changed, 4 insertions(+), 521 deletions(-)
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index ae1db494baa..9e482825a73 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -1,7 +1,6 @@
-const { chmodSync, existsSync, mkdtempSync, readdirSync, rmSync } = require('node:fs')
+const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
-const { basename, dirname, join, resolve } = require('node:path')
-const { tmpdir } = require('node:os')
+const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
@@ -145,9 +144,6 @@ module.exports = {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
}
},
- afterSign: async (context) => {
- await notarizeMacServeSimCameraDylibs(context)
- },
win: {
executableName: 'Orca',
// Why: Windows installers are signed after electron-builder packaging by
@@ -375,184 +371,6 @@ function chmodMacServeSimHelpers(resourcesDir, electronPlatformName) {
}
}
-async function notarizeMacServeSimCameraDylibs(context) {
- await notarizeMacServeSimCameraDylibsWithHost(context, macNotarizationHost)
-}
-
-async function notarizeMacServeSimCameraDylibsWithHost(context, host) {
- if (!isMacRelease || context.electronPlatformName !== 'darwin') {
- return
- }
- const resourcesDir = join(
- context.appOutDir,
- `${context.packager.appInfo.productFilename}.app`,
- 'Contents',
- 'Resources'
- )
- const requiredDylibPaths = [
- join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'libSimCameraInjector.dylib'),
- join(resourcesDir, 'node_modules', 'serve-sim', 'dist', 'simcam', 'libSimCameraInjector.dylib')
- ]
- const optionalDylibPaths = [
- join(
- resourcesDir,
- 'app.asar.unpacked',
- 'node_modules',
- 'serve-sim',
- 'dist',
- 'simcam',
- 'libSimCameraInjector.dylib'
- )
- ]
- for (const dylibPath of requiredDylibPaths) {
- if (!host.existsSync(dylibPath)) {
- throw new Error(`Missing serve-sim camera dylib for notarization: ${dylibPath}`)
- }
- }
- const dylibPaths = [
- ...requiredDylibPaths,
- ...optionalDylibPaths.filter((dylibPath) => host.existsSync(dylibPath))
- ]
- // Why: stapler cannot attach tickets to bare dylibs; submitting each signed
- // dylib zip registers its cdhash for direct Gatekeeper assessment.
- for (const dylibPath of dylibPaths) {
- notarizeMacStandaloneBinary(dylibPath, host)
- }
-}
-
-let macNotarizationHost = {
- existsSync,
- execFileSync,
- mkdtempSync,
- rmSync
-}
-
-async function withMacNotarizationHost(host, callback) {
- const previousHost = macNotarizationHost
- macNotarizationHost = host
- try {
- return await callback()
- } finally {
- macNotarizationHost = previousHost
- }
-}
-
-function notarizeMacStandaloneBinary(binaryPath, host) {
- const archiveDir = host.mkdtempSync(join(tmpdir(), 'orca-mac-standalone-notary-'))
- const archivePath = join(archiveDir, `${basename(binaryPath)}.zip`)
- try {
- host.execFileSync(
- 'ditto',
- ['-c', '-k', '--sequesterRsrc', '--keepParent', basename(binaryPath), archivePath],
- {
- cwd: dirname(binaryPath),
- stdio: 'inherit',
- timeout: macDylibArchiveTimeoutMs
- }
- )
- const credentialArgs = macNotarytoolCredentialArgs()
- const output = execFileSyncWithRedactedError(
- host,
- 'xcrun',
- ['notarytool', 'submit', archivePath, ...credentialArgs, '--wait', '--output-format', 'json'],
- {
- encoding: 'utf8',
- timeout: macDylibNotarytoolTimeoutMs
- },
- credentialArgs,
- binaryPath
- )
- const result = JSON.parse(output)
- if (result.status !== 'Accepted') {
- throw new Error(`Standalone binary notarization failed for ${binaryPath}: ${output}`)
- }
- } finally {
- host.rmSync(archiveDir, { recursive: true, force: true })
- }
-}
-
-const macDylibArchiveTimeoutMs = 2 * 60 * 1000
-// Why: release jobs should fail loudly instead of hanging on a stuck submission.
-const macDylibNotarytoolTimeoutMs = 45 * 60 * 1000
-
-const macNotarytoolCredentialFlags = new Set([
- '--apple-id',
- '--password',
- '--team-id',
- '--key',
- '--key-id',
- '--issuer',
- '--keychain',
- '--keychain-profile'
-])
-
-function execFileSyncWithRedactedError(host, command, args, options, credentialArgs, binaryPath) {
- try {
- return host.execFileSync(command, args, options)
- } catch (error) {
- throw new Error(
- `Standalone binary notarization subprocess failed for ${binaryPath}: ${redactCredentialValues(
- error instanceof Error ? error.message : String(error),
- credentialArgs
- )}`
- )
- }
-}
-
-function redactCredentialValues(message, credentialArgs) {
- let redacted = message
- for (let index = 0; index < credentialArgs.length - 1; index += 2) {
- const value = credentialArgs[index + 1]
- if (macNotarytoolCredentialFlags.has(credentialArgs[index]) && value) {
- redacted = redacted.split(value).join('[REDACTED]')
- }
- }
- return redacted
-}
-
-function macNotarytoolCredentialArgs() {
- if (process.env.APPLE_ID || process.env.APPLE_APP_SPECIFIC_PASSWORD) {
- if (!process.env.APPLE_ID) {
- throw new Error('APPLE_ID env var needs to be set')
- }
- if (!process.env.APPLE_APP_SPECIFIC_PASSWORD) {
- throw new Error('APPLE_APP_SPECIFIC_PASSWORD env var needs to be set')
- }
- if (!process.env.APPLE_TEAM_ID) {
- throw new Error('APPLE_TEAM_ID env var needs to be set')
- }
- return [
- '--apple-id',
- process.env.APPLE_ID,
- '--password',
- process.env.APPLE_APP_SPECIFIC_PASSWORD,
- '--team-id',
- process.env.APPLE_TEAM_ID
- ]
- }
- if (process.env.APPLE_API_KEY && process.env.APPLE_API_KEY_ID && process.env.APPLE_API_ISSUER) {
- return [
- '--key',
- process.env.APPLE_API_KEY,
- '--key-id',
- process.env.APPLE_API_KEY_ID,
- '--issuer',
- process.env.APPLE_API_ISSUER
- ]
- }
- if (process.env.APPLE_KEYCHAIN_PROFILE) {
- return [
- ...(process.env.APPLE_KEYCHAIN ? ['--keychain', process.env.APPLE_KEYCHAIN] : []),
- '--keychain-profile',
- process.env.APPLE_KEYCHAIN_PROFILE
- ]
- }
- if (process.env.APPLE_API_KEY || process.env.APPLE_API_KEY_ID || process.env.APPLE_API_ISSUER) {
- throw new Error('Env vars APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER need to be set')
- }
- throw new Error('Missing notarization credentials for serve-sim camera dylibs')
-}
-
async function signMacComputerUseHelper(helperAppPath, packager) {
if (!existsSync(helperAppPath)) {
if (isMacRelease) {
@@ -616,14 +434,3 @@ function findInstalledMacSigningIdentity(keychainFile) {
} catch {}
return null
}
-
-if (process.env.VITEST || process.env.VITEST_WORKER_ID) {
- Object.defineProperty(module.exports, '__test', {
- enumerable: false,
- value: {
- macNotarytoolCredentialArgs,
- notarizeMacServeSimCameraDylibsWithHost,
- withMacNotarizationHost
- }
- })
-}
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index c3a32e99c87..3f318219eeb 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -1,8 +1,8 @@
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
@@ -18,128 +18,6 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
-const electronBuilderConfigPath = require.resolve('../electron-builder.config.cjs')
-const macNotaryEnvKeys = [
- 'APPLE_ID',
- 'APPLE_APP_SPECIFIC_PASSWORD',
- 'APPLE_TEAM_ID',
- 'APPLE_API_KEY',
- 'APPLE_API_KEY_ID',
- 'APPLE_API_ISSUER',
- 'APPLE_KEYCHAIN_PROFILE',
- 'APPLE_KEYCHAIN',
- 'ORCA_MAC_RELEASE'
-]
-const appleIdMacReleaseEnv = {
- APPLE_APP_SPECIFIC_PASSWORD: 'app-password',
- APPLE_ID: 'release@example.com',
- APPLE_TEAM_ID: 'TEAMID',
- ORCA_MAC_RELEASE: '1'
-}
-
-async function withElectronBuilderConfigEnv(env, callback) {
- const previous = new Map(macNotaryEnvKeys.map((key) => [key, process.env[key]]))
- try {
- for (const key of macNotaryEnvKeys) {
- delete process.env[key]
- }
- Object.assign(process.env, env)
- delete require.cache[electronBuilderConfigPath]
- return await callback(require('../electron-builder.config.cjs'))
- } finally {
- for (const key of macNotaryEnvKeys) {
- const value = previous.get(key)
- if (value === undefined) {
- delete process.env[key]
- } else {
- process.env[key] = value
- }
- }
- delete require.cache[electronBuilderConfigPath]
- }
-}
-
-function createMacNotarizationContext(appOutDir) {
- return {
- appOutDir,
- electronPlatformName: 'darwin',
- packager: {
- appInfo: {
- productFilename: 'Orca'
- }
- }
- }
-}
-
-function requiredMacServeSimDylibPaths(appOutDir) {
- const resourcesDir = join(appOutDir, 'Orca.app', 'Contents', 'Resources')
- return [
- join(resourcesDir, 'serve-sim', 'dist', 'simcam', 'libSimCameraInjector.dylib'),
- join(resourcesDir, 'node_modules', 'serve-sim', 'dist', 'simcam', 'libSimCameraInjector.dylib')
- ]
-}
-
-function optionalMacServeSimDylibPaths(appOutDir) {
- const resourcesDir = join(appOutDir, 'Orca.app', 'Contents', 'Resources')
- return [
- join(
- resourcesDir,
- 'app.asar.unpacked',
- 'node_modules',
- 'serve-sim',
- 'dist',
- 'simcam',
- 'libSimCameraInjector.dylib'
- )
- ]
-}
-
-function macServeSimDylibPaths(appOutDir) {
- return [...requiredMacServeSimDylibPaths(appOutDir), ...optionalMacServeSimDylibPaths(appOutDir)]
-}
-
-function createMacNotarizationHost({ existingPaths, output = { status: 'Accepted' } }) {
- const calls = []
- let temporaryDirectoryIndex = 0
- const host = {
- calls,
- execFileSync: vi.fn((command, args, options = {}) => {
- calls.push({ args, command, options })
- if (command === 'xcrun') {
- return typeof output === 'string' ? output : JSON.stringify(output)
- }
- return ''
- }),
- existsSync: vi.fn((targetPath) => existingPaths.has(targetPath)),
- mkdtempSync: vi.fn((prefix) => `${prefix}${++temporaryDirectoryIndex}`),
- rmSync: vi.fn()
- }
- return host
-}
-
-async function runMacAfterSignWithHost(config, appOutDir, host) {
- return await config.__test.withMacNotarizationHost(host, async () => {
- await config.afterSign(createMacNotarizationContext(appOutDir))
- })
-}
-
-function expectNotaryCredentials(calls, credentials, expectedCallCount = 3) {
- const notaryCalls = calls.filter(({ command }) => command === 'xcrun')
- expect(notaryCalls).toHaveLength(expectedCallCount)
- for (const notaryCall of notaryCalls) {
- expect(notaryCall.args).toEqual(
- expect.arrayContaining([
- 'notarytool',
- 'submit',
- ...credentials,
- '--wait',
- '--output-format',
- 'json'
- ])
- )
- }
-}
-
describe('electron-builder config', () => {
it('excludes repo-only source trees from app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
@@ -185,208 +63,6 @@ describe('electron-builder config', () => {
)
})
- it('keeps the macOS afterSign hook inert outside macOS release builds', async () => {
- for (const { context, env } of [
- { context: { electronPlatformName: 'darwin' }, env: {} },
- { context: { electronPlatformName: 'linux' }, env: { ORCA_MAC_RELEASE: '1' } }
- ]) {
- await withElectronBuilderConfigEnv(env, async (config) => {
- const host = createMacNotarizationHost({ existingPaths: new Set() })
-
- await expect(
- config.__test.withMacNotarizationHost(host, async () => {
- await config.afterSign(context)
- })
- ).resolves.toBeUndefined()
- expect(host.execFileSync).not.toHaveBeenCalled()
- })
- }
- })
-
- it('submits all packaged serve-sim camera dylib copies in macOS releases', async () => {
- await withElectronBuilderConfigEnv(appleIdMacReleaseEnv, async (config) => {
- const appOutDir = join(tmpdir(), 'orca-electron-builder-test')
- const dylibPaths = macServeSimDylibPaths(appOutDir)
- const host = createMacNotarizationHost({ existingPaths: new Set(dylibPaths) })
-
- await runMacAfterSignWithHost(config, appOutDir, host)
-
- const dittoCalls = host.calls.filter(({ command }) => command === 'ditto')
- const notaryCalls = host.calls.filter(({ command }) => command === 'xcrun')
- expect(dittoCalls).toHaveLength(dylibPaths.length)
- expect(notaryCalls).toHaveLength(dylibPaths.length)
- expect(dittoCalls.map(({ options }) => options.cwd)).toEqual(
- dylibPaths.map((dylibPath) => dirname(dylibPath))
- )
- expect(dittoCalls.every(({ options }) => options.timeout === 2 * 60 * 1000)).toBe(true)
- expect(notaryCalls.every(({ options }) => options.timeout === 45 * 60 * 1000)).toBe(true)
- expect(dittoCalls.map(({ args }) => args.at(-1))).toEqual(
- notaryCalls.map(({ args }) => args[2])
- )
- expectNotaryCredentials(host.calls, [
- '--apple-id',
- 'release@example.com',
- '--password',
- 'app-password',
- '--team-id',
- 'TEAMID'
- ])
- expect(host.rmSync).toHaveBeenCalledTimes(dylibPaths.length)
- })
- })
-
- it('does not require optional app.asar.unpacked serve-sim dylibs in macOS releases', async () => {
- await withElectronBuilderConfigEnv(appleIdMacReleaseEnv, async (config) => {
- const appOutDir = join(tmpdir(), 'orca-electron-builder-test')
- const dylibPaths = requiredMacServeSimDylibPaths(appOutDir)
- const host = createMacNotarizationHost({ existingPaths: new Set(dylibPaths) })
-
- await runMacAfterSignWithHost(config, appOutDir, host)
-
- const dittoCalls = host.calls.filter(({ command }) => command === 'ditto')
- expect(dittoCalls).toHaveLength(dylibPaths.length)
- expect(dittoCalls.map(({ options }) => options.cwd)).toEqual(
- dylibPaths.map((dylibPath) => dirname(dylibPath))
- )
- expectNotaryCredentials(
- host.calls,
- ['--apple-id', 'release@example.com', '--password', 'app-password', '--team-id', 'TEAMID'],
- dylibPaths.length
- )
- expect(host.rmSync).toHaveBeenCalledTimes(dylibPaths.length)
- })
- })
-
- it('fails before notarization when a packaged serve-sim camera dylib is missing', async () => {
- await withElectronBuilderConfigEnv({ ORCA_MAC_RELEASE: '1' }, async (config) => {
- const appOutDir = join(tmpdir(), 'orca-electron-builder-test')
- const [firstDylibPath] = macServeSimDylibPaths(appOutDir)
- const host = createMacNotarizationHost({ existingPaths: new Set([firstDylibPath]) })
-
- await expect(
- config.__test.notarizeMacServeSimCameraDylibsWithHost(
- createMacNotarizationContext(appOutDir),
- host
- )
- ).rejects.toThrow('Missing serve-sim camera dylib for notarization')
-
- expect(host.execFileSync).not.toHaveBeenCalled()
- })
- })
-
- it('maps supported macOS notarization credential families to notarytool flags', async () => {
- const cases = [
- {
- credentials: ['--key', '/tmp/AuthKey.p8', '--key-id', 'KEYID', '--issuer', 'issuer'],
- env: {
- APPLE_API_ISSUER: 'issuer',
- APPLE_API_KEY: '/tmp/AuthKey.p8',
- APPLE_API_KEY_ID: 'KEYID',
- ORCA_MAC_RELEASE: '1'
- }
- },
- {
- credentials: [
- '--apple-id',
- 'release@example.com',
- '--password',
- 'app-password',
- '--team-id',
- 'TEAMID'
- ],
- env: {
- APPLE_API_ISSUER: 'issuer',
- APPLE_API_KEY: '/tmp/AuthKey.p8',
- APPLE_API_KEY_ID: 'KEYID',
- ...appleIdMacReleaseEnv
- }
- },
- {
- credentials: ['--keychain', '/tmp/login.keychain-db', '--keychain-profile', 'orca-release'],
- env: {
- APPLE_KEYCHAIN: '/tmp/login.keychain-db',
- APPLE_KEYCHAIN_PROFILE: 'orca-release',
- ORCA_MAC_RELEASE: '1'
- }
- }
- ]
-
- for (const { credentials, env } of cases) {
- await withElectronBuilderConfigEnv(env, async (config) => {
- const appOutDir = join(tmpdir(), 'orca-electron-builder-test')
- const dylibPaths = macServeSimDylibPaths(appOutDir)
- const host = createMacNotarizationHost({ existingPaths: new Set(dylibPaths) })
-
- await runMacAfterSignWithHost(config, appOutDir, host)
- expectNotaryCredentials(host.calls, credentials)
- })
- }
- })
-
- it('fails macOS standalone notarization on partial credentials and bad notary output', async () => {
- for (const { env, message } of [
- {
- env: { APPLE_ID: 'release@example.com', ORCA_MAC_RELEASE: '1' },
- message: 'APPLE_APP_SPECIFIC_PASSWORD env var needs to be set'
- },
- {
- env: { APPLE_API_KEY: '/tmp/AuthKey.p8', APPLE_API_KEY_ID: 'KEYID', ORCA_MAC_RELEASE: '1' },
- message: 'Env vars APPLE_API_KEY, APPLE_API_KEY_ID and APPLE_API_ISSUER need to be set'
- }
- ]) {
- await withElectronBuilderConfigEnv(env, async (config) => {
- expect(() => config.__test.macNotarytoolCredentialArgs()).toThrow(message)
- })
- }
-
- for (const { output, message } of [
- { message: 'Standalone binary notarization failed', output: { status: 'Invalid' } },
- { message: '', output: '{not-json' }
- ]) {
- await withElectronBuilderConfigEnv(appleIdMacReleaseEnv, async (config) => {
- const appOutDir = join(tmpdir(), 'orca-electron-builder-test')
- const dylibPaths = macServeSimDylibPaths(appOutDir)
- const host = createMacNotarizationHost({ existingPaths: new Set(dylibPaths), output })
-
- const expectation = expect(
- config.__test.notarizeMacServeSimCameraDylibsWithHost(
- createMacNotarizationContext(appOutDir),
- host
- )
- ).rejects
- await (message ? expectation.toThrow(message) : expectation.toThrow())
- })
- }
- })
-
- it('surfaces macOS standalone notarization subprocess failures without secrets', async () => {
- await withElectronBuilderConfigEnv(appleIdMacReleaseEnv, async (config) => {
- const appOutDir = join(tmpdir(), 'orca-electron-builder-test')
- const dylibPaths = macServeSimDylibPaths(appOutDir)
- const host = createMacNotarizationHost({ existingPaths: new Set(dylibPaths) })
- host.execFileSync.mockImplementation((command, args, options = {}) => {
- host.calls.push({ args, command, options })
- if (command === 'xcrun') {
- throw new Error(`Command failed: xcrun ${args.join(' ')}`)
- }
- return ''
- })
-
- try {
- await config.__test.notarizeMacServeSimCameraDylibsWithHost(
- createMacNotarizationContext(appOutDir),
- host
- )
- throw new Error('Expected notarization to fail')
- } catch (error) {
- expect(error.message).toContain('Standalone binary notarization subprocess failed')
- expect(error.message).not.toMatch(/release@example\.com|app-password|TEAMID/)
- expect(error.message).toContain('[REDACTED]')
- }
- expect(host.rmSync).toHaveBeenCalled()
- })
- })
-
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
From 11ca4955ae5abd7a56280abeab49fbd97cd2a738 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 2 Jul 2026 18:09:14 -0700
Subject: [PATCH 36/70] Run the repo's Custom GitHub Issue Command when
starting a workspace from an issue (#6827)
Co-authored-by: Orca
---
...github-work-item-background-create.test.ts | 209 ++++++++++++++++++
.../lib/github-work-item-background-create.ts | 58 ++++-
.../lib/hook-command-delayed-delivery.test.ts | 77 +++++++
.../src/lib/hook-command-delayed-delivery.ts | 67 ++++++
.../src/lib/pending-worktree-creation.ts | 3 +
.../src/lib/worktree-activation.test.ts | 48 ++++
src/renderer/src/lib/worktree-activation.ts | 19 ++
.../src/lib/worktree-creation-flow.test.ts | 64 ++++++
.../src/lib/worktree-creation-flow.ts | 5 +-
9 files changed, 545 insertions(+), 5 deletions(-)
create mode 100644 src/renderer/src/lib/hook-command-delayed-delivery.test.ts
create mode 100644 src/renderer/src/lib/hook-command-delayed-delivery.ts
diff --git a/src/renderer/src/lib/github-work-item-background-create.test.ts b/src/renderer/src/lib/github-work-item-background-create.test.ts
index 17a70124d38..a4e6d8f0adf 100644
--- a/src/renderer/src/lib/github-work-item-background-create.test.ts
+++ b/src/renderer/src/lib/github-work-item-background-create.test.ts
@@ -97,6 +97,13 @@ function makeDeps(store = makeStore()) {
resolveSetupDecision: vi.fn().mockResolvedValue({ kind: 'decided', decision: 'inherit' }),
resolvePrStartPoint: vi.fn(),
confirmHooks: vi.fn().mockResolvedValue('run'),
+ readIssueCommand: vi.fn().mockResolvedValue({
+ effectiveContent: null,
+ localContent: null,
+ sharedContent: null,
+ localFilePath: '',
+ source: 'none'
+ }),
beginBackgroundCreate: vi.fn(() => 'creation-1'),
continueBackgroundCreate: vi.fn(() => true),
activatePendingCreate: vi.fn(),
@@ -607,4 +614,206 @@ describe('createGitHubWorkItemWorkspaceInBackground', () => {
expect(request.startup?.command).toBe('codex --prompt-file')
expect(buildAgentStartupPlan).not.toHaveBeenCalled()
})
+
+ it('attaches the rendered issue command when the repo configured one and trust runs', async () => {
+ const deps = makeDeps()
+ deps.readIssueCommand.mockResolvedValueOnce({
+ effectiveContent: 'gh issue view {{issue}} --repo {{artifact_url}}',
+ localContent: null,
+ sharedContent: 'gh issue view {{issue}} --repo {{artifact_url}}',
+ localFilePath: '',
+ source: 'shared'
+ })
+
+ await createGitHubWorkItemWorkspaceInBackground(
+ {
+ item: makeIssue(),
+ repoId: 'repo-1',
+ openModalFallback: vi.fn()
+ },
+ deps
+ )
+
+ expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'setup')
+ expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand')
+ const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
+ expect(continueCall).toBeDefined()
+ const request = continueCall?.[1] as WorktreeCreationRequest
+ expect(request.issueCommand?.command).toBe(
+ 'gh issue view 42 --repo https://github.com/stablyai/orca/issues/42'
+ )
+ })
+
+ it('omits the issue command when the repo configured none', async () => {
+ const deps = makeDeps()
+
+ await createGitHubWorkItemWorkspaceInBackground(
+ {
+ item: makeIssue(),
+ repoId: 'repo-1',
+ openModalFallback: vi.fn()
+ },
+ deps
+ )
+
+ const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
+ expect(continueCall).toBeDefined()
+ const request = continueCall?.[1] as WorktreeCreationRequest
+ expect(request.issueCommand).toBeUndefined()
+ expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand')
+ })
+
+ it('skips the issue command when setup trust was declined', async () => {
+ const deps = makeDeps()
+ deps.readIssueCommand.mockResolvedValueOnce({
+ effectiveContent: 'echo {{issue}}',
+ localContent: 'echo {{issue}}',
+ sharedContent: null,
+ localFilePath: '/repo/.orca/issue-command',
+ source: 'local'
+ })
+ // Why: the setup confirmHooks call resolves 'skip', which mirrors the
+ // composer suppressing the issue command when setup trust is declined.
+ deps.confirmHooks.mockResolvedValue('skip')
+
+ await createGitHubWorkItemWorkspaceInBackground(
+ {
+ item: makeIssue(),
+ repoId: 'repo-1',
+ openModalFallback: vi.fn()
+ },
+ deps
+ )
+
+ const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
+ expect(continueCall).toBeDefined()
+ const request = continueCall?.[1] as WorktreeCreationRequest
+ expect(request.issueCommand).toBeUndefined()
+ expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand')
+ // Why: a declined setup trust short-circuits before the (up-to-15s) read,
+ // so workspace creation is never stalled to fetch a command we will drop.
+ expect(deps.readIssueCommand).not.toHaveBeenCalled()
+ })
+
+ it('does not run the issue command for PR items', async () => {
+ const deps = makeDeps()
+ deps.resolvePrStartPoint.mockResolvedValueOnce({
+ baseBranch: 'feature/from-pr',
+ pushTarget: { remote: 'origin', branch: 'feature/from-pr' },
+ branchNameOverride: 'feature/from-pr',
+ compareBaseRef: 'main'
+ })
+ deps.readIssueCommand.mockResolvedValueOnce({
+ effectiveContent: 'echo {{issue}}',
+ localContent: 'echo {{issue}}',
+ sharedContent: null,
+ localFilePath: '/repo/.orca/issue-command',
+ source: 'local'
+ })
+
+ await createGitHubWorkItemWorkspaceInBackground(
+ {
+ item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }),
+ repoId: 'repo-1',
+ openModalFallback: vi.fn()
+ },
+ deps
+ )
+
+ const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
+ expect(continueCall).toBeDefined()
+ const request = continueCall?.[1] as WorktreeCreationRequest
+ expect(request.issueCommand).toBeUndefined()
+ expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand')
+ })
+
+ it('fails closed when reading the issue command rejects', async () => {
+ const deps = makeDeps()
+ deps.readIssueCommand.mockRejectedValueOnce(new Error('runtime offline'))
+
+ const result = await createGitHubWorkItemWorkspaceInBackground(
+ {
+ item: makeIssue(),
+ repoId: 'repo-1',
+ openModalFallback: vi.fn()
+ },
+ deps
+ )
+
+ expect(result).toEqual({ kind: 'background-started' })
+ const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
+ expect(continueCall).toBeDefined()
+ const request = continueCall?.[1] as WorktreeCreationRequest
+ expect(request.issueCommand).toBeUndefined()
+ })
+
+ it('re-reads the store for each trust check so mid-flow trust updates are honored', async () => {
+ const snapshots = [makeStore(), makeStore(), makeStore()]
+ let getStoreCall = 0
+ const deps = makeDeps(snapshots[0])
+ deps.getStore = vi.fn(
+ () => snapshots[Math.min(getStoreCall++, snapshots.length - 1)] ?? snapshots[0]
+ )
+ deps.readIssueCommand.mockResolvedValueOnce({
+ effectiveContent: 'echo {{issue}}',
+ localContent: null,
+ sharedContent: 'echo {{issue}}',
+ localFilePath: '',
+ source: 'shared'
+ })
+
+ await createGitHubWorkItemWorkspaceInBackground(
+ {
+ item: makeIssue(),
+ repoId: 'repo-1',
+ openModalFallback: vi.fn()
+ },
+ deps
+ )
+
+ // Why: an "Always trust" stamped by the setup prompt only exists in a fresh
+ // snapshot. Each trust check must read the store at call time instead of
+ // reusing the snapshot captured when the flow began.
+ const setupCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'setup')
+ const issueCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'issueCommand')
+ expect(setupCall?.[0]).toBeDefined()
+ expect(issueCall?.[0]).toBeDefined()
+ expect(setupCall?.[0]).not.toBe(snapshots[0])
+ expect(issueCall?.[0]).not.toBe(snapshots[0])
+ expect(issueCall?.[0]).not.toBe(setupCall?.[0])
+ })
+
+ it('keeps the seeded agent prompt as the bare issue URL (not Complete )', async () => {
+ const store = makeStore({
+ ensureDetectedAgents: vi.fn().mockResolvedValue(['codex'])
+ })
+ const deps = makeDeps(store)
+ deps.readIssueCommand.mockResolvedValueOnce({
+ effectiveContent: 'echo {{issue}}',
+ localContent: 'echo {{issue}}',
+ sharedContent: null,
+ localFilePath: '/repo/.orca/issue-command',
+ source: 'local'
+ })
+
+ await createGitHubWorkItemWorkspaceInBackground(
+ {
+ item: makeIssue(),
+ repoId: 'repo-1',
+ openModalFallback: vi.fn()
+ },
+ deps
+ )
+
+ const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined
+ expect(continueCall).toBeDefined()
+ const request = continueCall?.[1] as WorktreeCreationRequest
+ // Why: Brennan's confirmed scope keeps the quick-start prompt the bare link;
+ // the issue command runs as a side-pane split, not as the agent prompt.
+ expect(request.startupPlan?.draftPrompt).toBe('https://github.com/stablyai/orca/issues/42')
+ expect(request.startupPlan?.draftPrompt ?? '').not.toContain('Complete')
+ expect(request.quickPrompt).not.toContain('Complete')
+ // The issue command itself still threads through as a side-pane split.
+ expect(request.issueCommand?.command).toBe('echo 42')
+ })
})
diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts
index edaaa92bb92..7fa94fd9d2b 100644
--- a/src/renderer/src/lib/github-work-item-background-create.ts
+++ b/src/renderer/src/lib/github-work-item-background-create.ts
@@ -19,7 +19,10 @@ import {
} from '@/lib/launch-work-item-direct-preflight'
import { agentLaunchCommandErrorMessage } from '@/lib/launch-work-item-direct-messages'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
+import { renderIssueCommandTemplate } from '@/lib/new-workspace'
import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
+import { readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
+import { isGitRepoKind } from '../../../shared/repo-kind'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host'
import { evaluateRuntimeCompat } from '../../../shared/protocol-compat'
import {
@@ -50,8 +53,9 @@ type BackgroundGitHubWorkItemCreateDeps = {
confirmHooks: (
store: GitHubWorkItemBackgroundStoreSnapshot,
repoId: string,
- scope: 'setup'
+ scope: 'setup' | 'issueCommand'
) => ReturnType
+ readIssueCommand: typeof readRuntimeIssueCommand
beginBackgroundCreate: typeof beginBackgroundWorktreePreparation
continueBackgroundCreate: typeof continueBackgroundWorktreeCreation
activatePendingCreate: (creationId: string) => void
@@ -78,8 +82,9 @@ const DEFAULT_DEPS: BackgroundGitHubWorkItemCreateDeps = {
useAppStore.getState().activePendingCreationId === creationId,
resolveSetupDecision: resolveDirectSetupDecision,
resolvePrStartPoint: resolveDirectPrStartPoint,
- confirmHooks: (store, repoId, scope) =>
+ confirmHooks: (store, repoId, scope: 'setup' | 'issueCommand') =>
ensureHooksConfirmed(store as ReturnType, repoId, scope),
+ readIssueCommand: readRuntimeIssueCommand,
beginBackgroundCreate: beginBackgroundWorktreePreparation,
continueBackgroundCreate: continueBackgroundWorktreeCreation,
activatePendingCreate: (creationId) => {
@@ -222,7 +227,10 @@ export async function createGitHubWorkItemWorkspaceInBackground(
}
}
- const trustDecision = await deps.confirmHooks(store, args.repoId, 'setup')
+ // Why: trust prompts are serialized app-wide, so read the store fresh at
+ // each check — an "Always trust" stamped by an earlier prompt (including
+ // this flow's own setup prompt) must short-circuit instead of re-prompting.
+ const trustDecision = await deps.confirmHooks(deps.getStore(), args.repoId, 'setup')
if (!deps.hasPendingCreate(creationId)) {
return { kind: 'background-started' }
}
@@ -246,6 +254,49 @@ export async function createGitHubWorkItemWorkspaceInBackground(
}
const backendStartup = buildGitHubWorkItemBackendStartup(agent, startupPlan, quickTelemetry)
+ // Why: mirror the composer's trust-gated issue-command split. Only GitHub
+ // issues (numeric issue number) on git repos run it; PRs/Linear/folders never
+ // do. Reuse the setup trust decision: a 'skip' there also skips the command.
+ let issueCommand: WorktreeCreationRequest['issueCommand']
+ // Why: a declined setup trust also skips the issue command, so short-circuit
+ // before the (up-to-15s) read rather than reading just to drop the result.
+ if (
+ trustDecision !== 'skip' &&
+ isGitRepoKind(repo) &&
+ args.item.type === 'issue' &&
+ typeof args.item.number === 'number'
+ ) {
+ // Why: read failures fail closed (no command), so create still proceeds.
+ let effectiveContent = ''
+ try {
+ const issueCommandRead = await deps.readIssueCommand(repoOwnerSettings, args.repoId)
+ effectiveContent = (issueCommandRead.effectiveContent ?? '').trim()
+ } catch {
+ effectiveContent = ''
+ }
+ if (!deps.hasPendingCreate(creationId)) {
+ return { kind: 'background-started' }
+ }
+ if (effectiveContent.length > 0) {
+ const issueCommandTrust = await deps.confirmHooks(
+ deps.getStore(),
+ args.repoId,
+ 'issueCommand'
+ )
+ if (!deps.hasPendingCreate(creationId)) {
+ return { kind: 'background-started' }
+ }
+ if (issueCommandTrust === 'run') {
+ issueCommand = {
+ command: renderIssueCommandTemplate(effectiveContent, {
+ issueNumber: args.item.number,
+ artifactUrl: args.item.url ?? null
+ })
+ }
+ }
+ }
+ }
+
const request: WorktreeCreationRequest = {
...initialRequest,
...(baseBranch ? { baseBranch } : {}),
@@ -255,6 +306,7 @@ export async function createGitHubWorkItemWorkspaceInBackground(
agent,
...(branchNameOverride ? { branchNameOverride } : {}),
...(backendStartup ? { startup: backendStartup } : {}),
+ ...(issueCommand ? { issueCommand } : {}),
startupPlan,
quickPrompt,
quickTelemetry
diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.test.ts b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts
new file mode 100644
index 00000000000..63fafaaae55
--- /dev/null
+++ b/src/renderer/src/lib/hook-command-delayed-delivery.test.ts
@@ -0,0 +1,77 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useAppStore } from '@/store'
+import {
+ queueHookCommandsForFirstWorktreeTab,
+ resetHookCommandDelayedDeliveryForTests
+} from './hook-command-delayed-delivery'
+
+type AppState = ReturnType
+
+const initialTabsByWorktree = useAppStore.getState().tabsByWorktree
+const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById
+
+function setStorePartial(partial: Record): void {
+ useAppStore.setState(partial as Partial)
+}
+
+function markWorktreeKnown(worktreeId: string): void {
+ setStorePartial({
+ getKnownWorktreeById: ((id: string) =>
+ id === worktreeId ? { id } : undefined) as unknown as AppState['getKnownWorktreeById']
+ })
+}
+
+afterEach(() => {
+ resetHookCommandDelayedDeliveryForTests()
+ setStorePartial({
+ tabsByWorktree: initialTabsByWorktree,
+ getKnownWorktreeById: initialGetKnownWorktreeById
+ })
+})
+
+describe('queueHookCommandsForFirstWorktreeTab', () => {
+ it('holds the delivery until the first worktree tab lands, then delivers exactly once', () => {
+ markWorktreeKnown('wt-1')
+ setStorePartial({ tabsByWorktree: {} })
+ const deliver = vi.fn()
+
+ queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver })
+ expect(deliver).not.toHaveBeenCalled()
+
+ setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } })
+
+ expect(deliver).toHaveBeenCalledTimes(1)
+ expect(deliver).toHaveBeenCalledWith(expect.anything(), 'mirror-tab-1')
+
+ // Later tab churn must not re-deliver the consumed entry.
+ setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }, { id: 'tab-2' }] } })
+ expect(deliver).toHaveBeenCalledTimes(1)
+ })
+
+ it('delivers immediately when the worktree already has a tab at queue time', () => {
+ markWorktreeKnown('wt-1')
+ setStorePartial({ tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } })
+ const deliver = vi.fn()
+
+ queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-1', deliver })
+
+ expect(deliver).toHaveBeenCalledTimes(1)
+ expect(deliver).toHaveBeenCalledWith(expect.anything(), 'tab-1')
+ })
+
+ it('drops the pending delivery when the worktree is no longer known', () => {
+ setStorePartial({
+ tabsByWorktree: {},
+ getKnownWorktreeById: (() => undefined) as unknown as AppState['getKnownWorktreeById']
+ })
+ const deliver = vi.fn()
+
+ queueHookCommandsForFirstWorktreeTab({ worktreeId: 'wt-gone', deliver })
+
+ // A tab appearing later (e.g. an id reused by mirror churn) must not
+ // deliver commands for a worktree that was dropped while unknown.
+ markWorktreeKnown('wt-gone')
+ setStorePartial({ tabsByWorktree: { 'wt-gone': [{ id: 'tab-1' }] } })
+ expect(deliver).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.ts b/src/renderer/src/lib/hook-command-delayed-delivery.ts
new file mode 100644
index 00000000000..234126f73ad
--- /dev/null
+++ b/src/renderer/src/lib/hook-command-delayed-delivery.ts
@@ -0,0 +1,67 @@
+import { useAppStore } from '@/store'
+
+type AppStoreSnapshot = ReturnType
+
+type PendingWorktreeHookCommandDelivery = {
+ worktreeId: string
+ deliver: (state: AppStoreSnapshot, firstTerminalTabId: string) => void
+}
+
+// Why: runtime-owned worktrees mirror their session tabs asynchronously, so a
+// fresh create usually has no tab to queue setup/issue commands on yet. Hold
+// the delivery until the first mirrored terminal tab lands instead of
+// dropping it. Mirrors agent-startup-delayed-delivery's lazy-subscription
+// shape: subscribed only while something is pending.
+const pendingHookCommandDeliveries = new Map()
+let unsubscribePendingHookCommandDeliveries: (() => void) | null = null
+
+export function queueHookCommandsForFirstWorktreeTab(
+ delivery: PendingWorktreeHookCommandDelivery
+): void {
+ pendingHookCommandDeliveries.set(delivery.worktreeId, delivery)
+ ensurePendingHookCommandSubscription()
+ flushPendingHookCommandDeliveries()
+}
+
+export function resetHookCommandDelayedDeliveryForTests(): void {
+ pendingHookCommandDeliveries.clear()
+ unsubscribePendingHookCommandDeliveries?.()
+ unsubscribePendingHookCommandDeliveries = null
+}
+
+function ensurePendingHookCommandSubscription(): void {
+ if (unsubscribePendingHookCommandDeliveries) {
+ return
+ }
+ unsubscribePendingHookCommandDeliveries = useAppStore.subscribe(() => {
+ flushPendingHookCommandDeliveries()
+ })
+}
+
+function stopPendingHookCommandSubscriptionIfIdle(): void {
+ if (pendingHookCommandDeliveries.size > 0 || !unsubscribePendingHookCommandDeliveries) {
+ return
+ }
+ unsubscribePendingHookCommandDeliveries()
+ unsubscribePendingHookCommandDeliveries = null
+}
+
+function flushPendingHookCommandDeliveries(): void {
+ const state = useAppStore.getState()
+ for (const [worktreeId, delivery] of pendingHookCommandDeliveries) {
+ const firstTerminalTabId = state.tabsByWorktree[worktreeId]?.[0]?.id
+ if (!firstTerminalTabId) {
+ // Why: a worktree can be removed before its tabs ever mirror; drop the
+ // entry so the subscription does not stay armed forever.
+ if (!state.getKnownWorktreeById(worktreeId)) {
+ pendingHookCommandDeliveries.delete(worktreeId)
+ }
+ continue
+ }
+ // Delete before delivering so store writes inside deliver cannot re-enter
+ // this entry through the subscription.
+ pendingHookCommandDeliveries.delete(worktreeId)
+ delivery.deliver(state, firstTerminalTabId)
+ }
+ stopPendingHookCommandSubscriptionIfIdle()
+}
diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts
index cad46eab62d..b7f042a7b36 100644
--- a/src/renderer/src/lib/pending-worktree-creation.ts
+++ b/src/renderer/src/lib/pending-worktree-creation.ts
@@ -77,6 +77,9 @@ export type WorktreeCreationRequest = {
* agent launch is self-contained; otherwise the renderer drives startup via
* `startupPlan`. */
startup?: WorktreeStartupLaunch
+ /** Repo Custom GitHub Issue Command to run in a side-pane split after the
+ * workspace's first terminal is created. Mirrors the composer's trust-gated issueCommand. */
+ issueCommand?: { command: string; env?: Record }
pendingFirstAgentMessageRename: boolean
/** Post-create note persisted as the worktree comment. */
note: string
diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts
index a1937bbe8e2..fb7e201123d 100644
--- a/src/renderer/src/lib/worktree-activation.test.ts
+++ b/src/renderer/src/lib/worktree-activation.test.ts
@@ -2,8 +2,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SetupScriptLaunchMode } from '../../../shared/types'
import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from './worktree-activation'
+import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery'
import { useAppStore } from '@/store'
+type AppStoreState = ReturnType
+
+const initialTabsByWorktree = useAppStore.getState().tabsByWorktree
+const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById
+const initialPendingIssueCommandSplitByTabId =
+ useAppStore.getState().pendingIssueCommandSplitByTabId
+
function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void {
useAppStore.setState((state) => ({
settings: state.settings
@@ -22,6 +30,12 @@ afterEach(() => {
: ({ activeRuntimeEnvironmentId: null } as unknown as typeof state.settings)
}))
setSetupScriptLaunchMode('new-tab')
+ resetHookCommandDelayedDeliveryForTests()
+ useAppStore.setState({
+ tabsByWorktree: initialTabsByWorktree,
+ getKnownWorktreeById: initialGetKnownWorktreeById,
+ pendingIssueCommandSplitByTabId: initialPendingIssueCommandSplitByTabId
+ } as Partial |