From 6cdae26e1c8a5c91c98d20d8bd1e790c0dd51905 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:36:15 -0700
Subject: [PATCH 01/11] fix(source-control): allow an empty AI-generated PR
description in Create PR (#16873)
---
...urce-control-create-pr-intent-flow.test.ts | 33 +++++++++++++++----
.../review/create-pr-intent-flow.ts | 6 ++--
.../review/use-create-pr-intent-review.ts | 7 +---
src/renderer/src/i18n/locales/en.json | 1 -
src/renderer/src/i18n/locales/es.json | 1 -
src/renderer/src/i18n/locales/ja.json | 1 -
src/renderer/src/i18n/locales/ko.json | 1 -
src/renderer/src/i18n/locales/zh.json | 1 -
src/shared/pull-request-generation.test.ts | 14 ++++++++
9 files changed, 44 insertions(+), 21 deletions(-)
diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts
index 3072fd76642..f89236426cb 100644
--- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts
@@ -342,12 +342,6 @@ describe('source-control Create PR intent flow helpers', () => {
error: 'Agent timed out.'
})
).toEqual({ ok: false, error: 'Agent timed out.' })
- expect(
- resolveCreatePrIntentGeneratedReviewFields(current, {
- success: true,
- fields: { ...current, title: 'Generated title', body: ' ' }
- })
- ).toEqual({ ok: false, error: null })
expect(
resolveCreatePrIntentGeneratedReviewFields(current, {
success: true,
@@ -369,6 +363,33 @@ describe('source-control Create PR intent flow helpers', () => {
})
})
+ it('accepts generated review fields with an empty description', () => {
+ expect(
+ resolveCreatePrIntentGeneratedReviewFields(
+ { base: 'main', title: 'Feature branch', body: '', draft: false },
+ {
+ success: true,
+ fields: { base: 'main', title: 'chore: add new app config', body: '', draft: false }
+ }
+ )
+ ).toEqual({
+ ok: true,
+ fields: { base: 'main', title: 'chore: add new app config', body: '', draft: false }
+ })
+ })
+
+ it('falls back to the current title when the generated title is blank and the body is empty', () => {
+ expect(
+ resolveCreatePrIntentGeneratedReviewFields(
+ { base: 'main', title: 'Feature branch', body: '', draft: false },
+ { success: true, fields: { base: 'main', title: ' ', body: ' ', draft: true } }
+ )
+ ).toEqual({
+ ok: true,
+ fields: { base: 'main', title: 'Feature branch', body: ' ', draft: true }
+ })
+ })
+
it('surfaces the commit failure summary in the Create PR intent notice', () => {
expect(
getCreatePrIntentCommitFailureNoticeMessage(
diff --git a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts
index 669f7b3142a..d72d6dcc86e 100644
--- a/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts
+++ b/src/renderer/src/components/right-sidebar/source-control/review/create-pr-intent-flow.ts
@@ -53,7 +53,7 @@ type CreatePrIntentReviewGeneration =
export type CreatePrIntentGeneratedReviewFields =
| { ok: true; fields: CreatePrIntentReviewFields }
- | { ok: false; error: string | null }
+ | { ok: false; error: string }
export function createCreatePrIntentRunToken(input: Omit) {
return { ...input, startedAt: Date.now() }
@@ -208,15 +208,13 @@ export function resolveCreatePrIntentGeneratedReviewFields(
if (!generated.success) {
return { ok: false, error: generated.error }
}
- if (!generated.fields.body.trim()) {
- return { ok: false, error: null }
- }
return {
ok: true,
fields: {
// Why: intent auto-submits, so generated details must not retarget the review without confirmation.
base: current.base,
title: generated.fields.title.trim() || current.title,
+ // Why: a description is optional everywhere else (composer, GitHub/GitLab), so an intentionally empty generated body is a valid result, not a failure.
body: generated.fields.body,
draft: generated.fields.draft
}
diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts
index e6b9b2f6b38..373148e4fc2 100644
--- a/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts
+++ b/src/renderer/src/components/right-sidebar/source-control/review/use-create-pr-intent-review.ts
@@ -132,12 +132,7 @@ export function useSourceControlCreatePrIntentReview({
if (!resolved.ok) {
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
tone: 'destructive',
- message:
- resolved.error ??
- translate(
- 'auto.components.right.sidebar.SourceControl.createPrIntentEmptyGeneratedBody',
- 'Generated review details did not include a description. Retry Create PR.'
- )
+ message: resolved.error
})
return false
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 3cfec9b3695..599c1a5d6d0 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -12000,7 +12000,6 @@
"a4e93c21d7": "Current branch: {{value0}}",
"c7d4e2f801": "Change base ref: {{value0}}",
"f3a1b8c204": "upstream",
- "createPrIntentEmptyGeneratedBody": "Generated review details did not include a description. Retry Create PR.",
"createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR."
},
"SourceControlAgentActionDialog": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 5142116a66b..8b014a386da 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -10779,7 +10779,6 @@
"a4e93c21d7": "Rama actual: {{value0}}",
"c7d4e2f801": "Cambiar ref base: {{value0}}",
"f3a1b8c204": "upstream",
- "createPrIntentEmptyGeneratedBody": "Los detalles de revisión generados no incluyeron una descripción. Reintentar Crear PR.",
"createPrIntentGenerateDetailsFailed": "No se pudieron generar los detalles de revisión. Reintentar Crear PR."
},
"SourceControlAgentActionDialog": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 1c72acfa367..0e5e5fd0e9d 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -10770,7 +10770,6 @@
"a4e93c21d7": "現在のブランチ: {{value0}}",
"c7d4e2f801": "ベース ref を変更: {{value0}}",
"f3a1b8c204": "upstream",
- "createPrIntentEmptyGeneratedBody": "生成されたレビューの詳細に説明が含まれていませんでした。PR の作成を再試行してください。",
"createPrIntentGenerateDetailsFailed": "レビューの詳細を生成できませんでした。PR の作成を再試行してください。"
},
"SourceControlAgentActionDialog": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 5b062ebeeee..36700af527d 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -10791,7 +10791,6 @@
"a4e93c21d7": "현재 브랜치: {{value0}}",
"c7d4e2f801": "베이스 ref 변경: {{value0}}",
"f3a1b8c204": "upstream",
- "createPrIntentEmptyGeneratedBody": "생성된 검토 세부 정보에 설명이 포함되지 않았습니다. PR 생성을 다시 시도하세요.",
"createPrIntentGenerateDetailsFailed": "검토 세부 정보를 생성할 수 없습니다. PR 생성을 다시 시도하세요."
},
"SourceControlAgentActionDialog": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 5a680d43a8b..099a734060a 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -10826,7 +10826,6 @@
"a4e93c21d7": "当前分支:{{value0}}",
"c7d4e2f801": "更改基引用:{{value0}}",
"f3a1b8c204": "upstream",
- "createPrIntentEmptyGeneratedBody": "生成的评审详情未包含描述。请重试创建 PR。",
"createPrIntentGenerateDetailsFailed": "无法生成评审详情。请重试创建 PR。"
},
"SourceControlAgentActionDialog": {
diff --git a/src/shared/pull-request-generation.test.ts b/src/shared/pull-request-generation.test.ts
index be18305e3f0..0bb21502bec 100644
--- a/src/shared/pull-request-generation.test.ts
+++ b/src/shared/pull-request-generation.test.ts
@@ -173,6 +173,20 @@ describe('parseGeneratedPullRequestFields', () => {
})
})
+ it('keeps an explicitly empty body instead of falling back', () => {
+ const fields = parseGeneratedPullRequestFields(
+ '{"base":"main","title":"chore: add new app config","body":"","draft":false}',
+ context
+ )
+
+ expect(fields).toEqual({
+ base: 'main',
+ title: 'chore: add new app config',
+ body: '',
+ draft: false
+ })
+ })
+
it('rejects excessive nesting before JSON.parse', () => {
const parseSpy = vi.spyOn(JSON, 'parse')
const depth = GENERATED_PULL_REQUEST_JSON_STRUCTURE_LIMITS.nestingDepth + 1
From 0665c758d139669efd4d6657cd4f527163539d47 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:49:52 -0700
Subject: [PATCH 02/11] feat(settings): name the agents the Chat UI supports
(#16830)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Chat UI setting described itself in terms of "supported agent terminal panes" without naming them, and an unsupported agent falls back to the terminal silently — no toast, no toggle, no explanation. A user on OpenCode reported this as a bug in Discord.
Adds a "Supported agents:" icon row under the toggle, matching the existing StatusBarUsageEmptyCta legend pattern, driven by the same list the availability predicate uses so it cannot drift from actual support. Icons carry role="img" plus a tooltip for identification. Also adds the missing openclaude/omp settings-search keywords.
---
.../native-chat-availability.test.ts | 23 ++---
.../NativeChatExperimentalSetting.tsx | 2 +
.../NativeChatSupportedAgents.test.tsx | 85 +++++++++++++++++++
.../settings/NativeChatSupportedAgents.tsx | 35 ++++++++
...ive-chat-experimental-search-entry.test.ts | 9 ++
.../native-chat-experimental-search-entry.ts | 8 ++
src/renderer/src/i18n/locales/en.json | 3 +
.../lib/native-chat-initial-view-mode.test.ts | 21 +++--
src/shared/native-chat-agent-support.ts | 13 ++-
.../pane-agent-identity-inventory.test.ts | 1 +
10 files changed, 178 insertions(+), 22 deletions(-)
create mode 100644 src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx
create mode 100644 src/renderer/src/components/settings/NativeChatSupportedAgents.tsx
create mode 100644 src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts
diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts
index 9c77e05992a..a08bc476d2f 100644
--- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts
@@ -117,16 +117,19 @@ describe('canToggleNativeChat', () => {
).toBe(true)
})
- it('rejects an unsupported agent detected live (Gemini)', () => {
- expect(
- canToggleNativeChat({
- experimentalNativeChatEnabled: true,
- contentType: 'terminal',
- launchAgent: null,
- detectedAgent: 'gemini'
- })
- ).toBe(false)
- })
+ it.each(['gemini', 'opencode'] as const)(
+ 'rejects unsupported agent %s detected live',
+ (agent) => {
+ expect(
+ canToggleNativeChat({
+ experimentalNativeChatEnabled: true,
+ contentType: 'terminal',
+ launchAgent: null,
+ detectedAgent: agent
+ })
+ ).toBe(false)
+ }
+ )
it('accepts Grok when resolved from the title', () => {
expect(
diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx
index d7ea704c553..6dac8022c8d 100644
--- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx
+++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx
@@ -2,6 +2,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { translate } from '@/i18n/i18n'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import { NativeChatSupportedAgents } from './NativeChatSupportedAgents'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSwitch } from './SettingsFormControls'
import { getExperimentalSearchEntry } from './experimental-search'
@@ -43,6 +44,7 @@ export function NativeChatExperimentalSetting({
'Adds a Chat UI view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.'
)}
+
)
+ const container = document.createElement('div')
+ container.innerHTML = markup
+ return Array.from(container.querySelectorAll('[data-agent]')).map((node) => ({
+ agent: node.getAttribute('data-agent') ?? '',
+ label: node.getAttribute('aria-label') ?? '',
+ role: node.getAttribute('role') ?? ''
+ }))
+}
+
+describe('NativeChatSupportedAgents', () => {
+ afterEach(async () => {
+ await i18n.changeLanguage('en')
+ })
+
+ it('keeps the advertised list and support predicate on the independent contract', () => {
+ expect(NATIVE_CHAT_SUPPORTED_AGENT_LIST).toEqual(EXPECTED_SUPPORTED_AGENTS)
+ for (const entry of getAgentCatalog()) {
+ expect(isNativeChatSupportedAgent(entry.id), entry.id).toBe(
+ EXPECTED_SUPPORTED_AGENTS.includes(entry.id as (typeof EXPECTED_SUPPORTED_AGENTS)[number])
+ )
+ }
+ })
+
+ it('renders exactly one chip for each supported agent', () => {
+ expect(getRenderedChips().map((chip) => chip.agent)).toEqual(EXPECTED_SUPPORTED_AGENTS)
+ })
+
+ it('gives each icon an accessible catalog agent name', () => {
+ for (const chip of getRenderedChips()) {
+ const entry = getAgentCatalog().find((candidate) => candidate.id === chip.agent)
+ expect(chip.label).toBe(entry?.label)
+ expect(chip.role).toBe('img')
+ }
+ })
+
+ it('omits agents native chat cannot render, including OpenCode', () => {
+ const rendered = getRenderedChips().map((chip) => chip.agent)
+
+ for (const entry of getAgentCatalog()) {
+ if (!isNativeChatSupportedAgent(entry.id)) {
+ expect(rendered).not.toContain(entry.id)
+ }
+ }
+ expect(rendered).not.toContain('opencode')
+ })
+
+ it('keeps the label in the English catalog', () => {
+ expect(i18n.getResource('en', 'translation', SUPPORTED_AGENTS_LABEL_KEY)).toBe(
+ 'Supported agents:'
+ )
+ })
+
+ it('renders the English fallback when the active locale lacks the label key', async () => {
+ await i18n.changeLanguage('es')
+ expect(i18n.getResource('es', 'translation', SUPPORTED_AGENTS_LABEL_KEY)).toBeUndefined()
+
+ const markup = renderToStaticMarkup()
+
+ expect(markup).toContain('Supported agents:')
+ })
+})
diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.tsx
new file mode 100644
index 00000000000..52001a573ca
--- /dev/null
+++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.tsx
@@ -0,0 +1,35 @@
+import { NATIVE_CHAT_SUPPORTED_AGENT_LIST } from '../../../../shared/native-chat-agent-support'
+import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import { translate } from '@/i18n/i18n'
+
+/** Names the agents so unsupported-agent terminal fallback does not look broken. */
+export function NativeChatSupportedAgents(): React.JSX.Element {
+ return (
+
+
+
+ {translate(
+ 'auto.components.settings.NativeChatSupportedAgents.label',
+ 'Supported agents:'
+ )}
+
+ {NATIVE_CHAT_SUPPORTED_AGENT_LIST.map((agent) => {
+ const label = getAgentLabel(agent)
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts b/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts
new file mode 100644
index 00000000000..502c407056a
--- /dev/null
+++ b/src/renderer/src/components/settings/native-chat-experimental-search-entry.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest'
+import { getNativeChatExperimentalSearchEntry } from './native-chat-experimental-search-entry'
+import { matchesSettingsSearch } from './settings-search'
+
+describe('native chat experimental search entry', () => {
+ it.each(['openclaude', 'omp'])('matches the supported-agent keyword %s', (query) => {
+ expect(matchesSettingsSearch(query, getNativeChatExperimentalSearchEntry())).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts
index 75026ea6ce1..87427d81e8a 100644
--- a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts
+++ b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts
@@ -30,10 +30,18 @@ export function getNativeChatExperimentalSearchEntry(): SettingsSearchEntry {
'auto.components.settings.experimental.search.nativeChat.codex',
'codex'
),
+ ...translateSearchKeyword(
+ 'auto.components.settings.experimental.search.nativeChat.openclaude',
+ 'openclaude'
+ ),
...translateSearchKeyword(
'auto.components.settings.experimental.search.nativeChat.grok',
'grok'
),
+ ...translateSearchKeyword(
+ 'auto.components.settings.experimental.search.nativeChat.omp',
+ 'omp'
+ ),
...translateSearchKeyword(
'auto.components.settings.experimental.search.nativeChat.terminal',
'terminal'
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 599c1a5d6d0..5a0bad3575b 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -11484,6 +11484,9 @@
},
"shortcutDefinitionCatalog": {
"missionControlConflict": "Blocked by Mission Control. Remap here or change it in System Settings."
+ },
+ "NativeChatSupportedAgents": {
+ "label": "Supported agents:"
}
},
"right": {
diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts
index e3a7a5b7aad..957e9b62ce2 100644
--- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts
+++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts
@@ -47,15 +47,18 @@ describe('decideInitialAgentTabViewMode', () => {
).toBeUndefined()
})
- it('returns undefined for unsupported agents', () => {
- expect(
- decideInitialAgentTabViewMode({
- experimentalNativeChat: true,
- openAgentTabsInChatByDefault: true,
- agent: 'gemini'
- })
- ).toBeUndefined()
- })
+ it.each(['gemini', 'opencode'] as const)(
+ 'keeps unsupported agent %s in terminal view',
+ (agent) => {
+ expect(
+ decideInitialAgentTabViewMode({
+ experimentalNativeChat: true,
+ openAgentTabsInChatByDefault: true,
+ agent
+ })
+ ).toBeUndefined()
+ }
+ )
it.each([
['local', null],
diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts
index 8210e907e0a..b5bd27c0016 100644
--- a/src/shared/native-chat-agent-support.ts
+++ b/src/shared/native-chat-agent-support.ts
@@ -1,13 +1,20 @@
+import type { TuiAgent } from './tui-agent'
+
export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp'
-/** Agents whose transcripts the native chat view can parse and render. */
-export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([
+/** Agents whose transcripts the native chat view can parse and render, in the
+ * order the settings pane advertises them. */
+export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [
'claude',
'openclaude',
'codex',
'grok',
'omp'
-])
+]
+
+export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set(
+ NATIVE_CHAT_SUPPORTED_AGENT_LIST
+)
export function isNativeChatSupportedAgent(agent: string | null | undefined): boolean {
return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent)
diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts
index 730c6b0ce05..d1fd17b3a4c 100644
--- a/src/shared/pane-agent-identity-inventory.test.ts
+++ b/src/shared/pane-agent-identity-inventory.test.ts
@@ -55,6 +55,7 @@ const INVENTORY: readonly InventoryGroup[] = [
['src/renderer/src/components/automations/automation-list-search-rows.ts', 2],
['src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx', 2],
['src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx', 2],
+ ['src/renderer/src/components/settings/NativeChatSupportedAgents.tsx', 2],
['src/renderer/src/components/settings/QuickCommandsList.tsx', 2],
['src/renderer/src/components/tab-bar/TabBarQuickCommandItem.tsx', 2],
['src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx', 2],
From 6ba6d58cd2d3d6abd4a08bfd8eaed8ae64195c08 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:53:05 -0700
Subject: [PATCH 03/11] fix(orchestration): route @agent messages by resolved
identity, not terminal title (#16237)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(agent-status): add the pane agent identity resolver
Four ladders answer "which agent is in this pane" independently — the tab icon, the
open-tab/search occupant, the sidebar title rows, and the sidebar hook-row fallback — and they
disagree. Two consult the terminal title before the launch record, so a string Orca parsed
outranks a fact Orca owns.
resolvePaneAgentIdentity is the single ranked answer. Two rules, one of which is not an ordering:
1. Evidence is ranked by how directly it observes the process; a display title is last.
2. Each observation carries the runId of the agent run it describes. Evidence from a superseded
run is INELIGIBLE, not merely outranked.
Rule 2 is the part reordering could never supply. A completed hook naming A plus a title naming
B is either a bug (hook right, title stale) or a legitimate pane reclaim (title right) —
identical signals, opposite correct answers. Run ids make them different facts: in the bug both
belong to the current run; in the reclaim the hook belongs to a previous one. That pair ships as
a test asserting the two produce opposite answers from the same evidence.
Missing run ids are treated as eligible. Absence means "this peer does not publish them", not
"this is stale", so an old host's rows are never blanked. Sibling evidence is opt-in so
pane-scoped consumers cannot inherit another pane's agent.
No consumer imports this yet; each migrates separately with its own evidence.
Verified non-vacuous: reversing the authority order fails 10 of 18 assertions and removing the
run filter fails 3.
* fix(agent-status): close three resolver contract holes found in review
**Duplicate evidence of one source resolved by array order.** `eligible.find(...)` returned the
first match, so two live hooks naming different agents were settled by input position — the exact
property this resolver exists to remove. The original order-independence test only used DISTINCT
sources, so it never exercised it. Conflicting same-class evidence now returns null with
`ambiguousAt`, and does NOT fall through to a weaker source: letting a title answer whenever two
hooks disagree is worse than saying nothing.
**A bare numeric runId collided across authority restarts.** `incarnation` is a total order only
within one `authorityId` (agent-status-observation.ts states this), and the id is regenerated per
authority instance, so a restarted host counting from its own floor would report `1` and match an
unrelated live run 1. The run key now carries its authority, and evidence from a DIFFERENT
authority is treated as incomparable — kept, like an absent key — rather than as stale.
**Title stayed reachable by consumers that authorize writes.** Ranking it last makes misuse
unlikely; `minimumSource` makes it impossible. An action consumer passes `'launch'` and weaker
evidence is dropped before ranking, so routing or delivery cannot name a target from a parsed
string even by reordering its inputs. Display surfaces omit it and are unaffected.
Also restores the generic agent-vocabulary parameter, which lives on the routing branch and was
lost when this branch was rebased.
Each fix is mutation-verified: first-match restored fails 3, ignoring authority fails 1, dropping
the floor fails 2. The authority test was itself vacuous on the first attempt — both sides used
`incarnation: 1`, so a resolver ignoring authority still passed on the numeric compare. It now uses
differing incarnations.
The remaining review finding, that `process > launch` has no freshness bound, is NOT fixed here:
it needs an observation timestamp the evidence type does not yet carry. Recorded rather than
silently dropped.
* fix(orchestration): route @agent messages by resolved identity, not terminal title
`@claude` picked its recipients with `buildAgentNameRe('claude').test(title)`, so any pane whose
TITLE contained the word received Claude's messages. Terminal titles carry task text, and people
describe agent work in them, so this is the ordinary case rather than a contrived one: the
recorded title "Switch Claude and Codex off the load balancer… - grok" is a Grok pane that
received both @claude and @codex. Misdelivered instructions, not a cosmetic slip.
The cause is that `RuntimeTerminalSummary` carried no identity at all — `title` was the only
identity-ish field on it, so routing by title was the only option available. Fix the input:
- `RuntimeTerminalSummary.agentIdentity?: TuiAgent` — optional, host-resolved from launch and
foreground-process evidence the host owns, with the title ranked last and contributing only
when the evidence parser finds an unambiguous name. A title that merely mentions an agent
yields no evidence, which is the whole point.
- `resolvePublishedPaneAgentIdentity` in `src/shared` rather than inside the runtime class, so
the decision is testable without a runtime and so routing, delivery and the UI cannot drift.
- Groups match `agentIdentity`; the title matcher and its bespoke Cursor predicate are deleted.
Unknown fails closed. `agentIdentity` is absent when the host predates the field or had no
evidence beyond the title, and delivery is an action: not delivering is visible and recoverable
(the sender sees no recipients), while delivering to the wrong agent is neither. The optional
field is additive, so an old client simply ignores it (wire rule 1).
This is also the first real caller of the evidence parser and the identity resolver.
Tests: 27 in groups, 8 for the publisher, 3 RPC fan-out cases updated to the new contract. The
`@cursor`-must-not-match-"text cursor blink" hazard is now excluded structurally instead of by a
per-agent predicate.
Verified non-vacuous by mutation: swapping the process/title ranks fails 2 publisher assertions,
and reverting groups to title matching fails 15 of 27. One earlier mutation silently failed to
apply after formatting reflowed the block — the file was checked before trusting the result.
* perf(runtime): reuse terminal title during summary build
* fix(orchestration): refuse title evidence when publishing identity for routing
Rebuilt on current main so this carries the hardened parser from #16148 and the corrected
resolver from #16157 (authority-scoped run keys, no order-dependent duplicate resolution).
Applies the resolver's new `minimumSource` floor at the publisher. What this publishes authorizes
an action — routing decides which real agent pane receives a message — so ranking title last is
not enough; the floor removes it from consideration entirely, and no amount of reordering by a
caller can bring it back.
The trade, stated because it is a real capability loss: a hook-less agent over SSH that Orca did
not launch, and whose foreground process the host cannot read, is no longer addressable by @agent.
Accepted because a message delivered into the wrong agent's prompt is unrecoverable while an
undelivered one is visible — the sender sees zero recipients. Whether real panes actually carry
launch/foreground evidence is the open question, and is what live validation must answer.
* fix(pty): preserve agent identity on daemon reattach
* fix(runtime): retire stale pane agent identity
* chore: normalize runtime types formatting
* fix(agent-status): identify a pane from its own hook, not from how it was started
Two defects, one cause: identity was inferred from the outside instead of read from the agent.
**Hook evidence was never plumbed in.** The publisher considered `process`, `launch` and `title`
and contained zero hook references — while the resolver ranks `live-hook` first. The top rung of
the ladder was never connected.
That made identity depend on Orca having launched the agent. Most agents are started by typing
`claude` or `codex` at a shell, which leaves no launch record. On macOS the foreground process
still names them, so the gap was invisible. On WSL the Windows host reads the foreground process
as `wsl.exe` — the distro wrapper, not the agent inside it — so those panes had no signal at all
and became unaddressable by `@agent`.
A hook is the agent reporting itself, so it survives both: no launch record needed, and no
dependency on reading a process across the WSL boundary.
**`launch` outranked `completed-hook`.** Ranking is now by TENSE rather than by how authoritative
a source sounds:
present: live-hook > process
past: completed-hook > launch > sleeping-session > sibling > title
A launch record is an event, not a state — it stays true after the agent exits, which is why a
pane reused after closing its agent kept reading as the old one. A completed hook at least proves
the agent actually ran in that pane; a launch record only proves Orca tried to start one.
Neither rank was covered: all 392 existing tests passed unchanged after reordering. Mutation now
fails 2 on the old order and 4 with hook evidence removed.
Known remaining gap, deliberately not papered over: a hand-started WSL agent with no managed hooks
has no identity signal at all. Restoring a title guess there would reinstate the misdelivery this
PR exists to prevent.
* fix(orchestration): restore title as the last resort, not a forbidden source
An earlier revision passed `minimumSource: 'launch'` so routing could not see a title at any rank,
reasoning that a display string must never authorize a write. That conflated the evidence parser
with the raw substring match it replaced.
`buildAgentNameRe('claude').test(title)` was the misdelivery. `collectAgentTitleEvidence` returns
null on exactly those shapes: "Review the Claude session-history fix" on a Codex pane yields
nothing, and "Switch Claude and Codex off the load balancer… - grok" yields grok from its owner
suffix. Ranking title last is therefore sufficient; refusing it is not necessary.
Refusing it had a real cost. An agent a user starts by hand inside an Orca WSL terminal has no
launch record, no readable foreground process (the Windows host sees `wsl.exe`, not the agent in
the distro), and — until managed Codex hooks install there — no hook either. An unambiguous title
was the only thing left, and dropping it made that pane unaddressable by @agent where the previous
code could reach it. That is a regression, and most agents are started that way.
End-to-end coverage added at the routing layer with title allowed: @claude still does not reach a
Codex pane whose task text names Claude, @codex still does not reach a Grok pane whose task text
names Codex, and a pane identified only by an unambiguous title is reachable again.
* revert(agent-status): keep launch above completed-hook until run keys exist
Reverts the tense-based reorder from this branch. The reasoning behind it was sound as far as it
went — a launch record is a past event, not an observation, which is why a reused pane kept reading
as its previous agent — but it fixed one staleness by opening a worse one.
A completed hook is past tense too, and without an agent-run key it never expires at all. Ranking
it above `launch` lets a stale hook from a previous agent outrank the launch record Orca stamped
for the process running NOW. pane-agent-owner.ts already says this in its own comment: "Ranking
launch/live-hook above the completed/sleeping records keeps a genuine pane on its real agent and
stops a stale record from hijacking it."
The reorder belongs with authority-scoped run generation, which is what makes any past-tense
evidence expire. It is staged in the migration plan rather than shipped here.
What this branch keeps: hook evidence feeding pane identity (so an agent a user starts by hand is
identified from its own report rather than needing a launch record), and title restored as a
genuine last resort behind the evidence parser.
* fix(runtime): guard the pane key so terminal.list survives a non-UUID leaf
`makePaneKey` throws on a leaf id that is not a UUID. The hook-evidence lookup called it unguarded
inside `buildTerminalSummary`, so a single such leaf took down `terminal.list` for the whole list
rather than degrading that one pane — 136 tests across 5 files, and the native code-quality gate
tripped separately on a duplicate test title.
Both were mine, and both were caught by CI rather than by me: I ran the focused suites before
pushing instead of the affected directories.
* fix(runtime): declare published terminal agent identity
* fix(runtime): demote completed hook identity evidence
---
.../ipc/pty-daemon-spawn-wsl-runtime.test.ts | 52 ++++
src/main/ipc/pty-ipc-mock-registry.ts | 1 +
.../pty-spawn-runtime-handle-binding.test.ts | 39 ++-
src/main/ipc/pty/ipc/spawn-commit.ts | 13 +-
.../ipc/pty/pane/launch-authority.test.ts | 40 +++
src/main/ipc/pty/pane/launch-authority.ts | 15 +
src/main/ipc/pty/runtime/spawn-commit.ts | 8 +-
...ntime-hook-agent-status-projection.test.ts | 19 +-
...-provider-reattach-launch-identity.test.ts | 100 +++++++
src/main/runtime/orca-runtime.test.ts | 7 +
src/main/runtime/orca-runtime.ts | 72 ++++-
src/main/runtime/orchestration/groups.test.ts | 274 ++++++------------
src/main/runtime/orchestration/groups.ts | 54 ++--
.../rpc/methods/orchestration-send.test.ts | 32 +-
.../pane-agent-identity-inventory.test.ts | 5 -
.../published-pane-agent-identity.test.ts | 123 ++++++++
src/shared/published-pane-agent-identity.ts | 61 ++++
src/shared/runtime-terminal-contracts.ts | 2 +
18 files changed, 678 insertions(+), 239 deletions(-)
create mode 100644 src/main/ipc/pty/pane/launch-authority.test.ts
create mode 100644 src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts
create mode 100644 src/shared/published-pane-agent-identity.test.ts
create mode 100644 src/shared/published-pane-agent-identity.ts
diff --git a/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts b/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts
index ad2b29955db..cbeddb333cd 100644
--- a/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts
+++ b/src/main/ipc/pty-daemon-spawn-wsl-runtime.test.ts
@@ -148,6 +148,58 @@ describe('registerPtyHandlers', () => {
false
)
})
+ it('restores daemon launch identity for a runtime-created reattach', async () => {
+ const incarnationId = 'runtime-reattach-incarnation'
+ const leafId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
+ const daemonSpawn = setupDaemonAdapter()
+ daemonSpawn.mockResolvedValueOnce({
+ id: 'runtime-reattach',
+ incarnationId,
+ isReattach: true,
+ launchAgent: 'codex'
+ } as never)
+ const runtime = {
+ setPtyController: vi.fn(),
+ registerPty: vi.fn(),
+ onPtySpawned: vi.fn(),
+ onPtyExit: vi.fn(),
+ onPtyData: vi.fn()
+ }
+ handlers.clear()
+ registerPtyHandlers(mainWindow as never, runtime as never)
+ const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
+ spawn(args: {
+ cols: number
+ rows: number
+ worktreeId: string
+ tabId: string
+ leafId: string
+ sessionId: string
+ }): Promise<{ id: string }>
+ }
+
+ await controller.spawn({
+ cols: 80,
+ rows: 24,
+ worktreeId: 'wt-runtime-reattach',
+ tabId: 'tab-runtime-reattach',
+ leafId,
+ sessionId: 'runtime-reattach'
+ })
+
+ expect(runtime.registerPty).toHaveBeenCalledWith(
+ 'runtime-reattach',
+ 'wt-runtime-reattach',
+ null,
+ {
+ tabId: 'tab-runtime-reattach',
+ leafId,
+ incarnationId,
+ providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' }
+ },
+ false
+ )
+ })
it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => {
await withWin32Platform(async () => {
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts
index 24dc333e6d1..bf3ec10dc27 100644
--- a/src/main/ipc/pty-ipc-mock-registry.ts
+++ b/src/main/ipc/pty-ipc-mock-registry.ts
@@ -87,6 +87,7 @@ export const fsModuleMock = () => ({
chmodSync: chmodSyncMock,
renameSync: renameSyncMock,
rmSync: rmSyncMock,
+ mkdtempSync: () => '/tmp/orca-watcher-canary-test',
constants: {
X_OK: 1,
R_OK: 4
diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts
index 6e5e57f3ad6..73c6e906d5e 100644
--- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts
+++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts
@@ -416,8 +416,14 @@ describe('registerPtyHandlers', () => {
baseEnv: expect.any(Object)
})
})
- it('does not echo launch config for provider reattach results', async () => {
- const spawn = vi.fn(async () => ({ id: 'ssh-reattach', isReattach: true }))
+ it('restores daemon launch identity without minting renderer authority on reattach', async () => {
+ const incarnationId = 'ssh-reattach-incarnation'
+ const spawn = vi.fn(async () => ({
+ id: 'ssh-reattach',
+ incarnationId,
+ isReattach: true as const,
+ launchAgent: 'codex' as const
+ }))
registerSshPtyProvider('ssh-reattach-1', {
spawn,
write: vi.fn(),
@@ -439,17 +445,30 @@ describe('registerPtyHandlers', () => {
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
+ const registerPty = vi.fn()
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
- registerPreAllocatedHandleForPty: vi.fn()
+ registerPreAllocatedHandleForPty: vi.fn(),
+ registerPty
}
+ const tabId = 'tab-reattach'
+ const leafId = '77777777-7777-4777-8777-777777777777'
+ const worktreeId = 'repo-1::/tmp/reattach'
registerPtyHandlers(mainWindow as never, runtime as never)
- const result = (await handlers.get('pty:spawn')!(null, {
+ const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-1',
+ worktreeId,
+ tabId,
+ leafId,
+ env: {
+ ORCA_PANE_KEY: makePaneKey(tabId, leafId),
+ ORCA_TAB_ID: tabId,
+ ORCA_WORKTREE_ID: worktreeId
+ },
launchConfig: {
agentCommand: 'codex --model gpt-5',
agentArgs: '--model gpt-5',
@@ -459,6 +478,18 @@ describe('registerPtyHandlers', () => {
expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true })
expect(result.launchConfig).toBeUndefined()
+ expect(registerPty).toHaveBeenCalledWith(
+ 'ssh-reattach',
+ worktreeId,
+ 'ssh-reattach-1',
+ expect.objectContaining({
+ tabId,
+ leafId,
+ incarnationId,
+ providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' }
+ }),
+ undefined
+ )
})
it('reuses the runtime background handle in local PTY spawn env', async () => {
type RuntimeSpawnController = {
diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts
index 09e6fa8b8c8..7793ccf17ee 100644
--- a/src/main/ipc/pty/ipc/spawn-commit.ts
+++ b/src/main/ipc/pty/ipc/spawn-commit.ts
@@ -18,7 +18,10 @@ import {
import { rememberPaneKeyForPty } from '../pane/key-state'
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
-import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority'
+import {
+ admitProviderReattachLaunchIdentity,
+ admitRendererAgentLaunchAuthority
+} from '../pane/launch-authority'
import type { PtyIpcSpawnState } from './spawn-state'
import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
@@ -82,6 +85,11 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise {
+ it('binds provider launch identity to a valid reattach incarnation', () => {
+ expect(
+ admitProviderReattachLaunchIdentity({
+ isReattach: true,
+ launchAgent: 'codex',
+ incarnationId: 'provider-incarnation'
+ })
+ ).toEqual({ launchAgent: 'codex', incarnationId: 'provider-incarnation' })
+ })
+
+ it.each([
+ { label: 'fresh spawn', isReattach: false, launchAgent: 'codex', incarnationId: 'incarnation' },
+ {
+ label: 'invalid agent',
+ isReattach: true,
+ launchAgent: 'unknown',
+ incarnationId: 'incarnation'
+ },
+ {
+ label: 'missing incarnation',
+ isReattach: true,
+ launchAgent: 'codex',
+ incarnationId: undefined
+ },
+ {
+ label: 'oversized incarnation',
+ isReattach: true,
+ launchAgent: 'codex',
+ incarnationId: 'x'.repeat(129)
+ }
+ ])('rejects $label metadata', ({ isReattach, launchAgent, incarnationId }) => {
+ expect(
+ admitProviderReattachLaunchIdentity({ isReattach, launchAgent, incarnationId })
+ ).toBeNull()
+ })
+})
diff --git a/src/main/ipc/pty/pane/launch-authority.ts b/src/main/ipc/pty/pane/launch-authority.ts
index 93f3061f0b3..ab00d29b4f5 100644
--- a/src/main/ipc/pty/pane/launch-authority.ts
+++ b/src/main/ipc/pty/pane/launch-authority.ts
@@ -30,6 +30,21 @@ export function admitRendererAgentLaunchAuthority(args: {
return { launchToken: args.launchToken, launchAgent: args.launchAgent }
}
+export function admitProviderReattachLaunchIdentity(args: {
+ isReattach?: boolean
+ launchAgent?: unknown
+ incarnationId?: unknown
+}): { incarnationId: string; launchAgent: TuiAgent } | null {
+ if (
+ !args.isReattach ||
+ !isTuiAgent(args.launchAgent) ||
+ !isPtyIncarnationId(args.incarnationId)
+ ) {
+ return null
+ }
+ return { incarnationId: args.incarnationId, launchAgent: args.launchAgent }
+}
+
export function shouldRefreshNativeClaudeAgentTeamsEnv(args: {
command?: string
launchConfig?: SleepingAgentLaunchConfig
diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts
index ecf4f4ebcd4..38f9ced106e 100644
--- a/src/main/ipc/pty/runtime/spawn-commit.ts
+++ b/src/main/ipc/pty/runtime/spawn-commit.ts
@@ -33,10 +33,12 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
import { clearProviderPtyState } from '../provider/state-cleanup'
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
+import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
import type { RuntimePtySpawnState } from './spawn-state'
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
const args = ctx.args
+ const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
try {
ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
store: ctx.hostSessionBinding?.store,
@@ -69,7 +71,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
{
tabId: owner.surface.tabId,
leafId: owner.surface.leafId,
- ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {})
+ ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
+ ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
)
if (!args.connectionId) {
@@ -204,7 +207,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
? {
tabId: args.tabId,
leafId: ctx.metadataLeafId,
- ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {})
+ ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
+ ...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
: undefined,
!args.connectionId
diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts
index 11b9fc97afd..f8171862728 100644
--- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts
+++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts
@@ -61,7 +61,8 @@ async function createRuntimeWithHookRows(
rows: AgentStatusIpcPayload[]
): Promise {
const runtime = new OrcaRuntimeService(null, undefined, {
- getAgentStatusSnapshot: () => rows
+ getAgentStatusSnapshot: () => rows,
+ getAgentProviderSessionRowsForPane: () => rows
})
const internals = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise
@@ -115,6 +116,22 @@ function lastOscTitleEpochMs(runtime: OrcaRuntimeService): number {
}
describe('headless hook agent-status projection (#11761)', () => {
+ it('ranks a completed hook below present launch evidence in terminal identity', async () => {
+ const runtime = await createRuntimeWithHookRows([
+ hookRow({
+ agentType: 'codex',
+ state: 'done',
+ prompt: '',
+ toolName: undefined,
+ interactivePrompt: undefined
+ })
+ ])
+
+ expect((await runtime.listTerminals()).terminals).toEqual([
+ expect.objectContaining({ agentIdentity: 'claude' })
+ ])
+ })
+
it('carries the hook state, tool and interactivePrompt to paired clients', async () => {
const agentStatus = await projectAgentStatus([hookRow()])
diff --git a/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts b/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts
new file mode 100644
index 00000000000..a463dd75cf4
--- /dev/null
+++ b/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it, vi } from 'vitest'
+import { makePaneKey } from '../../shared/stable-pane-id'
+import { OrcaRuntimeService } from './orca-runtime'
+
+vi.mock('electron', () => ({
+ app: { getPath: vi.fn(() => '/tmp'), isPackaged: false },
+ BrowserWindow: { fromId: vi.fn(() => null) },
+ ipcMain: { on: vi.fn(), removeListener: vi.fn() },
+ webContents: { fromId: vi.fn(() => null) }
+}))
+
+const TAB_ID = '11111111-1111-4111-8111-111111111111'
+const LEAF_ID = '22222222-2222-4222-8222-222222222222'
+const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
+const INCARNATION_ID = 'provider-reattach-incarnation'
+const WORKTREE_ID = 'repo-1::/tmp/provider-reattach'
+
+type RuntimePtyLaunchIdentity = {
+ incarnationId: string | null
+ paneKey: string | null
+ launchAgent: string | null
+ launchToken: string | null
+ launchIncarnationId: string | null
+}
+
+function getPty(runtime: OrcaRuntimeService, ptyId: string): RuntimePtyLaunchIdentity | undefined {
+ return (runtime as unknown as { ptysById: Map }).ptysById.get(
+ ptyId
+ )
+}
+
+describe('provider reattach launch identity', () => {
+ it('restores daemon-owned agent identity without minting renderer authority', () => {
+ const runtime = new OrcaRuntimeService(null)
+
+ runtime.registerPty('pty-reattach', WORKTREE_ID, null, {
+ tabId: TAB_ID,
+ leafId: LEAF_ID,
+ incarnationId: INCARNATION_ID,
+ providerReattachLaunchIdentity: {
+ incarnationId: INCARNATION_ID,
+ launchAgent: 'codex'
+ }
+ })
+
+ expect(getPty(runtime, 'pty-reattach')).toMatchObject({
+ incarnationId: INCARNATION_ID,
+ paneKey: PANE_KEY,
+ launchAgent: 'codex',
+ launchToken: null,
+ launchIncarnationId: null
+ })
+ })
+
+ it('rejects provider identity from a different process incarnation', () => {
+ const runtime = new OrcaRuntimeService(null)
+
+ runtime.registerPty('pty-mismatched-reattach', WORKTREE_ID, null, {
+ tabId: TAB_ID,
+ leafId: LEAF_ID,
+ incarnationId: INCARNATION_ID,
+ providerReattachLaunchIdentity: {
+ incarnationId: 'stale-provider-incarnation',
+ launchAgent: 'codex'
+ }
+ })
+
+ expect(getPty(runtime, 'pty-mismatched-reattach')).toMatchObject({
+ incarnationId: INCARNATION_ID,
+ paneKey: PANE_KEY,
+ launchAgent: null,
+ launchToken: null,
+ launchIncarnationId: null
+ })
+ })
+
+ it('retires daemon launch identity when the agent command finishes', () => {
+ const runtime = new OrcaRuntimeService(null)
+
+ runtime.registerPty('pty-finished-reattach', WORKTREE_ID, null, {
+ tabId: TAB_ID,
+ leafId: LEAF_ID,
+ incarnationId: INCARNATION_ID,
+ providerReattachLaunchIdentity: {
+ incarnationId: INCARNATION_ID,
+ launchAgent: 'codex'
+ }
+ })
+ runtime.emitDaemonPtyTransientFact('pty-finished-reattach', {
+ kind: 'command-finished',
+ exitCode: 0
+ })
+
+ expect(getPty(runtime, 'pty-finished-reattach')).toMatchObject({
+ launchAgent: null,
+ launchToken: null,
+ launchIncarnationId: null
+ })
+ })
+})
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 3d507c5da88..b8b12bf2b1a 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -13769,6 +13769,7 @@ describe('OrcaRuntimeService', () => {
const terminal = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex',
+ launchAgent: 'codex',
launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }
})
const spawnEnv =
@@ -13785,6 +13786,9 @@ describe('OrcaRuntimeService', () => {
launchToken: spawnEnv.ORCA_AGENT_LAUNCH_TOKEN
})
).toBeDefined()
+ expect((await runtime.listTerminals()).terminals).toEqual([
+ expect.objectContaining({ handle: terminal.handle, agentIdentity: 'codex' })
+ ])
runtime.onPtyData('pty-authority', '\x1b]133;D;0\x07', 100)
@@ -13795,6 +13799,9 @@ describe('OrcaRuntimeService', () => {
launchToken: spawnEnv.ORCA_AGENT_LAUNCH_TOKEN
})
).toBeUndefined()
+ expect((await runtime.listTerminals()).terminals).toEqual([
+ expect.not.objectContaining({ agentIdentity: expect.anything() })
+ ])
})
it('retires only receipted restored PTY authority on command completion and exit', () => {
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index b57e44aacf3..f7807df3f16 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -347,6 +347,7 @@ import type {
TerminalPaneLayoutNode,
TerminalTab
} from '../../shared/terminal-tab-types'
+import { resolvePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity'
import type { TuiAgent } from '../../shared/tui-agent'
import type { BranchPrefixStrategy } from '../../shared/ui-chrome-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
@@ -11456,6 +11457,10 @@ export class OrcaRuntimeService {
leafId: string
incarnationId?: PtyIncarnationId
agentLaunchAuthority?: { launchToken: string; launchAgent: TuiAgent }
+ providerReattachLaunchIdentity?: {
+ incarnationId: PtyIncarnationId
+ launchAgent: TuiAgent
+ }
},
isWsl?: boolean
): void {
@@ -11494,6 +11499,18 @@ export class OrcaRuntimeService {
pty.launchIncarnationId = binding.incarnationId
pty.launchAgent = agentLaunchAuthority.launchAgent
}
+ const providerReattachLaunchIdentity = binding?.providerReattachLaunchIdentity
+ if (
+ providerReattachLaunchIdentity &&
+ paneKey &&
+ binding.incarnationId === providerReattachLaunchIdentity.incarnationId &&
+ pty.incarnationId === providerReattachLaunchIdentity.incarnationId &&
+ pty.paneKey === paneKey &&
+ isTuiAgent(providerReattachLaunchIdentity.launchAgent)
+ ) {
+ // Why: daemon metadata owns the surviving process; its incarnation fence restores identity without minting renderer launch authority.
+ pty.launchAgent = providerReattachLaunchIdentity.launchAgent
+ }
const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId)
if (
pendingIncarnation === null ||
@@ -14689,13 +14706,14 @@ export class OrcaRuntimeService {
return
}
const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId)
- if (!pty.launchToken && !receipt) {
+ if (!pty.launchToken && !receipt && !pty.launchAgent) {
return
}
const paneKeys = this.collectPaneKeysForPty(ptyId)
this.restoredOrchestrationAuthorityByPtyId.delete(ptyId)
pty.launchToken = null
pty.launchIncarnationId = null
+ pty.launchAgent = null
for (const paneKey of paneKeys) {
this.retireAgentHookCompatibilityAuthorityFn?.(paneKey)
}
@@ -33984,6 +34002,30 @@ export class OrcaRuntimeService {
return null
}
+ /** Thin adapter so the summary builders stay declarative; the decision lives in `src/shared`. */
+ private resolvePaneAgentIdentityField(
+ launchAgent: TuiAgent | null | undefined,
+ foregroundAgent: TuiAgent | null | undefined,
+ title: string | null,
+ paneKey: string | null
+ ): { agentIdentity?: TuiAgent } {
+ // Why hooks here: an agent the USER started from a shell has no launch record, and on WSL the
+ // Windows host reads its foreground process as `wsl.exe` rather than the agent inside the
+ // distro. The hook is the only signal that survives both, because the agent reports itself.
+ const hookRow = paneKey
+ ? this.getHookAgentRowForPane(this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? [])
+ : null
+ const hookAgent = isTuiAgent(hookRow?.agentType) ? hookRow.agentType : null
+ const agentIdentity = resolvePublishedPaneAgentIdentity({
+ hookAgent,
+ hookIsLive: hookRow?.agentIsLive,
+ launchAgent,
+ foregroundAgent,
+ title
+ })
+ return agentIdentity ? { agentIdentity } : {}
+ }
+
private buildTerminalSummary(
leaf: RuntimeLeafRecord,
worktreesById: Map,
@@ -33993,6 +34035,7 @@ export class OrcaRuntimeService {
const tab = this.tabs.get(leaf.tabId) ?? null
const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined
+ const title = getLatestLeafTitle(leaf, tab?.title ?? null)
// Why: leaf.connected mirrors the renderer graph (`ptyId !== null`), so a
// restored surface whose PTY died with a prior run still reads connected.
// Demote only on a controller-proven absence, and only for locally-scoped
@@ -34018,13 +34061,21 @@ export class OrcaRuntimeService {
branch: worktree?.branch ?? '',
tabId: leaf.tabId,
leafId: leaf.leafId,
- title: getLatestLeafTitle(leaf, tab?.title ?? null),
+ title,
connected: provenAbsent ? false : leaf.connected,
writable: provenAbsent ? false : leaf.writable,
lastOutputAt: leaf.lastOutputAt,
preview: leaf.preview,
...(leaf.lastExitCause ? { exitCause: leaf.lastExitCause } : {}),
- ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId)
+ ...this.terminalExecutionHostField(leaf.ptyId, leaf.worktreeId),
+ ...this.resolvePaneAgentIdentityField(
+ pty?.launchAgent,
+ pty?.foregroundAgent,
+ title,
+ // Why guarded: makePaneKey THROWS on a non-UUID leaf id, and an unguarded call here took
+ // down terminal.list for every pane in the list, not just the odd one.
+ isTerminalLeafId(leaf.leafId) ? makePaneKey(leaf.tabId, leaf.leafId) : null
+ )
}
}
@@ -35225,6 +35276,7 @@ export class OrcaRuntimeService {
providerSessionAgentType: string | null
providerSessionReceivedAt: number | null
agentType: string | null
+ agentIsLive: boolean
live: HookLiveAgentRow | null
} {
let session: AgentStatusIpcPayload | null = null
@@ -35265,6 +35317,9 @@ export class OrcaRuntimeService {
providerSessionAgentType: session?.agentType ?? null,
providerSessionReceivedAt: session?.receivedAt ?? null,
agentType: agent?.agentType ?? null,
+ // A fresh completed row still projects its terminal `done` status, but it is
+ // past-tense identity evidence and must not outrank process or launch facts.
+ agentIsLive: agent != null && agent.state !== 'done',
live: live
? {
payload: pickParsedAgentStatusPayload(live),
@@ -36048,6 +36103,7 @@ export class OrcaRuntimeService {
worktreesById: Map
): RuntimeTerminalSummary {
const worktree = worktreesById.get(pty.worktreeId)
+ const title = getLatestPtyTitle(pty)
const pane = parsePaneKey(pty.paneKey ?? '')
const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId
@@ -36061,13 +36117,19 @@ export class OrcaRuntimeService {
branch: worktree?.branch ?? '',
tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!,
leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId,
- title: getLatestPtyTitle(pty),
+ title,
connected: pty.connected,
writable: pty.connected,
lastOutputAt: pty.lastOutputAt,
preview: pty.preview,
...(pty.lastExitCause ? { exitCause: pty.lastExitCause } : {}),
- ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId)
+ ...this.terminalExecutionHostField(pty.ptyId, pty.worktreeId),
+ ...this.resolvePaneAgentIdentityField(
+ pty.launchAgent,
+ pty.foregroundAgent,
+ title,
+ pty.paneKey ?? null
+ )
}
}
diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts
index 81c2345c8b2..7fb30f733f0 100644
--- a/src/main/runtime/orchestration/groups.test.ts
+++ b/src/main/runtime/orchestration/groups.test.ts
@@ -18,7 +18,10 @@ function makeSummary(
connected: opts.connected ?? true,
writable: opts.writable ?? true,
lastOutputAt: opts.lastOutputAt ?? null,
- preview: opts.preview ?? ''
+ preview: opts.preview ?? '',
+ // Why spread and not a default: `agentIdentity` absent is meaningful (unknown), so the
+ // helper must be able to produce a summary that genuinely lacks the field.
+ ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {})
}
}
@@ -97,209 +100,108 @@ describe('resolveGroupAddress', () => {
})
describe('agent name groups', () => {
- it('matches @claude by terminal title', () => {
+ // Why identity and not title: these groups used to match `@` against the terminal
+ // title, so any pane whose task text mentioned an agent received that agent's messages.
+ // Routing now reads the identity the host resolved from launch/process evidence it owns.
+
+ it('routes to every pane the host resolved as that agent', () => {
const terminals = [
- makeSummary('term_a', { title: 'Claude Code' }),
- makeSummary('term_b', { title: 'Claude Code' }),
- makeSummary('term_c', { title: 'Codex CLI' })
+ makeSummary('term_a', { agentIdentity: 'claude' }),
+ makeSummary('term_b', { agentIdentity: 'claude' }),
+ makeSummary('term_c', { agentIdentity: 'codex' })
]
- const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus)
- expect(result).toEqual(['term_b'])
+ expect(resolveGroupAddress('@claude', 'term_a', terminals, noStatus)).toEqual(['term_b'])
})
- it('matches @mimo by terminal title', () => {
+ it.each([
+ ['@codex', 'codex'],
+ ['@openclaude', 'openclaude'],
+ ['@mimo', 'mimo-code'],
+ ['@gemini', 'gemini'],
+ ['@droid', 'droid'],
+ ['@grok', 'grok'],
+ ['@cursor', 'cursor'],
+ ['@opencode', 'opencode']
+ ])('routes %s to its agent id', (group, agentIdentity) => {
const terminals = [
- makeSummary('term_a', { title: 'mimo' }),
- makeSummary('term_b', { title: 'MiMo Code session' }),
- makeSummary('term_c', { title: 'OpenCode' })
+ makeSummary('sender'),
+ makeSummary('target', { agentIdentity: agentIdentity as never }),
+ makeSummary('other', { agentIdentity: 'claude' })
]
- const result = resolveGroupAddress('@mimo', 'term_a', terminals, noStatus)
- expect(result).toEqual(['term_b'])
+ const expected = agentIdentity === 'claude' ? ['target', 'other'] : ['target']
+ expect(resolveGroupAddress(group, 'sender', terminals, noStatus)).toEqual(expected)
})
- it('matches @openclaude by terminal title', () => {
- const terminals = [
- makeSummary('term_a', { title: 'OpenClaude' }),
- makeSummary('term_b', { title: 'OpenClaude running' }),
- makeSummary('term_c', { title: 'Claude Code' })
- ]
- const result = resolveGroupAddress('@openclaude', 'term_a', terminals, noStatus)
- expect(result).toEqual(['term_b'])
+ it('is case-insensitive for the group address', () => {
+ const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: 'claude' })]
+ expect(resolveGroupAddress('@CLAUDE', 'sender', terminals, noStatus)).toEqual(['target'])
})
- it('does not match OpenClaude titles through @claude', () => {
+ it('excludes the sender even when the sender is that agent', () => {
const terminals = [
- makeSummary('term_a', { title: 'Claude Code' }),
- makeSummary('term_b', { title: 'OpenClaude running' })
+ makeSummary('sender', { agentIdentity: 'grok' }),
+ makeSummary('target', { agentIdentity: 'grok' })
]
- const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus)
- expect(result).toEqual([])
+ expect(resolveGroupAddress('@grok', 'sender', terminals, noStatus)).toEqual(['target'])
})
- it('matches @codex by terminal title', () => {
- const terminals = [
- makeSummary('term_a', { title: 'Codex CLI' }),
- makeSummary('term_b', { title: 'Codex CLI' })
- ]
- const result = resolveGroupAddress('@codex', 'term_a', terminals, noStatus)
- expect(result).toEqual(['term_b'])
+ describe('a task title can no longer redirect a message', () => {
+ // The bug. Recorded titles of this exact shape exist: a Grok pane named
+ // "Switch Claude and Codex off the load balancer… - grok" received both @claude and @codex.
+ it('does not route @claude to a Codex pane whose task text names Claude', () => {
+ const terminals = [
+ makeSummary('sender'),
+ makeSummary('codex_pane', {
+ agentIdentity: 'codex',
+ title: 'Review the Claude session-history fix'
+ })
+ ]
+ expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([])
+ })
+
+ it('does not route @codex to a Grok pane whose task text names Codex', () => {
+ const terminals = [
+ makeSummary('sender'),
+ makeSummary('grok_pane', {
+ agentIdentity: 'grok',
+ title: 'Switch Claude and Codex off the load balancer… - grok'
+ })
+ ]
+ expect(resolveGroupAddress('@codex', 'sender', terminals, noStatus)).toEqual([])
+ expect(resolveGroupAddress('@grok', 'sender', terminals, noStatus)).toEqual(['grok_pane'])
+ })
+
+ it('does not route @cursor to a pane merely discussing a text cursor', () => {
+ const terminals = [
+ makeSummary('sender'),
+ makeSummary('claude_pane', {
+ agentIdentity: 'claude',
+ title: 'fix the text cursor blink'
+ })
+ ]
+ expect(resolveGroupAddress('@cursor', 'sender', terminals, noStatus)).toEqual([])
+ })
})
- it('matches @droid by terminal title and excludes sender', () => {
- const terminals = [
- makeSummary('term_a', { title: 'Droid ready' }),
- makeSummary('term_b', { title: 'Droid ready' }),
- makeSummary('term_c', { title: 'Droid - action required' })
- ]
- const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus)
- expect(result).toEqual(['term_b', 'term_c'])
+ describe('unknown identity fails closed', () => {
+ // Why: `agentIdentity` is absent when the host predates the field or had no evidence
+ // beyond the title. Delivery is an action, so unknown must not deliver. The sender sees
+ // no recipients, which is visible and recoverable; a message in the wrong agent's prompt
+ // is neither.
+ it('does not route to a pane with no resolved identity, whatever its title says', () => {
+ const terminals = [makeSummary('sender'), makeSummary('unknown', { title: 'Claude Code' })]
+ expect(resolveGroupAddress('@claude', 'sender', terminals, noStatus)).toEqual([])
+ })
+
+ it('still routes the identity-free groups, which do not depend on the field', () => {
+ const terminals = [makeSummary('sender'), makeSummary('other', { title: 'Claude Code' })]
+ expect(resolveGroupAddress('@all', 'sender', terminals, noStatus)).toEqual(['other'])
+ })
})
- it('does not match Android, path, or hyphenated tokens through @droid', () => {
- const terminals = [
- makeSummary('term_a', { title: 'Codex CLI' }),
- makeSummary('term_b', { title: 'Android build' }),
- makeSummary('term_c', { title: '/tmp/android' }),
- makeSummary('term_d', { title: 'my-droid-worker' })
- ]
- const result = resolveGroupAddress('@droid', 'term_a', terminals, noStatus)
- expect(result).toEqual([])
- })
-
- it('is case-insensitive for group address', () => {
- const terminals = [makeSummary('term_a'), makeSummary('term_b', { title: 'Claude Code' })]
- const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus)
- expect(result).toEqual(['term_b'])
- })
-
- it('matches @grok as a standalone title token and excludes sender', () => {
- const terminals = [
- makeSummary('term_a', { title: 'Grok' }),
- makeSummary('term_b', { title: 'GROK CLI' }),
- makeSummary('term_c', { title: '⠋ Grok' }),
- makeSummary('term_d', { title: 'ngrok' }),
- makeSummary('term_e', { title: '/tmp/grok' }),
- makeSummary('term_f', { title: 'my-grok-worker' }),
- makeSummary('term_g', { title: 'Codex CLI' })
- ]
-
- const result = resolveGroupAddress('@GrOk', 'term_a', terminals, noStatus)
-
- expect(result).toEqual(['term_b', 'term_c'])
- })
-
- // Why: the resolver sees the raw OSC title, and Grok CLI's real working/session
- // titles carry a trailing " - grok" identity or a spinner-collapsed "⠋ grok"
- // (see terminal-title-agent-type.ts). Prove those production shapes resolve.
- it('matches real Grok OSC working and session titles', () => {
- const terminals = [
- makeSummary('coordinator', { title: 'Coordinator' }),
- makeSummary('term_rotating', { title: '⠋ - fix the flaky suite - grok' }),
- makeSummary('term_collapsed', { title: '⠋ grok' }),
- makeSummary('term_session', { title: 'Fix the auth bug - grok' })
- ]
-
- const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus)
-
- expect(result).toEqual(['term_rotating', 'term_collapsed', 'term_session'])
- })
-
- // Why: Windows agent titles can surface the launcher process name (`grok.exe`);
- // the shared matcher accepts .exe/.cmd/.bat/.ps1 suffixes but still rejects
- // arbitrary dotted fragments like `grok.py`.
- it('matches Windows launcher-suffix titles but not arbitrary dotted tokens', () => {
- const terminals = [
- makeSummary('coordinator', { title: 'Coordinator' }),
- makeSummary('term_exe', { title: 'grok.exe' }),
- makeSummary('term_cmd', { title: 'grok.cmd running' }),
- makeSummary('term_dotted', { title: 'grok.py' })
- ]
-
- const result = resolveGroupAddress('@grok', 'coordinator', terminals, noStatus)
-
- expect(result).toEqual(['term_exe', 'term_cmd'])
- })
-
- // Why: these are the titles cursor-agent natively emits and the ones Orca synthesizes
- // from Cursor hooks, so each must resolve.
- it('matches native and Orca-synthesized Cursor titles', () => {
- const terminals = [
- makeSummary('coordinator', { title: 'Coordinator' }),
- makeSummary('term_native', { title: 'Cursor Agent' }),
- makeSummary('term_working', { title: '⠋ Cursor Agent' }),
- makeSummary('term_idle', { title: 'Cursor ready' }),
- makeSummary('term_permission', { title: 'Cursor - action required' })
- ]
-
- const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus)
-
- expect(result).toEqual(['term_native', 'term_working', 'term_idle', 'term_permission'])
- })
-
- it('is case-insensitive for @cursor', () => {
- const terminals = [
- makeSummary('coordinator', { title: 'Coordinator' }),
- makeSummary('term_b', { title: 'CURSOR READY' })
- ]
-
- const result = resolveGroupAddress('@CuRsOr', 'coordinator', terminals, noStatus)
-
- expect(result).toEqual(['term_b'])
- })
-
- // Why: this is the false positive that a whole-token matcher cannot catch. Claude
- // and Codex put their task summary in the OSC title, and none of Claude's status
- // prefixes carries its own name, so "cursor" as ordinary editor vocabulary is the
- // only agent token such a title has. Routing @cursor there would write into a live
- // non-Cursor agent's prompt.
- it('does not match another agent whose task title mentions a text cursor', () => {
- const terminals = [
- makeSummary('coordinator', { title: 'Coordinator' }),
- makeSummary('term_claude_working', {
- title: '⠋ preserve cursor visibility across replays'
- }),
- makeSummary('term_claude_idle', { title: '✳ Fix the text cursor blink' }),
- makeSummary('term_claude_dot', { title: '. fix cursor position' }),
- makeSummary('term_claude_star', { title: '* cursor rendering done' }),
- makeSummary('term_codex', { title: '⠋ Codex: fix cursor offsets' }),
- makeSummary('term_grok', { title: '⠋ - restoring cursor state - grok' }),
- makeSummary('term_shell', { title: 'Terminal Cursor and Orca slows down' })
- ]
-
- const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus)
-
- expect(result).toEqual([])
- })
-
- // Why: pin the deliberate tradeoff. A renamed Cursor pane outranks its OSC title, so it
- // is group-unaddressable and resolves to nothing instead of resolving loosely. A silent
- // miss (address it by handle) is preferred over delivering into another agent's prompt.
- it('does not match a renamed Cursor terminal or the bare process name', () => {
- const terminals = [
- makeSummary('coordinator', { title: 'Coordinator' }),
- makeSummary('term_renamed', { title: 'Cursor - reviewer' }),
- makeSummary('term_worker', { title: 'cursor worker 2' }),
- makeSummary('term_process', { title: 'cursor-agent' })
- ]
-
- const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus)
-
- expect(result).toEqual([])
- })
-
- it('does not match cursor paths, hyphenated compounds, or dotted tokens', () => {
- const terminals = [
- makeSummary('coordinator', { title: 'Coordinator' }),
- makeSummary('term_rules', { title: '~/cursor-rules' }),
- makeSummary('term_path', { title: '/tmp/cursor' }),
- makeSummary('term_worker', { title: 'my-cursor-worker' }),
- makeSummary('term_file', { title: 'render-cursor-after-bracketed-paste' }),
- makeSummary('term_dotted', { title: 'cursor.ts' })
- ]
-
- const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus)
-
- expect(result).toEqual([])
+ it('returns no recipients for an unknown group', () => {
+ const terminals = [makeSummary('sender'), makeSummary('target', { agentIdentity: 'claude' })]
+ expect(resolveGroupAddress('@nonsense', 'sender', terminals, noStatus)).toEqual([])
})
})
diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts
index 6f31f5df261..64c0900bfb9 100644
--- a/src/main/runtime/orchestration/groups.ts
+++ b/src/main/runtime/orchestration/groups.ts
@@ -1,6 +1,5 @@
-import { isCursorAgentTitle } from '../../../shared/agent-title-core'
-import { buildAgentNameRe } from '../../../shared/agent-name-token-match'
import type { RuntimeTerminalSummary } from '../../../shared/runtime-types'
+import type { TuiAgent } from '../../../shared/tui-agent'
// Why: group addresses enable broadcast messaging to logical groups of agents.
// Resolution is done at send-time: one message record per recipient, same thread_id,
@@ -24,23 +23,36 @@ export function isGroupAddress(to: string): boolean {
return to.startsWith('@')
}
-// Why: a name token identifies an agent only when the name is a coined word. `cursor` is
-// also ordinary vocabulary in another agent's task-summary title ("fix the text cursor
-// blink"), so token-matching it would route @cursor into a live Claude/Codex prompt. Names
-// with that ambiguity register the identity predicate delivery already applies to them.
-const GROUP_TITLE_MATCHERS: Partial boolean>> = {
- cursor: isCursorAgentTitle
+/** Group name to the agent id the host publishes for a pane. */
+const GROUP_AGENT_IDS: Record = {
+ claude: 'claude',
+ openclaude: 'openclaude',
+ codex: 'codex',
+ opencode: 'opencode',
+ mimo: 'mimo-code',
+ gemini: 'gemini',
+ droid: 'droid',
+ grok: 'grok',
+ cursor: 'cursor'
}
-function titleMatchesAgentNameGroup(title: string, agentName: string): boolean {
- const identityMatcher = GROUP_TITLE_MATCHERS[agentName as AgentNameGroup]
- if (identityMatcher) {
- return identityMatcher(title)
- }
- // Why: reuse the shared whole-token matcher so orchestration groups honor the
- // same Windows launcher-suffix rule (e.g. `grok.exe`) as the rest of Orca's
- // agent-title detection, instead of maintaining a divergent regex here.
- return buildAgentNameRe(agentName).test(title)
+/**
+ * Whether this terminal IS the addressed agent.
+ *
+ * Why the host's resolved identity and not the title: a terminal title is a decoration channel
+ * that routinely contains other agents' names, because people describe agent work in their task
+ * titles. Matching `@claude` against the title delivered the message to any pane whose task text
+ * happened to say "claude" — a Codex pane reviewing a Claude PR received Claude's instructions.
+ * Recorded titles like "Switch Claude and Codex off the load balancer… - grok" are the ordinary
+ * case, not a contrived one.
+ *
+ * Why an absent identity means NO: `agentIdentity` is absent when the host predates the field or
+ * had no evidence beyond the title. Delivery is an action, so unknown fails closed. Not
+ * delivering is visible and recoverable — the sender sees no recipients; delivering to the wrong
+ * agent is neither.
+ */
+function terminalIsAgent(terminal: RuntimeTerminalSummary, agentName: AgentNameGroup): boolean {
+ return terminal.agentIdentity === GROUP_AGENT_IDS[agentName]
}
export function resolveGroupAddress(
@@ -76,9 +88,9 @@ export function resolveGroupAddress(
.map((t) => t.handle)
}
- // Why: agent-name groups (@claude, @droid, etc.) match by terminal title so
- // the sender can address all instances of a particular agent type without
- // knowing their handles.
+ // Why: agent-name groups (@claude, @droid, etc.) resolve against the identity the HOST
+ // published for each pane, so the sender can address every instance of an agent without
+ // knowing their handles — and without a task title being able to redirect the message.
const agentName = group.slice(1) // remove @
if ((AGENT_NAME_GROUPS as readonly string[]).includes(agentName)) {
return terminals
@@ -86,7 +98,7 @@ export function resolveGroupAddress(
if (t.handle === senderHandle) {
return false
}
- return titleMatchesAgentNameGroup(t.title ?? '', agentName)
+ return terminalIsAgent(t, agentName as AgentNameGroup)
})
.map((t) => t.handle)
}
diff --git a/src/main/runtime/rpc/methods/orchestration-send.test.ts b/src/main/runtime/rpc/methods/orchestration-send.test.ts
index 36eaaa8b0a9..e4312c34a3a 100644
--- a/src/main/runtime/rpc/methods/orchestration-send.test.ts
+++ b/src/main/runtime/rpc/methods/orchestration-send.test.ts
@@ -591,7 +591,10 @@ describe('orchestration RPC methods', () => {
connected: opts.connected ?? true,
writable: opts.writable ?? true,
lastOutputAt: opts.lastOutputAt ?? null,
- preview: opts.preview ?? ''
+ preview: opts.preview ?? '',
+ // Why spread: absent `agentIdentity` means unknown, so the helper must be able to
+ // produce a summary that genuinely lacks the field.
+ ...(opts.agentIdentity ? { agentIdentity: opts.agentIdentity } : {})
}
}
@@ -726,11 +729,11 @@ describe('orchestration RPC methods', () => {
expect(result.messages[0].to_handle).toBe('term_b')
})
- it('fans out agent name group (@claude) by title match', async () => {
+ it('fans out an agent name group by host-resolved identity', async () => {
setupWithTerminals([
- makeSummary('term_a', { title: 'Claude Code' }),
- makeSummary('term_b', { title: 'Claude Code' }),
- makeSummary('term_c', { title: 'Codex' })
+ makeSummary('term_a', { agentIdentity: 'claude' }),
+ makeSummary('term_b', { agentIdentity: 'claude' }),
+ makeSummary('term_c', { agentIdentity: 'codex' })
])
const result = (await call('orchestration.send', {
@@ -743,11 +746,13 @@ describe('orchestration RPC methods', () => {
expect(result.messages[0].to_handle).toBe('term_b')
})
- it('fans out @droid by title match', async () => {
+ it('fans out @droid without claiming a pane whose title merely contains the word', async () => {
setupWithTerminals([
- makeSummary('term_a', { title: 'Codex' }),
- makeSummary('term_b', { title: 'Droid ready' }),
- makeSummary('term_c', { title: 'Android build' })
+ makeSummary('term_a', { agentIdentity: 'codex' }),
+ makeSummary('term_b', { agentIdentity: 'droid' }),
+ // Why kept: "Android build" contains `droid` as a substring. It was excluded before by
+ // whole-token matching and is excluded now because its identity is not droid.
+ makeSummary('term_c', { agentIdentity: 'claude', title: 'Android build' })
])
const result = (await call('orchestration.send', {
@@ -760,11 +765,12 @@ describe('orchestration RPC methods', () => {
expect(result.messages[0].to_handle).toBe('term_b')
})
- it('fans out @cursor by title match without claiming a cursor-mentioning title', async () => {
+ it('fans out @cursor without claiming a Claude pane discussing a text cursor', async () => {
setupWithTerminals([
- makeSummary('term_a', { title: 'Codex' }),
- makeSummary('term_b', { title: 'Cursor ready' }),
- makeSummary('term_c', { title: '✳ Fix the text cursor blink' })
+ makeSummary('term_a', { agentIdentity: 'codex' }),
+ makeSummary('term_b', { agentIdentity: 'cursor' }),
+ // The original hazard, now excluded structurally rather than by a bespoke predicate.
+ makeSummary('term_c', { agentIdentity: 'claude', title: '✳ Fix the text cursor blink' })
])
const result = (await call('orchestration.send', {
diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts
index d1fd17b3a4c..a0297e5c33e 100644
--- a/src/shared/pane-agent-identity-inventory.test.ts
+++ b/src/shared/pane-agent-identity-inventory.test.ts
@@ -124,11 +124,6 @@ const INVENTORY: readonly InventoryGroup[] = [
classification: 'evidence-producer',
paths: [['src/renderer/src/hooks/ipc-events/agent-status-routing.ts', 2]]
},
- {
- helper: 'buildAgentNameRe',
- classification: 'action-consumer',
- paths: [['src/main/runtime/orchestration/groups.ts', 2]]
- },
{
helper: 'buildAgentNameRe',
classification: 'parser-implementation',
diff --git a/src/shared/published-pane-agent-identity.test.ts b/src/shared/published-pane-agent-identity.test.ts
new file mode 100644
index 00000000000..6a338e891c6
--- /dev/null
+++ b/src/shared/published-pane-agent-identity.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest'
+import { resolvePublishedPaneAgentIdentity } from './published-pane-agent-identity'
+
+const resolve = resolvePublishedPaneAgentIdentity
+
+describe('resolvePublishedPaneAgentIdentity', () => {
+ describe('a task title cannot name the pane', () => {
+ // Minimized from real recorded titles. Each is a pane of one agent whose task text names
+ // another; before this, `@` routing delivered to them.
+ it.each([
+ ['Switch Claude and Codex off the load balancer… - grok', 'grok'],
+ ['Review the Claude session-history fix', 'codex'],
+ ['✳ Fix the text cursor blink', 'claude']
+ ])('keeps %j as its launched agent', (title, launchAgent) => {
+ expect(resolve({ launchAgent: launchAgent as never, title })).toBe(launchAgent)
+ })
+ })
+
+ it('keeps the launch record when an unambiguous title names a different agent', () => {
+ // The ranking assertion. Every other case here either yields no title evidence or agrees with
+ // the launch record, so without this one the suite passes even with title ranked FIRST —
+ // verified by mutation. `✳ Claude Code` is a parseable, unambiguous Claude title, and it still
+ // must not rename a pane Orca launched as Codex.
+ expect(resolve({ launchAgent: 'codex', title: '✳ Claude Code' })).toBe('codex')
+ })
+
+ it('prefers the live foreground process to the launch record', () => {
+ // The pane was launched as Claude and the user then started Codex in it. The process is the
+ // more direct observation, so it wins.
+ expect(resolve({ launchAgent: 'claude', foregroundAgent: 'codex' })).toBe('codex')
+ })
+
+ describe('title is the last resort, and the parser is what makes that safe', () => {
+ // The misdelivery this PR exists to stop, re-checked with title ALLOWED at the bottom. The old
+ // code matched `buildAgentNameRe('claude').test(title)`; the parser is categorically stricter
+ // and yields nothing for a name that only appears in task text.
+ it('does not name a Codex pane Claude from its task text', () => {
+ expect(resolve({ title: 'Review the Claude session-history fix' })).toBeUndefined()
+ })
+
+ it('reads the owner suffix, not the agents named in the task text', () => {
+ expect(resolve({ title: 'Switch Claude and Codex off the load balancer… - grok' })).toBe(
+ 'grok'
+ )
+ })
+
+ it('identifies a pane whose title unambiguously names its agent', () => {
+ // The WSL case: no launch record, no readable foreground process, no hooks installed. An
+ // unambiguous title is the only thing left, and refusing it made the pane unaddressable.
+ expect(resolve({ title: '✳ Claude Code' })).toBe('claude')
+ })
+
+ it('still declines a bare worktree name that merely contains an agent word', () => {
+ expect(resolve({ title: 'review-14600-codex' })).toBeUndefined()
+ })
+
+ it('lets every stronger source outrank an unambiguous title', () => {
+ expect(resolve({ title: '✳ Claude Code', hookAgent: 'codex', hookIsLive: true })).toBe(
+ 'codex'
+ )
+ expect(resolve({ title: '✳ Claude Code', foregroundAgent: 'codex' })).toBe('codex')
+ expect(resolve({ title: '✳ Claude Code', launchAgent: 'codex' })).toBe('codex')
+ })
+ })
+
+ it('publishes nothing when the title names no agent unambiguously', () => {
+ // Absence is meaningful: it tells a caller to fail closed rather than guess.
+ expect(resolve({ title: '◐ Rebase PR #14624 onto main' })).toBeUndefined()
+ expect(resolve({ title: 'Fix the codex bug' })).toBeUndefined()
+ expect(resolve({})).toBeUndefined()
+ })
+
+ it('publishes nothing for a hyphenated worktree name that contains an agent word', () => {
+ expect(resolve({ title: 'review-14600-codex' })).toBeUndefined()
+ })
+
+ describe('identity must not depend on how the agent was started', () => {
+ // Most agents are started by typing `claude` / `codex` at a shell, not through Orca's agent
+ // launcher. Those panes have no launch record at all, so anything that leans on one works for
+ // roughly half of real usage.
+ it('identifies a shell-started agent from its own hook report', () => {
+ expect(resolve({ hookAgent: 'codex', hookIsLive: true })).toBe('codex')
+ })
+
+ it('identifies a shell-started agent on WSL, where the process signal is useless', () => {
+ // The Windows host reads the foreground process of a WSL pane as `wsl.exe`, not the agent
+ // running inside the distro — so `foregroundAgent` cannot name it and there is no launch
+ // record. Without hook evidence this pane is unaddressable.
+ expect(resolve({ hookAgent: 'codex', hookIsLive: false })).toBe('codex')
+ })
+
+ it('still resolves when only a launch record exists', () => {
+ expect(resolve({ launchAgent: 'claude' })).toBe('claude')
+ })
+ })
+
+ describe('a launch record is outranked by live observation, not by other records', () => {
+ // NOTE: `launch` deliberately still ranks ABOVE `completed-hook`, matching
+ // pane-agent-owner.ts. Demoting it looked right — a launch record is past tense — but a
+ // completed hook is past tense too, and without run keys it never expires. Ranking it higher
+ // lets a stale hook from a previous agent hijack a pane from the fresh launch record of the
+ // agent running now. The reorder belongs with run generation, not before it.
+
+ // Field report: launch an agent, close it, reuse the terminal — the pane kept reading as the
+ // old agent. A launch record does not stop being true when the thing it describes ends.
+ it('keeps the launch record when only a completed hook competes', () => {
+ // Both are records, and neither expires without run keys. The launch record is the one Orca
+ // stamped for the CURRENT process, so promoting the hook above it lets a stale hook from a
+ // previous agent hijack the pane. Matches pane-agent-owner.ts.
+ expect(resolve({ launchAgent: 'claude', hookAgent: 'codex', hookIsLive: false })).toBe(
+ 'claude'
+ )
+ })
+
+ it('lets a live hook outrank a stale launch record', () => {
+ expect(resolve({ launchAgent: 'claude', hookAgent: 'codex', hookIsLive: true })).toBe('codex')
+ })
+
+ it('lets the live foreground process outrank a stale launch record', () => {
+ expect(resolve({ launchAgent: 'claude', foregroundAgent: 'codex' })).toBe('codex')
+ })
+ })
+})
diff --git a/src/shared/published-pane-agent-identity.ts b/src/shared/published-pane-agent-identity.ts
new file mode 100644
index 00000000000..a00864a3b3a
--- /dev/null
+++ b/src/shared/published-pane-agent-identity.ts
@@ -0,0 +1,61 @@
+import { collectAgentTitleEvidence } from './agent-title-evidence'
+import { resolvePaneAgentIdentity } from './pane-agent-identity-resolver'
+import type { TuiAgent } from './tui-agent'
+
+/**
+ * The agent a host publishes for a pane, for consumers that ACT on identity.
+ *
+ * Kept out of the runtime class so it can be tested without one, and so routing, delivery and the
+ * UI all read the same decision instead of each re-deriving it.
+ *
+ * Hook evidence ranks first because the agent reports its own identity regardless of how it was
+ * started — Orca's launcher, a shell prompt, or a resumed session — and regardless of host.
+ *
+ * Title ranks LAST and is a genuine last resort, not forbidden. An earlier revision refused it
+ * outright for action consumers, reasoning that a title must never authorize a write. That
+ * conflated the parser with the raw substring match it replaced: `collectAgentTitleEvidence`
+ * returns null on exactly the shapes that caused misdelivery — "Review the Claude session-history
+ * fix" on a Codex pane yields nothing, and "Switch Claude and Codex off the load balancer… - grok"
+ * yields grok from its owner suffix. Refusing it instead cost real panes their identity: an agent
+ * a user started by hand inside an Orca WSL terminal has no launch record, no readable foreground
+ * process (the Windows host sees `wsl.exe`), and — until managed Codex hooks install there — no
+ * hook either, leaving a title that names it unambiguously as the only thing left.
+ *
+ * Returns undefined when nothing is known, and absence is published as absence. A caller that
+ * authorizes an action must fail closed on it rather than falling back to parsing the title.
+ */
+export function resolvePublishedPaneAgentIdentity(args: {
+ /**
+ * The agent a provider hook reported for this pane. The ONLY signal that does not depend on how
+ * the agent was started: a user who types `claude` at a shell still posts hooks. It is also the
+ * only one that survives WSL, where the Windows host reads the foreground process as `wsl.exe`
+ * rather than the agent running inside the distro.
+ */
+ hookAgent?: TuiAgent | null
+ /** Whether that hook belongs to a turn in progress, as opposed to one that finished. */
+ hookIsLive?: boolean
+ launchAgent?: TuiAgent | null
+ foregroundAgent?: TuiAgent | null
+ title?: string | null
+}): TuiAgent | undefined {
+ const titleAgent = args.title ? collectAgentTitleEvidence(args.title).agent : null
+ return (
+ resolvePaneAgentIdentity({
+ evidence: [
+ ...(args.hookAgent
+ ? [
+ {
+ source: args.hookIsLive ? ('live-hook' as const) : ('completed-hook' as const),
+ agent: args.hookAgent
+ }
+ ]
+ : []),
+ ...(args.foregroundAgent
+ ? [{ source: 'process' as const, agent: args.foregroundAgent }]
+ : []),
+ ...(args.launchAgent ? [{ source: 'launch' as const, agent: args.launchAgent }] : []),
+ ...(titleAgent ? [{ source: 'title' as const, agent: titleAgent }] : [])
+ ]
+ }).agent ?? undefined
+ )
+}
diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts
index 02eb5df9ebd..8e889129949 100644
--- a/src/shared/runtime-terminal-contracts.ts
+++ b/src/shared/runtime-terminal-contracts.ts
@@ -26,6 +26,8 @@ export type RuntimeTerminalSummary = {
writable: boolean
lastOutputAt: number | null
preview: string
+ /** Host-resolved agent identity for action consumers; absent when unknown or unsupported. */
+ agentIdentity?: TuiAgent
/** Absent while running or when the host predates the field; never infer a clean finish. */
exitCause?: TerminalExitCause
/** Absent when the host predates the field or could not name the execution host. */
From 81ae98e10d88484470ca0f1257c8eeddb133df6a Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:02:38 -0700
Subject: [PATCH 04/11] fix(mobile): honor host worktree create retention
(#16342)
* fix(mobile): honor host worktree create retention
* fix(mobile): cover malformed worktree retention policy
* fix(mobile): fail closed on malformed retention policy
* fix(mobile): fail closed on missing dedupe ttl
---
.../use-new-workspace-create-submit.ts | 7 +-
.../src/tasks/blank-workspace-create.test.ts | 17 ++-
mobile/src/tasks/blank-workspace-create.ts | 5 +-
.../src/tasks/source-workspace-create.test.ts | 6 +-
mobile/src/tasks/source-workspace-create.ts | 17 +--
.../tasks/worktree-create-capability.test.ts | 132 +++++++++++++++--
.../src/tasks/worktree-create-capability.ts | 36 +++--
.../worktree-create-idempotency-policy.ts | 40 ++++++
.../src/tasks/worktree-create-retry.test.ts | 136 +++++++++++++-----
mobile/src/tasks/worktree-create-retry.ts | 45 +++---
src/main/runtime/orca-runtime.test.ts | 1 +
src/main/runtime/orca-runtime.ts | 10 +-
.../worktree-create-retry-policy.ts | 7 -
src/shared/protocol-version.ts | 3 +-
src/shared/runtime-session-contracts.ts | 4 +
15 files changed, 356 insertions(+), 110 deletions(-)
create mode 100644 mobile/src/tasks/worktree-create-idempotency-policy.ts
diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts
index 14d9f9bfae0..1ab3ac5d5c4 100644
--- a/mobile/src/components/use-new-workspace-create-submit.ts
+++ b/mobile/src/components/use-new-workspace-create-submit.ts
@@ -16,6 +16,7 @@ import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
+import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy'
import {
pickPreferredNewWorktreeAgent,
type NewWorktreeAgentOption,
@@ -54,7 +55,7 @@ export function useNewWorkspaceCreateSubmit(args: {
runSetup: boolean
trustedOrcaHooks: PersistedTrustedOrcaHooks
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
- getWorktreeCreateCutoverSupport: () => Promise
+ getWorktreeCreateCutoverSupport: () => Promise
transitionDrawer: (view: Exclude) => void
setError: Dispatch>
onCreated: (worktreeId: string, name: string) => void
@@ -163,7 +164,7 @@ export function useNewWorkspaceCreateSubmit(args: {
workspaceName: trimmedName || undefined,
note: trimmedNote,
nameIsAutoManaged: args.composer.isNameAutoManaged,
- supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
+ worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
})
: await createBlankWorkspace({
client,
@@ -173,7 +174,7 @@ export function useNewWorkspaceCreateSubmit(args: {
createdWithAgentId,
comment: trimmedNote,
setupDecision,
- supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
+ worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
})
if ('error' in result) {
args.setError(result.error)
diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts
index 32e9e162ef4..1d3cf2c15eb 100644
--- a/mobile/src/tasks/blank-workspace-create.test.ts
+++ b/mobile/src/tasks/blank-workspace-create.test.ts
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createBlankWorkspace } from './blank-workspace-create'
+import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
type Call = { method: string; params: unknown }
+const IDEMPOTENT_CREATE_SUPPORT = {
+ dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
+}
+
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
@@ -35,7 +40,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
- supportsIdempotentCutoverRetry: true
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' })
@@ -70,7 +75,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: true,
- supportsIdempotentCutoverRetry: true
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true })
@@ -90,7 +95,7 @@ describe('createBlankWorkspace', () => {
comment: 'spike',
setupDecision: 'run',
nameWasGenerated: false,
- supportsIdempotentCutoverRetry: true
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
const params = calls[0]?.params as Record
@@ -122,7 +127,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
- supportsIdempotentCutoverRetry: true
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' })
@@ -148,7 +153,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
- supportsIdempotentCutoverRetry: true
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' })
@@ -167,7 +172,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'skip',
nameWasGenerated: false,
- supportsIdempotentCutoverRetry: true
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ error: 'SSH connection is not available' })
diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts
index d81d75d5a69..77d0dccc4d3 100644
--- a/mobile/src/tasks/blank-workspace-create.ts
+++ b/mobile/src/tasks/blank-workspace-create.ts
@@ -1,6 +1,7 @@
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { RpcClient } from '../transport/rpc-client'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
+import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
import {
agentLaunchCreateFields,
type WorkspaceCreateSetupDecision
@@ -19,13 +20,13 @@ export async function createBlankWorkspace(args: {
/** True when `baseName` is a generated creature name rather than one the user typed; only then
* may the host retire it. */
nameWasGenerated: boolean
- supportsIdempotentCutoverRetry: boolean | Promise
+ worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise {
return createWorktreeWithNameRetry({
client: args.client,
baseName: args.baseName,
nameWasGenerated: args.nameWasGenerated,
- supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
+ worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (name) => {
const params: Record = {
repo: `id:${args.repoId}`,
diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts
index ba1ddc4b300..66974f63443 100644
--- a/mobile/src/tasks/source-workspace-create.test.ts
+++ b/mobile/src/tasks/source-workspace-create.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createWorkspaceFromComposerSource } from './source-workspace-create'
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
+import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
type Call = { method: string; params: Record }
@@ -24,6 +25,9 @@ function fakeClient(handle: (method: string, call: number) => unknown, calls: Ca
}
const agent = { choice: 'blank' as const }
+const IDEMPOTENT_CREATE_SUPPORT = {
+ dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
+}
const baseArgs = {
targetRepoId: 'repo-1',
@@ -31,7 +35,7 @@ const baseArgs = {
agent,
workspaceName: undefined,
note: undefined,
- supportsIdempotentCutoverRetry: true
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
}
describe('createWorkspaceFromComposerSource', () => {
diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts
index 53b6a2bd03f..53e13e68ea3 100644
--- a/mobile/src/tasks/source-workspace-create.ts
+++ b/mobile/src/tasks/source-workspace-create.ts
@@ -13,6 +13,7 @@ import {
type WorkspaceCreateTaskItem
} from './workspace-create-params'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
+import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
// The agent bundle the modal resolved: `choice` drives launch resolution — the
// host applies the agent's launch args (permission flags) and shell quoting.
@@ -29,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = {
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
- supportsIdempotentCutoverRetry: boolean | Promise
+ worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}
export async function createWorkspaceFromComposerSource(
@@ -92,7 +93,7 @@ async function createWorkItemWorkspace(args: {
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
- supportsIdempotentCutoverRetry: boolean | Promise
+ worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const item = selection.item
@@ -137,7 +138,7 @@ async function createWorkItemWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
- supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
+ worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (name) => ({ ...params, name })
})
}
@@ -150,7 +151,7 @@ async function createBranchWorkspace(args: {
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
- supportsIdempotentCutoverRetry: boolean | Promise
+ worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
@@ -174,7 +175,7 @@ async function createBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
- supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
+ worktreeCreateIdempotency: args.worktreeCreateIdempotency,
maxAttempts: 1,
buildParams: (name) =>
applyCommon({
@@ -196,7 +197,7 @@ async function createBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
- supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
+ worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (candidate) => {
const params: Record = {
repo: `id:${targetRepoId}`,
@@ -220,7 +221,7 @@ async function createNewBranchWorkspace(args: {
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
- supportsIdempotentCutoverRetry: boolean | Promise
+ worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise {
const { client, selection, targetRepoId, setupDecision, agent, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
@@ -232,7 +233,7 @@ async function createNewBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName: selection.branchName,
- supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
+ worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (candidate) => {
const params: Record = {
repo: `id:${targetRepoId}`,
diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts
index 8b1e55efcae..cad108e29d7 100644
--- a/mobile/src/tasks/worktree-create-capability.test.ts
+++ b/mobile/src/tasks/worktree-create-capability.test.ts
@@ -2,8 +2,21 @@ import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
+import {
+ WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS,
+ WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
+} from './worktree-create-idempotency-policy'
-function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient {
+type StatusOutcome =
+ | 'cutover'
+ | 'error'
+ | string[]
+ | {
+ capabilities?: string[]
+ worktreeCreateIdempotency?: unknown
+ }
+
+function statusClient(outcomes: StatusOutcome[]): RpcClient {
let call = 0
return {
sendRequest: async () => {
@@ -15,10 +28,13 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien
if (outcome === 'error') {
throw new Error('offline')
}
+ const result = Array.isArray(outcome)
+ ? { capabilities: outcome, hostPlatform: 'darwin' }
+ : { ...outcome, hostPlatform: 'darwin' }
return {
id: '1',
ok: true,
- result: { capabilities: outcome, hostPlatform: 'darwin' },
+ result,
_meta: { runtimeId: 'r' }
}
}
@@ -29,23 +45,123 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
it('reads task and idempotent-create support from status.get', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
- statusClient([['mobile.tasks.v1', 'worktree.create-idempotency.v1']])
+ statusClient([
+ {
+ capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'],
+ worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }
+ }
+ ])
)
).resolves.toEqual({
tasksSupported: true,
- idempotentWorktreeCreateSupported: true,
+ worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
hostPlatform: 'darwin'
})
})
- it('retries the safe status probe after a connection cutover', async () => {
+ it('uses the bounded fallback for an old idempotent host without an advertisement', async () => {
+ await expect(
+ readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']]))
+ ).resolves.toEqual({
+ tasksSupported: false,
+ worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS },
+ hostPlatform: 'darwin'
+ })
+ })
+
+ it('fails closed when a valid idempotency container omits dedupeTtlMs', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
- statusClient(['cutover', ['worktree.create-idempotency.v1']])
+ statusClient([
+ {
+ capabilities: ['worktree.create-idempotency.v1'],
+ worktreeCreateIdempotency: {}
+ }
+ ])
)
).resolves.toEqual({
tasksSupported: false,
- idempotentWorktreeCreateSupported: true,
+ worktreeCreateIdempotency: { dedupeTtlMs: 0 },
+ hostPlatform: 'darwin'
+ })
+ })
+
+ it.each([
+ { label: 'null', value: null },
+ { label: 'string', value: 'nonsense' },
+ { label: 'number', value: 20_000 },
+ { label: 'empty array', value: [] }
+ ])(
+ 'fails closed for a malformed idempotency container ($label)',
+ async ({ value: worktreeCreateIdempotency }) => {
+ await expect(
+ readNewWorktreeRuntimeCapabilities(
+ statusClient([
+ {
+ capabilities: ['worktree.create-idempotency.v1'],
+ worktreeCreateIdempotency
+ }
+ ])
+ )
+ ).resolves.toEqual({
+ tasksSupported: false,
+ worktreeCreateIdempotency: { dedupeTtlMs: 0 },
+ hostPlatform: 'darwin'
+ })
+ }
+ )
+
+ it('clamps an over-large host advertisement to the client fallback ceiling', async () => {
+ await expect(
+ readNewWorktreeRuntimeCapabilities(
+ statusClient([
+ {
+ capabilities: ['worktree.create-idempotency.v1'],
+ worktreeCreateIdempotency: { dedupeTtlMs: 600_000 }
+ }
+ ])
+ )
+ ).resolves.toEqual({
+ tasksSupported: false,
+ worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS },
+ hostPlatform: 'darwin'
+ })
+ })
+
+ it.each(['60000', -1, Number.NaN, 60_000.5, {}])(
+ 'fails closed for malformed host advertisement %j',
+ async (advertisedDedupeTtlMs) => {
+ await expect(
+ readNewWorktreeRuntimeCapabilities(
+ statusClient([
+ {
+ capabilities: ['worktree.create-idempotency.v1'],
+ worktreeCreateIdempotency: { dedupeTtlMs: advertisedDedupeTtlMs }
+ }
+ ])
+ )
+ ).resolves.toEqual({
+ tasksSupported: false,
+ worktreeCreateIdempotency: { dedupeTtlMs: 0 },
+ hostPlatform: 'darwin'
+ })
+ }
+ )
+
+ it('retries the safe status probe after a connection cutover', async () => {
+ await expect(
+ readNewWorktreeRuntimeCapabilities(
+ statusClient([
+ 'cutover',
+ {
+ capabilities: ['worktree.create-idempotency.v1'],
+ worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }
+ }
+ ])
+ )
+ ).resolves.toEqual({
+ tasksSupported: false,
+ worktreeCreateIdempotency: { dedupeTtlMs: 30_000 },
hostPlatform: 'darwin'
})
})
@@ -53,7 +169,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
it('fails closed when capability detection is unavailable', async () => {
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
tasksSupported: false,
- idempotentWorktreeCreateSupported: false,
+ worktreeCreateIdempotency: false,
hostPlatform: null
})
})
diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts
index 6083120c7a4..c4ca8170312 100644
--- a/mobile/src/tasks/worktree-create-capability.ts
+++ b/mobile/src/tasks/worktree-create-capability.ts
@@ -4,6 +4,11 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
import type { RpcSuccess } from '../transport/types'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
+import {
+ WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
+ resolveWorktreeCreateIdempotencySupport,
+ type WorktreeCreateIdempotencySupport
+} from './worktree-create-idempotency-policy'
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
// replay an ambiguous create unless the host advertises idempotency support.
@@ -14,13 +19,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5
export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
- idempotentWorktreeCreateSupported: boolean
+ worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
hostPlatform: NodeJS.Platform | null
}
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
tasksSupported: false,
- idempotentWorktreeCreateSupported: false,
+ worktreeCreateIdempotency: false,
hostPlatform: null
}
@@ -35,13 +40,28 @@ export async function readNewWorktreeRuntimeCapabilities(
if (!response.ok) {
return UNSUPPORTED_CAPABILITIES
}
- const result = (response as RpcSuccess).result as { capabilities?: string[] }
+ const result = (response as RpcSuccess).result as {
+ capabilities?: string[]
+ worktreeCreateIdempotency?: unknown
+ }
const capabilities = result.capabilities ?? []
+ const supportsIdempotency = capabilities.includes(
+ MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
+ )
+ const advertisedIdempotency = result.worktreeCreateIdempotency
return {
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
- idempotentWorktreeCreateSupported: capabilities.includes(
- MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
- ),
+ worktreeCreateIdempotency: supportsIdempotency
+ ? advertisedIdempotency === undefined
+ ? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
+ : advertisedIdempotency !== null &&
+ typeof advertisedIdempotency === 'object' &&
+ !Array.isArray(advertisedIdempotency)
+ ? resolveWorktreeCreateIdempotencySupport(
+ (advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs
+ )
+ : { dedupeTtlMs: 0 }
+ : false,
hostPlatform: readMobileRuntimeHostPlatform(result)
}
} catch (error) {
@@ -58,7 +78,7 @@ export function useNewWorktreeRuntimeCapabilities(
): {
tasksSupported: boolean
hostPlatform: NodeJS.Platform | null
- getWorktreeCreateCutoverSupport: () => Promise
+ getWorktreeCreateCutoverSupport: () => Promise
} {
const [tasksSupported, setTasksSupported] = useState(false)
const [hostPlatform, setHostPlatform] = useState(null)
@@ -97,7 +117,7 @@ export function useNewWorktreeRuntimeCapabilities(
}, [client, enabled, getCapabilities, setTasksSupported])
const getWorktreeCreateCutoverSupport = useCallback(
- () => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
+ () => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency),
[getCapabilities]
)
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
diff --git a/mobile/src/tasks/worktree-create-idempotency-policy.ts b/mobile/src/tasks/worktree-create-idempotency-policy.ts
new file mode 100644
index 00000000000..797b62305e0
--- /dev/null
+++ b/mobile/src/tasks/worktree-create-idempotency-policy.ts
@@ -0,0 +1,40 @@
+// Why: old idempotent hosts omit policy, so preserve today's effective replay window.
+export const WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS = 60_000
+
+// Why: a client must not trust a host advertisement beyond its tested ceiling.
+export const WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS = 60_000
+
+// Why: the replay still has to reach the host before its dedupe record expires.
+const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
+
+export type WorktreeCreateIdempotencySupport = {
+ dedupeTtlMs: number
+}
+
+export type WorktreeCreateIdempotencyProbe =
+ | WorktreeCreateIdempotencySupport
+ | false
+ | Promise
+
+export function resolveWorktreeCreateIdempotencySupport(
+ advertisedDedupeTtlMs: unknown
+): WorktreeCreateIdempotencySupport {
+ if (advertisedDedupeTtlMs === undefined) {
+ return { dedupeTtlMs: 0 }
+ }
+ if (
+ typeof advertisedDedupeTtlMs !== 'number' ||
+ !Number.isSafeInteger(advertisedDedupeTtlMs) ||
+ advertisedDedupeTtlMs < 0
+ ) {
+ // Truthy support preserves immediate cutover retries while disabling ambiguous replay.
+ return { dedupeTtlMs: 0 }
+ }
+ return {
+ dedupeTtlMs: Math.min(advertisedDedupeTtlMs, WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS)
+ }
+}
+
+export function getWorktreeCreateReplayWindowMs(support: WorktreeCreateIdempotencySupport): number {
+ return Math.max(0, support.dedupeTtlMs - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS)
+}
diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts
index 52d6d7b9782..b4463207ebe 100644
--- a/mobile/src/tasks/worktree-create-retry.test.ts
+++ b/mobile/src/tasks/worktree-create-retry.test.ts
@@ -3,16 +3,24 @@ import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { ConnectionState } from '../transport/types'
-import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
+import {
+ getWorktreeCreateReplayWindowMs,
+ WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
+ type WorktreeCreateIdempotencySupport
+} from './worktree-create-idempotency-policy'
import {
createWorktreeWithNameRetry,
- WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS,
- WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
+ WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS
} from './worktree-create-retry'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
type Attempt = { method: string; params: Record }
+const IDEMPOTENT_CREATE_SUPPORT = {
+ dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
+}
+const LEGACY_HOST_REPLAY_WINDOW_MS = getWorktreeCreateReplayWindowMs(IDEMPOTENT_CREATE_SUPPORT)
+
// Drives the transport state a replay has to wait on. Production couples the two:
// a socket close rejects the pending frame AND drops the client off 'connected'.
function connectionController(): {
@@ -106,21 +114,21 @@ describe('createWorktreeWithNameRetry', () => {
it('waits for capability detection before sending a create', async () => {
const attempts: Attempt[] = []
const client = scriptedClient([{ id: 'wt-ready' }], attempts)
- let resolveSupport!: (supported: boolean) => void
- const support = new Promise((resolve) => {
+ let resolveSupport!: (supported: WorktreeCreateIdempotencySupport) => void
+ const support = new Promise((resolve) => {
resolveSupport = resolve
})
const pending = createWorktreeWithNameRetry({
client,
baseName: 'puffin',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: support,
+ worktreeCreateIdempotency: support,
mintMutationId: () => 'key-ready'
})
await Promise.resolve()
expect(attempts).toHaveLength(0)
- resolveSupport(true)
+ resolveSupport(IDEMPOTENT_CREATE_SUPPORT)
await expect(pending).resolves.toEqual({ worktreeId: 'wt-ready', name: 'puffin' })
expect(attempts).toHaveLength(1)
@@ -134,7 +142,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'otter',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-1'
})
expect(result).toEqual({ worktreeId: 'wt-1', name: 'otter' })
@@ -152,7 +160,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'seal',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-mig'
})
expect(result).toEqual({ worktreeId: 'wt-2', name: 'seal' })
@@ -172,7 +180,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'crab',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-x'
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
@@ -190,7 +198,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'eel',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-t'
})
).rejects.toThrow('Request timed out')
@@ -208,7 +216,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'topic',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => `key-${(n += 1)}`
})
expect(result).toEqual({ worktreeId: 'wt-3', name: 'topic-2' })
@@ -231,7 +239,7 @@ describe('createWorktreeWithNameRetry', () => {
baseName: 'nautilus-2',
nameWasGenerated: true,
buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }),
- supportsIdempotentCutoverRetry: false
+ worktreeCreateIdempotency: false
})
expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' })
@@ -246,7 +254,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'ray',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: false,
+ worktreeCreateIdempotency: false,
mintMutationId: () => 'must-not-be-used'
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
@@ -273,7 +281,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-drop'
})
@@ -301,7 +309,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: false,
+ worktreeCreateIdempotency: false,
mintMutationId: () => 'must-not-be-used'
})
).rejects.toThrow('Connection interrupted')
@@ -309,6 +317,32 @@ describe('createWorktreeWithNameRetry', () => {
expect(attempts[0]!.params.clientMutationId).toBeUndefined()
})
+ it('disables ambiguous replay for a malformed advertisement', async () => {
+ const attempts: Attempt[] = []
+ const connection = connectionController()
+ const client = scriptedClient(
+ [
+ {
+ throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
+ dropsConnection: true
+ }
+ ],
+ attempts,
+ connection
+ )
+ await expect(
+ createWorktreeWithNameRetry({
+ client,
+ baseName: 'limpet',
+ buildParams: (name) => ({ repo: 'id:r', name }),
+ worktreeCreateIdempotency: { dedupeTtlMs: 0 },
+ mintMutationId: () => 'key-malformed'
+ })
+ ).rejects.toThrow('Connection interrupted')
+ expect(attempts).toHaveLength(1)
+ expect(attempts[0]!.params.clientMutationId).toBe('key-malformed')
+ })
+
it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => {
vi.useFakeTimers()
try {
@@ -331,21 +365,21 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'barnacle',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-budget'
}).catch((error: unknown) => {
settledAt = Date.now()
throw error
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
- await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS)
+ await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
await settled
// Initial attempt + 2 replays.
expect(attempts).toHaveLength(3)
// The budget is a count, but what keeps a replay reconciling instead of building
// a second worktree is wall clock: every attempt has to land while the host still
// holds the record, with the watchdog's detection latency already deducted.
- expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
+ expect(settledAt - startedAt).toBeLessThanOrEqual(LEGACY_HOST_REPLAY_WINDOW_MS)
} finally {
vi.useRealTimers()
}
@@ -373,7 +407,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'anemone',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-stuck'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
@@ -388,6 +422,41 @@ describe('createWorktreeWithNameRetry', () => {
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
+ it('stops replaying when the host-advertised dedupe record has expired', async () => {
+ vi.useFakeTimers()
+ try {
+ const attempts: Attempt[] = []
+ const connection = connectionController()
+ const client = scriptedClient(
+ [
+ {
+ throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
+ dropsConnection: true,
+ takesMs: 12_000,
+ reconnectsAfterMs: 1_000
+ },
+ { id: 'wt-duplicate' }
+ ],
+ attempts,
+ connection
+ )
+
+ const pending = createWorktreeWithNameRetry({
+ client,
+ baseName: 'mussel',
+ buildParams: (name) => ({ repo: 'id:r', name }),
+ worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
+ mintMutationId: () => 'key-short-ttl'
+ })
+ const settled = expect(pending).rejects.toThrow('Connection interrupted')
+ await vi.advanceTimersByTimeAsync(30_000)
+ await settled
+ expect(attempts).toHaveLength(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => {
vi.useFakeTimers()
try {
@@ -415,7 +484,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'cuttlefish',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-expired'
})
const settled = expect(pending).rejects.toThrow('Request timed out')
@@ -464,19 +533,19 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'nautilus',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-clamped'
}).catch((error: unknown) => {
settledAt = Date.now()
throw error
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
- await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
+ await vi.advanceTimersByTimeAsync(LEGACY_HOST_REPLAY_WINDOW_MS)
await settled
expect(attempts).toHaveLength(2)
// The window is anchored at the FIRST ambiguity, so the second wait gets only the
// remainder — it must not restart a full wait and carry the replay past the record.
- expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
+ expect(settledAt - startedAt).toBe(LEGACY_HOST_REPLAY_WINDOW_MS)
} finally {
vi.useRealTimers()
}
@@ -509,7 +578,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-stale'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
@@ -551,7 +620,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'kelp',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-long'
})
await vi.advanceTimersByTimeAsync(250_000)
@@ -606,7 +675,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-anchored'
})
const settled = expect(pending).rejects.toThrow('Connection lost')
@@ -638,7 +707,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
- supportsIdempotentCutoverRetry: true,
+ worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-unmarked'
})
).rejects.toThrow('Socket closed before send')
@@ -649,11 +718,10 @@ describe('createWorktreeWithNameRetry', () => {
// The window is measured from a lower bound on when the host could have resolved, so
// it has to leave the record room for the replay to still be in flight. Widening it
// to the whole TTL would let the last replay land on a record that just expired.
- expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0)
- expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS)
- // A single wait must not be able to consume the window on its own.
- expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan(
- WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
- )
+ expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeGreaterThan(0)
+ expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
+ // A reconnect wait is clamped when a short advertisement leaves less time.
+ const shortReplayWindowMs = getWorktreeCreateReplayWindowMs({ dedupeTtlMs: 20_000 })
+ expect(Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, shortReplayWindowMs)).toBe(10_000)
})
})
diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts
index 7bacc95aa72..971dfa759cf 100644
--- a/mobile/src/tasks/worktree-create-retry.ts
+++ b/mobile/src/tasks/worktree-create-retry.ts
@@ -7,10 +7,14 @@ import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
getGeneratedWorktreeCreateRetryCandidate,
- isRetryableWorktreeCreateConflict,
- WORKTREE_CREATE_DEDUPE_TTL_MS
+ isRetryableWorktreeCreateConflict
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
+import {
+ getWorktreeCreateReplayWindowMs,
+ type WorktreeCreateIdempotencyProbe,
+ type WorktreeCreateIdempotencySupport
+} from './worktree-create-idempotency-policy'
// Why: server-side collision checks (branch already exists locally / on a remote
// / already has PR #N) can fire even after a pre-flight basename dedupe —
@@ -36,17 +40,6 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
// finished the worktree. Replay on the same clientMutationId instead.
const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2
-// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS
-// after it resolves; a replay that lands later misses it and the host's suffix loop
-// builds a SECOND worktree — and for folder workspaces a second one with the SAME name
-// and no collision check at all. So a replay is bounded by how stale our knowledge of the
-// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while
-// the app is backgrounded, so any ceiling derived from timer intervals — a watchdog probe
-// budget, a request timeout — can be arbitrarily wrong across a background cycle, which is
-// exactly when a create sits ambiguous for minutes.
-const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
-export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS =
- WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS
// Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it.
export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000
@@ -55,7 +48,7 @@ export type CreateWorktreeWithNameRetryArgs = {
baseName: string
nameWasGenerated?: boolean
buildParams: (name: string) => Record
- supportsIdempotentCutoverRetry: boolean | Promise
+ worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
maxAttempts?: number
// Injected in tests; production mints a fresh idempotency key per candidate.
mintMutationId?: () => string
@@ -72,7 +65,7 @@ export async function createWorktreeWithNameRetry(
const { client, baseName, buildParams } = args
// Why: creating before status.get settles would silently disable safe replay
// during the exact slow-network window this path is meant to recover from.
- const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry
+ const worktreeCreateIdempotency = await args.worktreeCreateIdempotency
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
let lastError: string | null = null
@@ -84,14 +77,10 @@ export async function createWorktreeWithNameRetry(
// Why: older hosts strip unknown fields, so only stamp and replay when the
// host advertises idempotency. One key per candidate makes cutover retries
// safe while a name-collision bump remains a genuinely new create.
- const params = supportsIdempotentCutoverRetry
+ const params = worktreeCreateIdempotency
? { ...candidateParams, clientMutationId: mintMutationId() }
: candidateParams
- const response = await sendWorktreeCreateResilient(
- client,
- params,
- supportsIdempotentCutoverRetry
- )
+ const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency)
if (response.ok) {
const result = (response as RpcSuccess).result as { worktree: { id: string } }
return { worktreeId: result.worktree.id, name: candidateName }
@@ -112,7 +101,7 @@ export async function createWorktreeWithNameRetry(
async function sendWorktreeCreateResilient(
client: RpcClient,
params: Record,
- supportsIdempotentCutoverRetry: boolean
+ worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
): Promise {
let migrationRetry = 0
let ambiguousRetry = 0
@@ -124,7 +113,7 @@ async function sendWorktreeCreateResilient(
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
})
} catch (error) {
- if (!supportsIdempotentCutoverRetry) {
+ if (!worktreeCreateIdempotency) {
throw error
}
if (isLogicalClientCutoverError(error)) {
@@ -152,7 +141,7 @@ async function sendWorktreeCreateResilient(
}
// Computed once: a later ambiguity reads a fresher lastInboundAt from the
// replacement session, which would push the deadline past the record it respects.
- replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt)
+ replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt, worktreeCreateIdempotency)
const remainingWindowMs = replayDeadlineAt - Date.now()
if (remainingWindowMs <= 0) {
throw error
@@ -179,10 +168,14 @@ async function sendWorktreeCreateResilient(
// so it stays honest across a suspension in a way a timer budget cannot; the send is the
// fallback, and a sound floor either way, because the host cannot resolve a create it has
// not yet received.
-function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number {
+function resolveReplayDeadline(
+ client: RpcClient,
+ firstSentAt: number,
+ support: WorktreeCreateIdempotencySupport
+): number {
const lastInboundAt = client.getLastInboundAt?.() ?? null
const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt
- return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
+ return anchor + getWorktreeCreateReplayWindowMs(support)
}
function defaultWorktreeCreateMutationId(): string {
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index b8b12bf2b1a..7747797de4f 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -2098,6 +2098,7 @@ describe('OrcaRuntimeService', () => {
expect(status.capabilities).toContain('mobile.tasks.v1')
expect(status.capabilities).toContain('terminal.quick-commands.v1')
expect(status.capabilities).toContain('worktree.create-idempotency.v1')
+ expect(status.worktreeCreateIdempotency).toEqual({ dedupeTtlMs: 60_000 })
expect(status.capabilities).toContain('files.mutation-ownership.v1')
expect(status.capabilities).toContain('project-host-setup.v1')
expect(status.capabilities).toContain('linear.issue-attribute-filter.v1')
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index f7807df3f16..e5cf999e4e4 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -1148,7 +1148,6 @@ import {
isGeneratedWorktreeCreateName,
WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS
} from '../worktree-create-candidates'
-import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../shared/new-workspace/worktree-create-retry-policy'
import {
failedWorktreeCreationNeedsRetirement,
getRetiredNameRegistryForRepo,
@@ -2133,11 +2132,9 @@ function getAgentLaunchPlatformForRepo(
// Why: long enough for a phone to reconnect and retry a create whose response
// was lost, short enough that an intentional later re-resume forks fresh.
const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000
-// Why: same idempotency window for worktree.create — a phone whose create was
-// interrupted by a connection migration retries with the same clientMutationId
-// and reuses the just-created worktree instead of spawning a duplicate. Shared with
-// the mobile client so its replay budget is sized against the real window.
-const WORKTREE_CREATE_RESULT_TTL_MS = WORKTREE_CREATE_DEDUPE_TTL_MS
+// Why: a phone whose create was interrupted retries with the same clientMutationId
+// and reuses the just-created worktree instead of spawning a duplicate.
+const WORKTREE_CREATE_RESULT_TTL_MS = 60_000
const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150
const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
@@ -6517,6 +6514,7 @@ export class OrcaRuntimeService {
// must not treat browser panes as supported just because runtime RPC is up.
capabilities,
...(degradations.length > 0 ? { degradations } : {}),
+ worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS },
hostPlatform: process.platform,
terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null,
floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false,
diff --git a/src/shared/new-workspace/worktree-create-retry-policy.ts b/src/shared/new-workspace/worktree-create-retry-policy.ts
index 2d3438b0218..e45da0d5530 100644
--- a/src/shared/new-workspace/worktree-create-retry-policy.ts
+++ b/src/shared/new-workspace/worktree-create-retry-policy.ts
@@ -65,10 +65,3 @@ export function getGeneratedWorktreeCreateRetryCandidate(
export function isRetryableWorktreeCreateConflict(message: string): boolean {
return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message))
}
-
-// Why: the host drops a *settled* worktree.create dedupe record this long after it
-// resolves, so a client replaying an idempotent create must land inside the window
-// or the replay misses the record and the host's suffix loop builds a second
-// worktree. Shared so the client's replay budget can be asserted against it instead
-// of assuming a value that lives in another process.
-export const WORKTREE_CREATE_DEDUPE_TTL_MS = 60_000
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index bfd58f181e1..8f10558d7e5 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -97,7 +97,8 @@ export const TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY = 'terminal.paired-parki
// terminal creation, so mobile must hide Quick Commands unless both are present.
export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-commands.v1' as const
// Why: older hosts strip worktree.create's clientMutationId, so mobile must only
-// replay ambiguous cutovers when the host advertises idempotent create support.
+// replay ambiguous cutovers when the host advertises idempotent create support;
+// status.worktreeCreateIdempotency carries the optional host retention policy.
export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'worktree.create-idempotency.v1' as const
export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const
diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts
index 9d94980fcae..8bf41a9cdc5 100644
--- a/src/shared/runtime-session-contracts.ts
+++ b/src/shared/runtime-session-contracts.ts
@@ -70,6 +70,10 @@ export type RuntimeStatus = {
runtimeProtocolVersion?: number
minCompatibleRuntimeClientVersion?: number
capabilities?: RuntimeCapability[]
+ /** Optional policy for clients that negotiated worktree.create-idempotency.v1. */
+ worktreeCreateIdempotency?: {
+ dedupeTtlMs: number
+ }
/**
* Optional for mixed-version peers. Absence means the host predates structured
* degradation reporting, not that the host proved every optional feature available.
From 64f537f442aa2276a7bc605baafe6e01f6b13cf8 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:02:56 -0700
Subject: [PATCH 05/11] test(terminal): pin that Hangul is two cells under
every unicode provider (#15477)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* test(terminal): pin that Hangul is two cells under every unicode provider
#15192 turned out to be an upstream Antigravity CLI defect, but the
investigation re-litigated Orca's Hangul cell width three separate times
before ruling it out. This makes that negative result durable.
The first test closes a real gap rather than restating the others. Nothing
verified which provider actually ends up active in production:
pane-lifecycle.test.ts asserts activeVersion=11, but its terminal mock has no
_core, so activateOrcaTerminalUnicodeProvider can only ever take the fallback
branch there. This asserts on a real terminal, in pane-lifecycle's order, that
the Orca provider is reached.
The sweep matters because it is what makes the width theory unavailable
rather than merely unproven: all 11,172 precomposed syllables budget two
cells under v6, v11 and the Orca provider, against both wcwidth and the
packed charProperties bits. Even total activation failure leaves them wide.
Also pins the wide-cell test oracle's disagreement with xterm on conjoining
jamo U+1160..U+11FF. Only decomposed Korean reaches them and no fixture
writes NFD today, so nothing mis-asserts now - but a repaint test would
silently assert against a wrong oracle if anyone added NFD text.
Refs #15192
* test(terminal): drop the version-sensitivity list as redundant
Its output is a strict subset of the oracle-divergence test's, measured a
different way, for about one bit of information across twenty lines.
Refs #15192
* test(terminal): assert both widths in the Hangul oracle divergence, drop the pane-lifecycle-order claim
The oracle divergence only asserted that xterm and the fixture disagree, not how:
widening the fixture's jamo range from one cell to two left the expected list
byte-identical, so the tripwire it exists to be would not have fired. Record both
widths in the run key.
Test 1 claimed to pin pane-lifecycle's activation order, but it mirrors that order
rather than importing it — deleting the call at pane-lifecycle.ts:88 or moving it
before loadAddon leaves it green (pane-lifecycle.test.ts covers both). Retitled to
what it does pin: that xterm's live _core shape still reaches the non-fallback branch.
Also oxfmt.
---
.../hangul-cell-width-agreement.test.ts | 152 ++++++++++++++++++
1 file changed, 152 insertions(+)
create mode 100644 src/main/daemon/hangul-cell-width-agreement.test.ts
diff --git a/src/main/daemon/hangul-cell-width-agreement.test.ts b/src/main/daemon/hangul-cell-width-agreement.test.ts
new file mode 100644
index 00000000000..f8235068faa
--- /dev/null
+++ b/src/main/daemon/hangul-cell-width-agreement.test.ts
@@ -0,0 +1,152 @@
+/**
+ * #15192, activation-order hypothesis: "the Orca unicode provider is not reached
+ * in production, so Hangul lays out at the wrong width."
+ *
+ * These pin the measurements that close it. Every precomposed Hangul syllable is
+ * two cells under xterm's Unicode 6 tables, its Unicode 11 tables, and Orca's
+ * provider alike, so no activation order — provider, v11 fallback, or the
+ * untouched v6 default — can change how a syllable is budgeted. Whatever moves
+ * Korean text off its cells is not the unicode version.
+ *
+ * The version-sensitive and oracle-sensitive code points are pinned too, as the
+ * complete list of Hangul-block characters where a width disagreement is even
+ * available. None of them occur in modern Korean prose.
+ */
+import { describe, expect, it } from 'vitest'
+import { Terminal } from '@xterm/headless'
+import { Unicode11Addon } from '@xterm/addon-unicode11'
+import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
+import { isWideGlyph } from './__fixtures__/terminal-wide-cell-grid'
+
+const ORCA_UNICODE_VERSION = 'orca-11-zwj'
+const HANGUL_SYLLABLES_FIRST = 0xac00
+const HANGUL_SYLLABLES_LAST = 0xd7a3
+
+/** Jamo, Compatibility Jamo, Jamo Extended-A, Syllables + Extended-B, halfwidth jamo. */
+const HANGUL_BLOCKS: [number, number][] = [
+ [0x1100, 0x11ff],
+ [0x3130, 0x318f],
+ [0xa960, 0xa97f],
+ [0xac00, 0xd7ff],
+ [0xffa0, 0xffdc]
+]
+
+function* hangulCodePoints(): Generator {
+ for (const [first, last] of HANGUL_BLOCKS) {
+ for (let cp = first; cp <= last; cp += 1) {
+ yield cp
+ }
+ }
+}
+
+type UnicodeServiceInternals = {
+ versions: string[]
+ activeVersion: string
+ wcwidth(codepoint: number): number
+ charProperties(codepoint: number, preceding: number): number
+}
+
+/**
+ * Addon first, activation after — the order every call site uses. This mirrors that
+ * order rather than importing it, so it does not guard the call sites themselves;
+ * `pane-lifecycle.test.ts` covers that the activation runs and runs after the addon.
+ * What is unguarded, and what these cover, is that the real `_core.unicodeService`
+ * shape still lets the activation reach its non-fallback branch on a live terminal.
+ */
+function openWithUnicode11AddonLoaded(): {
+ terminal: Terminal
+ unicode: UnicodeServiceInternals
+} {
+ const terminal = new Terminal({ cols: 40, rows: 10, allowProposedApi: true })
+ terminal.loadAddon(new Unicode11Addon())
+ activateOrcaTerminalUnicodeProvider(terminal as never)
+ const unicode = (
+ terminal as unknown as {
+ _core: { unicodeService: UnicodeServiceInternals }
+ }
+ )._core.unicodeService
+ return { terminal, unicode }
+}
+
+/** Width bits of xterm's packed char properties — what the buffer actually budgets. */
+function propertyWidth(properties: number): number {
+ return (properties >> 1) & 3
+}
+
+/**
+ * Contiguous code points carrying the same detail collapse into one line. The detail
+ * is part of the run key so a changed width re-partitions the output instead of
+ * silently reusing a range that only ever asserted "these two disagree somehow".
+ */
+function summarize(entries: { codepoint: number; detail: string }[]): string[] {
+ const runs: { start: number; end: number; detail: string }[] = []
+ for (const { codepoint, detail } of entries) {
+ const last = runs.at(-1)
+ if (last && last.end === codepoint - 1 && last.detail === detail) {
+ last.end = codepoint
+ continue
+ }
+ runs.push({ start: codepoint, end: codepoint, detail })
+ }
+ const hex = (value: number): string => `U+${value.toString(16).toUpperCase().padStart(4, '0')}`
+ return runs.map(
+ ({ start, end, detail }) =>
+ `${start === end ? hex(start) : `${hex(start)}..${hex(end)}`} ${detail}`
+ )
+}
+
+describe('Hangul cell width agreement (#15192)', () => {
+ it('reaches the Orca provider, not the v11 fallback, on a live terminal', () => {
+ const { terminal, unicode } = openWithUnicode11AddonLoaded()
+ expect(unicode.versions).toContain(ORCA_UNICODE_VERSION)
+ expect(unicode.activeVersion).toBe(ORCA_UNICODE_VERSION)
+ terminal.dispose()
+ })
+
+ it('budgets every precomposed syllable at two cells under v6, v11 and Orca', () => {
+ const { terminal, unicode } = openWithUnicode11AddonLoaded()
+ const disagreeing: { codepoint: number; detail: string }[] = []
+ for (const version of ['6', '11', ORCA_UNICODE_VERSION]) {
+ unicode.activeVersion = version
+ for (let cp = HANGUL_SYLLABLES_FIRST; cp <= HANGUL_SYLLABLES_LAST; cp += 1) {
+ const wcwidth = unicode.wcwidth(cp)
+ const packed = propertyWidth(unicode.charProperties(cp, 0))
+ if (wcwidth !== 2 || packed !== 2) {
+ disagreeing.push({
+ codepoint: cp,
+ detail: `v${version} wcwidth=${wcwidth} packed=${packed}`
+ })
+ }
+ }
+ }
+ expect(summarize(disagreeing)).toEqual([])
+ terminal.dispose()
+ })
+
+ it('records where the wide-cell oracle diverges from xterm on conjoining jamo', () => {
+ const { terminal, unicode } = openWithUnicode11AddonLoaded()
+ const divergent: { codepoint: number; detail: string }[] = []
+ for (const cp of hangulCodePoints()) {
+ const oracle = isWideGlyph(String.fromCodePoint(cp)) ? 2 : 1
+ const xterm = unicode.wcwidth(cp)
+ if (xterm !== oracle) {
+ divergent.push({ codepoint: cp, detail: `oracle=${oracle} xterm=${xterm}` })
+ }
+ }
+ // Why pinned rather than fixed: xterm treats medial/final jamo as zero-width
+ // combining marks, the oracle as one cell — and `isWideGlyph` returns a boolean,
+ // so it cannot express zero-width without changing shape. Only decomposed (NFD)
+ // Korean reaches them and no fixture writes NFD today, so nothing mis-asserts;
+ // this is the tripwire for the fixture that adds it. U+3130/U+318F are unassigned
+ // Compatibility Jamo edges. U+A960..U+A97C is assigned Jamo Extended-A (Old Korean
+ // initials) that xterm makes wide and the oracle narrow — the dangerous direction,
+ // but no fixture uses it. Both widths are asserted so changing either trips this.
+ expect(summarize(divergent)).toEqual([
+ 'U+1160..U+11FF oracle=1 xterm=0',
+ 'U+3130 oracle=2 xterm=1',
+ 'U+318F oracle=2 xterm=1',
+ 'U+A960..U+A97C oracle=1 xterm=2'
+ ])
+ terminal.dispose()
+ })
+})
From 074339478a41c514819316b4f3575476908e0085 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:06:43 -0700
Subject: [PATCH 06/11] fix(terminal): compose iPadOS Hangul by holding the
syllable in the renderer (#13345) (#15480)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(terminal): compose iPadOS Hangul by holding the syllable in the renderer (#13345)
Korean typed on an iPad with a hardware keyboard reached the PTY as separate
jamo: `한글깨짐` arrived as `ㅎㅏㄴㄱㅡㄹ...`. iPadOS fires no composition
events for it — each jamo is a plain keydown while the IME rewrites the
syllable in place in the helper textarea — so xterm consumes the keydown, sends
the raw jamo from `_keyPress`, and drops the composed `insertText` because
`_inputEvent` admits a composed insert only when no key is down.
The jamo keydown is handed to the system by a new bypass rule, and the syllable
it builds is held in the renderer until the IME proves it final. The PTY sees
one write per syllable and nothing is ever sent then retracted, so raw-mode
TUIs never receive DEL bytes they need not read as "erase one cell" and SSH and
relay sessions pay no round trip for them. `한글깨짐` is four writes and zero
DELs.
Sitting upstream of `xterm-bypass-policy.ts` rather than inside xterm is what
makes this work for Shift-typed double consonants: Orca's own Shift rule
already hides `ㄲ ㄸ ㅃ ㅆ ㅉ` keydowns from xterm, so a fix living in
CompositionHelper never sees them and every syllable starting with one — 깨 꿈
딸 빵 쓰다 짜다 — stays broken. That placement call is dvpaa's, from #13346.
Composition sessions are left alone entirely, so Chinese pinyin on the same
device keeps working; that state is derived from the existing composition
tracker rather than latched, so a session that never ends cannot disable the
pane. The bypass claims jamo only — a Cyrillic or kana key would lose its
keydown, keypress and `input` alike and reach the PTY as nothing.
Co-authored-by: dvpaa <82706622+dvpaa@users.noreply.github.com>
* refactor(terminal): drop the dead session check from the iOS preedit input guard
`isCompositionOwnedInput(event) || options.isCompositionActive()` could never
take its second branch. The composition tracker's own `input` listener runs
first on the same element and clears its active flag for every input except
`insertCompositionText` — which is exactly the first disjunct. Verified by
construction: instrumented to throw on the combination, nothing in the renderer
suite (24k tests) reached it, and five deliberate attempts to build one, via a
resumed preedit and a post-compositionend insert, all failed to.
Reading composition ownership off the event alone also makes the decision
independent of listener registration order, which the previous comment at the
call site claimed to depend on. It does not: swapping the tracker and the
preedit controller leaves every test passing. The comment now states the one
coupling that is real — the controller stops propagation on `input` while a
syllable is held — without asserting a behavioral dependency that does not exist.
`isCompositionActive` remains the gate on opening a hold, where it is pinned.
* fix(terminal): settle iPad Hangul by diffing the field, not assuming it grows
The hold released a syllable only when the textarea tail grew past it and
still started with it. Korean batchim migration breaks that: a device
capture on iPadOS 26 shows `깨` + `ㅈ` rewritten to `깾` — a different
codepoint, not an extension — and only becoming `깨주` once the next vowel
decides where the `ㅈ` belongs. The prefix check failed there, the hold
stopped advancing, and `깨쥠` reached the PTY as one chunk on blur.
Locate the IME's edit with a prefix diff of the two field states instead.
Everything before where it began rewriting is settled: the batchim question
for those syllables is already answered. Still hold-and-commit, so no DEL
ever reaches the pty.
The capture is now the fixture, replayed both verbatim and as keystrokes.
* fix(terminal): keep the iPad Hangul hold open across the IME's erase rewrite
Backspace can decompose a held syllable as deleteContentBackward then a
replacing insertText, the same shape the IME uses to grow one. The hold
closed on the emptied half, so the replacement landed with nothing held:
typing 한, Backspace, ㄹ put a bare `ㄹ` on the wire and dropped 하.
The empty field now collapses the hold instead of closing it, and the
Backspace that finds nothing held is the one that reaches the PTY. An
`imeWrote` flag keeps an erased hold from resurrecting its opening jamo.
Also diff the field as NFC. Decomposed Hangul grows by appending jamo,
which the common-prefix diff reads as the previous syllable settling, so
an NFD source emitted one bare jamo per keystroke. The recorded device
trace is NFC, where normalization is a no-op.
* test(terminal): cover Japanese, Hanja and Greek on the iPad Hangul path
The coexistence suite proved pinyin and a short list of non-Hangul keys.
Widen it: a kana-to-kanji session, a Hanja lookup over a live preedit, a
digit that ends a held syllable as literal text, and Greek, halfwidth
kana and accented Latin among the keys the bypass must not claim.
---------
Co-authored-by: dvpaa <82706622+dvpaa@users.noreply.github.com>
---
...e-2set-hangul-batchim-migration-trace.json | 286 ++++++++++++
.../terminal-pane/hangul-jamo-key.ts | 9 +
...nal-ios-hangul-preedit-coexistence.test.ts | 234 ++++++++++
...al-ios-hangul-preedit-device-trace.test.ts | 169 +++++++
.../terminal-ios-hangul-preedit-fixture.ts | 341 ++++++++++++++
.../terminal-ios-hangul-preedit-overlay.ts | 53 +++
.../terminal-ios-hangul-preedit.test.ts | 436 ++++++++++++++++++
.../terminal-ios-hangul-preedit.ts | 316 +++++++++++++
.../use-terminal-pane-lifecycle.test.ts | 37 --
.../use-terminal-pane-lifecycle.ts | 31 +-
.../xterm-bypass-policy-ios.test.ts | 120 +++++
.../terminal-pane/xterm-bypass-policy.ts | 38 ++
src/renderer/src/lib/ios-web-platform.test.ts | 49 ++
src/renderer/src/lib/ios-web-platform.ts | 28 ++
14 files changed, 2100 insertions(+), 47 deletions(-)
create mode 100644 src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json
create mode 100644 src/renderer/src/components/terminal-pane/hangul-jamo-key.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-overlay.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.ts
create mode 100644 src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts
create mode 100644 src/renderer/src/lib/ios-web-platform.test.ts
create mode 100644 src/renderer/src/lib/ios-web-platform.ts
diff --git a/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json
new file mode 100644
index 00000000000..832e08ed997
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/__fixtures__/ipados-hardware-2set-hangul-batchim-migration-trace.json
@@ -0,0 +1,286 @@
+{
+ "source": "iPadOS 26 hardware keyboard, Korean 2-set, xterm helper textarea",
+ "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15",
+ "maxTouchPoints": 5,
+ "typed": "gksrmfRownla",
+ "expected": "한글깨쥠",
+ "observedSent": [
+ "한",
+ "글",
+ "깨쥠"
+ ],
+ "events": [
+ {
+ "type": "keydown",
+ "key": "ㅎ",
+ "keyCode": 71,
+ "inputType": null,
+ "data": "",
+ "value": ""
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "ㅎ",
+ "value": "ㅎ"
+ },
+ {
+ "type": "keydown",
+ "key": "ㅏ",
+ "keyCode": 75,
+ "inputType": null,
+ "data": "",
+ "value": "ㅎ"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": ""
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "하",
+ "value": "하"
+ },
+ {
+ "type": "keydown",
+ "key": "ㄴ",
+ "keyCode": 83,
+ "inputType": null,
+ "data": "",
+ "value": "하"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": ""
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "한",
+ "value": "한"
+ },
+ {
+ "type": "keydown",
+ "key": "ㄱ",
+ "keyCode": 82,
+ "inputType": null,
+ "data": "",
+ "value": "한"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "ㄱ",
+ "value": "한ㄱ"
+ },
+ {
+ "type": "keydown",
+ "key": "ㅡ",
+ "keyCode": 77,
+ "inputType": null,
+ "data": "",
+ "value": "한ㄱ"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": "한"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "그",
+ "value": "한그"
+ },
+ {
+ "type": "keydown",
+ "key": "ㄹ",
+ "keyCode": 70,
+ "inputType": null,
+ "data": "",
+ "value": "한그"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": "한"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "글",
+ "value": "한글"
+ },
+ {
+ "type": "keydown",
+ "key": "Shift",
+ "keyCode": 16,
+ "inputType": null,
+ "data": "",
+ "value": "한글"
+ },
+ {
+ "type": "keydown",
+ "key": "ㄲ",
+ "keyCode": 82,
+ "inputType": null,
+ "data": "",
+ "value": "한글"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "ㄲ",
+ "value": "한글ㄲ"
+ },
+ {
+ "type": "keydown",
+ "key": "ㅐ",
+ "keyCode": 79,
+ "inputType": null,
+ "data": "",
+ "value": "한글ㄲ"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": "한글"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "깨",
+ "value": "한글깨"
+ },
+ {
+ "type": "keydown",
+ "key": "ㅈ",
+ "keyCode": 87,
+ "inputType": null,
+ "data": "",
+ "value": "한글깨"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": "한글"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "깾",
+ "value": "한글깾"
+ },
+ {
+ "type": "keydown",
+ "key": "ㅜ",
+ "keyCode": 78,
+ "inputType": null,
+ "data": "",
+ "value": "한글깾"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": "한글"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "깨주",
+ "value": "한글깨주"
+ },
+ {
+ "type": "keydown",
+ "key": "ㅣ",
+ "keyCode": 76,
+ "inputType": null,
+ "data": "",
+ "value": "한글깨주"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": "한글깨"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "쥐",
+ "value": "한글깨쥐"
+ },
+ {
+ "type": "keydown",
+ "key": "ㅁ",
+ "keyCode": 65,
+ "inputType": null,
+ "data": "",
+ "value": "한글깨쥐"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "deleteContentBackward",
+ "data": "",
+ "value": "한글깨"
+ },
+ {
+ "type": "input",
+ "key": "",
+ "keyCode": null,
+ "inputType": "insertText",
+ "data": "쥠",
+ "value": "한글깨쥠"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/renderer/src/components/terminal-pane/hangul-jamo-key.ts b/src/renderer/src/components/terminal-pane/hangul-jamo-key.ts
new file mode 100644
index 00000000000..16b6eb2e853
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/hangul-jamo-key.ts
@@ -0,0 +1,9 @@
+/** Jamo a hardware keyboard reports directly in `KeyboardEvent.key`: conjoining,
+ * compatibility, extended and halfwidth forms. Shift-typed doubles (ㄲ ㄸ ㅃ ㅆ ㅉ)
+ * live in the compatibility block alongside the singles. */
+const HANGUL_JAMO_KEY = /^[ᄀ-ᇿ-ꥠ-ힰ-ᅠ-ᅵ]$/
+
+/** Returns whether `key` is a single Hangul jamo. */
+export function isHangulJamoKeyText(key: string): boolean {
+ return HANGUL_JAMO_KEY.test(key)
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts
new file mode 100644
index 00000000000..567ef3fbf5e
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-coexistence.test.ts
@@ -0,0 +1,234 @@
+// @vitest-environment happy-dom
+/**
+ * What the iPadOS Hangul path must not disturb: the composition sessions other
+ * input sources on the same device do run, non-Hangul scripts, desktop
+ * platforms, and anything else that writes to the PTY.
+ */
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer'
+import {
+ disposeOpenTerminals,
+ dispatchComposition,
+ dispatchInput,
+ dispatchKey,
+ nextEventLoop,
+ openIosTerminal,
+ pretendIosWeb,
+ typeHangeul,
+ typeJamo,
+ typePrintable,
+ type IosHangulRig
+} from './terminal-ios-hangul-preedit-fixture'
+
+/** Chinese pinyin, which fires real composition events on the very same iPad. */
+async function typePinyin(rig: IosHangulRig, spelling: string, committed: string): Promise {
+ const base = rig.textarea.value
+ dispatchComposition(rig, 'compositionstart', '')
+ for (let index = 1; index <= spelling.length; index += 1) {
+ const preedit = spelling.slice(0, index)
+ dispatchKey(rig, 'keydown', {
+ key: spelling[index - 1],
+ keyCode: 229,
+ isComposing: true
+ })
+ dispatchComposition(rig, 'compositionupdate', preedit)
+ rig.textarea.value = base + preedit
+ dispatchInput(rig, 'insertCompositionText', preedit, { isComposing: true })
+ }
+ rig.textarea.value = base + committed
+ dispatchComposition(rig, 'compositionend', committed)
+ dispatchInput(rig, 'insertCompositionText', committed)
+ await nextEventLoop()
+ await nextEventLoop()
+}
+
+describe('the iPadOS Hangul path alongside everything else', () => {
+ let originalUserAgent: PropertyDescriptor | undefined
+ let originalMaxTouchPoints: PropertyDescriptor | undefined
+
+ beforeEach(() => {
+ originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent')
+ originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints')
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
+ measureText: () => ({ width: 10 })
+ } as unknown as CanvasRenderingContext2D)
+ })
+
+ afterEach(() => {
+ disposeOpenTerminals()
+ if (originalUserAgent) {
+ Object.defineProperty(navigator, 'userAgent', originalUserAgent)
+ }
+ if (originalMaxTouchPoints) {
+ Object.defineProperty(navigator, 'maxTouchPoints', originalMaxTouchPoints)
+ }
+ vi.restoreAllMocks()
+ document.body.replaceChildren()
+ })
+
+ describe('Chinese pinyin on the same device', () => {
+ it('commits once, not twice', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typePinyin(rig, 'ni', '你')
+
+ expect(rig.emitted).toEqual(['你'])
+ })
+
+ it('commits two compositions in a row', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typePinyin(rig, 'ni', '你')
+ await typePinyin(rig, 'hao', '好')
+
+ expect(rig.emitted).toEqual(['你', '好'])
+ })
+
+ it('still works after Hangul was typed first', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeHangeul(rig)
+ await typePinyin(rig, 'ni', '你')
+
+ // The open syllable is committed by the composition that supersedes it.
+ expect(rig.emitted).toEqual(['한', '글', '你'])
+ })
+
+ it('leaves Hangul working after a composition finished', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typePinyin(rig, 'ni', '你')
+ await typeHangeul(rig)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted).toEqual(['你', '한', '글', '\r'])
+ })
+ })
+
+ it('composes Hangul after a composition that never ended, then a blur', async () => {
+ // Why this case: a `composing` flag latched on compositionstart and cleared
+ // only on compositionend stays true forever when the session is abandoned —
+ // blur mid-composition, pane teardown, a cancelled IME — and silently kills
+ // every claimed key for the rest of the pane's life. The state is derived
+ // from the composition tracker instead, which a blur clears.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ dispatchComposition(rig, 'compositionstart', '')
+ dispatchComposition(rig, 'compositionupdate', 'n')
+ rig.textarea.dispatchEvent(new FocusEvent('blur', { bubbles: false }))
+ rig.textarea.value = ''
+ await nextEventLoop()
+
+ await typeHangeul(rig)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted.join('')).toContain('한글')
+ })
+
+ it('commits a Japanese kana-to-kanji session once, like pinyin', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typePinyin(rig, 'ka', '蚊')
+
+ expect(rig.emitted).toEqual(['蚊'])
+ })
+
+ it('leaves a Hanja lookup to the session that owns its preedit', async () => {
+ // Why: ibus-hangul indexes its Hanja table by digit, but only over a live
+ // composition — which xterm's CompositionHelper already commits.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typePinyin(rig, 'han', '韓')
+
+ expect(rig.emitted).toEqual(['韓'])
+ })
+
+ it('sends a digit after a held syllable as the literal text that ends it', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ await typeJamo(rig, 'ㅏ', '하', { replaces: true })
+ await typePrintable(rig, { key: '1', keyCode: 49, written: '1', replaces: false })
+
+ expect(rig.emitted).toEqual(['하', '1'])
+ })
+
+ describe('desktop platforms', () => {
+ it('is inert on a Mac reporting no touch points', async () => {
+ pretendIosWeb(0)
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+
+ expect(rig.emitted).toEqual(['ㅎ'])
+ expect(rig.compositionView.classList.contains('active')).toBe(false)
+ })
+
+ it('is inert on a Mac whose touch peripheral reports a single point', async () => {
+ pretendIosWeb(1)
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+
+ expect(rig.emitted).toEqual(['ㅎ'])
+ expect(rig.compositionView.classList.contains('active')).toBe(false)
+ })
+
+ it('is inert on Windows and Linux', async () => {
+ for (const userAgent of [
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0',
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/126.0.0.0'
+ ]) {
+ pretendIosWeb(5, userAgent)
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ expect(rig.emitted).toEqual(['ㅎ'])
+ }
+ })
+
+ it('reads the platform once, so no second gate can drift from it', () => {
+ const lifecycle = readFileSync(
+ resolve(
+ process.cwd(),
+ 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
+ ),
+ 'utf8'
+ )
+ expect(lifecycle.match(/const isIosWeb =/g)).toHaveLength(1)
+ expect(lifecycle.match(/isCurrentPlatformIosWeb\(/g)).toHaveLength(1)
+ // And that the one gate is what installs the controller.
+ expect(lifecycle).toMatch(
+ /const iosHangulPreedit = isIosWeb\s*\?\s*installTerminalIosHangulPreedit\(/
+ )
+ })
+ })
+
+ it('composes Hangul after a paste written straight to the transport', async () => {
+ // Why: `writeTerminalPastePtyInput` bypasses xterm entirely, so it fires no
+ // `onData` a mirror could resync on. A hold reads the field only when it
+ // opens, so there is no state for the paste to make stale.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ const sent: string[] = []
+ writeTerminalPastePtyInput({ sendInput: (data) => (sent.push(data), true) }, 'echo ')
+ expect(sent).toEqual(['echo '])
+
+ await typeHangeul(rig)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted).toEqual(['한', '글', '\r'])
+ })
+
+ it('leaves non-Hangul scripts on the same device reaching the PTY', async () => {
+ // Why: the bypass claims a key that nothing downstream re-sends, so it must
+ // claim only jamo. A Cyrillic or kana key would otherwise lose its keydown,
+ // its keypress and its `input` alike and arrive as nothing.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ for (const key of ['п', 'р', 'α', 'ω', 'あ', 'カ', 'é', 'ü', '€']) {
+ await typePrintable(rig, { key, keyCode: 71, written: key, replaces: false })
+ }
+
+ expect(rig.emitted).toEqual(['п', 'р', 'α', 'ω', 'あ', 'カ', 'é', 'ü', '€'])
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts
new file mode 100644
index 00000000000..210697ab308
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-device-trace.test.ts
@@ -0,0 +1,169 @@
+// @vitest-environment happy-dom
+/**
+ * `gksrmfRownla` → `한글깨쥠`, replayed from a capture taken on an iPad running
+ * iPadOS 26 with a hardware Korean 2-set keyboard (#13345). The recording is
+ * the fixture: an earlier round of this work passed against a hand-written
+ * shape the device never produces, and shipped a bug the device shows in event
+ * 23 of this file.
+ *
+ * What the device does that no reconstruction guessed — batchim migration:
+ *
+ * keydown 'ㅈ' → deleteContentBackward, insertText '깾' value '한글깾'
+ * keydown 'ㅜ' → deleteContentBackward, insertText '깨주' value '한글깨주'
+ *
+ * The `ㅈ` first attaches to `깨` as a final consonant, making a different
+ * codepoint entirely, and only migrates forward once `ㅜ` says it starts the
+ * next syllable. A syllable is not final until the following syllable's vowel
+ * decides, so `깨` must not be committed when the `ㅈ` arrives.
+ *
+ * Two properties are pinned here:
+ *
+ * - `깨` opens on `ㄲ`, a Shift-typed double consonant. Orca's own Shift rule
+ * (`xterm-bypass-policy.ts`) already hides those keydowns from xterm, so a fix
+ * living inside xterm's CompositionHelper never sees them and every syllable
+ * starting with one — 깨 꿈 딸 빵 쓰다 짜다 — stays broken. Sitting upstream of
+ * the bypass policy is what makes the source of the keydown irrelevant.
+ * - Nothing is sent and then retracted. A field-diffing mirror emits one write
+ * per edit and erases the superseded syllable with DEL, which raw-mode TUIs
+ * need not read as "erase one cell" and which costs a round trip each over
+ * SSH or the relay.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ deviceTraceKeystrokes,
+ disposeOpenTerminals,
+ dispatchKey,
+ loadIosDeviceTrace,
+ openIosTerminal,
+ pretendIosWeb,
+ replayIosDeviceTrace,
+ typeJamo,
+ typePrintable,
+ type IosHangulRig
+} from './terminal-ios-hangul-preedit-fixture'
+
+const TRACE = loadIosDeviceTrace('ipados-hardware-2set-hangul-batchim-migration-trace.json')
+const KEYSTROKES = deviceTraceKeystrokes(TRACE)
+
+function pretendRecordedDevice(): void {
+ pretendIosWeb(TRACE.maxTouchPoints, TRACE.userAgent)
+}
+
+/** The capture as keystrokes, which adds the keypress the recorder did not log. */
+async function replayKeystrokes(rig: IosHangulRig, upTo = KEYSTROKES.length): Promise {
+ for (const step of KEYSTROKES.slice(0, upTo)) {
+ await typePrintable(rig, step)
+ }
+}
+
+describe('the recorded iPad device trace for 한글깨쥠', () => {
+ beforeEach(() => {
+ // happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
+ measureText: () => ({ width: 10 })
+ } as unknown as CanvasRenderingContext2D)
+ })
+
+ afterEach(() => {
+ disposeOpenTerminals()
+ vi.restoreAllMocks()
+ document.body.replaceChildren()
+ })
+
+ describe('replayed event for event, exactly as recorded', () => {
+ it('commits per syllable instead of hoarding the batchim migration', async () => {
+ pretendRecordedDevice()
+ const rig = openIosTerminal()
+ const drift = await replayIosDeviceTrace(rig, TRACE)
+
+ // The device build reached the wire as ['한', '글', '깨쥠'] — correct text,
+ // but everything from `깨` on arrived in one chunk at blur, because the
+ // hold stalled the moment `깨` became `깾`.
+ expect(TRACE.observedSent).toEqual(['한', '글', '깨쥠'])
+ expect(rig.emitted).toEqual(['한', '글', '깨'])
+ expect(rig.preedit.heldText()).toBe('쥠')
+ expect(drift).toEqual([])
+ })
+
+ it('holds 깨 while the ㅈ could still belong to it', async () => {
+ pretendRecordedDevice()
+ const rig = openIosTerminal()
+ // Up to and including the `깾` write, which is where the device build stopped.
+ await replayIosDeviceTrace(rig, { ...TRACE, events: TRACE.events.slice(0, 25) })
+
+ expect(rig.emitted).toEqual(['한', '글'])
+ expect(rig.preedit.heldText()).toBe('깾')
+ })
+
+ it('reaches the PTY as 한글깨쥠', async () => {
+ pretendRecordedDevice()
+ const rig = openIosTerminal()
+ await replayIosDeviceTrace(rig, TRACE)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted.join('')).toBe(`${TRACE.expected}\r`)
+ })
+
+ it('puts no DEL or backspace byte on the wire', async () => {
+ pretendRecordedDevice()
+ const rig = openIosTerminal()
+ await replayIosDeviceTrace(rig, TRACE)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ const wire = rig.emitted.join('')
+ expect(wire).not.toContain('\x7f')
+ expect(wire).not.toContain('\b')
+ // One write per syllable plus the Enter: no retraction, and no per-edit churn.
+ expect(rig.emitted).toHaveLength(5)
+ })
+ })
+
+ describe('replayed as keystrokes, with the keypress a browser also fires', () => {
+ it('sends the composed syllables and never the raw jamo', async () => {
+ pretendRecordedDevice()
+ const rig = openIosTerminal()
+ await replayKeystrokes(rig)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted.join('')).toBe(`${TRACE.expected}\r`)
+ expect(rig.emitted).toEqual(['한', '글', '깨', '쥠', '\r'])
+ })
+
+ it('composes a mid-word Shift+jamo after a committed syllable', async () => {
+ pretendRecordedDevice()
+ const rig = openIosTerminal()
+ // Through `깨`, the eighth keystroke, whose initial is a Shift-typed ㄲ.
+ await replayKeystrokes(rig, 8)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted.join('')).toBe('한글깨\r')
+ })
+ })
+
+ it('composes a syllable-initial Shift+jamo with nothing before it', async () => {
+ pretendRecordedDevice()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㄲ', 'ㄲ', { replaces: false, shiftKey: true })
+ await typeJamo(rig, 'ㅐ', '깨', { replaces: true })
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted).toEqual(['깨', '\r'])
+ })
+
+ it('migrates a batchim with nothing committed before it', async () => {
+ // The same rewrite as the trace's `깨` → `깾` → `깨주`, but opening the line:
+ // the settled prefix is empty, so nothing can be released early by accident.
+ pretendRecordedDevice()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㄲ', 'ㄲ', { replaces: false, shiftKey: true })
+ await typeJamo(rig, 'ㅐ', '깨', { replaces: true })
+ await typeJamo(rig, 'ㅈ', '깾', { replaces: true })
+ expect(rig.emitted).toEqual([])
+
+ await typeJamo(rig, 'ㅜ', '깨주', { replaces: true })
+ await typeJamo(rig, 'ㅣ', '쥐', { replaces: true })
+
+ expect(rig.emitted).toEqual(['깨'])
+ expect(rig.preedit.heldText()).toBe('쥐')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts
new file mode 100644
index 00000000000..7bf337c196b
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-fixture.ts
@@ -0,0 +1,341 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { Terminal } from '@xterm/xterm'
+import { isCurrentPlatformIosWeb } from '../../lib/ios-web-platform'
+import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
+import { createTerminalIosHangulPreeditRenderer } from './terminal-ios-hangul-preedit-overlay'
+import {
+ installTerminalIosHangulPreedit,
+ type TerminalIosHangulPreedit
+} from './terminal-ios-hangul-preedit'
+import {
+ shouldBypassXtermKeyboardEvent,
+ shouldSuppressTerminalImeKeyboardEvent
+} from './xterm-bypass-policy'
+
+/**
+ * Shared rig for the iPadOS Hangul suites: a real xterm `Terminal` wired to the
+ * same tracker, bypass policy and preedit controller the pane lifecycle
+ * installs, so the tests exercise the whole path rather than a mock of it.
+ */
+
+export const IPAD_DESKTOP_MODE_UA =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
+
+export type IosHangulRig = {
+ compositionView: HTMLElement
+ /** Every chunk the terminal handed to the PTY, in order. */
+ emitted: string[]
+ preedit: TerminalIosHangulPreedit
+ terminal: Terminal
+ textarea: HTMLTextAreaElement
+}
+
+const openTerminals: Terminal[] = []
+
+export function nextEventLoop(): Promise {
+ return new Promise((resolve) => window.setTimeout(resolve, 0))
+}
+
+/** iPadOS reports a Mac user agent, so the touch-point count is what separates it from a desktop. */
+export function pretendIosWeb(maxTouchPoints = 5, userAgent = IPAD_DESKTOP_MODE_UA): void {
+ Object.defineProperty(navigator, 'userAgent', { value: userAgent, configurable: true })
+ Object.defineProperty(navigator, 'maxTouchPoints', {
+ value: maxTouchPoints,
+ configurable: true
+ })
+}
+
+export function disposeOpenTerminals(): void {
+ for (const terminal of openTerminals.splice(0)) {
+ terminal.dispose()
+ }
+}
+
+export function openIosTerminal(
+ options: {
+ isIosWeb?: boolean
+ /** Overrides the tracker, to isolate what the controller reads it for. */
+ isCompositionActive?: () => boolean
+ screenReaderMode?: boolean
+ } = {}
+): IosHangulRig {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const terminal = new Terminal({
+ cols: 40,
+ rows: 8,
+ screenReaderMode: options.screenReaderMode
+ })
+ openTerminals.push(terminal)
+ terminal.open(container)
+ const textarea = terminal.textarea
+ const compositionView = container.querySelector('.composition-view')
+ if (!textarea || !compositionView) {
+ throw new Error('xterm did not create the helper textarea and composition view')
+ }
+
+ // Mirrors the lifecycle: the platform decides, so a desktop rig exercises the
+ // real gate rather than a flag the test set.
+ const isIosWeb = options.isIosWeb ?? isCurrentPlatformIosWeb()
+ const tracker = installTerminalImeCompositionTracker(terminal.element)
+ const preedit = isIosWeb
+ ? installTerminalIosHangulPreedit({
+ terminalElement: terminal.element,
+ isCompositionActive: options.isCompositionActive ?? (() => tracker.isActive()),
+ isScreenReaderMode: () => terminal.options.screenReaderMode === true,
+ sendInput: (data) => terminal.input(data),
+ renderPreedit: createTerminalIosHangulPreeditRenderer(terminal)
+ })
+ : { heldText: () => '', dispose: () => undefined }
+
+ terminal.attachCustomKeyEventHandler((event) => {
+ if (
+ shouldSuppressTerminalImeKeyboardEvent(event, {
+ compositionActive: tracker.isActive(),
+ candidateKeyGuardActive: tracker.isCandidateKeyGuardActive(),
+ pendingCandidateKeyReleaseActive: false,
+ hangulPreedit: tracker.isHangulPreedit(),
+ isMac: true,
+ isLinux: false
+ })
+ ) {
+ return false
+ }
+ return !shouldBypassXtermKeyboardEvent(event, {
+ isMac: true,
+ isIosWeb,
+ hasSelection: false
+ })
+ })
+
+ const emitted: string[] = []
+ terminal.onData((data) => emitted.push(data))
+ return { compositionView, emitted, preedit, terminal, textarea }
+}
+
+export function dispatchKey(
+ { textarea }: IosHangulRig,
+ type: 'keydown' | 'keypress' | 'keyup',
+ init: {
+ key: string
+ code?: string
+ keyCode?: number
+ charCode?: number
+ shiftKey?: boolean
+ isComposing?: boolean
+ }
+): boolean {
+ const event = new KeyboardEvent(type, {
+ key: init.key,
+ code: init.code ?? 'KeyQ',
+ shiftKey: init.shiftKey ?? false,
+ bubbles: true,
+ cancelable: true
+ })
+ // happy-dom drops the legacy numeric fields from KeyboardEventInit; xterm's key paths read them.
+ Object.defineProperty(event, 'keyCode', { value: init.keyCode ?? init.key.charCodeAt(0) })
+ Object.defineProperty(event, 'charCode', { value: init.charCode ?? 0 })
+ Object.defineProperty(event, 'isComposing', { value: init.isComposing ?? false })
+ textarea.dispatchEvent(event)
+ return event.defaultPrevented
+}
+
+export function dispatchInput(
+ { textarea }: IosHangulRig,
+ inputType: string,
+ data: string | null,
+ init: { isComposing?: boolean } = {}
+): void {
+ const event = new InputEvent('input', { bubbles: true })
+ Object.defineProperty(event, 'inputType', { value: inputType })
+ Object.defineProperty(event, 'data', { value: data })
+ Object.defineProperty(event, 'isComposing', { value: init.isComposing ?? false })
+ // Trusted user events are always composed; that is the flag that makes xterm drop the commit.
+ Object.defineProperty(event, 'composed', { value: true })
+ textarea.dispatchEvent(event)
+}
+
+export function dispatchComposition(
+ { textarea }: IosHangulRig,
+ type: 'compositionstart' | 'compositionupdate' | 'compositionend',
+ data?: string
+): void {
+ const event = new CompositionEvent(type, { bubbles: true })
+ if (data !== undefined) {
+ Object.defineProperty(event, 'data', { value: data })
+ }
+ textarea.dispatchEvent(event)
+}
+
+/**
+ * One printable keystroke as a browser produces it: the key, then — only if
+ * xterm did not consume the keydown — the keypress and the text the IME writes.
+ */
+export async function typePrintable(
+ rig: IosHangulRig,
+ {
+ key,
+ keyCode,
+ written,
+ replaces,
+ shiftKey
+ }: {
+ key: string
+ keyCode: number
+ written: string
+ replaces: boolean
+ shiftKey?: boolean
+ }
+): Promise {
+ if (dispatchKey(rig, 'keydown', { key, keyCode, shiftKey })) {
+ await nextEventLoop()
+ return
+ }
+ dispatchKey(rig, 'keypress', { key, keyCode, charCode: key.charCodeAt(0), shiftKey })
+ if (replaces) {
+ rig.textarea.value = rig.textarea.value.slice(0, -1)
+ dispatchInput(rig, 'deleteContentBackward', null)
+ }
+ rig.textarea.value += written
+ dispatchInput(rig, 'insertText', written)
+ await nextEventLoop()
+}
+
+/**
+ * A jamo keystroke. `replaces` is the delete-then-insert the IME uses while a
+ * syllable is still growing; without it the syllable is finished and the jamo
+ * simply appends.
+ */
+export function typeJamo(
+ rig: IosHangulRig,
+ key: string,
+ written: string,
+ options: { replaces: boolean; shiftKey?: boolean }
+): Promise {
+ return typePrintable(rig, {
+ key,
+ keyCode: key.charCodeAt(0),
+ written,
+ replaces: options.replaces,
+ shiftKey: options.shiftKey
+ })
+}
+
+/** `한글`, keystroke for keystroke, as captured from the iPad. */
+export async function typeHangeul(rig: IosHangulRig): Promise {
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ await typeJamo(rig, 'ㅏ', '하', { replaces: true })
+ await typeJamo(rig, 'ㄴ', '한', { replaces: true })
+ await typeJamo(rig, 'ㄱ', 'ㄱ', { replaces: false })
+ await typeJamo(rig, 'ㅡ', '그', { replaces: true })
+ await typeJamo(rig, 'ㄹ', '글', { replaces: true })
+}
+
+/** One DOM event exactly as the on-device recorder saw it. */
+export type IosDeviceTraceEvent = {
+ type: 'keydown' | 'input'
+ key: string
+ keyCode: number | null
+ inputType: string | null
+ data: string
+ /** `textarea.value` at the moment the event fired. */
+ value: string
+}
+
+export type IosDeviceTrace = {
+ source: string
+ userAgent: string
+ maxTouchPoints: number
+ typed: string
+ expected: string
+ /** What the device build actually put on the wire, bug included. */
+ observedSent: string[]
+ events: IosDeviceTraceEvent[]
+}
+
+export function loadIosDeviceTrace(fileName: string): IosDeviceTrace {
+ const path = resolve(
+ process.cwd(),
+ 'src/renderer/src/components/terminal-pane/__fixtures__',
+ fileName
+ )
+ return JSON.parse(readFileSync(path, 'utf8')) as IosDeviceTrace
+}
+
+/** A recorded keydown whose field value no longer matches on replay. */
+export type FieldDrift = { index: number; recorded: string; actual: string }
+
+/**
+ * Replays a capture verbatim — the recorded events only, with the field set to
+ * the value the device held at each one. No keypress: the recorder captured
+ * keydown and input, so the browser's keypress is not replayed here and the
+ * hand-driven suites keep that coverage.
+ *
+ * Returns every keydown at which the field had drifted from the recording,
+ * which is how the controller having disturbed the IME's own state shows up.
+ */
+export async function replayIosDeviceTrace(
+ rig: IosHangulRig,
+ trace: IosDeviceTrace
+): Promise {
+ const drift: FieldDrift[] = []
+ for (const [index, event] of trace.events.entries()) {
+ if (event.type === 'keydown') {
+ if (rig.textarea.value !== event.value) {
+ drift.push({ index, recorded: event.value, actual: rig.textarea.value })
+ }
+ dispatchKey(rig, 'keydown', { key: event.key, keyCode: event.keyCode ?? 0 })
+ } else {
+ rig.textarea.value = event.value
+ dispatchInput(rig, event.inputType ?? 'insertText', event.data || null)
+ }
+ await nextEventLoop()
+ }
+ return drift
+}
+
+/**
+ * The same capture as keystrokes, so the shared `typeJamo` path replays it with
+ * the keypress a browser fires. `replaces` is read off the recording: a jamo
+ * that first attaches to the previous syllable arrives as delete-then-insert.
+ */
+export type DeviceTraceKeystroke = {
+ key: string
+ keyCode: number
+ written: string
+ replaces: boolean
+ shiftKey: boolean
+}
+
+export function deviceTraceKeystrokes(trace: IosDeviceTrace): DeviceTraceKeystroke[] {
+ const steps: DeviceTraceKeystroke[] = []
+ let shiftKey = false
+ for (const event of trace.events) {
+ if (event.type === 'keydown') {
+ if (event.key === 'Shift') {
+ shiftKey = true
+ continue
+ }
+ steps.push({
+ key: event.key,
+ keyCode: event.keyCode ?? 0,
+ written: '',
+ replaces: false,
+ shiftKey
+ })
+ shiftKey = false
+ continue
+ }
+ const step = steps.at(-1)
+ if (!step) {
+ throw new Error('trace opens with an input event, which no keystroke owns')
+ }
+ if (event.inputType?.startsWith('delete')) {
+ step.replaces = true
+ } else {
+ step.written = event.data
+ }
+ }
+ return steps
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-overlay.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-overlay.ts
new file mode 100644
index 00000000000..ec7d11d0a92
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit-overlay.ts
@@ -0,0 +1,53 @@
+import type { Terminal } from '@xterm/xterm'
+
+// Why: xterm draws an open syllable from its own CompositionHelper, which iPad
+// Hangul never reaches — it fires no composition events. Korean users expect to
+// watch `하` become `한`, so Orca drives xterm's `.composition-view` itself.
+//
+// Cell metrics come from the public `.xterm-screen` bounds (xterm sizes that
+// element to cols x rows cells) rather than `_core._renderService.dimensions`,
+// matching `terminal-ime-candidate-anchor.ts` — an xterm bump cannot silently
+// move the overlay off the cursor.
+
+/**
+ * Returns a renderer for the held Hangul syllable, or a no-op when the terminal
+ * has not opened its DOM. Called with '' to hide the overlay.
+ */
+export function createTerminalIosHangulPreeditRenderer(terminal: Terminal): (text: string) => void {
+ const view = terminal.element?.querySelector('.composition-view')
+ const screen = terminal.element?.querySelector('.xterm-screen')
+ if (!view || !screen) {
+ return () => undefined
+ }
+
+ return (text: string): void => {
+ if (!text) {
+ view.classList.remove('active')
+ view.textContent = ''
+ return
+ }
+ view.textContent = text
+ view.classList.add('active')
+
+ const rect = screen.getBoundingClientRect()
+ const cellWidth = rect.width / terminal.cols
+ const cellHeight = rect.height / terminal.rows
+ if (!(cellWidth > 0) || !(cellHeight > 0)) {
+ return
+ }
+ const buffer = terminal.buffer.active
+ const left = Math.min(buffer.cursorX, terminal.cols - 1) * cellWidth
+ view.style.left = `${left}px`
+ view.style.top = `${buffer.cursorY * cellHeight}px`
+ view.style.height = `${cellHeight}px`
+ view.style.lineHeight = `${cellHeight}px`
+ view.style.fontFamily = terminal.options.fontFamily ?? ''
+ view.style.fontSize = `${terminal.options.fontSize ?? 0}px`
+ // Clip at the terminal's right edge instead of overflowing the pane.
+ view.style.maxWidth = `${terminal.cols * cellWidth - left}px`
+ view.style.overflow = 'hidden'
+ // Themed rather than stock #000/#FFF, so light themes keep contrast.
+ view.style.background = terminal.options.theme?.background ?? '#000'
+ view.style.color = terminal.options.theme?.foreground ?? '#FFF'
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts
new file mode 100644
index 00000000000..2ced09f36a8
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.test.ts
@@ -0,0 +1,436 @@
+// @vitest-environment happy-dom
+/**
+ * iPadOS Hangul with a hardware keyboard fires no composition events at all
+ * (#13345). Captured on the device, each jamo is a plain keydown whose `key` is
+ * the jamo, followed by the IME rewriting the composing syllable in place:
+ *
+ * keydown 'ㅎ' → insertText 'ㅎ' value 'ㅎ'
+ * keydown 'ㅏ' → deleteContentBackward, insertText '하' value '하'
+ * keydown 'ㄴ' → deleteContentBackward, insertText '한' value '한'
+ * keydown 'ㄱ' → insertText 'ㄱ' value '한ㄱ'
+ *
+ * Unpatched xterm sends the jamo from `_keyPress` and drops the composed
+ * `insertText`, because `_inputEvent` admits a composed insert only when no key
+ * is down — and one is down for every one of them. The PTY sees `ㅎㅏㄴㄱㅡㄹ`.
+ *
+ * These suites drive a real xterm `Terminal` through the same tracker, bypass
+ * policy and preedit controller the pane lifecycle installs.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ disposeOpenTerminals,
+ dispatchComposition,
+ dispatchInput,
+ dispatchKey,
+ nextEventLoop,
+ openIosTerminal,
+ pretendIosWeb,
+ typeHangeul,
+ typeJamo,
+ typePrintable
+} from './terminal-ios-hangul-preedit-fixture'
+
+describe('iPadOS Hangul typed as bare keydowns', () => {
+ let originalUserAgent: PropertyDescriptor | undefined
+ let originalMaxTouchPoints: PropertyDescriptor | undefined
+
+ beforeEach(() => {
+ originalUserAgent = Object.getOwnPropertyDescriptor(navigator, 'userAgent')
+ originalMaxTouchPoints = Object.getOwnPropertyDescriptor(navigator, 'maxTouchPoints')
+ // happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
+ measureText: () => ({ width: 10 })
+ } as unknown as CanvasRenderingContext2D)
+ })
+
+ afterEach(() => {
+ disposeOpenTerminals()
+ if (originalUserAgent) {
+ Object.defineProperty(navigator, 'userAgent', originalUserAgent)
+ }
+ if (originalMaxTouchPoints) {
+ Object.defineProperty(navigator, 'maxTouchPoints', originalMaxTouchPoints)
+ }
+ vi.restoreAllMocks()
+ document.body.replaceChildren()
+ })
+
+ it('sends composed syllables, not the jamo the keydowns carry', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeHangeul(rig)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted).toEqual(['한', '글', '\r'])
+ })
+
+ it('commits a syllable once, with no intermediate state to retract', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeHangeul(rig)
+
+ // Only the syllable the IME finished has been sent; `글` is still held.
+ expect(rig.emitted).toEqual(['한'])
+ expect(rig.preedit.heldText()).toBe('글')
+ })
+
+ it('shows the open syllable in the preedit overlay as it grows', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ expect(rig.compositionView.classList.contains('active')).toBe(true)
+ expect(rig.compositionView.textContent).toBe('ㅎ')
+
+ await typeJamo(rig, 'ㅏ', '하', { replaces: true })
+ expect(rig.compositionView.textContent).toBe('하')
+ expect(rig.emitted).toEqual([])
+ })
+
+ it('hides the overlay once the syllable is committed', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.compositionView.classList.contains('active')).toBe(false)
+ expect(rig.compositionView.textContent).toBe('')
+ expect(rig.emitted).toEqual(['ㅎ', '\r'])
+ })
+
+ it('composes after text already sitting on the line', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ for (const [key, keyCode] of [
+ ['e', 69],
+ ['c', 67],
+ ['h', 72],
+ ['o', 79],
+ [' ', 32]
+ ] as const) {
+ await typePrintable(rig, { key, keyCode, written: key, replaces: false })
+ }
+ await typeHangeul(rig)
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+
+ expect(rig.emitted.join('')).toBe('echo 한글\r')
+ })
+
+ it('leaves ASCII typing on the same device untouched', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typePrintable(rig, { key: 'l', keyCode: 76, written: 'l', replaces: false })
+ await typePrintable(rig, { key: 's', keyCode: 83, written: 's', replaces: false })
+
+ expect(rig.emitted).toEqual(['l', 's'])
+ expect(rig.compositionView.classList.contains('active')).toBe(false)
+ })
+
+ it('commits the open syllable before the space that follows it', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeHangeul(rig)
+ await typePrintable(rig, { key: ' ', keyCode: 32, written: ' ', replaces: false })
+
+ expect(rig.emitted).toEqual(['한', '글', ' '])
+ })
+
+ it('commits the open syllable when the textarea loses focus', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeHangeul(rig)
+ rig.textarea.dispatchEvent(new FocusEvent('blur', { bubbles: false }))
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual(['한', '글'])
+ expect(rig.compositionView.classList.contains('active')).toBe(false)
+ })
+
+ it('lets Backspace decompose the open syllable without reaching the PTY', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ await typeJamo(rig, 'ㅏ', '하', { replaces: true })
+
+ // The IME walks the syllable back a jamo at a time by rewriting the field.
+ dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 })
+ rig.textarea.value = 'ㅎ'
+ dispatchInput(rig, 'deleteContentBackward', null)
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual([])
+ expect(rig.compositionView.textContent).toBe('ㅎ')
+ })
+
+ it('sends Backspace to the PTY once no syllable is open', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+
+ dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 })
+ rig.textarea.value = ''
+ dispatchInput(rig, 'deleteContentBackward', null)
+ await nextEventLoop()
+ expect(rig.emitted).toEqual([])
+
+ dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 })
+ await nextEventLoop()
+ expect(rig.emitted).toEqual(['\x7f'])
+ })
+
+ it('keeps the syllable painted between the delete and insert that replace it', async () => {
+ // Why: the IME empties the field for one event in the middle of growing a
+ // syllable, and reading it there would blank the overlay every keystroke.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, '\u314E', '\u314E', { replaces: false })
+
+ dispatchKey(rig, 'keydown', { key: '\u3153', keyCode: '\u3153'.charCodeAt(0) })
+ rig.textarea.value = ''
+ dispatchInput(rig, 'deleteContentBackward', null)
+ expect(rig.compositionView.textContent).toBe('\u314E')
+
+ rig.textarea.value = '\uD5E4'
+ dispatchInput(rig, 'insertText', '\uD5E4')
+ await nextEventLoop()
+ expect(rig.compositionView.textContent).toBe('\uD5E4')
+ })
+
+ it('holds the syllable when Backspace decomposes it as delete-then-insert', async () => {
+ // Why: the IME rewrites a growing syllable as deleteContentBackward then a
+ // replacing insertText, and there is no reason a shrinking one differs. A
+ // hold that closed on the emptied half sent the next jamo raw.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, '\u314E', '\uD558', { replaces: false })
+ await typeJamo(rig, '\u3153', '\uD5E4', { replaces: true })
+ await typeJamo(rig, '\u3134', '\uD5E8', { replaces: true })
+
+ dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 })
+ rig.textarea.value = ''
+ dispatchInput(rig, 'deleteContentBackward', null)
+ rig.textarea.value = '\uD5E4'
+ dispatchInput(rig, 'insertText', '\uD5E4')
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual([])
+ expect(rig.preedit.heldText()).toBe('\uD5E4')
+
+ await typeJamo(rig, '\u3139', '\uD5EC', { replaces: true })
+ dispatchKey(rig, 'keydown', { key: ' ', code: 'Space', keyCode: 32 })
+ await nextEventLoop()
+ expect(rig.emitted.join('')).toContain('\uD5EC')
+ })
+
+ it('does not resurrect the opening jamo after Backspace emptied the field', async () => {
+ // Why: the openKey is owed to the PTY only while the IME has written nothing.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, '\u314E', '\u314E', { replaces: false })
+
+ dispatchKey(rig, 'keydown', { key: 'Backspace', code: 'Backspace', keyCode: 8 })
+ rig.textarea.value = ''
+ dispatchInput(rig, 'deleteContentBackward', null)
+ await nextEventLoop()
+
+ dispatchKey(rig, 'keydown', { key: ' ', code: 'Space', keyCode: 32 })
+ await nextEventLoop()
+ expect(rig.emitted.join('')).not.toContain('\u314E')
+ })
+
+ it('composes one syllable from a field the IME leaves decomposed', async () => {
+ // Why: NFD grows the field by appending jamo, and an append is what the diff
+ // reads as the previous syllable settling — each jamo would go out alone.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ const steps: [string, string][] = [
+ ['\u314E', '\u1112'],
+ ['\u3153', '\u1112\u1165'],
+ ['\u3134', '\u1112\u1165\u11AB']
+ ]
+ for (const [key, field] of steps) {
+ dispatchKey(rig, 'keydown', { key, keyCode: key.charCodeAt(0) })
+ rig.textarea.value = field
+ dispatchInput(rig, 'insertText', field.slice(-1))
+ await nextEventLoop()
+ expect(rig.emitted).toEqual([])
+ }
+
+ dispatchKey(rig, 'keydown', { key: ' ', code: 'Space', keyCode: 32 })
+ await nextEventLoop()
+ expect(rig.emitted[0]).toBe('\uD5CC')
+ })
+
+ it('clears the overlay when the pane disposes the controller', async () => {
+ // Why: the pane disposes the controller before xterm tears its DOM down, so
+ // a held syllable would otherwise stay painted over a dead pane.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, '\u314E', '\u314E', { replaces: false })
+ expect(rig.compositionView.classList.contains('active')).toBe(true)
+
+ rig.preedit.dispose()
+
+ expect(rig.compositionView.classList.contains('active')).toBe(false)
+ expect(rig.compositionView.textContent).toBe('')
+ })
+
+ it('discards the open syllable on Escape, as a composition does', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ dispatchKey(rig, 'keydown', { key: 'Escape', code: 'Escape', keyCode: 27 })
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual([])
+ expect(rig.textarea.value).toBe('')
+ expect(rig.compositionView.classList.contains('active')).toBe(false)
+ })
+
+ it('sends a jamo the IME never composed, once the next keydown settles it', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ dispatchKey(rig, 'keydown', { key: 'ㅎ', keyCode: 'ㅎ'.charCodeAt(0) })
+ await nextEventLoop()
+
+ // Nothing is sent on a timer: the device delivers its write later than a
+ // macrotask, so a deferred read that concluded "nothing was composed" would
+ // fire first and send every jamo raw. The hold stays open instead.
+ expect(rig.emitted).toEqual([])
+
+ dispatchKey(rig, 'keydown', { key: 'Enter', code: 'Enter', keyCode: 13 })
+ expect(rig.emitted).toEqual(['ㅎ', '\r'])
+ })
+
+ it('holds the jamo when the write lands after a full event loop turn', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ dispatchKey(rig, 'keydown', { key: 'ㅎ', keyCode: 'ㅎ'.charCodeAt(0) })
+ await nextEventLoop()
+
+ // The shape the device produces: the field is written only after the turn.
+ rig.textarea.value = 'ㅎ'
+ dispatchInput(rig, 'insertText', 'ㅎ')
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual([])
+ expect(rig.compositionView.textContent).toBe('ㅎ')
+ })
+
+ it('keeps a screen reader on its existing path, so input cannot vanish', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal({ screenReaderMode: true })
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+
+ expect(rig.preedit.heldText()).toBe('')
+ expect(rig.compositionView.classList.contains('active')).toBe(false)
+ })
+
+ it('leaves the syllable held when an input event changes nothing', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ await typeJamo(rig, 'ㅏ', '하', { replaces: true })
+
+ // A rewrite that lands on the same text is not the IME finishing `하`.
+ dispatchInput(rig, 'insertText', '하')
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual([])
+ expect(rig.preedit.heldText()).toBe('하')
+ })
+
+ it('does not commit a held syllable the field has stopped showing', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+
+ // Longer than what is held, but not an extension of it: the IME respelled
+ // the syllable rather than finishing it, so `ㅎ` is no longer owed.
+ rig.textarea.value = '가나'
+ dispatchInput(rig, 'insertText', '가나')
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual([])
+ expect(rig.preedit.heldText()).toBe('가나')
+ })
+
+ it('commits the held syllable when the field is rewritten out from under it', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeHangeul(rig)
+
+ // Not the IME rewriting its own syllable: the text the hold measures against
+ // is gone entirely, so `글` is still owed to the PTY rather than droppable.
+ rig.textarea.value = 'ls'
+ dispatchInput(rig, 'insertText', 's')
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual(['한', '글'])
+ expect(rig.preedit.heldText()).toBe('')
+ })
+
+ it('sends a jamo composed inside a session once, not twice', async () => {
+ // The iPad on-screen keyboard does run a composition for Hangul, unlike the
+ // hardware one. A hold opened over that session would commit the raw jamo
+ // alongside xterm's own commit. `isComposing` is unreliable on a session's
+ // keydowns — the reason xterm has a 229 path at all — so the session state
+ // is what has to gate the hold.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ dispatchComposition(rig, 'compositionstart', '')
+ dispatchKey(rig, 'keydown', { key: 'ㅎ', keyCode: 'ㅎ'.charCodeAt(0) })
+ dispatchComposition(rig, 'compositionupdate', 'ㅎ')
+ rig.textarea.value = 'ㅎ'
+ dispatchInput(rig, 'insertCompositionText', 'ㅎ', { isComposing: true })
+ dispatchKey(rig, 'keydown', { key: 'ㅏ', keyCode: 'ㅏ'.charCodeAt(0) })
+ dispatchComposition(rig, 'compositionupdate', '하')
+ rig.textarea.value = '하'
+ dispatchInput(rig, 'insertCompositionText', '하', { isComposing: true })
+ dispatchComposition(rig, 'compositionend', '하')
+ dispatchInput(rig, 'insertCompositionText', '하')
+ await nextEventLoop()
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual(['하'])
+ })
+
+ it('claims the field write even once the key has been released', async () => {
+ // xterm drops a composed `insertText` only while a key is down. When the
+ // device delivers the write after the keyup, nothing but the claim keeps
+ // xterm from sending the syllable a second time.
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ dispatchKey(rig, 'keydown', { key: 'ㅎ', keyCode: 'ㅎ'.charCodeAt(0) })
+ dispatchKey(rig, 'keyup', { key: 'ㅎ', keyCode: 'ㅎ'.charCodeAt(0) })
+ rig.textarea.value = 'ㅎ'
+ dispatchInput(rig, 'insertText', 'ㅎ')
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual([])
+ expect(rig.preedit.heldText()).toBe('ㅎ')
+ })
+
+ it('stands aside for composition-owned input without consulting session state', async () => {
+ // Standing aside is read off the event, not off the tracker, so it cannot
+ // depend on which `input` listener on the pane element runs first.
+ pretendIosWeb()
+ const rig = openIosTerminal({ isCompositionActive: () => false })
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ rig.textarea.value = 'ㅎ하'
+ dispatchInput(rig, 'insertCompositionText', '하')
+ await nextEventLoop()
+
+ expect(rig.emitted).toEqual(['ㅎ'])
+ expect(rig.preedit.heldText()).toBe('')
+ })
+
+ it('stands aside for a composition that starts over an open syllable', async () => {
+ pretendIosWeb()
+ const rig = openIosTerminal()
+ await typeJamo(rig, 'ㅎ', 'ㅎ', { replaces: false })
+ dispatchComposition(rig, 'compositionstart', '')
+
+ expect(rig.emitted).toEqual(['ㅎ'])
+ expect(rig.preedit.heldText()).toBe('')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.ts b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.ts
new file mode 100644
index 00000000000..baa0c27d516
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/terminal-ios-hangul-preedit.ts
@@ -0,0 +1,316 @@
+import type { IDisposable } from '@xterm/xterm'
+import { isHangulJamoKeyText } from './hangul-jamo-key'
+
+// Why: iPadOS composes Hangul from a hardware keyboard with no composition
+// events at all (#13345). Each jamo arrives as a plain keydown while the IME
+// rewrites the syllable in place in the helper textarea —
+// `deleteContentBackward` then a replacing `insertText`. xterm consumes the
+// keydown, so the raw compatibility jamo reaches the PTY and the composed
+// syllable is dropped: `한글` arrives as `ㅎㅏㄴㄱㅡㄹ`.
+//
+// The keydown is handed to the system by `shouldBypassXtermForIosTextEdit`, and
+// the syllable it builds is held here until the IME proves it is final —
+// nothing is ever sent and then retracted, so raw-mode TUIs never see DEL bytes
+// they would have to interpret as "erase one cell".
+//
+// Everything is driven from `input`. iPadOS delivers it later than a macrotask,
+// so any timer-based read of the field loses the race and concludes, wrongly,
+// that nothing was composed.
+
+type OpenPreedit = {
+ /** Field text preceding the syllable; everything after it is uncommitted. */
+ baseValue: string
+ /** Held back: shown, not sent. The open syllable, plus the one before it
+ * while a migrating batchim could still pull back into it. */
+ heldText: string
+ /** The jamo that opened the hold, owed to the PTY if the IME never writes. */
+ openKey: string
+ /** Set once the IME has written to the field, which retires `openKey`. */
+ imeWrote: boolean
+ /** What the last keydown asked the IME to do to the syllable. */
+ editKind: 'compose' | 'erase'
+}
+
+export type TerminalIosHangulPreedit = IDisposable & {
+ /** The text currently held back from the PTY, or '' when none is. */
+ heldText: () => string
+}
+
+export type TerminalIosHangulPreeditOptions = {
+ terminalElement: HTMLElement | null | undefined
+ /** Whether a composition session is under way, which bars a hold from opening
+ * over it — Chinese pinyin on the same device does run one, and xterm's
+ * CompositionHelper already commits it. Must be derived and expiring, not
+ * latched: a session that never ends would otherwise disable the pane. */
+ isCompositionActive: () => boolean
+ /** Screen reader mode writes the textarea itself, so a field diff would not
+ * be the IME's alone. */
+ isScreenReaderMode: () => boolean
+ sendInput: (data: string) => void
+ /** Draws the open syllable; called with '' when there is none. */
+ renderPreedit?: (text: string) => void
+}
+
+const NO_OP_PREEDIT: TerminalIosHangulPreedit = {
+ heldText: () => '',
+ dispose: () => undefined
+}
+
+function asHelperTextarea(target: EventTarget | null): HTMLTextAreaElement | null {
+ if (!(target instanceof HTMLTextAreaElement)) {
+ return null
+ }
+ return target.classList.contains('xterm-helper-textarea') ? target : null
+}
+
+function isUnmodified(event: KeyboardEvent): boolean {
+ return !event.ctrlKey && !event.altKey && !event.metaKey
+}
+
+function isJamoKey(event: KeyboardEvent): boolean {
+ return isHangulJamoKeyText(event.key) && isUnmodified(event)
+}
+
+function isDeletion(event: Event): boolean {
+ return event instanceof InputEvent && event.inputType.startsWith('delete')
+}
+
+/** One field rewrite, located: where the IME began and what it put there. */
+type FieldEdit = {
+ /** Index of the first character it changed. */
+ start: number
+ /** What it wrote from `start` on; empty when the rewrite wrote nothing. */
+ text: string
+}
+
+/**
+ * Why NFC: a decomposed field grows `ᄒ` -> `하` -> `한` by appending, and an
+ * append is exactly what the diff reads as the previous syllable settling — so
+ * an NFD source would put one bare jamo on the wire per keystroke. Composed,
+ * the same three keystrokes rewrite one character in place, as the device trace
+ * does. Idempotent on the NFC text iPadOS was recorded producing.
+ */
+function normalizeFieldText(value: string): string {
+ return value.normalize('NFC')
+}
+
+/**
+ * Diffs two field states, taking the common prefix as where the IME began.
+ *
+ * Why not "did the tail grow past what is held": Korean batchim migration
+ * rewrites in place. `깨` + `ㅈ` becomes `깾` — a different codepoint, not an
+ * extension — and only becomes `깨주` once the next vowel decides where the `ㅈ`
+ * belongs. Any monotonic-growth assumption stalls there.
+ *
+ * No common-suffix half, unlike the usual textarea-IME diff: the IME edits at
+ * the caret and the caret is the end of the helper textarea, so there is never
+ * an untouched tail to discount.
+ */
+function diffFieldEdit(prev: string, next: string): FieldEdit {
+ const limit = Math.min(prev.length, next.length)
+ let start = 0
+ while (start < limit && prev[start] === next[start]) {
+ start += 1
+ }
+ return { start, text: next.slice(start) }
+}
+
+/** True for input the browser attributes to a composition session, whichever
+ * order it interleaves those events in. */
+function isCompositionOwnedInput(event: Event): boolean {
+ return (
+ event instanceof InputEvent &&
+ (event.isComposing === true || event.inputType === 'insertCompositionText')
+ )
+}
+
+/**
+ * Holds the Hangul syllable iPadOS is still building and commits it only once
+ * the IME proves it final, so the PTY sees one write per syllable.
+ */
+export function installTerminalIosHangulPreedit(
+ options: TerminalIosHangulPreeditOptions
+): TerminalIosHangulPreedit {
+ const root = options.terminalElement
+ if (!root) {
+ return NO_OP_PREEDIT
+ }
+
+ let preedit: OpenPreedit | null = null
+
+ const render = (text: string): void => options.renderPreedit?.(text)
+
+ const close = (): void => {
+ preedit = null
+ render('')
+ }
+
+ const commit = (): void => {
+ const open = preedit
+ if (!open) {
+ return
+ }
+ // A hold the IME never wrote to still owes the keystroke it swallowed.
+ const text =
+ open.heldText || (!open.imeWrote && isHangulJamoKeyText(open.openKey) ? open.openKey : '')
+ close()
+ if (text) {
+ options.sendInput(text)
+ }
+ }
+
+ const discard = (textarea: HTMLTextAreaElement): void => {
+ const open = preedit
+ if (!open) {
+ return
+ }
+ // The cancelled syllable never reached the PTY, so it must not survive in
+ // the field either.
+ textarea.value = open.baseValue
+ close()
+ }
+
+ /**
+ * Re-derives the hold from the field, releasing whatever the IME has stopped
+ * rewriting.
+ *
+ * A syllable is settled once the IME's own edit point has moved past it: up
+ * to that index the batchim question — does this consonant end this syllable
+ * or start the next — is already answered, and nothing there can change
+ * without a keystroke that ends the hold anyway.
+ */
+ const sync = (textarea: HTMLTextAreaElement): void => {
+ const open = preedit
+ if (!open) {
+ return
+ }
+ const value = normalizeFieldText(textarea.value)
+ if (!value.startsWith(open.baseValue)) {
+ // The field was rewritten out from under the hold; commit rather than
+ // measure against text that is gone.
+ commit()
+ return
+ }
+ const tail = value.slice(open.baseValue.length)
+ if (tail.length === 0) {
+ // Backspace can decompose as delete-then-insert, so an emptied field is
+ // not proof the syllable is gone — only that this half of the rewrite
+ // wrote nothing. The hold stays open, empty; the Backspace that finds it
+ // empty is the one the PTY gets. Nothing was sent, so nothing needs
+ // undoing either way.
+ open.heldText = ''
+ render('')
+ return
+ }
+ const edit = diffFieldEdit(open.heldText, tail)
+ // An edit that wrote nothing settles nothing: a rewrite landing on the same
+ // text, or a Backspace shortening the tail, is not the IME deciding.
+ if (edit.start > 0 && edit.text.length > 0) {
+ const settled = tail.slice(0, edit.start)
+ open.baseValue += settled
+ options.sendInput(settled)
+ }
+ open.heldText = value.slice(open.baseValue.length)
+ render(open.heldText)
+ }
+
+ const handleKeyDown = (event: Event): void => {
+ if (!(event instanceof KeyboardEvent)) {
+ return
+ }
+ const textarea = root.querySelector('.xterm-helper-textarea')
+ if (!textarea) {
+ return
+ }
+ if (preedit) {
+ preedit.editKind = 'compose'
+ if (event.key === 'Escape' && isUnmodified(event)) {
+ // Escape cancels the syllable the way it cancels a composition. Stopped
+ // here so xterm cannot also send it; the default action still lets the
+ // IME clear its own state.
+ event.stopImmediatePropagation()
+ discard(textarea)
+ return
+ }
+ if (event.key === 'Backspace' && isUnmodified(event)) {
+ if (!preedit.heldText) {
+ // Nothing left to decompose: this erase is the PTY's, and xterm sends it.
+ close()
+ return
+ }
+ // Backspace decomposes the held syllable in the field rather than
+ // erasing a written cell, so it must not reach the PTY as DEL.
+ preedit.editKind = 'erase'
+ event.stopImmediatePropagation()
+ return
+ }
+ if (isJamoKey(event)) {
+ return
+ }
+ // Anything else ends the syllable, and runs before xterm sends the key.
+ commit()
+ return
+ }
+ if (
+ isJamoKey(event) &&
+ event.isComposing !== true &&
+ !options.isCompositionActive() &&
+ !options.isScreenReaderMode()
+ ) {
+ preedit = {
+ baseValue: normalizeFieldText(textarea.value),
+ heldText: '',
+ openKey: event.key,
+ imeWrote: false,
+ editKind: 'compose'
+ }
+ }
+ }
+
+ const handleInput = (event: Event): void => {
+ if (!preedit) {
+ return
+ }
+ const textarea = asHelperTextarea(event.target)
+ if (!textarea) {
+ return
+ }
+ if (isCompositionOwnedInput(event)) {
+ // A session took the field over; it owns the commit from here. Read off
+ // the event rather than the session state, so this cannot depend on which
+ // `input` listener on the pane element happens to run first.
+ commit()
+ return
+ }
+ // Why: the field keeps the syllable so the IME can rewrite it, and xterm
+ // must not read that as fresh input.
+ event.stopImmediatePropagation()
+ preedit.imeWrote = true
+ if (preedit.editKind === 'compose' && isDeletion(event)) {
+ // Half of the delete-then-insert the IME uses to replace a growing
+ // syllable. Reading the field between the two would see it emptied.
+ return
+ }
+ sync(textarea)
+ }
+
+ // A real session owns the field from here, so anything held is final.
+ const handleCompositionStart = (): void => commit()
+ const handleBlur = (): void => commit()
+
+ root.addEventListener('keydown', handleKeyDown, true)
+ root.addEventListener('input', handleInput, true)
+ root.addEventListener('compositionstart', handleCompositionStart, true)
+ root.addEventListener('blur', handleBlur, true)
+
+ return {
+ heldText: () => preedit?.heldText ?? '',
+ dispose: () => {
+ root.removeEventListener('keydown', handleKeyDown, true)
+ root.removeEventListener('input', handleInput, true)
+ root.removeEventListener('compositionstart', handleCompositionStart, true)
+ root.removeEventListener('blur', handleBlur, true)
+ close()
+ }
+ }
+}
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
index 053a22405d5..a8b26cd6341 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts
@@ -6,7 +6,6 @@ import {
createQueuedStartupConsumer,
getPreviousVisibleForTerminalPane,
isTerminalPaneVisibilityResume,
- isTouchIOSUserAgent,
mapRestoredPaneTitlesByPaneId,
paneOwnsQueuedStartup,
resolvePaneLinkCwd,
@@ -674,39 +673,3 @@ describe('terminal pane visibility resume tracking', () => {
expect(isTerminalPaneVisibilityResume({ previousIsVisible: false, isVisible: true })).toBe(true)
})
})
-
-describe('isTouchIOSUserAgent', () => {
- it('is false for a real Mac (Macintosh UA, no touch points)', () => {
- expect(
- isTouchIOSUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 0)
- ).toBe(false)
- })
-
- it('is true for iPadOS desktop-mode Safari (Macintosh UA plus touch points)', () => {
- expect(
- isTouchIOSUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 5)
- ).toBe(true)
- })
-
- it('is true for iPhone Safari ("like Mac OS X" UA plus touch points)', () => {
- expect(
- isTouchIOSUserAgent(
- 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15',
- 5
- )
- ).toBe(true)
- })
-
- it('is false for non-Mac UAs regardless of touch points', () => {
- expect(isTouchIOSUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', 5)).toBe(false)
- expect(
- isTouchIOSUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 5)
- ).toBe(false)
- })
-
- it('keeps the forwarder on a Mac whose touch peripheral reports a single point', () => {
- expect(
- isTouchIOSUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', 1)
- ).toBe(false)
- })
-})
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index 52a348b627d..0f033c1c3c8 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -95,6 +95,7 @@ import {
resolveNonLatinControlChordInput
} from './terminal-non-latin-control-chord'
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
+import { isCurrentPlatformIosWeb } from '@/lib/ios-web-platform'
import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state'
import {
armTerminalImePendingCandidateKeyRelease,
@@ -103,6 +104,8 @@ import {
shouldApplyTerminalImePendingCandidateKeyRelease
} from './terminal-ime-candidate-key-release-guard'
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
+import { installTerminalIosHangulPreedit } from './terminal-ios-hangul-preedit'
+import { createTerminalIosHangulPreeditRenderer } from './terminal-ios-hangul-preedit-overlay'
import {
shouldBypassXtermKeyboardEvent,
shouldHandleTerminalInterruptKeyboardEvent,
@@ -453,13 +456,6 @@ export function resolvePaneSeedCwd(splitPaneCwd: string | undefined, fallbackCwd
return splitPaneCwd ?? fallbackCwd
}
-// Why > 1, matching isIOSWebView in mobile/src/terminal/terminal-webview-html.ts: a Mac with a
-// touch peripheral can report exactly 1, and it must keep the forwarder. Real iPads report 5.
-// Why UA rather than that helper's platform check: an iPhone reports platform "iPhone", not "MacIntel".
-export function isTouchIOSUserAgent(userAgent: string, maxTouchPoints: number): boolean {
- return userAgent.includes('Mac') && maxTouchPoints > 1
-}
-
type SplitStartupPayload = { command: string; env?: Record }
type SplitWithStartupDeps = {
@@ -1061,22 +1057,36 @@ export function useTerminalPaneLifecycle({
!isMac &&
navigator.userAgent.includes('Linux') &&
!/Android|CrOS/.test(navigator.userAgent)
- // Why: gates the forwarder only — isMac stays as-is for the Ctrl+C, clipboard, JIS-yen and 229 policies.
- const isTouchIOS = isTouchIOSUserAgent(navigator.userAgent, navigator.maxTouchPoints)
+ // Why: gates the iOS text-edit paths only — isMac stays as-is for the Ctrl+C, clipboard, JIS-yen and 229 policies.
+ const isIosWeb = isCurrentPlatformIosWeb()
const linuxImeCandidateState = isLinux
? installTerminalImeLinuxCandidateState(pane.terminal.element)
: null
const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element)
+ // Why after the tracker: the preedit stops propagation on `input` while
+ // a syllable is held, so anything on this element that needs those
+ // events has to be registered ahead of it. Nothing does today — the
+ // preedit reads composition ownership off the event itself.
+ const iosHangulPreedit = isIosWeb
+ ? installTerminalIosHangulPreedit({
+ terminalElement: pane.terminal.element,
+ isCompositionActive: () => imeCompositionTracker.isActive(),
+ isScreenReaderMode: () => pane.terminal.options.screenReaderMode === true,
+ sendInput: (data) => pane.terminal.input(data),
+ renderPreedit: createTerminalIosHangulPreeditRenderer(pane.terminal)
+ })
+ : null
imeCompositionDisposablesRef.current.set(pane.id, {
dispose: () => {
imeCompositionTracker.dispose()
linuxImeCandidateState?.dispose()
+ iosHangulPreedit?.dispose()
}
})
// Why: macOS commits an input source's substituted text through the input event alone, so printable keydowns must not reach xterm's encoder.
// Not on touch iOS/iPadOS: the forwarder stands aside for IME input via composition events, which iPad Hangul appears not to fire (#13345).
const imeNativeTextForwarder =
- isMac && !isTouchIOS
+ isMac && !isIosWeb
? installTerminalImeNativeTextForwarder({
terminalElement: pane.terminal.element,
isComposing: () => imeCompositionTracker.isActive(),
@@ -1216,6 +1226,7 @@ export function useTerminalPaneLifecycle({
const shouldBypass = shouldBypassXtermKeyboardEvent(e, {
isMac,
+ isIosWeb,
hasSelection: pane.terminal.hasSelection(),
kittyKeyboardFlags: paneKittyKeyboardModesRef.current.get(pane.id)?.flags ?? 0
})
diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts
new file mode 100644
index 00000000000..9c8d7ccff79
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-ios.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest'
+import {
+ shouldBypassXtermForIosTextEdit,
+ shouldBypassXtermKeyboardEvent
+} from './xterm-bypass-policy'
+import { event } from './xterm-bypass-event-fixture'
+
+// iPadOS reports a real physical key for every jamo — `key: 'ㅎ'`, `code: 'KeyG'`,
+// `keyCode: 71`, `isComposing: false` — and runs no composition session. Only by
+// leaving those keys to the default handler does the system compose them.
+const HANGUL_JAMO_KEYDOWN = { key: 'ㅎ', code: 'KeyG', keyCode: 71 }
+
+describe('shouldBypassXtermForIosTextEdit', () => {
+ it('claims an unmodified jamo keydown on iOS web', () => {
+ expect(shouldBypassXtermForIosTextEdit(event(HANGUL_JAMO_KEYDOWN), true)).toBe(true)
+ })
+
+ it('claims the matching keyup and keypress so xterm cannot re-send the glyph', () => {
+ for (const type of ['keyup', 'keypress']) {
+ expect(shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, type }), true)).toBe(
+ true
+ )
+ }
+ })
+
+ it('leaves ASCII alone so English typing keeps the normal xterm path', () => {
+ expect(
+ shouldBypassXtermForIosTextEdit(event({ key: 'a', code: 'KeyA', keyCode: 65 }), true)
+ ).toBe(false)
+ })
+
+ it('leaves other non-ASCII scripts alone, since nothing downstream re-sends them', () => {
+ // Why: this claims the keydown, the keypress and (through the preedit
+ // controller) the `input` too. Only Hangul has a handler on the other side;
+ // a Cyrillic or kana key would reach the PTY as nothing at all.
+ for (const key of ['п', 'あ', 'é', '中']) {
+ expect(shouldBypassXtermForIosTextEdit(event({ key }), true)).toBe(false)
+ }
+ })
+
+ it('claims Shift-typed double consonants, which start 깨 꿈 딸 빵 쓰다 짜다', () => {
+ for (const key of ['ㄲ', 'ㄸ', 'ㅃ', 'ㅆ', 'ㅉ']) {
+ expect(shouldBypassXtermForIosTextEdit(event({ key, shiftKey: true }), true)).toBe(true)
+ // Orca's own Shift rule already hides these keydowns from xterm, so only
+ // the keypress claim keeps `_keyPress` from sending the raw jamo.
+ expect(
+ shouldBypassXtermForIosTextEdit(event({ key, shiftKey: true, type: 'keypress' }), true)
+ ).toBe(true)
+ }
+ })
+
+ it('leaves named keys alone so Enter and arrows still reach the shell', () => {
+ for (const key of ['Enter', 'Backspace', 'ArrowLeft', 'Escape']) {
+ expect(shouldBypassXtermForIosTextEdit(event({ key }), true)).toBe(false)
+ }
+ })
+
+ it('leaves modifier chords alone so shortcuts are not swallowed', () => {
+ expect(
+ shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, ctrlKey: true }), true)
+ ).toBe(false)
+ expect(
+ shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, metaKey: true }), true)
+ ).toBe(false)
+ expect(
+ shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, altKey: true }), true)
+ ).toBe(false)
+ })
+
+ it('leaves composing keystrokes to xterm, for iOS sources that do compose', () => {
+ // Why: the on-screen keyboard and the Japanese/Chinese IMEs run a real
+ // composition session, which xterm's CompositionHelper already commits.
+ expect(
+ shouldBypassXtermForIosTextEdit(event({ ...HANGUL_JAMO_KEYDOWN, isComposing: true }), true)
+ ).toBe(false)
+ })
+
+ it('is inert off iOS web, leaving desktop IME handling untouched', () => {
+ expect(shouldBypassXtermForIosTextEdit(event(HANGUL_JAMO_KEYDOWN), false)).toBe(false)
+ })
+})
+
+describe('shouldBypassXtermKeyboardEvent — iOS web', () => {
+ const iosOptions = { isMac: true, isIosWeb: true, hasSelection: false }
+ const macOptions = { isMac: true, hasSelection: false }
+
+ it('bypasses a bare jamo keydown', () => {
+ expect(shouldBypassXtermKeyboardEvent(event(HANGUL_JAMO_KEYDOWN), iosOptions)).toBe(true)
+ })
+
+ it('does not bypass the same key on a Mac desktop browser', () => {
+ // Why: iPadOS reports `Macintosh` in its default desktop mode, so `isMac`
+ // alone must not turn the iOS path on for real Macs.
+ expect(shouldBypassXtermKeyboardEvent(event(HANGUL_JAMO_KEYDOWN), macOptions)).toBe(false)
+ })
+
+ it('still bypasses Shift+jamo without the iOS flag, as it did before', () => {
+ expect(
+ shouldBypassXtermKeyboardEvent(
+ event({ key: 'ㄲ', code: 'KeyR', keyCode: 82, shiftKey: true }),
+ macOptions
+ )
+ ).toBe(true)
+ })
+
+ it('keeps Cmd+C bubbling on iOS web', () => {
+ expect(
+ shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', metaKey: true }), iosOptions)
+ ).toBe(true)
+ })
+
+ it('keeps Enter on xterm so the command is submitted', () => {
+ expect(
+ shouldBypassXtermKeyboardEvent(
+ event({ key: 'Enter', code: 'Enter', keyCode: 13 }),
+ iosOptions
+ )
+ ).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts
index 6e8c2753802..402d452e056 100644
--- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts
+++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts
@@ -1,4 +1,5 @@
import { keybindingMatchesInput } from '../../../../shared/keybindings'
+import { isHangulJamoKeyText } from './hangul-jamo-key'
import { getLayoutBaseCharacterForCode } from '../../lib/keyboard-layout/layout-base-character'
import {
isTerminalImeCandidateDigitKeyEvent,
@@ -38,6 +39,10 @@ export type XtermBypassOptions = {
* Windows/Linux should only bubble to clipboard when something is selected,
* otherwise it must reach the shell as SIGINT. */
hasSelection: boolean
+ /** True when the renderer runs inside iOS/iPadOS WebKit, where the system
+ * composes CJK text by rewriting the field instead of running a composition
+ * session. See `terminal-ios-hangul-preedit.ts`. */
+ isIosWeb?: boolean
}
export type XtermImeKeyboardOptions = {
@@ -94,6 +99,36 @@ function isXtermHandledKeyEvent(type: string): boolean {
return type === 'keydown' || type === 'keyup'
}
+/**
+ * Why: iOS/iPadOS composes CJK text by rewriting the field through
+ * `beforeinput`/`input`, with no composition session, and that only runs when
+ * the printable keydown reaches the default handler. xterm has to stay out of
+ * the way for those keys so `terminal-ios-hangul-preedit.ts` can read the
+ * resulting edits. `keypress` is included because `_keyPress` would otherwise
+ * send the glyph a second time alongside the preedit commit.
+ */
+export function shouldBypassXtermForIosTextEdit(
+ event: XtermBypassEvent,
+ isIosWeb: boolean
+): boolean {
+ if (!isIosWeb || event.ctrlKey || event.metaKey || event.altKey) {
+ return false
+ }
+ if (event.isComposing === true) {
+ // Why: input sources that do run a composition session stay with xterm's
+ // CompositionHelper, which already commits them correctly.
+ return false
+ }
+ if (!isXtermHandledKeyEvent(event.type) && event.type !== 'keypress') {
+ return false
+ }
+ // Why jamo and not every non-ASCII key: nothing downstream re-sends a key
+ // this claims. A Cyrillic or kana key would lose its keydown, its keypress,
+ // and then its `input` too — xterm drops a composed insert while a key is
+ // down — and reach the PTY as nothing at all.
+ return isHangulJamoKeyText(event.key)
+}
+
/** Returns whether the Linux orphan-keyup window may claim this digit. */
function claimsOrphanCandidateDigit(
event: XtermBypassEvent,
@@ -256,6 +291,9 @@ export function shouldBypassXtermKeyboardEvent(
event: XtermBypassEvent,
options: XtermBypassOptions
): boolean {
+ if (shouldBypassXtermForIosTextEdit(event, options.isIosWeb === true)) {
+ return true
+ }
if (!isXtermHandledKeyEvent(event.type)) {
return false
}
diff --git a/src/renderer/src/lib/ios-web-platform.test.ts b/src/renderer/src/lib/ios-web-platform.test.ts
new file mode 100644
index 00000000000..e5381b312ce
--- /dev/null
+++ b/src/renderer/src/lib/ios-web-platform.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import { isIosWebPlatform } from './ios-web-platform'
+
+const IPAD_DESKTOP_MODE_UA =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
+const IPAD_MOBILE_MODE_UA =
+ 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148 Safari/604.1'
+const IPHONE_UA =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
+const IPOD_UA =
+ 'Mozilla/5.0 (iPod touch; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1'
+const MAC_DESKTOP_UA =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
+const MAC_ELECTRON_UA =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36'
+const WINDOWS_UA =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
+const ANDROID_UA =
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'
+
+describe('isIosWebPlatform', () => {
+ it('detects iPadOS desktop mode, which reports Macintosh and omits iPad', () => {
+ expect(isIosWebPlatform(IPAD_DESKTOP_MODE_UA, 5)).toBe(true)
+ })
+
+ it('detects iPad, iPhone and iPod mobile-mode user agents', () => {
+ expect(isIosWebPlatform(IPAD_MOBILE_MODE_UA, 5)).toBe(true)
+ expect(isIosWebPlatform(IPHONE_UA, 5)).toBe(true)
+ expect(isIosWebPlatform(IPOD_UA, 5)).toBe(true)
+ })
+
+ it('detects a mobile-mode iPad even when the touch count is unavailable', () => {
+ expect(isIosWebPlatform(IPAD_MOBILE_MODE_UA, 0)).toBe(true)
+ })
+
+ it('does not claim a Mac browser or the Electron app', () => {
+ expect(isIosWebPlatform(MAC_DESKTOP_UA, 0)).toBe(false)
+ expect(isIosWebPlatform(MAC_ELECTRON_UA, 0)).toBe(false)
+ })
+
+ it('does not claim a Mac whose touch peripheral reports a single point', () => {
+ expect(isIosWebPlatform(MAC_DESKTOP_UA, 1)).toBe(false)
+ })
+
+ it('does not claim Windows, Linux or Android', () => {
+ expect(isIosWebPlatform(WINDOWS_UA, 0)).toBe(false)
+ expect(isIosWebPlatform(ANDROID_UA, 5)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/lib/ios-web-platform.ts b/src/renderer/src/lib/ios-web-platform.ts
new file mode 100644
index 00000000000..7451cff8768
--- /dev/null
+++ b/src/renderer/src/lib/ios-web-platform.ts
@@ -0,0 +1,28 @@
+/**
+ * True when the renderer runs inside iOS/iPadOS WebKit — the web client opened
+ * in a browser on an iPhone or iPad.
+ *
+ * Every iOS and iPadOS user agent contains `Mac`: iPad desktop mode (the
+ * default since iPadOS 13) reports `Macintosh; Intel Mac OS X`, and mobile mode
+ * reports `like Mac OS X`. Desktop mode omits `iPad`, so the touch-point count
+ * is the only signal separating an iPad from a Mac. Both browsers on the
+ * platform are WebKit — iOS bars third-party engines — so this covers Safari,
+ * Chrome and the rest alike.
+ */
+export function isIosWebPlatform(userAgent: string, maxTouchPoints: number): boolean {
+ if (/iPad|iPhone|iPod/.test(userAgent)) {
+ return true
+ }
+ return userAgent.includes('Mac') && maxTouchPoints > 1
+}
+
+/**
+ * Returns true if the current environment is an iOS or iPadOS web client browser.
+ * Safe to invoke in SSR or non-DOM environments where navigator is undefined.
+ */
+export function isCurrentPlatformIosWeb(): boolean {
+ if (typeof navigator === 'undefined') {
+ return false
+ }
+ return isIosWebPlatform(navigator.userAgent, navigator.maxTouchPoints)
+}
From 350423b7cbb640cf47e46c55187d5d3b85eee511 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:09:07 -0700
Subject: [PATCH 07/11] Speed up PR CI with path skips, native caches, and
fewer shards (#16863)
* Speed up PR CI with per-job path skips and native caches
Skip git-compat, xterm, packaging, and shell jobs when their inputs are
unchanged, reuse the composite install action (including Windows node-pty
cache), skip compiling the Windows CLI launcher on a cache hit, and cut the
test matrix from 16x2 to 8x2 shards without dropping coverage.
* Widen PR job skip prefixes for orcad browser and live shells
Chrome session/tab modules and zsh/fish wrapper templates are inputs to
required jobs the classifier previously skipped. Include that implementation
graph so those jobs still run when the files they load change.
* Fix CI cache safety and required gates
* Build scriptless Windows addons explicitly
* Preserve node-pty Windows support prebuild
* Remove duplicated Windows launcher unit lane
---
.../install-node-dependencies/action.yml | 52 +++-
.github/workflows/computer-e2e.yml | 27 +-
.github/workflows/e2e.yml | 113 +-------
.github/workflows/pr.yml | 184 +++++++------
config/scripts/build-windows-cli-launcher.mjs | 93 ++++---
.../build-windows-cli-launcher.test.mjs | 30 +++
config/scripts/computer-e2e-workflow.test.mjs | 5 +-
config/scripts/ensure-native-runtime.mjs | 58 ++--
config/scripts/ensure-native-runtime.test.mjs | 97 ++++++-
config/scripts/pr-code-change-scope.mjs | 254 +++++++++++++++++-
config/scripts/pr-code-change-scope.test.mjs | 213 +++++++++++++--
config/scripts/pr-e2e-gate-contract.test.mjs | 26 +-
.../scripts/pr-workflow-parallelism.test.mjs | 57 ++--
config/scripts/rebuild-native-deps.mjs | 26 +-
config/scripts/rebuild-native-deps.test.mjs | 7 +
.../skills-cli-package-workflow.test.mjs | 1 +
...ws-pty-native-capability-workflow.test.mjs | 21 +-
docs/reference/windows-process-enumeration.md | 8 +-
...pty-subprocess-foreground-identity.test.ts | 1 +
...subprocess-foreground-scan-cadence.test.ts | 9 +
.../pty-subprocess-handle-lifecycle.test.ts | 1 +
21 files changed, 961 insertions(+), 322 deletions(-)
diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml
index 39cb1465852..e6d6f8f15e3 100644
--- a/.github/actions/install-node-dependencies/action.yml
+++ b/.github/actions/install-node-dependencies/action.yml
@@ -1,5 +1,5 @@
name: Install Node dependencies
-description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
+description: Installs the Node toolchain and repository dependencies for CI jobs.
inputs:
native-runtime:
@@ -10,6 +10,18 @@ inputs:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
+ persist-native-cache:
+ description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
+ required: false
+ default: 'true'
+
+outputs:
+ node-version:
+ description: Resolved Node.js version used for the install.
+ value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
+ native-cache-scope:
+ description: Operating-system image scope used by the native module cache.
+ value: ${{ steps.native-cache-scope.outputs.scope }}
runs:
using: composite
@@ -51,7 +63,7 @@ runs:
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
- name: Use external node-gyp
- if: inputs.native-runtime != 'none'
+ if: runner.os == 'Linux' && inputs.native-runtime != 'none'
shell: bash
run: |
npm install -g node-gyp@11.5.0
@@ -80,12 +92,42 @@ runs:
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
# Node version, and the patch whose contents the build has to match.
- - name: Restore compiled native modules
+ # Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
+ # an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
+ # Native addons built on a newer Linux image can require glibc symbols
+ # missing from an older runner/container. ImageOS distinguishes hosted
+ # Windows/macOS images; /etc/os-release also distinguishes Linux containers.
+ - name: Resolve native cache scope
+ id: native-cache-scope
if: inputs.native-runtime != 'none'
+ shell: bash
+ run: |
+ scope="${ImageOS:-$RUNNER_OS}"
+ if [ -r /etc/os-release ]; then
+ . /etc/os-release
+ scope="${ID:-linux}-${VERSION_ID:-unknown}"
+ fi
+ echo "scope=$scope" >> "$GITHUB_OUTPUT"
+
+ - name: Restore compiled native modules
+ if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
uses: actions/cache@v5
with:
- path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
- key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
+ path: |
+ node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
+ node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
+ key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
+
+ - name: Restore compiled native modules without saving
+ if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
+ uses: actions/cache/restore@v5
+ with:
+ path: |
+ node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
+ node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
+ key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml
index ab1c6ce687b..b24c8a52f2c 100644
--- a/.github/workflows/computer-e2e.yml
+++ b/.github/workflows/computer-e2e.yml
@@ -71,30 +71,17 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- - uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- - uses: pnpm/action-setup@v6
- with:
- run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
- # Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
- # permission on Linux runners; node-pty's install fallback then fails
- # before this smoke job can exercise the native package.
- - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
- if: runner.os == 'Linux'
- run: |
- npm install -g node-gyp@11.5.0
- echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- - run: pnpm install --frozen-lockfile
+ - uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run --config config/vitest.config.ts
- config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
@@ -159,13 +146,9 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- - uses: actions/setup-node@v6
+ - uses: ./.github/actions/install-node-dependencies
with:
- node-version-file: package.json
- - uses: pnpm/action-setup@v6
- with:
- run_install: false
- - run: pnpm install --frozen-lockfile
+ native-runtime: electron
- name: Owner-loss benchmark process cleanup
run: >-
pnpm vitest run
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 24c0ffa0e5b..989137b8831 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -40,36 +40,9 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
- # Why: the E2E build compiles native modules via node-gyp. Mirrors the
- # install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
- # errors.
- - name: Install native build tools
- run: sudo apt-get update && sudo apt-get install -y build-essential python3
-
- # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
- # Without that cache every E2E job re-downloaded the whole dependency set.
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
- with:
- run_install: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- # Why: this job runs the same pnpm install path as pr.yml's verify
- # job, so it needs the same pinned node-gyp override to avoid pnpm's
- # broken bundled gyp_main.py on Linux.
- - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
- if: runner.os == 'Linux'
- run: |
- npm install -g node-gyp@11.5.0
- echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
+ # Why no native-runtime: this job only produces JS bundles. Native modules
+ # are prepared in the consumer shards that actually launch Electron.
+ - uses: ./.github/actions/install-node-dependencies
# Why: building here avoids parallel builds inside Playwright globalSetup;
# paired-browser specs also need the standalone web bundle.
@@ -130,11 +103,11 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
- # Why: pnpm install rebuilds native modules, and those postinstall
- # scripts still need the Linux toolchain even though this shard reuses
- # the prebuilt Electron output.
+ # Why: pnpm install used to rebuild native modules here; the composite
+ # action restores them from cache and only compiles on a miss. The
+ # toolchain is still required for that miss path, and for paired Quick
+ # Open coverage which exercises the resource-bounded host search.
- name: Install native build tools
- # Why: paired Quick Open coverage exercises the resource-bounded host search.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
# Why: Electron on Linux needs an X display even when the app
@@ -143,33 +116,9 @@ jobs:
- name: Install xvfb
run: sudo apt-get install -y xvfb
- # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
- # Without that cache every E2E job re-downloaded the whole dependency set.
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
+ - uses: ./.github/actions/install-node-dependencies
with:
- run_install: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- # Why: this job runs the same pnpm install path as pr.yml's verify
- # job, so it needs the same pinned node-gyp override to avoid pnpm's
- # broken bundled gyp_main.py on Linux. Gate on runner.os matches
- # release.yml so the invariant "this workaround is Linux-only" is
- # consistent across all three workflows, even though this job
- # currently pins runs-on: ubuntu-latest.
- - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
- if: runner.os == 'Linux'
- run: |
- npm install -g node-gyp@11.5.0
- echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
+ native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -222,25 +171,9 @@ jobs:
# lane now receives those specs from pr.yml's SSH source mapping.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
- # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
- # Without that cache every E2E job re-downloaded the whole dependency set.
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
+ - uses: ./.github/actions/install-node-dependencies
with:
- run_install: false
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- - name: Use external node-gyp to avoid pnpm's bundled copy
- run: |
- npm install -g node-gyp@11.5.0
- echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
+ native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -305,29 +238,9 @@ jobs:
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
- # Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
- # Without that cache every E2E job re-downloaded the whole dependency set.
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
+ - uses: ./.github/actions/install-node-dependencies
with:
- run_install: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- # Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
- # stays consistent across workflows even while this job is ubuntu-latest.
- - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
- if: runner.os == 'Linux'
- run: |
- npm install -g node-gyp@11.5.0
- echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
+ native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index a256da5112c..5eadeb9be81 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -16,15 +16,28 @@ permissions:
contents: read
jobs:
- # Why: a README/docs-only PR used to start the full matrix (32 test shards,
+ # Why: a README/docs-only PR used to start the full matrix (test shards,
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
# filters on `on.pull_request` would drop the `verify` check entirely; this
# detector keeps verify as the required aggregate and skips the expensive jobs.
+ # Per-job outputs also skip git-compat/xterm/packaging/shell when those
+ # inputs are unchanged; empty diffs fail closed and run everything.
code_paths:
name: detect code-relevant changes
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
+ static_analysis: ${{ steps.filter.outputs.static_analysis }}
+ typecheck: ${{ steps.filter.outputs.typecheck }}
+ git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
+ xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
+ shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
+ test: ${{ steps.filter.outputs.test }}
+ orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
+ cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
+ managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
+ package: ${{ steps.filter.outputs.package }}
+ package_windows: ${{ steps.filter.outputs.package_windows }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -48,14 +61,12 @@ jobs:
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
- SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
- echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
- echo "should_run=$SHOULD_RUN"
+ printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
static_analysis:
name: static analysis
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.static_analysis == 'true'
runs-on: ubuntu-latest
steps:
@@ -70,6 +81,8 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
+ with:
+ native-runtime: node
- name: Lint
run: pnpm exec oxlint --format github
@@ -188,7 +201,7 @@ jobs:
typecheck:
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.typecheck == 'true'
runs-on: ubuntu-latest
steps:
@@ -216,7 +229,7 @@ jobs:
git_compatibility:
name: Git compatibility
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.git_compatibility == 'true'
runs-on: ubuntu-latest
steps:
@@ -283,7 +296,7 @@ jobs:
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.xterm_patch_sync == 'true'
runs-on: ubuntu-latest
steps:
@@ -313,7 +326,7 @@ jobs:
shell_contracts:
name: shell contracts
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.shell_contracts == 'true'
runs-on: ubuntu-latest
# Why: this job's cost is almost entirely package download, and a stalled mirror has
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
@@ -432,14 +445,14 @@ jobs:
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.test == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
- shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
- shard_total: [16]
+ shard: [1, 2, 3, 4, 5, 6, 7, 8]
+ shard_total: [8]
steps:
- name: Checkout
@@ -477,12 +490,12 @@ jobs:
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
- # Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
- # would pay for it 32 times to run one file in whichever shard it landed in.
+ # Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
+ # would pay for it on every shard to run one file in whichever shard it landed in.
orcad_browser:
name: orcad browser provider
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.orcad_browser == 'true'
runs-on: ubuntu-latest
steps:
@@ -518,7 +531,7 @@ jobs:
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.cross-version-wire == 'true'
runs-on: ubuntu-latest
steps:
@@ -550,7 +563,7 @@ jobs:
managed_hook_node18:
name: managed hooks on Node 18
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.managed_hook_node18 == 'true'
runs-on: ubuntu-latest
steps:
@@ -575,7 +588,7 @@ jobs:
package:
name: package
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
steps:
@@ -647,7 +660,7 @@ jobs:
package_windows:
name: package (windows)
needs: [code_paths]
- if: needs.code_paths.outputs.should_run == 'true'
+ if: needs.code_paths.outputs.package_windows == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -657,17 +670,6 @@ jobs:
with:
persist-credentials: false
- - name: Setup pnpm
- uses: pnpm/action-setup@v6
- with:
- run_install: false
-
- - name: Setup Node.js
- uses: actions/setup-node@v6
- with:
- node-version-file: package.json
- cache: pnpm
-
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
@@ -678,18 +680,22 @@ jobs:
restore-keys: |
electron-builder-windows-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
+ # Why persist-native-cache false: this job later rebuilds the same path for
+ # Electron. A post-job save would store the Electron ABI under the Node key.
+ - uses: ./.github/actions/install-node-dependencies
+ id: deps
+ with:
+ native-runtime: node
+ persist-native-cache: 'false'
- # Why: node-pty prefers its upstream prebuild, which does not contain
- # Orca's Windows patch, so the job-object exports would be absent and the
- # suite below would test an unpatched binary. build_from_source removes
- # the prebuild, and the package's postinstall restores the ConPTY runtime
- # files that a bare node-gyp rebuild would miss.
- - name: Rebuild node-pty from patched source
- env:
- npm_config_build_from_source: 'true'
- run: pnpm rebuild node-pty
+ - name: Save compiled Node native modules
+ uses: actions/cache/save@v5
+ with:
+ path: |
+ node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
+ node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
+ key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Test Windows-specific boundaries
run: >-
@@ -724,9 +730,26 @@ jobs:
# Why the :parallel variant: identical to build:release except the three
# electron-vite targets overlap instead of running back to back. The Linux package
# job already packages and smoke-tests an AppImage built that way.
+ - name: Cache Windows CLI launcher
+ uses: actions/cache@v5
+ with:
+ path: native/windows-cli-launcher/.build
+ key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
+
- name: Build package inputs
+ env:
+ ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
run: pnpm run build:release:parallel
+ - name: Restore compiled Electron native modules
+ uses: actions/cache@v5
+ with:
+ path: |
+ node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
+ node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
+ node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
+ key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
+
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
@@ -807,6 +830,7 @@ jobs:
- shell_contracts
- test
- orcad_browser
+ - cross-version-wire
- managed_hook_node18
- package
- package_windows
@@ -826,57 +850,63 @@ jobs:
CODE_PATHS: ${{ needs.code_paths.result }}
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
+ STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
+ TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
+ GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
+ XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
+ SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
TEST: ${{ needs.test.result }}
+ TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
+ ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
+ CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
+ CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
+ MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
PACKAGE: ${{ needs.package.result }}
+ PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
+ PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
run: |
if [ "$CODE_PATHS" != "success" ]; then
exit 1
fi
+ if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
+ exit 1
+ fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "Docs-only change; expensive PR checks skipped."
- if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
- exit 1
- fi
- for result in \
- "$STATIC_ANALYSIS" \
- "$TYPECHECK" \
- "$GIT_COMPATIBILITY" \
- "$XTERM_PATCH_SYNC" \
- "$SHELL_CONTRACTS" \
- "$TEST" \
- "$ORCAD_BROWSER" \
- "$MANAGED_HOOK_NODE18" \
- "$PACKAGE" \
- "$PACKAGE_WINDOWS"; do
- if [ "$result" != "skipped" ]; then
- exit 1
- fi
- done
- exit 0
fi
- # Require success when the PR has code-relevant changes
- for result in \
- "$CODE_PATHS" \
- "$STATIC_ANALYSIS" \
- "$ROOT_DIRECTORY_GUARD" \
- "$TYPECHECK" \
- "$GIT_COMPATIBILITY" \
- "$XTERM_PATCH_SYNC" \
- "$SHELL_CONTRACTS" \
- "$TEST" \
- "$ORCAD_BROWSER" \
- "$MANAGED_HOOK_NODE18" \
- "$PACKAGE" \
- "$PACKAGE_WINDOWS"; do
- if [ "$result" != "success" ]; then
- exit 1
+ failed=0
+ check_job() {
+ local name="$1" result="$2" should="$3"
+ if [ "$should" = "true" ]; then
+ if [ "$result" != "success" ]; then
+ echo "$name: expected success, got $result"
+ failed=1
+ fi
+ else
+ if [ "$result" != "skipped" ]; then
+ echo "$name: expected skipped, got $result"
+ failed=1
+ fi
fi
- done
+ }
+ # Require success when the PR has code-relevant changes
+ check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
+ check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
+ check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
+ check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
+ check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
+ check_job test "$TEST" "$TEST_SHOULD_RUN"
+ check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
+ check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
+ check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
+ check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
+ check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
+ exit "$failed"
diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs
index 27c17b3d15c..9d3c9020706 100644
--- a/config/scripts/build-windows-cli-launcher.mjs
+++ b/config/scripts/build-windows-cli-launcher.mjs
@@ -1,41 +1,24 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
-import { existsSync, mkdirSync } from 'node:fs'
+import { existsSync, mkdirSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
-if (process.platform !== 'win32') {
- // Why: electron-builder treats a skipped native build like success and can
- // continue toward a Windows package whose declared orca.exe does not exist.
- throw new Error(
- 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
- )
-}
-
-const repoRoot = resolve(import.meta.dirname, '../..')
-const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
-const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
-const compilerPath = findFrameworkCompiler(process.env)
-
-if (!compilerPath) {
- throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
-}
-
-mkdirSync(dirname(outputPath), { recursive: true })
-const result = spawnSync(
- compilerPath,
- ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
- { cwd: repoRoot, stdio: 'inherit' }
-)
-
-if (result.signal) {
- process.kill(process.pid, result.signal)
-}
-if (result.error) {
- throw result.error
-}
-if (result.status !== 0) {
- process.exit(result.status ?? 1)
+export function shouldReuseCompiledWindowsCliLauncher(
+ outputPath,
+ sourcePath,
+ { reuseCached = false } = {}
+) {
+ if (!existsSync(outputPath)) {
+ return false
+ }
+ // Why reuseCached: Actions cache keys already hash the C# source, but restore
+ // does not preserve mtimes, so a hit would look stale and recompile anyway.
+ if (reuseCached) {
+ return true
+ }
+ return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
}
function defaultOutputPath(projectRoot) {
@@ -58,3 +41,47 @@ function readArg(name) {
const index = process.argv.indexOf(name)
return index !== -1 ? process.argv[index + 1] : undefined
}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ if (process.platform !== 'win32') {
+ // Why: electron-builder treats a skipped native build like success and can
+ // continue toward a Windows package whose declared orca.exe does not exist.
+ throw new Error(
+ 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
+ )
+ }
+
+ const repoRoot = resolve(import.meta.dirname, '../..')
+ const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
+ const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
+ const compilerPath = findFrameworkCompiler(process.env)
+
+ if (!compilerPath) {
+ throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
+ }
+
+ mkdirSync(dirname(outputPath), { recursive: true })
+ if (
+ shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
+ reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
+ })
+ ) {
+ console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
+ process.exit(0)
+ }
+ const result = spawnSync(
+ compilerPath,
+ ['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
+ { cwd: repoRoot, stdio: 'inherit' }
+ )
+
+ if (result.signal) {
+ process.kill(process.pid, result.signal)
+ }
+ if (result.error) {
+ throw result.error
+ }
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1)
+ }
+}
diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs
index 4bb219a5687..82332897715 100644
--- a/config/scripts/build-windows-cli-launcher.test.mjs
+++ b/config/scripts/build-windows-cli-launcher.test.mjs
@@ -5,12 +5,15 @@ import {
mkdtempSync,
readFileSync,
rmSync,
+ statSync,
+ utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
+import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -37,6 +40,33 @@ function itWindows(name, test) {
}
describe('Windows CLI launcher', () => {
+ it('reuses a compiled launcher that is at least as new as the C# source', () => {
+ const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
+ try {
+ const sourcePath = join(root, 'OrcaCliLauncher.cs')
+ const outputPath = join(root, '.build', 'orca.exe')
+ mkdirSync(join(root, '.build'))
+ writeFileSync(sourcePath, 'source\n')
+ writeFileSync(outputPath, 'binary\n')
+ const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
+ utimesSync(outputPath, later, later)
+
+ expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
+ writeFileSync(sourcePath, 'changed\n')
+ const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
+ utimesSync(sourcePath, sourceLater, sourceLater)
+ expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
+ expect(
+ shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
+ ).toBe(true)
+ expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
+ false
+ )
+ } finally {
+ removeFixtureTree(root)
+ }
+ })
+
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs
index 11eae65d996..d0489db64cd 100644
--- a/config/scripts/computer-e2e-workflow.test.mjs
+++ b/config/scripts/computer-e2e-workflow.test.mjs
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
- 'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
+ const install = job.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
+ expect(install.with['native-runtime']).toBe('electron')
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs
index b570ac0e7ae..051ea445413 100644
--- a/config/scripts/ensure-native-runtime.mjs
+++ b/config/scripts/ensure-native-runtime.mjs
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
-import { basename, resolve } from 'node:path'
+import { basename, dirname, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
if (!initial.ok) {
printCheckError(initial)
}
- runPnpm(['rebuild', 'node-pty'])
+ const failedModules = initial.failures.map((failure) => failure.moduleName)
+ const rebuildModules = [
+ 'node-pty',
+ ...failedModules.filter((moduleName) => moduleName !== 'node-pty')
+ ]
+ rebuildNodeRuntimeModules(rebuildModules)
verifyNodeRuntimeAfterRebuild()
return
}
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
- runPnpm(['rebuild', ...failedModules])
+ rebuildNodeRuntimeModules(failedModules)
verifyNodeRuntimeAfterRebuild()
}
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
return null
}
- // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
- // patch only lands in the source-built build/Release artifacts.
+ // Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
+ // Windows patches only land in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
- const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
- // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
- if (process.platform === 'darwin') {
- artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
- }
+ const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
}
-function requiresPatchedNodePtySourceBuild() {
+function patchedNodePtyArtifactPaths(nodePtyDir) {
if (process.platform === 'win32') {
- return false
+ const releaseDir = resolve(nodePtyDir, 'build', 'Release')
+ return [
+ resolve(releaseDir, 'conpty.node'),
+ ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
+ ]
}
+ const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
+ // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
+ if (process.platform === 'darwin') {
+ artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
+ }
+ return artifactPaths
+}
+
+function requiresPatchedNodePtySourceBuild() {
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
@@ -351,16 +365,28 @@ function getWindowsBuildNumber() {
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
-function runPnpm(args) {
+function rebuildNodeRuntimeModules(moduleNames) {
+ for (const moduleName of moduleNames) {
+ const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
+ console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
+ runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
+ if (moduleName === 'node-pty' && process.platform === 'win32') {
+ runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
+ }
+ }
+}
+
+function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(command, args, {
- cwd: projectDir,
+ cwd,
stdio: 'inherit',
- shell: process.platform === 'win32'
+ shell: process.platform === 'win32',
+ env: process.env
})
if (result.error || result.status !== 0) {
- console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
+ console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
if (result.error) {
console.error(formatError(result.error))
}
diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs
index d1ea104b863..2086aad2a54 100644
--- a/config/scripts/ensure-native-runtime.test.mjs
+++ b/config/scripts/ensure-native-runtime.test.mjs
@@ -42,7 +42,8 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
- expect(log).toContain('pnpm rebuild node-pty\n')
+ expect(log).toContain('pnpm exec node-gyp rebuild\n')
+ expect(log).toContain(join('node_modules', 'node-pty'))
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
@@ -52,6 +53,41 @@ describe('ensure-native-runtime', () => {
}
})
+ it.skipIf(process.platform !== 'win32')(
+ 'rebuilds other failed Windows addons with patched node-pty',
+ () => {
+ const projectDir = mkTempProject()
+
+ try {
+ const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
+ const logPath = join(projectDir, 'native-runtime.log')
+ const markerPath = join(projectDir, 'rebuilt.marker')
+ const binDir = join(projectDir, 'bin')
+ copyFileSync(sourceScriptPath, scriptPath)
+ writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
+ writeNodePtyPatchFile(projectDir)
+ writeFakePnpm(binDir)
+
+ const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
+ cwd: projectDir,
+ encoding: 'utf8',
+ env: envWithPrependedPath(binDir, {
+ ORCA_NATIVE_TEST_LOG: logPath,
+ ORCA_NATIVE_TEST_MARKER: markerPath
+ })
+ })
+
+ expect(result.status, result.stderr).toBe(0)
+ const log = readFileSync(logPath, 'utf8')
+ expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
+ expect(log).toContain(join('node_modules', 'node-pty'))
+ expect(log).toContain(join('node_modules', 'windows-native-registry'))
+ } finally {
+ rmSync(projectDir, { recursive: true, force: true })
+ }
+ }
+ )
+
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
@@ -80,7 +116,7 @@ describe('ensure-native-runtime', () => {
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
- expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
+ expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -114,7 +150,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
- expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
+ expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -148,7 +184,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
- expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
+ expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -178,9 +214,15 @@ function envWithPrependedPath(binDir, extraEnv) {
}
}
-function writeFakeNativeModules(projectDir) {
+function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
+ writeFileSync(
+ join(nodePtyDir, 'package.json'),
+ '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
+ )
+ mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -200,12 +242,18 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
}
`
)
- writeFakeWindowsRegistry(projectDir)
+ writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
+ writeFileSync(
+ join(nodePtyDir, 'package.json'),
+ '{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
+ )
+ mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
+ writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -218,23 +266,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
- return { dir, module: {} }
+ return {
+ dir,
+ module: {
+ listJobProcessIds: () => [],
+ terminateJob: () => true,
+ assignCurrentProcessToJob: () => true
+ }
+ }
}
`
)
writeFakeWindowsRegistry(projectDir)
}
-function writeFakeWindowsRegistry(projectDir) {
+function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
- join(registryDir, 'index.js'),
- 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
+ join(registryDir, 'package.json'),
+ '{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
)
+ const markerGate = requiresMarker
+ ? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
+ : ''
+ writeFileSync(
+ join(registryDir, 'index.js'),
+ `exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
+ )
+ const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
+ mkdirSync(processTreeDir, { recursive: true })
+ writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeNodePtyPatchFile(projectDir) {
@@ -245,6 +310,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
+ if (process.platform === 'win32') {
+ writeFileSync(join(buildDir, 'conpty.node'), '')
+ mkdirSync(join(buildDir, 'conpty'), { recursive: true })
+ writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
+ writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
+ return
+ }
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -260,6 +332,11 @@ function writeFakePnpm(binDir) {
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
+appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
+appendFileSync(
+ process.env.ORCA_NATIVE_TEST_LOG,
+ \`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
+)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs
index d19eee52454..8ccc864aabb 100644
--- a/config/scripts/pr-code-change-scope.mjs
+++ b/config/scripts/pr-code-change-scope.mjs
@@ -16,6 +16,183 @@ const DOCS_ONLY_FILES = new Set([
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
+export const PR_CHECK_JOBS = [
+ 'static_analysis',
+ 'typecheck',
+ 'git_compatibility',
+ 'xterm_patch_sync',
+ 'shell_contracts',
+ 'test',
+ 'orcad_browser',
+ 'cross-version-wire',
+ 'managed_hook_node18',
+ 'package',
+ 'package_windows'
+]
+
+const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
+
+const GLOBAL_FORCE_PREFIXES = [
+ '.github/workflows/pr.yml',
+ '.github/actions/install-node-dependencies/',
+ 'config/scripts/pr-code-change-scope'
+]
+
+const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
+
+const GIT_COMPAT_PREFIXES = [
+ 'src/shared/git-',
+ 'src/shared/review-head-tracking-ref',
+ 'src/main/git/',
+ 'src/relay/git-',
+ 'config/scripts/git-binary-compatibility'
+]
+
+const XTERM_PREFIXES = [
+ 'config/patches/xterm-upstream.json',
+ 'config/patches/@xterm',
+ 'config/patches/xterm-src/',
+ 'config/scripts/regenerate-xterm-patches'
+]
+
+const SHELL_PREFIXES = [
+ 'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
+ 'src/main/daemon/shell-ready',
+ 'src/main/daemon/daemon-bash-shell-ready',
+ 'src/main/daemon/daemon-shell-ready-wrapper',
+ 'src/main/daemon/node-pty-fd-leak',
+ 'src/main/providers/local-pty-shell-ready',
+ 'src/main/providers/__tests__/shell-ready-framework-example',
+ 'src/main/pty/',
+ 'src/main/shell-templates',
+ 'src/main/shell-startup-',
+ 'src/main/shell-wrapper-',
+ 'src/main/terminal-history-fish',
+ 'src/main/zsh-',
+ 'src/renderer/src/components/terminal-pane/fish-color-scheme',
+ 'src/shared/fish-',
+ 'src/shared/pty-reply-echo-shapes',
+ 'src/shared/startup-shell-portability',
+ 'src/shared/posix-command-path-lookup',
+ 'config/patches/node-pty@',
+ 'config/scripts/ensure-native-runtime',
+ 'config/scripts/node-pty-job-ownership'
+]
+
+const ORCAD_BROWSER_PREFIXES = [
+ 'src/main/orcad/external-chromium-',
+ 'src/main/orcad/orcad-browser-provider',
+ 'src/main/orcad/orcad-agent-browser-binary',
+ 'src/main/orcad/electron-serve-browser-process'
+]
+
+const CROSS_VERSION_WIRE_PREFIXES = [
+ 'tests/e2e/cross-version-wire/',
+ 'src/shared/protocol-version',
+ 'src/shared/terminal-stream-protocol',
+ 'src/shared/browser-client-host-protocol',
+ 'src/shared/browser-network-tunnel-protocol',
+ 'src/shared/browser-client-host-placement',
+ 'src/main/runtime/rpc/dispatcher',
+ 'src/main/runtime/rpc/methods/browser-tab-create-schema',
+ 'src/main/runtime/rpc/methods/terminal',
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
+]
+
+const MANAGED_HOOK_PREFIXES = [
+ 'config/scripts/smoke-managed-hook-runtime-node18',
+ 'config/scripts/build-relay',
+ 'src/relay/',
+ 'src/shared/agent-hook',
+ 'src/main/agent-hooks/'
+]
+
+const NATIVE_RUNTIME_PREFIXES = [
+ 'config/scripts/ensure-native-runtime',
+ 'config/scripts/rebuild-native-deps',
+ 'config/scripts/node-pty-job-ownership',
+ 'config/scripts/electron-builder-native-rebuild',
+ 'config/patches/node-pty@',
+ 'config/patches/@vscode__windows-process-tree'
+]
+
+const SHARED_PACKAGE_PREFIXES = [
+ 'electron.vite.config.ts',
+ 'config/electron-builder',
+ 'config/packaged-runtime',
+ 'config/build-plugins/',
+ 'config/scripts/build-',
+ 'config/scripts/smoke-packaged',
+ 'config/scripts/install-electron-package-binary',
+ 'config/scripts/verify-packaged',
+ 'config/scripts/verify-linux-glibc',
+ 'config/scripts/run-electron-vite',
+ 'skills/',
+ 'skill-guides/',
+ 'resources/build/',
+ 'resources/onboarding/',
+ 'resources/plugins/',
+ 'resources/skills/',
+ ...NATIVE_RUNTIME_PREFIXES
+]
+
+const LINUX_PACKAGE_PREFIXES = [
+ ...SHARED_PACKAGE_PREFIXES,
+ 'native/computer-use-linux/',
+ 'resources/linux/',
+ 'config/scripts/run-headless-serve'
+]
+
+const WINDOWS_PACKAGE_PREFIXES = [
+ ...SHARED_PACKAGE_PREFIXES,
+ 'native/windows-cli-launcher/',
+ 'native/computer-use-windows/',
+ 'resources/win32/',
+ 'config/scripts/build-windows-cli-launcher',
+ 'config/scripts/windows-pty-native-capability',
+ 'tests/tools/windows-pty-native-capability-smoke/'
+]
+
+const LINUX_PACKAGE_TESTS = [
+ 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
+ 'src/main/browser/browser-route-tcp-egress.electron.test.ts',
+ 'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
+ 'src/main/browser/browser-route-h3-egress.electron.test.ts',
+ 'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
+]
+
+const WINDOWS_PACKAGE_TESTS = [
+ ...LINUX_PACKAGE_TESTS,
+ 'config/scripts/rebuild-native-deps.test.mjs',
+ 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
+ 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
+ 'src/shared/child-process/windows-command-line.win32.test.ts',
+ 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
+ 'src/main/windows/windows-pty-job.win32.test.ts',
+ 'src/main/windows/windows-host-job.win32.test.ts',
+ 'src/main/wsl/wsl-runner.test.ts',
+ 'src/main/wsl/wsl-guest-environment.test.ts',
+ 'src/main/wsl/wsl-invocation-boundary.test.ts',
+ 'src/main/wsl/wsl-executable-path.win32.test.ts',
+ 'src/main/wsl/wsl-w1-w3-contract.test.ts',
+ 'src/shared/source-scan/source-tree-scan.test.ts',
+ 'src/main/cli/wsl-cli-powershell-boundary.test.ts',
+ 'src/main/cursor/hook-service.test.ts',
+ 'src/main/orca-profiles/profile-index-store.test.ts',
+ 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
+ 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
+ 'src/shared/secure-file-fsync-flags.test.ts',
+ 'src/main/ipc/pty-codex-account-attribution.test.ts',
+ 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
+]
+
+const DESKTOP_IRRELEVANT_PREFIXES = [
+ 'mobile/',
+ '.github/workflows/mobile.yml',
+ '.github/workflows/mobile-ios-release.yml',
+ '.github/workflows/mobile-android-release.yml'
+]
+
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
@@ -32,10 +209,83 @@ export function shouldRunPrChecks(changedFiles) {
if (changedFiles.length === 0) {
return true
}
- return changedFiles.some((file) => !isDocsOnlyPath(file))
+ return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
+}
+
+export function classifyPrJobs(changedFiles) {
+ const emptyDiff = changedFiles.length === 0
+ const shouldRun = shouldRunPrChecks(changedFiles)
+ const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
+ const jobs = Object.fromEntries(
+ PR_CHECK_JOBS.map((job) => [
+ job,
+ shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
+ ])
+ )
+ return { should_run: shouldRun, ...jobs }
+}
+
+function jobDetector(job) {
+ switch (job) {
+ case 'git_compatibility':
+ return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
+ case 'xterm_patch_sync':
+ return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
+ case 'shell_contracts':
+ return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
+ case 'orcad_browser':
+ return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
+ case 'cross-version-wire':
+ return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
+ case 'managed_hook_node18':
+ return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
+ case 'package':
+ return (files) => files.some(isLinuxPackagePath)
+ case 'package_windows':
+ return (files) => files.some(isWindowsPackagePath)
+ default:
+ return () => true
+ }
+}
+
+function isLinuxPackagePath(file) {
+ return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
+}
+
+function isWindowsPackagePath(file) {
+ return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
+}
+
+function isProductBundlePath(file, extraPrefixes) {
+ if (isTestFile(file)) {
+ return false
+ }
+ if (file.startsWith('src/')) {
+ return true
+ }
+ return matchesPrefix(file, extraPrefixes)
+}
+
+function isTestFile(file) {
+ return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
+}
+
+function isDesktopIrrelevantPath(file) {
+ return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
+}
+
+function isGlobalForcePath(file) {
+ return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
+}
+
+function matchesPrefix(file, prefixes) {
+ return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
- process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
+ const classification = classifyPrJobs(files)
+ for (const [name, value] of Object.entries(classification)) {
+ process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
+ }
}
diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs
index f71d6c42934..0ce697d1511 100644
--- a/config/scripts/pr-code-change-scope.test.mjs
+++ b/config/scripts/pr-code-change-scope.test.mjs
@@ -1,13 +1,18 @@
+import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
-import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
+import {
+ classifyPrJobs,
+ isDocsOnlyPath,
+ PR_CHECK_JOBS,
+ shouldRunPrChecks
+} from './pr-code-change-scope.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
-const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
const expensiveJobs = [
'static_analysis',
'typecheck',
@@ -15,12 +20,34 @@ const expensiveJobs = [
'xterm_patch_sync',
'shell_contracts',
'test',
+ 'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
+const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
+
+function expectedJobs(overrides, { alwaysOn = true } = {}) {
+ return Object.fromEntries(
+ PR_CHECK_JOBS.map((job) => [
+ job,
+ (alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
+ ])
+ )
+}
+
+function expectClassification(files, overrides) {
+ const result = classifyPrJobs(files)
+ const shouldRun = shouldRunPrChecks(files)
+ expect(result.should_run).toBe(shouldRun)
+ expect(result).toMatchObject({
+ should_run: shouldRun,
+ ...expectedJobs(overrides, { alwaysOn: shouldRun })
+ })
+}
+
describe('docs-only path classification', () => {
it('treats the WeChat README PR files as docs-only', () => {
expect(
@@ -54,9 +81,154 @@ describe('docs-only path classification', () => {
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
expect(shouldRunPrChecks([])).toBe(true)
})
+
+ it('does not start desktop PR Checks for mobile-only diffs', () => {
+ expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
+ })
})
-describe('PR Checks docs-only skip wiring', () => {
+describe('per-job path classification', () => {
+ it('runs every expensive job on an empty diff rather than skipping by accident', () => {
+ const result = classifyPrJobs([])
+ expect(result.should_run).toBe(true)
+ for (const job of PR_CHECK_JOBS) {
+ expect(result[job], job).toBe(true)
+ }
+ })
+
+ it('skips every expensive job for docs-only diffs', () => {
+ expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
+ })
+
+ it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
+ expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs Git compatibility only when git capability inputs change', () => {
+ expectClassification(['src/shared/git-capability-cache.ts'], {
+ git_compatibility: true,
+ package: true,
+ package_windows: true
+ })
+ expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
+ git_compatibility: true
+ })
+ })
+
+ it('runs xterm patch sync only when xterm inputs change', () => {
+ expectClassification(['config/patches/xterm-upstream.json'], {
+ xterm_patch_sync: true
+ })
+ expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
+ xterm_patch_sync: true
+ })
+ })
+
+ it('runs native package jobs only for the platform that ships the changed native', () => {
+ expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
+ package_windows: true
+ })
+ expectClassification(['native/computer-use-linux/runtime.py'], {
+ package: true
+ })
+ expectClassification(['native/computer-use-macos/Package.swift'], {})
+ })
+
+ it('runs shell contracts when live-shell inputs change', () => {
+ expectClassification(['src/main/daemon/shell-ready.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
+ expectClassification(['src/main/shell-templates.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
+ shell_contracts: true,
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
+ for (const file of [
+ 'src/main/orcad/external-chromium-browser-session.ts',
+ 'src/main/orcad/external-chromium-command-arguments.ts',
+ 'src/main/orcad/external-chromium-tab-registry.ts',
+ 'src/main/orcad/external-chromium-tab-projection.ts'
+ ]) {
+ expectClassification([file], {
+ orcad_browser: true,
+ package: true,
+ package_windows: true
+ })
+ }
+ expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
+ package: true,
+ package_windows: true
+ })
+ })
+
+ it('runs cross-version wire checks for every working-tree wire module', () => {
+ for (const file of [
+ 'src/shared/protocol-version.ts',
+ 'src/shared/terminal-stream-protocol.ts',
+ 'src/main/runtime/rpc/dispatcher.ts',
+ 'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
+ 'src/main/runtime/rpc/methods/terminal.ts',
+ 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
+ ]) {
+ expectClassification([file], {
+ 'cross-version-wire': true,
+ package: true,
+ package_windows: true
+ })
+ }
+ expectClassification(
+ ['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
+ { 'cross-version-wire': true }
+ )
+ })
+
+ it('runs workflow-self-change and lockfile diffs as force-all', () => {
+ const result = classifyPrJobs(['.github/workflows/pr.yml'])
+ expect(result.should_run).toBe(true)
+ for (const job of PR_CHECK_JOBS) {
+ expect(result[job], job).toBe(true)
+ }
+ expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
+ })
+
+ it('keeps unit-test-only diffs out of packaging', () => {
+ expectClassification(['src/main/git/git-status.test.ts'], {
+ git_compatibility: true
+ })
+ })
+
+ it('emits GitHub output pairs from the shipped CLI', () => {
+ const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
+ cwd: projectDir,
+ encoding: 'utf8',
+ input: 'config/patches/xterm-upstream.json\n'
+ })
+ expect(result.status, result.stderr).toBe(0)
+ expect(result.stdout).toContain('should_run=true\n')
+ expect(result.stdout).toContain('xterm_patch_sync=true\n')
+ expect(result.stdout).toContain('git_compatibility=false\n')
+ expect(result.stdout).toContain('package=false\n')
+ expect(result.stdout).toContain('test=true\n')
+ })
+})
+
+describe('PR Checks skip wiring', () => {
it('classifies the PR range with a tested script and expands renames', () => {
const classify = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Classify changed paths'
@@ -65,9 +237,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(classify.run).toContain('--no-renames')
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
- expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
- '${{ steps.filter.outputs.should_run }}'
- )
+ expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
+ for (const jobName of ['should_run', ...expensiveJobs]) {
+ expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
+ `\${{ steps.filter.outputs.${jobName} }}`
+ )
+ }
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
@@ -75,10 +250,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
- it('skips expensive jobs unless the detector says the PR has code', () => {
+ it('gates each expensive job on its own classifier output', () => {
for (const jobName of expensiveJobs) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
- expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
+ expect(prWorkflow.jobs[jobName].if, jobName).toBe(
+ `needs.code_paths.outputs.${jobName} == 'true'`
+ )
}
})
@@ -89,19 +266,23 @@ describe('PR Checks docs-only skip wiring', () => {
)
})
- it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
+ it('lets verify pass skipped jobs the classifier turned off', () => {
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
- expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
- const docsOnlyBranch = verifyStep.run.slice(
- 0,
- verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
- )
- expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
- expect(docsOnlyBranch).not.toContain('"$result" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
+ expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
+ expect(verifyStep.run).toContain('expected skipped')
+ expect(verifyStep.run).toContain('expected success')
+ for (const job of prWorkflow.jobs.verify.needs) {
+ if (job === 'code_paths' || job === 'root_directory_guard') {
+ continue
+ }
+ const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
+ expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
+ expect(verifyStep.run).toContain(`"$${envVar}"`)
+ }
})
})
diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs
index 2c1243d04fa..6aec44fca87 100644
--- a/config/scripts/pr-e2e-gate-contract.test.mjs
+++ b/config/scripts/pr-e2e-gate-contract.test.mjs
@@ -100,17 +100,19 @@ describe('PR E2E gate contract', () => {
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
- // Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
const successMarker = '# Require success when the PR has code-relevant changes'
- const successLoop = verifyStep.run.slice(
- verifyStep.run.indexOf(successMarker),
- verifyStep.run.lastIndexOf('done')
- )
+ const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
expect(successLoop.length).toBeGreaterThan(0)
+ expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
+ expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
for (const job of prWorkflow.jobs.verify.needs) {
- const envVar = job.toUpperCase()
+ const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
+ if (job === 'code_paths' || job === 'root_directory_guard') {
+ continue
+ }
expect(successLoop).toContain(`"$${envVar}"`)
+ expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
}
})
@@ -157,6 +159,18 @@ describe('PR E2E gate contract', () => {
expect(sshDockerRunner).toContain("'electron-headful'")
})
+ it('reuses the composite install action instead of duplicating pnpm setup', () => {
+ const installFor = (jobName) =>
+ e2eWorkflow.jobs[jobName].steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+
+ expect(installFor('build').with).toBeUndefined()
+ for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
+ expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
+ }
+ })
+
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs
index 42540f6bcb4..a61244d42d3 100644
--- a/config/scripts/pr-workflow-parallelism.test.mjs
+++ b/config/scripts/pr-workflow-parallelism.test.mjs
@@ -50,9 +50,9 @@ describe('PR workflow parallelism', () => {
it('shards the general test suite across Node 24 and Node 26', () => {
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
- Array.from({ length: 16 }, (_, index) => index + 1)
+ Array.from({ length: 8 }, (_, index) => index + 1)
)
- expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
+ expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8])
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
const installStep = workflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
@@ -233,21 +233,20 @@ describe('PR workflow parallelism', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
- for (const jobName of [
- 'static_analysis',
- 'typecheck',
- 'git_compatibility',
- 'xterm_patch_sync'
- ]) {
+ for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
+ expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(installFor('test').with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
+ expect(installFor('package_windows').with['native-runtime']).toBe('node')
+ expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
+ expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
- ).toBe("inputs.native-runtime != 'none'")
+ ).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
@@ -295,14 +294,39 @@ describe('PR workflow parallelism', () => {
// overwritten and one after the rebuild would never save a hit.
expect(installIndex).toBeLessThan(cacheIndex)
expect(cacheIndex).toBeLessThan(prepareIndex)
- expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
+ expect(steps[cacheIndex].if).toBe(
+ "inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
+ )
+ const restoreOnly = steps.find(
+ (step) => step.name === 'Restore compiled native modules without saving'
+ )
+ expect(restoreOnly.if).toBe(
+ "inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
+ )
+ expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
- expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
- expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
- expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
- // No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
- expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
+ for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
+ expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
+ expect(cacheStep.with.key).toContain('${{ runner.os }}')
+ expect(cacheStep.with.key).toContain('${{ runner.arch }}')
+ expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
+ expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
+ expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
+ expect(cacheStep.with.key).toContain(
+ 'config/patches/@vscode__windows-process-tree@0.8.0.patch'
+ )
+ expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
+ expect(cacheStep.with.path).toContain('windows-native-registry@')
+ expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
+ expect(cacheStep.with['restore-keys']).toBeUndefined()
+ }
+ const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
+ expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
+ expect(cacheScope.run).toContain('/etc/os-release')
+ expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
+ '${{ steps.native-cache-scope.outputs.scope }}'
+ )
})
it('reuses TypeScript incremental state across typecheck runs', () => {
@@ -346,6 +370,7 @@ describe('PR workflow parallelism', () => {
'shell_contracts',
'test',
'orcad_browser',
+ 'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
@@ -359,5 +384,7 @@ describe('PR workflow parallelism', () => {
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
+ expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
+ expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
})
})
diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs
index 78e25d3634c..4eca4bac33a 100644
--- a/config/scripts/rebuild-native-deps.mjs
+++ b/config/scripts/rebuild-native-deps.mjs
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
return null
}
- // Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
- // load successfully in Electron while missing the patched fd/error handling.
+ // Why: Orca patches node-pty's native Unix spawn path and Windows job-object
+ // exports; upstream prebuilds can load while missing those patches.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
- const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
- // Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
- if (process.platform === 'darwin') {
- artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
- }
+ const artifactPaths =
+ rebuildPlatform === 'win32'
+ ? [
+ resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
+ ...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
+ resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
+ )
+ ]
+ : [
+ resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
+ ...(osPlatform() === 'darwin'
+ ? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
+ : [])
+ ]
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
if (!onlyModules.includes('node-pty')) {
return false
}
- if (rebuildPlatform === 'win32') {
- return false
- }
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
return false
}
diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs
index b158ee57f21..5ebc39dfcaa 100644
--- a/config/scripts/rebuild-native-deps.test.mjs
+++ b/config/scripts/rebuild-native-deps.test.mjs
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
+ if (process.platform === 'win32') {
+ writeFileSync(join(buildDir, 'conpty.node'), '')
+ mkdirSync(join(buildDir, 'conpty'), { recursive: true })
+ writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
+ writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
+ return
+ }
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs
index 489437dd61f..07f7beb46ac 100644
--- a/config/scripts/skills-cli-package-workflow.test.mjs
+++ b/config/scripts/skills-cli-package-workflow.test.mjs
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release:parallel')
+ expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs
index 9a6b8b285cf..8d4b7d568fd 100644
--- a/config/scripts/windows-pty-native-capability-workflow.test.mjs
+++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
const job = workflow.jobs.package_windows
- const sourceRebuild = job.steps.find(
- (step) => step.name === 'Rebuild node-pty from patched source'
+ const install = job.steps.find(
+ (step) => step.uses === './.github/actions/install-node-dependencies'
+ )
+ const nodeCacheSave = job.steps.find(
+ (step) => step.name === 'Save compiled Node native modules'
+ )
+ const electronCache = job.steps.find(
+ (step) => step.name === 'Restore compiled Electron native modules'
)
const build = job.steps.find((step) => step.name === 'Build package inputs')
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
@@ -37,10 +43,17 @@ describe('packaged Windows PTY native capability routing', () => {
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
+ const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
- expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
- expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
+ expect(install.with['native-runtime']).toBe('node')
+ expect(install.with['persist-native-cache']).toBe('false')
+ expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
+ expect(nodeCacheSave.with.key).toContain('-node-node')
+ expect(electronCache.with.key).toContain('-electron-node')
+ expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
+ expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
expect(build.run).toBe('pnpm run build:release:parallel')
+ expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(workflow.jobs.verify.needs).toContain('package_windows')
diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md
index e4c91329c46..11e39b35b7e 100644
--- a/docs/reference/windows-process-enumeration.md
+++ b/docs/reference/windows-process-enumeration.md
@@ -326,8 +326,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
it is true before asserting anything else, so an unpatched binary fails loudly
instead of passing every case vacuously. That guard is what caught this.
-`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
-exempts win32, on the premise that the patch is Unix-only. That premise is now
-false, but lifting the exemption also needs `pnpm rebuild` to force a source
-build — otherwise the assertion fires and the remedy does not fix it. Left as a
-follow-up rather than changed blind.
+`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
+win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
+so the patched source build actually replaces the upstream prebuild.
diff --git a/src/main/daemon/pty-subprocess-foreground-identity.test.ts b/src/main/daemon/pty-subprocess-foreground-identity.test.ts
index ff739e32d0c..76793cdbab2 100644
--- a/src/main/daemon/pty-subprocess-foreground-identity.test.ts
+++ b/src/main/daemon/pty-subprocess-foreground-identity.test.ts
@@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
+ getNodePtySpawnHelperCandidates: () => [import.meta.filename],
resolveUnixShellPath: resolveUnixShellPathMock,
validateWorkingDirectory: validateWorkingDirectoryMock,
validateWorkingDirectoryAsync: validateWorkingDirectoryMock
diff --git a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts
index 7d23ffc0a49..47513702b7a 100644
--- a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts
+++ b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts
@@ -10,6 +10,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
+import type * as LocalPtyUtils from '../providers/local-pty-utils'
const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
@@ -39,6 +40,14 @@ vi.mock('../providers/windows-powershell-executable', () => ({
getWindowsCmdPath: () => CMD_ABS
}))
+vi.mock('../providers/local-pty-utils', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getNodePtySpawnHelperCandidates: () => [import.meta.filename]
+ }
+})
+
vi.mock('../providers/agent-foreground-process', () => ({
resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => {
const value = await resolveAgentForegroundProcessMock(...args)
diff --git a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts
index 6467d020821..6ab32840010 100644
--- a/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts
+++ b/src/main/daemon/pty-subprocess-handle-lifecycle.test.ts
@@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
+ getNodePtySpawnHelperCandidates: () => [import.meta.filename],
resolveUnixShellPath: resolveUnixShellPathMock,
validateWorkingDirectory: validateWorkingDirectoryMock,
validateWorkingDirectoryAsync: validateWorkingDirectoryMock
From bb77a2b1595ebbb65de29c73b98d68feba2dd71e Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:09:43 -0700
Subject: [PATCH 08/11] Add coverage for skill lock release and fix WebRTC test
flakiness (#16846)
* test: add coverage for skill lock release and simplify WebRTC test
- Add test for cleanupReleasedSkillInstallLock handling rmdir races
- Improve error handling to cover all documented directory removal error codes
- Simplify WebRTC egress test to use localhost addresses consistently
* test: use network interface address for WebRTC egress probe
- Discover the first non-internal IPv4 address instead of hardcoding
localhost, allowing the test to work in CI and varied environments
- Update proxy rules to use loopback designation for clarity
- Bind UDP socket to all interfaces (0.0.0.0) to receive on the
discovered address
---
...untime-rpc-mobile-method-allowlist.test.ts | 1 -
.../skills/skill-install-lock-release.test.ts | 36 +++++++++++++++++++
src/main/skills/skill-install-lock-release.ts | 6 ++--
3 files changed, 39 insertions(+), 4 deletions(-)
create mode 100644 src/main/skills/skill-install-lock-release.test.ts
diff --git a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts
index b0d5b18b84c..9c590994c41 100644
--- a/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts
+++ b/src/main/runtime/runtime-rpc-mobile-method-allowlist.test.ts
@@ -479,7 +479,6 @@ describe('OrcaRuntimeRpcServer', () => {
deviceToken: mobile.token,
params: { worktree: 'id:wt-1', page: 'page-1' }
})
-
expect(replies).toContainEqual(
expect.objectContaining({
id: 'req_forbidden',
diff --git a/src/main/skills/skill-install-lock-release.test.ts b/src/main/skills/skill-install-lock-release.test.ts
new file mode 100644
index 00000000000..6811e0d08f9
--- /dev/null
+++ b/src/main/skills/skill-install-lock-release.test.ts
@@ -0,0 +1,36 @@
+import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanupReleasedSkillInstallLock } from './skill-install-lock-release'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('cleanupReleasedSkillInstallLock', () => {
+ it('keeps a released lock recoverable when directory removal races', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skill-lock-release-test-'))
+ roots.push(root)
+ const releasePath = join(root, 'released')
+ const token = '11111111-1111-4111-8111-111111111111'
+ await mkdir(releasePath)
+ await Promise.all([
+ writeFile(join(releasePath, `${token}.owner`), 'owner'),
+ writeFile(join(releasePath, `${token}.released`), '')
+ ])
+
+ const removeDirectory = async (): Promise => {
+ const error = new Error('injected-rmdir-race') as NodeJS.ErrnoException
+ error.code = 'ENOTEMPTY'
+ throw error
+ }
+
+ await expect(
+ cleanupReleasedSkillInstallLock(releasePath, token, removeDirectory)
+ ).resolves.toBeUndefined()
+ await expect(readdir(releasePath)).resolves.toEqual([])
+ })
+})
diff --git a/src/main/skills/skill-install-lock-release.ts b/src/main/skills/skill-install-lock-release.ts
index 0b56ffcc9a1..b3e02bf869b 100644
--- a/src/main/skills/skill-install-lock-release.ts
+++ b/src/main/skills/skill-install-lock-release.ts
@@ -2,7 +2,7 @@ import { readdir, rmdir, unlink } from 'node:fs/promises'
import { join } from 'node:path'
const RELEASE_ENTRY_NAME = /^[a-f0-9-]{36}\.(?:owner|released)$/
-const RECOVERY_RMDIR_IGNORED_CODES = new Set(['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY'])
+const LOCK_DIRECTORY_RMDIR_IGNORED_CODES = new Set(['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EBUSY'])
async function unlinkIfPresent(path: string): Promise {
await unlink(path).catch((error) => {
@@ -17,7 +17,7 @@ async function removeDirectoryIfPresent(
removeDirectory: (path: string) => Promise
): Promise {
await removeDirectory(path).catch((error) => {
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ if (!LOCK_DIRECTORY_RMDIR_IGNORED_CODES.has((error as NodeJS.ErrnoException).code ?? '')) {
throw error
}
})
@@ -46,7 +46,7 @@ export async function reclaimReleasedSkillInstallLock(path: string): Promise unlinkIfPresent(join(path, entry.name)))
)
await rmdir(path).catch((error) => {
- if (!RECOVERY_RMDIR_IGNORED_CODES.has((error as NodeJS.ErrnoException).code ?? '')) {
+ if (!LOCK_DIRECTORY_RMDIR_IGNORED_CODES.has((error as NodeJS.ErrnoException).code ?? '')) {
throw error
}
})
From 7ee8b5e1a601562ef1fd68282762dd35b41de8db Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:10:51 -0700
Subject: [PATCH 09/11] Refactor lower max-lines modules (#16760)
---
config/max-lines-baseline.txt | 22 -
mobile/.oxlintrc.json | 36 -
mobile/app/pair-scan.tsx | 187 +----
mobile/scripts/repro-terminal-color-output.ts | 60 ++
mobile/scripts/repro-terminal-colors.ts | 76 +-
.../scripts/repro-worktree-startup-output.ts | 75 ++
.../scripts/repro-worktree-startup-stream.ts | 79 +-
.../src/components/CustomKeyModal.styles.ts | 276 +++++++
mobile/src/components/CustomKeyModal.tsx | 279 +-------
...obile-rich-markdown-editor-body-primary.ts | 279 ++++++++
...ile-rich-markdown-editor-body-secondary.ts | 278 +++++++
...le-rich-markdown-editor-document-suffix.ts | 93 +++
.../mobile-rich-markdown-editor-html.ts | 649 +----------------
mobile/src/pair-scan-styles.ts | 185 +++++
.../src/terminal/terminal-accessory-keys.ts | 148 +---
.../src/terminal/terminal-key-definitions.ts | 143 ++++
src/cli/handlers/automation-handler-flags.ts | 308 ++++++++
src/cli/handlers/automations.ts | 309 +-------
src/cli/help.ts | 367 +---------
src/cli/root-help-text-primary.ts | 184 +++++
src/cli/root-help-text-secondary.ts | 183 +++++
src/main/cli/wsl-cli-installer.ts | 215 +-----
src/main/cli/wsl-cli-registration-command.ts | 70 ++
src/main/cli/wsl-cli-status.ts | 163 +++++
.../keybindings/keybinding-file-parser.ts | 241 +++++++
src/main/keybindings/keybinding-file.ts | 247 +------
src/main/memory/collector.ts | 214 ++----
src/main/memory/memory-snapshot-buckets.ts | 101 +++
src/main/memory/memory-snapshot-values.ts | 49 ++
.../automation-definition-operations.ts | 2 +-
.../automation-schedule-operations.ts | 2 +-
.../project-groups/nested-repo-discovery.ts | 191 +----
.../project-groups/nested-repo-scan-rules.ts | 174 +++++
.../rate-limits/claude-pty-stop-markers.ts | 14 +
.../rate-limits/claude-pty-usage-parser.ts | 166 +++++
src/main/rate-limits/claude-pty.ts | 216 +-----
src/main/rate-limits/codex-fetcher.ts | 9 +-
.../rate-limits/hidden-rate-limit-shell.ts | 3 +
.../runtime/rpc/methods/automation-schemas.ts | 2 +-
.../src/components/LinearIssueTextEditor.tsx | 2 +-
.../src/components/LinearItemDrawer.tsx | 2 +-
.../automations/AutomationCustomCronPanel.tsx | 6 +-
.../AutomationEditorSettingsSidebar.tsx | 2 +-
.../AutomationSchedulePicker.render.test.tsx | 2 +-
.../AutomationSchedulePicker.test.ts | 2 +-
.../automations/AutomationSchedulePicker.tsx | 6 +-
.../automations/AutomationsPage.tsx | 4 +-
.../automations/HermesCronOutputView.tsx | 2 +-
.../automations/automation-draft-model.ts | 2 +-
.../automations/automation-edit-draft.ts | 2 +-
.../external-automation-schedule-display.ts | 2 +-
.../linear-issue-project-selector.test.tsx | 7 +-
.../linear-issue-project-selector.tsx | 3 +-
.../components/linear-issue-sub-issues.tsx | 2 +-
...near-issue-workspace-detail-state.test.tsx | 2 +-
.../linear-issue-workspace-detail-state.ts | 2 +-
.../FolderWorkspacePrChecksPanel.test.tsx | 2 +-
.../FolderWorkspacePrChecksPanel.tsx | 2 +-
.../FolderWorkspacePrChecksRow.test.tsx | 2 +-
.../FolderWorkspacePrChecksRow.tsx | 2 +-
...-checks-panel-check-and-review-actions.tsx | 2 +-
.../use-checks-panel-context-state.tsx | 2 +-
.../use-checks-panel-create-review.tsx | 2 +-
...-panel-entry-refresh-and-title-actions.tsx | 2 +-
.../use-checks-panel-manual-refresh.test.tsx | 6 +-
.../use-checks-panel-manual-refresh.tsx | 2 +-
...rent-pr-checks-projection-selector.test.ts | 2 +-
.../right-sidebar/parent-pr-checks-refresh.ts | 6 +-
.../parent-pr-checks-rows.test.ts | 6 +-
.../right-sidebar/parent-pr-checks-rows.ts | 14 +-
.../listing/use-worktree-context.ts | 2 +-
.../folder-workspace-card-pr-display.ts | 6 +-
.../use-workspace-kanban-area-selection.ts | 60 +-
.../use-worktree-card-review-details.ts | 2 +-
.../workspace-board-task-status-sync.ts | 10 +-
.../workspace-kanban-area-selection-state.ts | 50 ++
.../workspace-kanban-area-selection.test.ts | 2 +-
.../status-bar/WorkspaceSpaceManagerPanel.tsx | 2 +-
.../use-task-page-create-linear-submits.tsx | 7 +-
.../hooks/use-task-page-linear-board.tsx | 3 +-
.../use-task-page-linear-create-dialogs.ts | 2 +-
.../task-page/linear/linear-state-cell.tsx | 2 +-
.../workspace-cleanup-presentation.test.ts | 2 +-
.../workspace-cleanup-presentation.ts | 2 +-
.../src/hooks/useIssueMetadata.test.tsx | 2 +-
src/renderer/src/hooks/useIssueMetadata.ts | 6 +-
.../src/runtime/runtime-linear-client.test.ts | 20 +-
.../src/runtime/runtime-linear-client.ts | 366 +---------
.../runtime/runtime-linear-issue-mutations.ts | 113 +++
.../runtime/runtime-linear-project-client.ts | 266 +++++++
.../store/slices/diff-comment-persistence.ts | 322 +++++++++
src/renderer/src/store/slices/diffComments.ts | 354 +--------
.../slices/hosted-review-cache-race.test.ts | 3 +-
.../store/slices/hosted-review-cache-state.ts | 207 ++++++
.../store/slices/hosted-review-cache.test.ts | 8 +-
.../slices/hosted-review-card-refresh.ts | 38 +
.../store/slices/hosted-review-pr-cache.ts | 39 +
.../slices/hosted-review-request-state.ts | 23 +
.../src/store/slices/hosted-review.test.ts | 10 +-
.../src/store/slices/hosted-review.ts | 315 +-------
.../linear-credential-error-recovery.test.ts | 32 +-
.../store/slices/linear-invalidation.test.ts | 17 +-
.../linear-scoped-collection-cache.test.ts | 32 +-
.../linear-source-context-cache-scope.test.ts | 32 +-
src/renderer/src/store/slices/linear.ts | 20 +-
.../worktrees-metadata-persistence.test.ts | 2 +-
.../metadata/update-worktree-meta.ts | 2 +-
.../session/worktree-unread-activity.ts | 2 +-
src/shared/automation-cron-occurrence.ts | 48 ++
src/shared/automation-schedule-occurrences.ts | 157 ++++
src/shared/automation-schedule-parsing.ts | 317 ++++++++
src/shared/automation-schedules.test.ts | 18 +-
src/shared/automation-schedules.ts | 512 +------------
src/shared/commit-message-agent-spec.test.ts | 10 +-
src/shared/commit-message-agent-spec.ts | 676 +-----------------
.../commit-message-agent-specs-primary.ts | 223 ++++++
.../commit-message-agent-specs-secondary.ts | 227 ++++++
src/shared/commit-message-model-parsers.ts | 248 +++++++
src/shared/constants.ts | 240 +------
src/shared/default-global-settings.ts | 257 +++++++
src/shared/marine-creature-names-primary.ts | 278 +++++++
src/shared/marine-creature-names-secondary.ts | 278 +++++++
src/shared/marine-creatures.ts | 560 +--------------
src/shared/text-search-glob-patterns.ts | 39 +
src/shared/text-search-match-accumulator.ts | 78 ++
src/shared/text-search-paths.ts | 20 +
src/shared/text-search.test.ts | 7 +-
src/shared/text-search.ts | 205 ++----
128 files changed, 6818 insertions(+), 6360 deletions(-)
create mode 100644 mobile/scripts/repro-terminal-color-output.ts
create mode 100644 mobile/scripts/repro-worktree-startup-output.ts
create mode 100644 mobile/src/components/CustomKeyModal.styles.ts
create mode 100644 mobile/src/components/mobile-rich-markdown-editor-body-primary.ts
create mode 100644 mobile/src/components/mobile-rich-markdown-editor-body-secondary.ts
create mode 100644 mobile/src/components/mobile-rich-markdown-editor-document-suffix.ts
create mode 100644 mobile/src/pair-scan-styles.ts
create mode 100644 mobile/src/terminal/terminal-key-definitions.ts
create mode 100644 src/cli/handlers/automation-handler-flags.ts
create mode 100644 src/cli/root-help-text-primary.ts
create mode 100644 src/cli/root-help-text-secondary.ts
create mode 100644 src/main/cli/wsl-cli-registration-command.ts
create mode 100644 src/main/cli/wsl-cli-status.ts
create mode 100644 src/main/keybindings/keybinding-file-parser.ts
create mode 100644 src/main/memory/memory-snapshot-buckets.ts
create mode 100644 src/main/memory/memory-snapshot-values.ts
create mode 100644 src/main/project-groups/nested-repo-scan-rules.ts
create mode 100644 src/main/rate-limits/claude-pty-stop-markers.ts
create mode 100644 src/main/rate-limits/claude-pty-usage-parser.ts
create mode 100644 src/main/rate-limits/hidden-rate-limit-shell.ts
create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-area-selection-state.ts
create mode 100644 src/renderer/src/runtime/runtime-linear-issue-mutations.ts
create mode 100644 src/renderer/src/runtime/runtime-linear-project-client.ts
create mode 100644 src/renderer/src/store/slices/diff-comment-persistence.ts
create mode 100644 src/renderer/src/store/slices/hosted-review-cache-state.ts
create mode 100644 src/renderer/src/store/slices/hosted-review-card-refresh.ts
create mode 100644 src/renderer/src/store/slices/hosted-review-pr-cache.ts
create mode 100644 src/renderer/src/store/slices/hosted-review-request-state.ts
create mode 100644 src/shared/automation-cron-occurrence.ts
create mode 100644 src/shared/automation-schedule-occurrences.ts
create mode 100644 src/shared/automation-schedule-parsing.ts
create mode 100644 src/shared/commit-message-agent-specs-primary.ts
create mode 100644 src/shared/commit-message-agent-specs-secondary.ts
create mode 100644 src/shared/commit-message-model-parsers.ts
create mode 100644 src/shared/default-global-settings.ts
create mode 100644 src/shared/marine-creature-names-primary.ts
create mode 100644 src/shared/marine-creature-names-secondary.ts
create mode 100644 src/shared/text-search-glob-patterns.ts
create mode 100644 src/shared/text-search-match-accumulator.ts
create mode 100644 src/shared/text-search-paths.ts
diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt
index 664bb4c28b3..e29a7426371 100644
--- a/config/max-lines-baseline.txt
+++ b/config/max-lines-baseline.txt
@@ -2,8 +2,6 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
-inline src/cli/handlers/automations.ts
-inline src/cli/help.ts
inline src/main/agent-hooks/server.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
@@ -12,7 +10,6 @@ inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/claude-accounts/runtime-auth-service.ts
-inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex/hook-service.ts
@@ -23,14 +20,10 @@ inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
-inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/issues.ts
-inline src/main/memory/collector.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
-inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.ts
-inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
@@ -69,7 +62,6 @@ inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
-inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -82,24 +74,16 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.ts
-inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
-inline src/renderer/src/store/slices/diffComments.ts
-inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/ui.ts
-inline src/shared/automation-schedules.ts
-inline src/shared/commit-message-agent-spec.ts
-inline src/shared/constants.ts
inline src/shared/keybindings.ts
-inline src/shared/marine-creatures.ts
inline src/shared/telemetry-events.ts
-inline src/shared/text-search.ts
inline tests/e2e/helpers/terminal.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
@@ -107,15 +91,9 @@ mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
-mobile-config app/pair-scan.tsx
mobile-config app/troubleshoot.tsx
mobile-config scripts/mock-server.ts
-mobile-config scripts/repro-terminal-colors.ts
-mobile-config scripts/repro-worktree-startup-stream.ts
mobile-config src/browser/MobileBrowserPane.tsx
-mobile-config src/components/CustomKeyModal.tsx
mobile-config src/components/NewWorktreeModal.tsx
-mobile-config src/components/mobile-rich-markdown-editor-html.ts
-mobile-config src/terminal/terminal-accessory-keys.ts
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json
index 877a6d7e123..f3338fc3616 100644
--- a/mobile/.oxlintrc.json
+++ b/mobile/.oxlintrc.json
@@ -75,54 +75,18 @@
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["src/components/mobile-rich-markdown-editor-html.ts"],
- "rules": {
- "max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["src/components/CustomKeyModal.tsx"],
- "rules": {
- "max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["app/pair-scan.tsx"],
- "rules": {
- "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["scripts/mock-server.ts"],
"rules": {
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["src/terminal/terminal-accessory-keys.ts"],
- "rules": {
- "max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["app/troubleshoot.tsx"],
"rules": {
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
}
},
- {
- "files": ["scripts/repro-worktree-startup-stream.ts"],
- "rules": {
- "max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
- }
- },
- {
- "files": ["scripts/repro-terminal-colors.ts"],
- "rules": {
- "max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
- }
- },
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx
index c78d7a907df..61e99f19f2a 100644
--- a/mobile/app/pair-scan.tsx
+++ b/mobile/app/pair-scan.tsx
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
import {
View,
Text,
- StyleSheet,
Pressable,
ActivityIndicator,
Linking,
@@ -19,13 +18,14 @@ import {
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useRefreshHostClient } from '../src/transport/client-context'
-import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
+import { colors, spacing } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
+import { pairScanStyles as styles } from '../src/pair-scan-styles'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
)
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase,
- padding: spacing.lg
- },
- backButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: spacing.sm
- },
- steps: {
- gap: spacing.sm,
- marginBottom: spacing.lg,
- marginLeft: 7
- },
- step: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- stepBadge: {
- width: 22,
- height: 22,
- borderRadius: 11,
- backgroundColor: colors.bgRaised,
- alignItems: 'center',
- justifyContent: 'center'
- },
- stepNumber: {
- fontSize: 12,
- fontWeight: '700',
- color: colors.textSecondary
- },
- stepText: {
- fontSize: typography.bodySize,
- color: colors.textSecondary
- },
- cameraWrap: {
- flex: 1,
- borderRadius: radii.camera,
- overflow: 'hidden'
- },
- // Why: holds the layout slot while the camera is unmounted during
- // paste, so the bottom action button doesn't snap up to fill the
- // empty space.
- cameraPlaceholder: {
- flex: 1,
- backgroundColor: colors.bgPanel,
- borderRadius: radii.camera
- },
- camera: {
- ...StyleSheet.absoluteFillObject
- },
- reticle: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center'
- },
- reticleFrame: {
- position: 'relative'
- },
- corner: {
- position: 'absolute',
- width: 28,
- height: 28,
- borderColor: 'rgba(255,255,255,0.7)'
- },
- cornerTL: {
- top: 0,
- left: 0,
- borderTopWidth: 2.5,
- borderLeftWidth: 2.5,
- borderTopLeftRadius: 6
- },
- cornerTR: {
- top: 0,
- right: 0,
- borderTopWidth: 2.5,
- borderRightWidth: 2.5,
- borderTopRightRadius: 6
- },
- cornerBL: {
- bottom: 0,
- left: 0,
- borderBottomWidth: 2.5,
- borderLeftWidth: 2.5,
- borderBottomLeftRadius: 6
- },
- cornerBR: {
- bottom: 0,
- right: 0,
- borderBottomWidth: 2.5,
- borderRightWidth: 2.5,
- borderBottomRightRadius: 6
- },
- centered: {
- flex: 1,
- alignItems: 'center',
- justifyContent: 'center'
- },
- title: {
- fontSize: typography.titleSize,
- fontWeight: '600',
- color: colors.textPrimary,
- marginBottom: spacing.sm
- },
- subtitle: {
- maxWidth: 310,
- fontSize: typography.bodySize,
- color: colors.textSecondary,
- textAlign: 'center',
- marginBottom: spacing.xl,
- lineHeight: 20
- },
- connectingText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- marginTop: spacing.lg
- },
- logSlot: {
- width: '100%',
- marginTop: spacing.lg,
- paddingHorizontal: spacing.sm
- },
- errorText: {
- color: colors.statusRed,
- fontSize: typography.bodySize,
- textAlign: 'center',
- marginBottom: spacing.xl,
- lineHeight: 20
- },
- primaryButton: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.xs,
- backgroundColor: colors.textPrimary,
- paddingHorizontal: spacing.xl,
- paddingVertical: spacing.sm + 2,
- borderRadius: radii.button
- },
- primaryButtonText: {
- color: colors.bgBase,
- fontSize: typography.bodySize,
- fontWeight: '600'
- },
- pasteButton: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.xs,
- marginTop: spacing.md,
- paddingVertical: spacing.sm,
- borderRadius: radii.button
- },
- pasteButtonPressed: {
- opacity: 0.6
- },
- pasteButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '500'
- },
- errorActions: {
- alignItems: 'center',
- gap: spacing.sm
- },
- secondaryButton: {
- paddingHorizontal: spacing.xl,
- paddingVertical: spacing.sm,
- borderRadius: radii.button
- },
- secondaryButtonText: {
- color: colors.textSecondary,
- fontSize: typography.bodySize,
- fontWeight: '500'
- }
-})
diff --git a/mobile/scripts/repro-terminal-color-output.ts b/mobile/scripts/repro-terminal-color-output.ts
new file mode 100644
index 00000000000..50e4e153b54
--- /dev/null
+++ b/mobile/scripts/repro-terminal-color-output.ts
@@ -0,0 +1,60 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+const ESC = String.fromCharCode(27)
+
+export type TerminalColorSnapshot = {
+ label: string
+ handle: string
+ cols: number | null
+ rows: number | null
+ serialized: string
+ lines: string
+}
+
+function countMatches(value: string, pattern: RegExp): number {
+ return value.match(pattern)?.length ?? 0
+}
+
+export function summarizeTerminalColorSnapshot(
+ snapshot: TerminalColorSnapshot
+): Record {
+ const data = snapshot.serialized
+ return {
+ label: snapshot.label,
+ handle: snapshot.handle,
+ cols: snapshot.cols,
+ rows: snapshot.rows,
+ serializedBytes: Buffer.byteLength(data),
+ sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
+ sgrColor: countMatches(
+ data,
+ new RegExp(
+ `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
+ 'g'
+ )
+ ),
+ sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
+ altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
+ containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
+ containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
+ }
+}
+
+export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
+ const dir = join(process.cwd(), 'terminal-color-repro')
+ mkdirSync(dir, { recursive: true })
+ for (const snapshot of snapshots) {
+ const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
+ writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
+ writeFileSync(
+ join(dir, `${base}.escaped.txt`),
+ JSON.stringify(snapshot.serialized || snapshot.lines)
+ )
+ }
+ writeFileSync(
+ join(dir, 'summary.json'),
+ JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
+ )
+ return dir
+}
diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts
index 3c21df64176..e6c302652a0 100644
--- a/mobile/scripts/repro-terminal-colors.ts
+++ b/mobile/scripts/repro-terminal-colors.ts
@@ -10,10 +10,13 @@
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
* pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]
*/
-import { mkdirSync, writeFileSync } from 'node:fs'
-import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
+import {
+ saveTerminalColorSnapshots,
+ summarizeTerminalColorSnapshot,
+ type TerminalColorSnapshot
+} from './repro-terminal-color-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const token = process.argv[2]
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
const worktreeSelector = process.argv[4]
const explicitHandleA = process.argv[5]
const explicitHandleB = process.argv[6]
-const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -37,15 +39,6 @@ type PendingRequest = {
reject: (error: Error) => void
}
-type Snapshot = {
- label: string
- handle: string
- cols: number | null
- rows: number | null
- serialized: string
- lines: string
-}
-
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
console.error(
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]'
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise {
+async function captureSnapshot(
+ ws: WebSocket,
+ label: string,
+ handle: string
+): Promise {
const id = nextId()
- const snapshot = await new Promise((resolve, reject) => {
+ const snapshot = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id)
streamListeners.delete(id)
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
return snapshot
}
-function countMatches(value: string, pattern: RegExp): number {
- return value.match(pattern)?.length ?? 0
-}
-
-function summarize(snapshot: Snapshot): Record {
- const data = snapshot.serialized
- return {
- label: snapshot.label,
- handle: snapshot.handle,
- cols: snapshot.cols,
- rows: snapshot.rows,
- serializedBytes: Buffer.byteLength(data),
- sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
- sgrColor: countMatches(
- data,
- new RegExp(
- `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
- 'g'
- )
- ),
- sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
- altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
- containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
- containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
- }
-}
-
-function saveSnapshots(snapshots: Snapshot[]): string {
- const dir = join(process.cwd(), 'terminal-color-repro')
- mkdirSync(dir, { recursive: true })
- for (const snapshot of snapshots) {
- const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
- writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
- writeFileSync(
- join(dir, `${base}.escaped.txt`),
- JSON.stringify(snapshot.serialized || snapshot.lines)
- )
- }
- writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
- return dir
-}
-
async function run(ws: WebSocket): Promise {
ws.send(
JSON.stringify({
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise {
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
const snapshots = [firstA, firstB, secondA]
- const dir = saveSnapshots(snapshots)
+ const dir = saveTerminalColorSnapshots(snapshots)
- console.table(snapshots.map(summarize))
+ console.table(snapshots.map(summarizeTerminalColorSnapshot))
console.log(`saved: ${dir}`)
- if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
+ if (
+ summarizeTerminalColorSnapshot(firstA).sgrColor !==
+ summarizeTerminalColorSnapshot(secondA).sgrColor
+ ) {
console.log(
'color SGR count changed between A snapshots: serialization/state changed on desktop'
)
diff --git a/mobile/scripts/repro-worktree-startup-output.ts b/mobile/scripts/repro-worktree-startup-output.ts
new file mode 100644
index 00000000000..83e127a3fa6
--- /dev/null
+++ b/mobile/scripts/repro-worktree-startup-output.ts
@@ -0,0 +1,75 @@
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+const ESC = String.fromCharCode(27)
+
+export type StartupTerminalCapture = {
+ handle: string
+ title: string | null
+ scrollback: Record | null
+ chunks: string[]
+}
+
+function stripAnsi(value: string): string {
+ return (
+ value
+ // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
+ // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
+ .replace(/\r/g, '\n')
+ )
+}
+
+function normalizePreview(value: string): string {
+ return stripAnsi(value)
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .slice(0, 8)
+ .join('\n')
+}
+
+export function summarizeStartupCapture(capture: StartupTerminalCapture): Record {
+ const serialized =
+ typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
+ const live = capture.chunks.join('')
+ const serializedPreview = normalizePreview(serialized)
+ const livePreview = normalizePreview(live)
+ return {
+ handle: capture.handle,
+ title: capture.title,
+ cols: capture.scrollback?.cols ?? null,
+ rows: capture.scrollback?.rows ?? null,
+ serializedBytes: Buffer.byteLength(serialized),
+ liveBytes: Buffer.byteLength(live),
+ liveChunks: capture.chunks.length,
+ serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
+ liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
+ livePreviewContainedInSerialized:
+ livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
+ serializedPreview,
+ livePreview
+ }
+}
+
+export function saveStartupCaptures(
+ captures: StartupTerminalCapture[],
+ worktreeName: string
+): string {
+ const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
+ mkdirSync(dir, { recursive: true })
+ for (const capture of captures) {
+ const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
+ const serialized =
+ typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
+ writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
+ writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
+ writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
+ }
+ writeFileSync(
+ join(dir, 'summary.json'),
+ JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
+ )
+ return dir
+}
diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts
index e4db6182f00..f3bbdc0d5d0 100644
--- a/mobile/scripts/repro-worktree-startup-stream.ts
+++ b/mobile/scripts/repro-worktree-startup-stream.ts
@@ -4,10 +4,15 @@
* Usage:
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]
*/
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
+import {
+ saveStartupCaptures,
+ summarizeStartupCapture,
+ type StartupTerminalCapture
+} from './repro-worktree-startup-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const USER_DATA =
@@ -15,7 +20,6 @@ const USER_DATA =
const repoSelector = process.argv[2]
const worktreeName = process.argv[3]
const startupCommand = process.argv[4] || 'claude'
-const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -36,13 +40,6 @@ type TerminalInfo = {
title: string | null
}
-type Capture = {
- handle: string
- title: string | null
- scrollback: Record | null
- chunks: string[]
-}
-
if (!repoSelector || !worktreeName) {
console.error(
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]'
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
}
-function stripAnsi(value: string): string {
- return (
- value
- // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
- .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
- // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
- .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
- .replace(/\r/g, '\n')
- )
-}
-
-function normalizePreview(value: string): string {
- return stripAnsi(value)
- .split('\n')
- .map((line) => line.trim())
- .filter(Boolean)
- .slice(0, 8)
- .join('\n')
-}
-
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise {
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise {
+async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise {
const id = nextId()
streamListeners.set(id, (result) => {
if (result.type === 'scrollback') {
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise {
})
}
-function summarize(capture: Capture): Record {
- const serialized =
- typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
- const live = capture.chunks.join('')
- const serializedPreview = normalizePreview(serialized)
- const livePreview = normalizePreview(live)
- return {
- handle: capture.handle,
- title: capture.title,
- cols: capture.scrollback?.cols ?? null,
- rows: capture.scrollback?.rows ?? null,
- serializedBytes: Buffer.byteLength(serialized),
- liveBytes: Buffer.byteLength(live),
- liveChunks: capture.chunks.length,
- serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
- liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
- livePreviewContainedInSerialized:
- livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
- serializedPreview,
- livePreview
- }
-}
-
-function save(captures: Capture[], worktreeName: string): string {
- const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
- mkdirSync(dir, { recursive: true })
- for (const capture of captures) {
- const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
- const serialized =
- typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
- writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
- writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
- writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
- }
- writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
- return dir
-}
-
async function run(ws: WebSocket): Promise {
ws.send(
JSON.stringify({
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise {
for (const capture of captures) {
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
}
- const dir = save(captures, worktreeName)
- console.table(captures.map(summarize))
+ const dir = saveStartupCaptures(captures, worktreeName)
+ console.table(captures.map(summarizeStartupCapture))
console.log(`saved: ${dir}`)
}
diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts
new file mode 100644
index 00000000000..991e557441f
--- /dev/null
+++ b/mobile/src/components/CustomKeyModal.styles.ts
@@ -0,0 +1,276 @@
+import { StyleSheet } from 'react-native'
+import { colors, spacing, radii, typography } from '../theme/mobile-theme'
+
+export const customKeyModalStyles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingBottom: spacing.sm
+ },
+ backButton: {
+ width: 30,
+ height: 30,
+ borderRadius: 15,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ backButtonPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ backSpacer: {
+ width: 30
+ },
+ title: {
+ flex: 1,
+ fontSize: 15,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ textAlign: 'center'
+ },
+ group: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: 12,
+ overflow: 'hidden'
+ },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ row: {
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ rowLabel: {
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary,
+ marginBottom: 1
+ },
+ rowHint: {
+ fontSize: 12,
+ color: colors.textMuted
+ },
+ shortcutForm: {
+ paddingTop: spacing.sm
+ },
+ preview: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.sm,
+ paddingVertical: spacing.lg + spacing.xs,
+ flexWrap: 'wrap'
+ },
+ previewKeycapRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
+ previewPlus: {
+ color: colors.textMuted,
+ fontSize: 16
+ },
+ keycap: {
+ minWidth: 48,
+ height: 48,
+ paddingHorizontal: spacing.md,
+ borderRadius: 10,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ keycapModifier: {
+ minWidth: 0
+ },
+ keycapWarn: {
+ borderColor: colors.statusAmber
+ },
+ keycapText: {
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily,
+ fontSize: 17,
+ fontWeight: '600'
+ },
+ keycapTextWarn: {
+ color: colors.statusAmber
+ },
+ keycapModifierText: {
+ color: colors.textSecondary,
+ fontFamily: typography.monoFamily,
+ fontSize: 14,
+ fontWeight: '600'
+ },
+ section: {
+ marginTop: spacing.md
+ },
+ sectionLabel: {
+ fontSize: 11,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ marginBottom: spacing.sm,
+ paddingLeft: 2
+ },
+ mods: {
+ flexDirection: 'row',
+ gap: spacing.sm
+ },
+ chip: {
+ flex: 1,
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: colors.bgPanel,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4
+ },
+ chipSelected: {
+ backgroundColor: colors.textPrimary
+ },
+ chipPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ chipText: {
+ color: colors.textSecondary,
+ fontSize: 14,
+ fontWeight: '500'
+ },
+ chipTextSelected: {
+ color: colors.bgBase
+ },
+ chipGlyph: {
+ color: colors.textMuted,
+ fontSize: 13,
+ fontFamily: typography.monoFamily
+ },
+ chipGlyphSelected: {
+ color: 'rgba(10,10,10,0.5)'
+ },
+ keyInput: {
+ width: '100%',
+ height: 56,
+ borderRadius: 10,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily,
+ fontSize: 22,
+ fontWeight: '600',
+ textAlign: 'center'
+ },
+ moreLink: {
+ paddingVertical: spacing.sm,
+ alignItems: 'center'
+ },
+ moreLinkPressed: {
+ opacity: 0.6
+ },
+ moreLinkText: {
+ color: colors.textSecondary,
+ fontSize: 13,
+ textDecorationLine: 'underline'
+ },
+ specialKeysForm: {
+ paddingTop: spacing.xs,
+ paddingBottom: spacing.md,
+ gap: spacing.md
+ },
+ specialGroup: {
+ gap: spacing.xs
+ },
+ specialGroupTitle: {
+ fontSize: 11,
+ color: colors.textMuted,
+ textTransform: 'uppercase',
+ letterSpacing: 0.8,
+ paddingLeft: 2,
+ marginBottom: spacing.xs
+ },
+ keyGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ marginHorizontal: -spacing.xs / 2
+ },
+ keyCellWrap: {
+ paddingHorizontal: spacing.xs / 2,
+ paddingVertical: spacing.xs / 2
+ },
+ keyCell: {
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: colors.bgPanel,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ keyCellPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ keyCellSelected: {
+ backgroundColor: colors.textPrimary
+ },
+ keyCellText: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ fontFamily: typography.monoFamily
+ },
+ keyCellTextSelected: {
+ color: colors.bgBase
+ },
+ macroForm: {
+ padding: spacing.md,
+ gap: spacing.sm
+ },
+ fieldLabel: {
+ fontSize: 13,
+ fontWeight: '500',
+ color: colors.textSecondary
+ },
+ fieldInput: {
+ backgroundColor: colors.bgBase,
+ color: colors.textPrimary,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ fontSize: 14,
+ fontFamily: typography.monoFamily,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ switchRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: spacing.xs
+ },
+ switchLabel: {
+ fontSize: typography.bodySize,
+ color: colors.textPrimary
+ },
+ saveButton: {
+ marginTop: spacing.md,
+ backgroundColor: colors.textPrimary,
+ paddingVertical: spacing.md,
+ borderRadius: 10,
+ alignItems: 'center'
+ },
+ saveButtonDisabled: {
+ backgroundColor: colors.bgRaised
+ },
+ saveButtonText: {
+ color: colors.bgBase,
+ fontSize: 15,
+ fontWeight: '600'
+ },
+ saveButtonTextDisabled: {
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx
index 2777e310dc8..0cc24f4c030 100644
--- a/mobile/src/components/CustomKeyModal.tsx
+++ b/mobile/src/components/CustomKeyModal.tsx
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react'
-import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
+import { View, Text, Pressable, TextInput, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
-import { colors, spacing, radii, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
buildTerminalShortcutKey,
@@ -11,6 +11,7 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
+import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
)
}
-
-const styles = StyleSheet.create({
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingBottom: spacing.sm
- },
- backButton: {
- width: 30,
- height: 30,
- borderRadius: 15,
- alignItems: 'center',
- justifyContent: 'center'
- },
- backButtonPressed: {
- backgroundColor: colors.bgRaised
- },
- backSpacer: {
- width: 30
- },
- title: {
- flex: 1,
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary,
- textAlign: 'center'
- },
- group: {
- backgroundColor: colors.bgPanel,
- borderRadius: 12,
- overflow: 'hidden'
- },
- separator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- row: {
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2
- },
- rowPressed: {
- backgroundColor: colors.bgRaised
- },
- rowLabel: {
- fontSize: typography.bodySize,
- fontWeight: '500',
- color: colors.textPrimary,
- marginBottom: 1
- },
- rowHint: {
- fontSize: 12,
- color: colors.textMuted
- },
- shortcutForm: {
- paddingTop: spacing.sm
- },
- preview: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: spacing.sm,
- paddingVertical: spacing.lg + spacing.xs,
- flexWrap: 'wrap'
- },
- previewKeycapRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm
- },
- previewPlus: {
- color: colors.textMuted,
- fontSize: 16
- },
- keycap: {
- minWidth: 48,
- height: 48,
- paddingHorizontal: spacing.md,
- borderRadius: 10,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- alignItems: 'center',
- justifyContent: 'center'
- },
- keycapModifier: {
- minWidth: 0
- },
- keycapWarn: {
- borderColor: colors.statusAmber
- },
- keycapText: {
- color: colors.textPrimary,
- fontFamily: typography.monoFamily,
- fontSize: 17,
- fontWeight: '600'
- },
- keycapTextWarn: {
- color: colors.statusAmber
- },
- keycapModifierText: {
- color: colors.textSecondary,
- fontFamily: typography.monoFamily,
- fontSize: 14,
- fontWeight: '600'
- },
- section: {
- marginTop: spacing.md
- },
- sectionLabel: {
- fontSize: 11,
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.8,
- marginBottom: spacing.sm,
- paddingLeft: 2
- },
- mods: {
- flexDirection: 'row',
- gap: spacing.sm
- },
- chip: {
- flex: 1,
- height: 40,
- borderRadius: 8,
- backgroundColor: colors.bgPanel,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- gap: 4
- },
- chipSelected: {
- backgroundColor: colors.textPrimary
- },
- chipPressed: {
- backgroundColor: colors.bgRaised
- },
- chipText: {
- color: colors.textSecondary,
- fontSize: 14,
- fontWeight: '500'
- },
- chipTextSelected: {
- color: colors.bgBase
- },
- chipGlyph: {
- color: colors.textMuted,
- fontSize: 13,
- fontFamily: typography.monoFamily
- },
- chipGlyphSelected: {
- color: 'rgba(10,10,10,0.5)'
- },
- keyInput: {
- width: '100%',
- height: 56,
- borderRadius: 10,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- color: colors.textPrimary,
- fontFamily: typography.monoFamily,
- fontSize: 22,
- fontWeight: '600',
- textAlign: 'center'
- },
- moreLink: {
- paddingVertical: spacing.sm,
- alignItems: 'center'
- },
- moreLinkPressed: {
- opacity: 0.6
- },
- moreLinkText: {
- color: colors.textSecondary,
- fontSize: 13,
- textDecorationLine: 'underline'
- },
- specialKeysForm: {
- paddingTop: spacing.xs,
- paddingBottom: spacing.md,
- gap: spacing.md
- },
- specialGroup: {
- gap: spacing.xs
- },
- specialGroupTitle: {
- fontSize: 11,
- color: colors.textMuted,
- textTransform: 'uppercase',
- letterSpacing: 0.8,
- paddingLeft: 2,
- marginBottom: spacing.xs
- },
- keyGrid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- marginHorizontal: -spacing.xs / 2
- },
- keyCellWrap: {
- paddingHorizontal: spacing.xs / 2,
- paddingVertical: spacing.xs / 2
- },
- keyCell: {
- height: 40,
- borderRadius: 8,
- backgroundColor: colors.bgPanel,
- alignItems: 'center',
- justifyContent: 'center'
- },
- keyCellPressed: {
- backgroundColor: colors.bgRaised
- },
- keyCellSelected: {
- backgroundColor: colors.textPrimary
- },
- keyCellText: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.textPrimary,
- fontFamily: typography.monoFamily
- },
- keyCellTextSelected: {
- color: colors.bgBase
- },
- macroForm: {
- padding: spacing.md,
- gap: spacing.sm
- },
- fieldLabel: {
- fontSize: 13,
- fontWeight: '500',
- color: colors.textSecondary
- },
- fieldInput: {
- backgroundColor: colors.bgBase,
- color: colors.textPrimary,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- fontSize: 14,
- fontFamily: typography.monoFamily,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- switchRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingVertical: spacing.xs
- },
- switchLabel: {
- fontSize: typography.bodySize,
- color: colors.textPrimary
- },
- saveButton: {
- marginTop: spacing.md,
- backgroundColor: colors.textPrimary,
- paddingVertical: spacing.md,
- borderRadius: 10,
- alignItems: 'center'
- },
- saveButtonDisabled: {
- backgroundColor: colors.bgRaised
- },
- saveButtonText: {
- color: colors.bgBase,
- fontSize: 15,
- fontWeight: '600'
- },
- saveButtonTextDisabled: {
- color: colors.textMuted
- }
-})
diff --git a/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts
new file mode 100644
index 00000000000..c4348ad15a2
--- /dev/null
+++ b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts
@@ -0,0 +1,279 @@
+export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
+ ';',
+ ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
+ ' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
+ ' }',
+ ' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
+ ' html, body {',
+ ' width: 100%;',
+ ' min-height: 100%;',
+ ' margin: 0;',
+ ' background: var(--editor-surface);',
+ ' color: var(--foreground);',
+ ' font-family: var(--font-sans);',
+ ' overscroll-behavior: contain;',
+ ' }',
+ ' body { overflow: auto; }',
+ ' #editor {',
+ ' min-height: 100vh;',
+ ' padding: 18px 16px 112px;',
+ ' outline: none;',
+ ' font-size: 14px;',
+ ' line-height: 1.7;',
+ ' word-wrap: break-word;',
+ ' overflow-wrap: anywhere;',
+ ' caret-color: var(--foreground);',
+ ' }',
+ ' #editor[contenteditable="false"] {',
+ ' opacity: 0.78;',
+ ' }',
+ ' #editor:empty::before,',
+ ' #editor p.is-empty:first-child::before {',
+ ' content: attr(data-placeholder);',
+ ' color: var(--muted-foreground);',
+ ' pointer-events: none;',
+ ' }',
+ ' #editor > :first-child { margin-top: 0; }',
+ ' h1, h2, h3, h4, h5, h6 {',
+ ' margin: 1.5em 0 0.5em;',
+ ' font-weight: 600;',
+ ' line-height: 1.3;',
+ ' letter-spacing: 0;',
+ ' }',
+ ' h1 { font-size: 1.85em; font-weight: 700; }',
+ ' h2 { font-size: 1.4em; }',
+ ' h3 { font-size: 1.15em; }',
+ ' p, ul, ol, blockquote { margin: 0.75em 0; }',
+ ' ul, ol { padding-left: 1.5em; }',
+ ' ul { list-style: disc; }',
+ ' ol { list-style: decimal; }',
+ ' li { margin: 0.15em 0; }',
+ ' li > p { margin: 0; }',
+ ' ul[data-type="taskList"] {',
+ ' padding-left: 0;',
+ ' list-style: none;',
+ ' }',
+ ' ul[data-type="taskList"] > li {',
+ ' display: flex;',
+ ' align-items: flex-start;',
+ ' gap: 6px;',
+ ' }',
+ ' ul[data-type="taskList"] > li > label {',
+ ' flex-shrink: 0;',
+ ' display: flex;',
+ ' align-items: center;',
+ ' height: 1.55em;',
+ ' user-select: none;',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"] {',
+ ' appearance: none;',
+ ' width: 16px;',
+ ' height: 16px;',
+ ' margin: 0;',
+ ' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
+ ' border-radius: 4px;',
+ ' background: transparent;',
+ ' position: relative;',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:checked {',
+ ' background: var(--primary);',
+ ' border-color: var(--primary);',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
+ ' content: "";',
+ ' position: absolute;',
+ ' left: 4px;',
+ ' top: 1px;',
+ ' width: 5px;',
+ ' height: 9px;',
+ ' border: solid var(--primary-foreground);',
+ ' border-width: 0 2px 2px 0;',
+ ' transform: rotate(45deg);',
+ ' }',
+ ' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
+ ' opacity: 0.65;',
+ ' }',
+ ' ul[data-type="taskList"] > li > div {',
+ ' flex: 1;',
+ ' min-width: 0;',
+ ' }',
+ ' ul[data-type="taskList"] > li[data-checked="true"] > div {',
+ ' text-decoration: line-through;',
+ ' color: var(--muted-foreground);',
+ ' }',
+ ' blockquote {',
+ ' padding: 0.5em 1em;',
+ ' border-left: 3px solid var(--border);',
+ ' border-radius: 0 6px 6px 0;',
+ ' color: var(--muted-foreground);',
+ ' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
+ ' }',
+ ' table {',
+ ' width: 100%;',
+ ' margin: 1em 0;',
+ ' border-collapse: collapse;',
+ ' font-size: 0.95em;',
+ ' }',
+ ' th, td {',
+ ' padding: 8px 14px;',
+ ' border: 1px solid var(--border);',
+ ' text-align: left;',
+ ' vertical-align: top;',
+ ' }',
+ ' th {',
+ ' font-weight: 600;',
+ ' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
+ ' }',
+ ' tr:nth-child(odd) td {',
+ ' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
+ ' }',
+ ' code {',
+ ' padding: 0.2em 0.4em;',
+ ' border-radius: 5px;',
+ ' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
+ ' font-size: 0.88em;',
+ ' font-family: var(--font-mono);',
+ ' }',
+ ' pre {',
+ ' margin: 0.75em 0;',
+ ' padding: 14px 18px;',
+ ' border-radius: 8px;',
+ ' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
+ ' overflow-x: auto;',
+ ' line-height: 1.55;',
+ ' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
+ ' font-family: var(--font-mono);',
+ ' white-space: pre-wrap;',
+ ' }',
+ ' pre::before {',
+ ' content: attr(data-language);',
+ ' display: block;',
+ ' min-height: 13px;',
+ ' margin-bottom: 4px;',
+ ' color: var(--muted-foreground);',
+ ' font-size: 11px;',
+ ' text-transform: uppercase;',
+ ' }',
+ ' pre code {',
+ ' padding: 0;',
+ ' border-radius: 0;',
+ ' background: transparent;',
+ ' font-size: 0.92em;',
+ ' white-space: pre-wrap;',
+ ' }',
+ ' hr {',
+ ' margin: 1.5em 0;',
+ ' border: none;',
+ ' border-top: 1px solid var(--border);',
+ ' }',
+ ' a {',
+ ' color: var(--accent-link);',
+ ' text-decoration: underline;',
+ ' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
+ ' text-underline-offset: 2px;',
+ ' }',
+ ' img {',
+ ' display: block;',
+ ' max-width: 100%;',
+ ' margin: 0.75em 0;',
+ ' border-radius: 8px;',
+ ' }',
+ ' ',
+ '',
+ '',
+ ' ',
+ ' ',
+ '',
+ '