diff --git a/README.md b/README.md
index 00989a01325..b449142d82d 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.16](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.17](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -229,7 +229,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
-- **Android:** [Download APK 0.0.16](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
+- **Android:** [Download APK 0.0.17](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk)
---
diff --git a/docs/native-chat-codex-tui-parity.md b/docs/native-chat-codex-tui-parity.md
new file mode 100644
index 00000000000..89b0d2bd1b9
--- /dev/null
+++ b/docs/native-chat-codex-tui-parity.md
@@ -0,0 +1,217 @@
+# Native Chat Codex TUI Parity
+
+This note maps Codex TUI behavior to Orca native chat on branch
+`inspect/pr-5824-native-chat`. It is intentionally concrete: the current Orca
+surface is a PTY harness around the running TUI, while real native parity should
+move selected paths to Codex app-server protocol v2.
+
+## Source Map
+
+- Codex TUI composer: `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/chat_composer.rs`
+- Slash command parsing and popup:
+ `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/prompt_args.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/slash_commands.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/tui/src/slash_command.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/tui/src/chatwidget/slash_dispatch.rs`
+- Skills and mentions:
+ `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/skill_popup.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/tui/src/skills_helpers.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/loader.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/root_loader.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/injection.rs`
+- Structured input and native protocol:
+ `/Users/jinwoohong/stably/codex/codex-rs/protocol/src/user_input.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/v2/turn.rs`,
+ `/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/common.rs`
+
+## Current Orca Architecture
+
+Orca native chat currently sends through the hosted terminal PTY. The composer
+builds paste bytes, writes them through `sendRuntimePtyInput`, then sends a
+delayed Enter. This preserves local and SSH behavior because it uses the same
+runtime path as terminal typing.
+
+That architecture is useful for incremental adoption, but it means native chat
+does not own Codex state. It cannot directly set model, reasoning, permissions,
+skills, or session lifecycle. It can only type commands into the TUI and observe
+agent hooks/transcripts after the fact.
+
+## Slash Commands
+
+Codex behavior:
+
+- The parser accepts a first-line command of `/name `.
+- The slash popup uses Codex's `SlashCommand` enum order as presentation order.
+- Enter on a selected popup row dispatches the command. Tab completes it into
+ the draft.
+- Some commands accept inline args: `review`, `rename`, `plan`, `goal`, `ide`,
+ `keymap`, `mcp`, `raw`, `usage`, `pets`, `side`, `resume`, and
+ `sandbox-add-read-dir`.
+- Commands are control actions, not ordinary user chat turns. For example
+ `/clear` sends `AppEvent::ClearUi`; `/compact` starts compaction; `/model`
+ opens the model picker; `/skills` opens skill management.
+
+Current Orca behavior:
+
+- Slash commands are still typed into the TUI over PTY.
+- Native optimistic chat bubbles are suppressed for slash drafts so `/clear`
+ does not render as a fake queued user message.
+- The Codex slash catalog now mirrors the visible TUI command list much more
+ closely, but it is still a copied catalog, not a live TUI query.
+
+Recommended route:
+
+- Short term: keep PTY dispatch for slash commands, but treat them as command
+ submissions. No optimistic chat bubbles. Enter dispatches; Tab completes.
+- Medium term: route commands with app-server equivalents directly. Examples:
+ `thread/compact/start`, `thread/list`, `thread/archive`, `thread/delete`,
+ `model/list`, permissions/config reads and writes, `skills/list`.
+- Long term: stop maintaining a renderer-side Codex command catalog. Either ask
+ Codex for the command inventory or host the Codex composer state machine.
+
+## Skills And `$`
+
+Codex behavior:
+
+- `$` opens the skill popup. Rows show display name, description, category tags,
+ selection state, filtering, sorting, and scrolling.
+- Codex discovers skills from repo, user, system, admin, and plugin roots. Repo
+ scope sorts before user/system/admin. Exact duplicate paths are deduped.
+- Skill selection is structured. `UserInput` has `Skill { name, path }`, and
+ app-server protocol v2 mirrors it. Text `$skill` mentions are only the
+ fallback path and must be unambiguous.
+- Skill injection reads the selected `SKILL.md` by path, records telemetry, and
+ avoids double-injecting already provided host skill prompts.
+
+Current Orca behavior:
+
+- Native chat discovers skills through Orca's skills IPC with the active
+ terminal tab's cwd. This is important for worktree symlinks like
+ `.agents/skills`.
+- `$` autocomplete inserts plain `$skillName` text. That can work through the
+ TUI's text fallback, but it is not equivalent to structured
+ `UserInput::Skill { name, path }`.
+
+Recommended route:
+
+- PTY mode: keep `$skill` text insertion, but preserve Codex-like filtering,
+ scrolling, dedupe, and active-cwd discovery.
+- Native mode: retain the selected skill's path and submit
+ `UserInput::Skill { name, path }` through app-server `turn` input. This avoids
+ ambiguity when multiple skills share a name and lets Codex inject the exact
+ file the user selected.
+
+## Files, Mentions, And Images
+
+Codex behavior:
+
+- User input supports `Text` with text elements, `Image`, `LocalImage`,
+ `Skill`, and `Mention`.
+- The TUI has file search/mentions and image placeholders. Large pastes become
+ placeholders so text element ranges stay aligned.
+- Remote image rows are first-class composer attachments and can be removed with
+ keyboard navigation.
+
+Current Orca behavior:
+
+- File attach inserts a path/reference into the draft and relies on the TUI to
+ interpret it.
+- Image paste saves a temp file, then inserts the agent-specific reference.
+- Local attachments are blocked for remote sessions because the local path may
+ not exist on the SSH target.
+
+Recommended route:
+
+- PTY mode: keep conservative path insertion and remote-session blocking.
+- Native mode: send structured `LocalImage` or `Image` input through Codex
+ protocol and use remote runtime file transfer semantics for SSH.
+
+## Model, Reasoning, Permissions
+
+Codex behavior:
+
+- `/model`, `/permissions`, `/keymap`, `/vim`, `/experimental`, and related
+ commands are stateful TUI/app-server surfaces.
+- App-server v2 already exposes model listing, config requirements, approval
+ policies, permission profiles, and reasoning effort fields.
+
+Current Orca behavior:
+
+- Native chat does not know or set Codex model/reasoning directly. Typing
+ `/model` opens Codex's TUI picker.
+- Earlier UI controls for model/thinking were removed because they were not
+ wired to real Codex state.
+
+Recommended route:
+
+- Do not re-add model or reasoning dropdowns until they read from and write to
+ Codex app-server state.
+- In PTY mode, expose `/model` as a command shortcut only.
+
+## Approvals, Elicitations, And Tool UI
+
+Codex behavior:
+
+- Approval overlays cover exec approval, permission approval, file change
+ approval, network approval, MCP elicitation, and request-user-input forms.
+- App-server notifications include thread status, waiting-on-approval/user-input
+ flags, item start/completion, diff/plan updates, and skill changes.
+
+Current Orca behavior:
+
+- Native chat has interactive cards sourced from Orca's existing agent status
+ hooks. This is good for common question/approval flows, but it is not the full
+ Codex approval overlay model.
+
+Recommended route:
+
+- Keep PTY fallback for anything not represented in Orca hooks.
+- For Codex-native mode, subscribe to app-server notifications and render
+ approvals/tool calls from protocol events rather than scraping terminal text.
+
+## Session And History
+
+Codex behavior:
+
+- `/new`, `/resume`, `/fork`, `/archive`, `/delete`, `/compact`, and `/clear`
+ are session lifecycle commands.
+- The composer has local and persistent history; Up/Down recall, Ctrl+R reverse
+ search, Esc edit/interrupt behavior, Ctrl+J newline, Ctrl+T transcript, and
+ Ctrl+C quit/interrupt behavior.
+
+Current Orca behavior:
+
+- Native chat has small in-memory draft history and Enter/Shift+Enter.
+- Session commands are typed into the hosted TUI.
+
+Recommended route:
+
+- Short term: keep TUI command dispatch and avoid fake optimistic bubbles for
+ lifecycle commands.
+- Native mode: use app-server thread APIs for lifecycle and expose real thread
+ transitions in the Orca UI.
+
+## Priority
+
+1. Fix PTY-command correctness: Enter dispatches slash commands, Tab completes,
+ slash commands never render as queued chat turns, interrupt clears working UI.
+2. Make `$` skill popup match Codex basics: active cwd, dedupe, scrolling,
+ filtering, source labels, and no product-specific hardcoding.
+3. Keep fake model/thinking controls out until backed by Codex app-server state.
+4. Add an app-server integration spike for Codex native mode: `skills/list`,
+ structured `UserInput::Skill`, model list/settings, and thread lifecycle.
+5. Move approvals/tool rendering from hook approximations to protocol events.
+
+## Test Targets
+
+- `/clear` from native slash popup dispatches immediately and produces no
+ pending user bubble.
+- `/compact`, `/model`, `/skills`, `/resume`, `/diff`, `/status`, and unknown
+ slash commands behave like the hosted TUI.
+- `$ref-oss` appears exactly once when the worktree has `.agents/skills` as a
+ symlink.
+- Down-arrow in `$` suggestions scrolls the popup window.
+- Interrupt during work returns the composer from Stop to Send after the agent
+ status settles.
+- SSH sessions never insert local-only attachment paths as if they were remote
+ files.
diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md
index cb78aa38095..e4bed124354 100644
--- a/docs/readme/README.es.md
+++ b/docs/readme/README.es.md
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
-[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index e2d93abd1f8..68f74264921 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 4a63f512b9f..7c8de89e9fe 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
-[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk)
---
diff --git a/package.json b/package.json
index 88f68d40de0..c8f437619a5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.104-rc.5",
+ "version": "1.4.105-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts
index 118d3524719..b5dc91792c9 100644
--- a/src/main/agent-hooks/server.ts
+++ b/src/main/agent-hooks/server.ts
@@ -256,6 +256,7 @@ function equivalentParsedAgentStatusPayload(
a.agentType === b.agentType &&
a.toolName === b.toolName &&
a.toolInput === b.toolInput &&
+ a.interactivePrompt === b.interactivePrompt &&
a.lastAssistantMessage === b.lastAssistantMessage &&
a.interrupted === b.interrupted
)
diff --git a/src/main/automations/headless-workspace-create.test.ts b/src/main/automations/headless-workspace-create.test.ts
index 3e247af5046..b29496eb283 100644
--- a/src/main/automations/headless-workspace-create.test.ts
+++ b/src/main/automations/headless-workspace-create.test.ts
@@ -38,6 +38,7 @@ const automation: Automation = {
workspaceMode: 'new_per_run',
workspaceId: null,
baseBranch: 'origin/main',
+ setupDecision: 'skip',
reuseSession: false,
timezone: 'UTC',
rrule: 'FREQ=DAILY',
@@ -67,7 +68,7 @@ describe('headless automation workspace create args', () => {
repoSelector: 'repo-1',
name: 'auto-nightly-review-run-20260102T0304',
baseBranch: 'origin/main',
- setupDecision: 'inherit',
+ setupDecision: 'skip',
activate: false,
createdWithAgent: 'codex',
startupAgent: 'codex',
@@ -88,4 +89,18 @@ describe('headless automation workspace create args', () => {
}
})
})
+
+ it('falls back to skip for legacy automations without a saved setup decision', () => {
+ const args = buildHeadlessAutomationWorktreeCreateArgs({
+ automation: { ...automation, setupDecision: undefined },
+ run: {
+ id: 'run-1',
+ title: 'Nightly review run',
+ scheduledFor: Date.UTC(2026, 0, 2, 3, 4, 5)
+ },
+ repo
+ })
+
+ expect(args.setupDecision).toBe('skip')
+ })
})
diff --git a/src/main/automations/headless-workspace-create.ts b/src/main/automations/headless-workspace-create.ts
index 497ad85573b..7e6e2f43e18 100644
--- a/src/main/automations/headless-workspace-create.ts
+++ b/src/main/automations/headless-workspace-create.ts
@@ -36,7 +36,7 @@ export function buildHeadlessAutomationWorktreeCreateArgs({
repoSelector: repo.id,
name: buildHeadlessAutomationWorkspaceName(run.title, run.scheduledFor),
baseBranch: automation.baseBranch ?? undefined,
- setupDecision: 'inherit',
+ setupDecision: automation.setupDecision ?? 'skip',
activate: false,
createdWithAgent: automation.agentId,
startupAgent: automation.agentId,
diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts
index 13de446dc76..ed0ab74dc11 100644
--- a/src/main/browser/agent-browser-bridge.test.ts
+++ b/src/main/browser/agent-browser-bridge.test.ts
@@ -1512,6 +1512,87 @@ describe('AgentBrowserBridge', () => {
expect(chunks).toEqual(['z'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'qq'])
})
+ // ── Cross-worktree text-injection guard ──
+
+ describe('scoped target for text-mutating commands', () => {
+ function twoWorktreeBridge(): AgentBrowserBridge {
+ const tabs = new Map([
+ ['tab-a', 1],
+ ['tab-b', 2]
+ ])
+ const worktrees = new Map([
+ ['tab-a', 'wt-1'],
+ ['tab-b', 'wt-2']
+ ])
+ const wc1 = mockWebContents(1, 'https://a.com', 'A')
+ const wc2 = mockWebContents(2, 'https://b.com', 'B')
+ webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : wc2))
+ return new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
+ }
+
+ function sessionNamesUsed(): string[] {
+ return execFileMock.mock.calls
+ .filter((call: unknown[]) => (call[1] as string[]).includes('--session'))
+ .map((call: unknown[]) => {
+ const args = call[1] as string[]
+ return args[args.indexOf('--session') + 1]
+ })
+ }
+
+ it.each([
+ ['inserttext', (b: AgentBrowserBridge) => b.keyboardInsertText('x', undefined, undefined)],
+ ['type', (b: AgentBrowserBridge) => b.type('x', undefined, undefined)],
+ ['fill', (b: AgentBrowserBridge) => b.fill('@input', 'x', undefined, undefined)]
+ ])(
+ 'refuses %s when worktrees are ambiguous instead of routing to the global active tab',
+ async (_name, run) => {
+ const b = twoWorktreeBridge()
+ // Why: simulates the user viewing worktree B's tab, which sets the global
+ // active webContents — the bug would route the agent's text there.
+ b.onTabChanged(2, 'wt-2')
+ succeedWith({ inserted: true })
+
+ await expect(run(b)).rejects.toMatchObject({
+ code: 'browser_target_ambiguous'
+ })
+ // Must not have dispatched the command to worktree B's session.
+ expect(sessionNamesUsed()).not.toContain('orca-tab-tab-b')
+ }
+ )
+
+ it('auto-scopes inserttext to the lone worktree that has a live tab', async () => {
+ const tabs = new Map([['tab-a', 1]])
+ const worktrees = new Map([['tab-a', 'wt-1']])
+ const wc1 = mockWebContents(1, 'https://a.com', 'A')
+ webContentsFromIdMock.mockReturnValue(wc1)
+ const b = new AgentBrowserBridge(mockBrowserManager(tabs, worktrees))
+ succeedWith({ inserted: true })
+
+ await b.keyboardInsertText('x', undefined, undefined)
+
+ expect(sessionNamesUsed()).toContain('orca-tab-tab-a')
+ })
+
+ it('throws browser_no_tab for inserttext when no live tab exists', async () => {
+ const b = new AgentBrowserBridge(mockBrowserManager(new Map()))
+ await expect(b.keyboardInsertText('x', undefined, undefined)).rejects.toMatchObject({
+ code: 'browser_no_tab'
+ })
+ })
+
+ it('keeps read-only snapshot on the lenient global active-tab fallback', async () => {
+ const b = twoWorktreeBridge()
+ // Why: read/navigation commands intentionally keep the global fallback so
+ // discovery still works without a worktree; only text writes are guarded.
+ b.onTabChanged(2, 'wt-2')
+ succeedWith({ snapshot: 'tree' })
+
+ await b.snapshot(undefined, undefined)
+
+ expect(sessionNamesUsed()).toContain('orca-tab-tab-b')
+ })
+ })
+
// ── Cookie command arg building ──
it('builds cookie set args with all options', async () => {
diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts
index 60a7019b724..d34b365f6c4 100644
--- a/src/main/browser/agent-browser-bridge.ts
+++ b/src/main/browser/agent-browser-bridge.ts
@@ -117,6 +117,9 @@ type AgentBrowserExecOptions = {
type EnqueueTargetedCommandOptions = {
ensureSession?: boolean
ensureVisible?: boolean
+ // Why: text-mutating commands must never fall back to the global active tab,
+ // which can point at a different worktree the user is currently viewing.
+ requireScopedTarget?: boolean
}
type AgentBrowserBridgeOptions = {
@@ -764,27 +767,32 @@ export class AgentBrowserBridge {
// Agent-browser's fill and click also fail for the same reason.
// Workaround: use agent-browser's focus to resolve the ref, then set the value
// directly via chunked JS and dispatch input/change events for React/framework compat.
- return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
- await this.execAgentBrowser(sessionName, ['focus', element])
- await this.execAgentBrowser(sessionName, [
- 'eval',
- focusedValueSetExpression(JSON.stringify(''))
- ])
- for (const chunk of iterateBrowserTextInsertionChunks(
- value,
- AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
- )) {
+ return this.enqueueTargetedCommand(
+ worktreeId,
+ browserPageId,
+ async (sessionName) => {
+ await this.execAgentBrowser(sessionName, ['focus', element])
await this.execAgentBrowser(sessionName, [
'eval',
- focusedValueSetExpression(JSON.stringify(chunk), { append: true })
+ focusedValueSetExpression(JSON.stringify(''))
])
- }
- await this.execAgentBrowser(sessionName, [
- 'eval',
- focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true })
- ])
- return { filled: element } as BrowserFillResult
- })
+ for (const chunk of iterateBrowserTextInsertionChunks(
+ value,
+ AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
+ )) {
+ await this.execAgentBrowser(sessionName, [
+ 'eval',
+ focusedValueSetExpression(JSON.stringify(chunk), { append: true })
+ ])
+ }
+ await this.execAgentBrowser(sessionName, [
+ 'eval',
+ focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true })
+ ])
+ return { filled: element } as BrowserFillResult
+ },
+ { requireScopedTarget: true }
+ )
}
async type(
@@ -793,15 +801,20 @@ export class AgentBrowserBridge {
browserPageId?: string
): Promise {
await assertClipboardTextWriteWithinLimitWithYield(input)
- return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
- for (const chunk of iterateBrowserTextInsertionChunks(
- input,
- AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
- )) {
- await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk])
- }
- return { typed: true } as BrowserTypeResult
- })
+ return this.enqueueTargetedCommand(
+ worktreeId,
+ browserPageId,
+ async (sessionName) => {
+ for (const chunk of iterateBrowserTextInsertionChunks(
+ input,
+ AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
+ )) {
+ await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk])
+ }
+ return { typed: true } as BrowserTypeResult
+ },
+ { requireScopedTarget: true }
+ )
}
async select(
@@ -878,16 +891,21 @@ export class AgentBrowserBridge {
browserPageId?: string
): Promise {
await assertClipboardTextWriteWithinLimitWithYield(text)
- return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
- let result: unknown = { inserted: true }
- for (const chunk of iterateBrowserTextInsertionChunks(
- text,
- AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
- )) {
- result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk])
- }
- return result
- })
+ return this.enqueueTargetedCommand(
+ worktreeId,
+ browserPageId,
+ async (sessionName) => {
+ let result: unknown = { inserted: true }
+ for (const chunk of iterateBrowserTextInsertionChunks(
+ text,
+ AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
+ )) {
+ result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk])
+ }
+ return result
+ },
+ { requireScopedTarget: true }
+ )
}
// ── Mouse commands ──
@@ -1841,7 +1859,7 @@ export class AgentBrowserBridge {
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise,
options: EnqueueTargetedCommandOptions = {}
): Promise {
- const target = this.resolveCommandTarget(worktreeId, browserPageId)
+ const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget)
const sessionName = `orca-tab-${target.browserPageId}`
if (options.ensureSession !== false) {
@@ -1962,10 +1980,13 @@ export class AgentBrowserBridge {
private resolveCommandTarget(
worktreeId?: string,
- browserPageId?: string
+ browserPageId?: string,
+ requireScopedTarget = false
): ResolvedBrowserCommandTarget {
if (!browserPageId) {
- return this.resolveActiveTab(worktreeId)
+ return requireScopedTarget
+ ? this.resolveScopedActiveTab(worktreeId)
+ : this.resolveActiveTab(worktreeId)
}
const tabs = this.getRegisteredTabs(worktreeId)
@@ -2039,6 +2060,40 @@ export class AgentBrowserBridge {
)
}
+ // Why: text-mutating commands (inserttext/type/fill) must not silently fall
+ // back to the global active tab when no worktree was resolved — that tab can
+ // belong to a worktree the user is currently viewing, so a goal-loop agent in
+ // another worktree would inject text into the user's foreground webview and
+ // steal OS focus. A scoped (worktreeId-bearing) call is already safe because
+ // the candidate set is pre-filtered to that worktree, so defer to the lenient
+ // resolver. An unscoped call instead requires an unambiguous target: scope to
+ // the lone worktree with live tabs, or refuse rather than guess.
+ private resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget {
+ if (worktreeId) {
+ return this.resolveActiveTab(worktreeId)
+ }
+
+ const worktreesWithLiveTabs = new Set()
+ for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) {
+ if (this.getWebContents(wcId)) {
+ worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId))
+ }
+ }
+
+ if (worktreesWithLiveTabs.size === 0) {
+ throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree')
+ }
+ if (worktreesWithLiveTabs.size > 1) {
+ throw new BrowserError(
+ 'browser_target_ambiguous',
+ 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely'
+ )
+ }
+
+ const [onlyWorktreeId] = worktreesWithLiveTabs
+ return this.resolveActiveTab(onlyWorktreeId)
+ }
+
private async ensureSession(
sessionName: string,
browserPageId: string,
diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts
index cfee7b8ad10..555e19175e7 100644
--- a/src/main/codex-accounts/runtime-home-service.test.ts
+++ b/src/main/codex-accounts/runtime-home-service.test.ts
@@ -81,7 +81,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'split-vertical',
- terminalScrollbackBytes: 10_000_000,
+ terminalScrollbackRows: 5_000,
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts
index 1f52a6470a0..ad5b5976a58 100644
--- a/src/main/codex-accounts/service.test.ts
+++ b/src/main/codex-accounts/service.test.ts
@@ -85,7 +85,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'split-vertical',
- terminalScrollbackBytes: 10_000_000,
+ terminalScrollbackRows: 5_000,
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
diff --git a/src/main/github/client-pr-check-details.test.ts b/src/main/github/client-pr-check-details.test.ts
index d41bc1ca34c..429969b52f1 100644
--- a/src/main/github/client-pr-check-details.test.ts
+++ b/src/main/github/client-pr-check-details.test.ts
@@ -166,10 +166,10 @@ describe('getPRCheckDetails', () => {
id: 8801,
name: 'verify',
status: 'completed',
- conclusion: 'failure',
+ conclusion: 'startup_failure',
completed_at: '2026-05-18T19:02:00Z',
html_url: actionUrl,
- steps: [{ name: 'Run tests', status: 'completed', conclusion: 'failure' }]
+ steps: [{ name: 'Run tests', status: 'completed', conclusion: 'startup_failure' }]
},
{
id: 8802,
@@ -190,7 +190,7 @@ describe('getPRCheckDetails', () => {
expect(details?.jobs[0]).toMatchObject({
id: 8801,
name: 'verify',
- conclusion: 'failure'
+ conclusion: 'startup_failure'
})
expect(details?.jobs[0].logTail).toContain('line 209')
expect(details?.jobs[0].logTail).not.toContain('line 0')
diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts
index 6d27a4e1c38..fbf27f26a13 100644
--- a/src/main/github/client-pr-checks.test.ts
+++ b/src/main/github/client-pr-checks.test.ts
@@ -151,6 +151,41 @@ describe('getPRChecks', () => {
])
})
+ it('maps remaining completed GitHub conclusions to failure', async () => {
+ getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
+ ghExecFileAsyncMock.mockResolvedValueOnce({
+ stdout: JSON.stringify({
+ check_runs: [
+ {
+ name: 'needs-approval',
+ status: 'completed',
+ conclusion: 'action_required',
+ html_url: 'https://github.com/acme/widgets/actions/runs/1',
+ details_url: null
+ },
+ {
+ name: 'old-run',
+ status: 'completed',
+ conclusion: 'stale',
+ html_url: 'https://github.com/acme/widgets/actions/runs/2',
+ details_url: null
+ },
+ {
+ name: 'boot',
+ status: 'completed',
+ conclusion: 'startup_failure',
+ html_url: 'https://github.com/acme/widgets/actions/runs/3',
+ details_url: null
+ }
+ ]
+ })
+ })
+
+ const checks = await getPRChecks('/repo-root', 42, 'head-oid')
+
+ expect(checks.map((check) => check.conclusion)).toEqual(['failure', 'failure', 'failure'])
+ })
+
it('treats gh pr checks "no checks reported" as an empty check list', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts
index 03de78343b4..d0d001764ed 100644
--- a/src/main/github/client.test.ts
+++ b/src/main/github/client.test.ts
@@ -106,6 +106,7 @@ import {
checkOrcaStarred,
getPRComments,
getPRForBranch,
+ getPRForBranchOutcome,
getRepoUpstream,
getWorkItem,
getPullRequestPushTarget,
@@ -835,6 +836,215 @@ describe('getPRForBranch', () => {
expect(pr).toMatchObject({ number: 42, title: 'Fallback PR lookup' })
})
+ it('reports upstream error when fallback branch discovery fails transiently then retry misses', async () => {
+ resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
+ candidates: [{ owner: 'stablyai', repo: 'orca' }],
+ headRepo: null
+ })
+ ghExecFileAsyncMock
+ .mockRejectedValueOnce(new Error('HTTP 429: API rate limit exceeded'))
+ .mockResolvedValueOnce({ stdout: JSON.stringify([]) })
+
+ const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test')
+
+ expect(outcome).toMatchObject({
+ kind: 'upstream-error',
+ errorType: 'rate_limited'
+ })
+ expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
+ 1,
+ [
+ 'pr',
+ 'list',
+ '--repo',
+ 'stablyai/orca',
+ '--head',
+ 'feature/test',
+ '--state',
+ 'all',
+ '--limit',
+ '1',
+ '--json',
+ 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
+ ],
+ { cwd: '/repo-root' }
+ )
+ expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
+ 2,
+ ['api', 'repos/stablyai/orca/pulls?head=stablyai%3Afeature%2Ftest&state=all&per_page=1'],
+ { cwd: '/repo-root' }
+ )
+ })
+
+ it('reports no PR when fallback branch discovery cleanly misses', async () => {
+ resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
+ candidates: [{ owner: 'stablyai', repo: 'orca' }],
+ headRepo: null
+ })
+ ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
+
+ const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test')
+
+ expect(outcome.kind).toBe('no-pr')
+ expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns found when fallback branch discovery retry finds the PR', async () => {
+ resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
+ candidates: [{ owner: 'stablyai', repo: 'orca' }],
+ headRepo: null
+ })
+ ghExecFileAsyncMock
+ .mockRejectedValueOnce(new Error('HTTP 429: API rate limit exceeded'))
+ .mockResolvedValueOnce({
+ stdout: JSON.stringify([
+ {
+ number: 42,
+ title: 'Retry branch PR',
+ state: 'open',
+ html_url: 'https://github.com/stablyai/orca/pull/42',
+ updated_at: '2026-03-28T00:00:00Z',
+ draft: false,
+ mergeable: true,
+ base: { ref: 'main', sha: 'base-oid' },
+ head: { ref: 'feature/test', sha: 'retry-head-oid' }
+ }
+ ])
+ })
+ .mockResolvedValueOnce({
+ stdout: JSON.stringify({
+ number: 42,
+ title: 'Hydrated retry branch PR',
+ state: 'OPEN',
+ url: 'https://github.com/stablyai/orca/pull/42',
+ statusCheckRollup: [],
+ updatedAt: '2026-03-28T00:00:00Z',
+ isDraft: false,
+ mergeable: 'MERGEABLE',
+ baseRefName: 'main',
+ headRefName: 'feature/test',
+ baseRefOid: 'base-oid',
+ headRefOid: 'retry-head-oid'
+ })
+ })
+
+ const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test')
+
+ expect(outcome).toMatchObject({
+ kind: 'found',
+ pr: {
+ number: 42,
+ title: 'Hydrated retry branch PR',
+ prRepo: { owner: 'stablyai', repo: 'orca' }
+ }
+ })
+ })
+
+ it('lets fallback PR number recovery win after fallback branch queries throw', async () => {
+ resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
+ candidates: [{ owner: 'stablyai', repo: 'orca' }],
+ headRepo: null
+ })
+ ghExecFileAsyncMock
+ .mockRejectedValueOnce(new Error('HTTP 429: API rate limit exceeded'))
+ .mockRejectedValueOnce(new Error('HTTP 502: Bad Gateway'))
+ .mockResolvedValueOnce({
+ stdout: JSON.stringify({
+ number: 42,
+ title: 'Fallback number recovered PR',
+ state: 'OPEN',
+ url: 'https://github.com/stablyai/orca/pull/42',
+ statusCheckRollup: [],
+ updatedAt: '2026-03-28T00:00:00Z',
+ isDraft: false,
+ mergeable: 'MERGEABLE',
+ baseRefName: 'main',
+ headRefName: 'feature/test',
+ baseRefOid: 'base-oid',
+ headRefOid: 'fallback-head-oid'
+ })
+ })
+
+ const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test', null, null, 42)
+
+ expect(outcome).toMatchObject({
+ kind: 'found',
+ pr: {
+ number: 42,
+ title: 'Fallback number recovered PR'
+ }
+ })
+ expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
+ 3,
+ [
+ 'pr',
+ 'view',
+ '42',
+ '--repo',
+ 'stablyai/orca',
+ '--json',
+ 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
+ ],
+ { cwd: '/repo-root' }
+ )
+ })
+
+ it('reports upstream error when fallback branch discovery has a network failure', async () => {
+ resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
+ candidates: [{ owner: 'stablyai', repo: 'orca' }],
+ headRepo: null
+ })
+ ghExecFileAsyncMock
+ .mockRejectedValueOnce(new Error('could not resolve host: api.github.com'))
+ .mockRejectedValueOnce(new Error('could not resolve host: api.github.com'))
+
+ const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test')
+
+ expect(outcome).toMatchObject({
+ kind: 'upstream-error',
+ errorType: 'network'
+ })
+ })
+
+ it('keeps a pending fallback branch error when a later candidate cleanly misses', async () => {
+ resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
+ candidates: [
+ { owner: 'stablyai', repo: 'orca' },
+ { owner: 'fork', repo: 'orca' }
+ ],
+ headRepo: null
+ })
+ ghExecFileAsyncMock
+ .mockRejectedValueOnce(new Error('HTTP 429: API rate limit exceeded'))
+ .mockResolvedValueOnce({ stdout: JSON.stringify([]) })
+ .mockResolvedValueOnce({ stdout: JSON.stringify([]) })
+
+ const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test')
+
+ expect(outcome).toMatchObject({
+ kind: 'upstream-error',
+ errorType: 'rate_limited'
+ })
+ expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
+ 3,
+ [
+ 'pr',
+ 'list',
+ '--repo',
+ 'fork/orca',
+ '--head',
+ 'feature/test',
+ '--state',
+ 'all',
+ '--limit',
+ '1',
+ '--json',
+ 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
+ ],
+ { cwd: '/repo-root' }
+ )
+ })
+
it('treats a merged branch lookup as a miss before using a fallback PR number', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
diff --git a/src/main/github/client.ts b/src/main/github/client.ts
index 377c5f1312e..99843f88b46 100644
--- a/src/main/github/client.ts
+++ b/src/main/github/client.ts
@@ -2554,8 +2554,14 @@ async function lookupPRByBranchName(args: {
headRepo: OwnerRepo | null
branchName: string
ghOptions: GhExecOptions
-}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> {
+}): Promise<{
+ data: PullRequestLookupData | null
+ dataRepo: OwnerRepo | null
+ pendingError?: unknown
+}> {
if (args.candidates.length > 0) {
+ let pendingError: unknown
+ let hasPendingError = false
for (const candidate of args.candidates) {
try {
const branchData = args.headRepo
@@ -2576,19 +2582,34 @@ async function lookupPRByBranchName(args: {
if (args.headRepo) {
throw err
}
- const branchData = await getRestPRForBranch(
- candidate,
- candidate.owner,
- args.branchName,
- args.ghOptions
- )
- const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions)
- if (data) {
- return { data, dataRepo: candidate }
+ if (!hasPendingError) {
+ pendingError = err
+ hasPendingError = true
+ }
+ try {
+ const branchData = await getRestPRForBranch(
+ candidate,
+ candidate.owner,
+ args.branchName,
+ args.ghOptions
+ )
+ const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions)
+ if (data) {
+ return { data, dataRepo: candidate }
+ }
+ } catch (retryErr) {
+ if (!hasPendingError) {
+ pendingError = retryErr
+ hasPendingError = true
+ }
}
}
}
- return { data: null, dataRepo: null }
+ // Why: branch-list failures are ambiguous for fork discovery, but exact
+ // fallback-number recovery should still get a chance before surfacing error.
+ return hasPendingError
+ ? { data: null, dataRepo: null, pendingError }
+ : { data: null, dataRepo: null }
}
try {
@@ -2778,6 +2799,8 @@ export async function getPRForBranchOutcome(
let data: PullRequestLookupData | null = null
let dataRepo: OwnerRepo | null = null
let dataHeadRepo: OwnerRepo | null = headRepo
+ let pendingBranchLookupError: unknown
+ let hasPendingBranchLookupError = false
let currentHeadOidForMergedImplicit: string | null | undefined
const explicitCurrentHeadOid =
@@ -2817,6 +2840,10 @@ export async function getPRForBranchOutcome(
})
data = branchLookup.data
dataRepo = branchLookup.dataRepo
+ if ('pendingError' in branchLookup) {
+ pendingBranchLookupError = branchLookup.pendingError
+ hasPendingBranchLookupError = true
+ }
if (!data) {
// Why: the tracked upstream can identify the real PR head by branch
// name or by fork owner even when branch names match locally.
@@ -2846,6 +2873,10 @@ export async function getPRForBranchOutcome(
})
data = upstreamLookup.data
dataRepo = upstreamLookup.dataRepo
+ if (!hasPendingBranchLookupError && 'pendingError' in upstreamLookup) {
+ pendingBranchLookupError = upstreamLookup.pendingError
+ hasPendingBranchLookupError = true
+ }
if (data) {
dataHeadRepo = upstreamHeadRepo
}
@@ -2870,6 +2901,9 @@ export async function getPRForBranchOutcome(
dataRepo = fallbackLookup.dataRepo
}
if (!data) {
+ if (hasPendingBranchLookupError) {
+ return prRefreshUpstreamError(pendingBranchLookupError)
+ }
return { kind: 'no-pr', fetchedAt: Date.now() }
}
const fallbackConfirmedMergedBranch =
@@ -3110,7 +3144,15 @@ function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['j
}
function isCheckJobFailureState(state: string | null | undefined): boolean {
- return state === 'failure' || state === 'failed' || state === 'cancelled' || state === 'timed_out'
+ return (
+ state === 'failure' ||
+ state === 'failed' ||
+ state === 'action_required' ||
+ state === 'cancelled' ||
+ state === 'stale' ||
+ state === 'startup_failure' ||
+ state === 'timed_out'
+ )
}
function sliceCheckLogTail(logText: string): string {
diff --git a/src/main/github/mappers.ts b/src/main/github/mappers.ts
index 59e26332425..54235a43405 100644
--- a/src/main/github/mappers.ts
+++ b/src/main/github/mappers.ts
@@ -22,7 +22,9 @@ const conclusionMap: Record = {
timed_out: 'timed_out',
skipped: 'skipped',
neutral: 'neutral',
- action_required: 'failure'
+ action_required: 'failure',
+ stale: 'failure',
+ startup_failure: 'failure'
}
export function mapCheckRunRESTConclusion(
@@ -59,6 +61,9 @@ export function mapCheckConclusion(state: string): PRCheckDetail['conclusion'] {
if (s === 'FAILURE' || s === 'FAIL') {
return 'failure'
}
+ if (s === 'ACTION_REQUIRED' || s === 'STALE' || s === 'STARTUP_FAILURE') {
+ return 'failure'
+ }
if (s === 'CANCELLED') {
return 'cancelled'
}
diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts
index 4ead1dc4d2e..87b7b590549 100644
--- a/src/main/hooks.test.ts
+++ b/src/main/hooks.test.ts
@@ -1020,6 +1020,40 @@ describe('runHook', () => {
})
})
+describe('createSetupRunnerScript', () => {
+ const makeRepo = (setupAgentStartupPolicy?: 'start-immediately' | 'wait-for-setup') =>
+ ({
+ id: 'test-id',
+ path: '/test/repo',
+ displayName: 'Test Repo',
+ badgeColor: '#000',
+ addedAt: Date.now(),
+ hookSettings: {
+ mode: 'auto',
+ setupAgentStartupPolicy,
+ scripts: { setup: '', archive: '' }
+ }
+ }) as unknown as Repo
+
+ it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => {
+ gitExecFileSyncMock.mockReset()
+ gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
+ const { createSetupRunnerScript } = await import('./hooks')
+
+ expect(
+ createSetupRunnerScript(makeRepo(), '/test/worktree', 'echo setup').waitForAgentStartup
+ ).toBeUndefined()
+ expect(
+ createSetupRunnerScript(makeRepo('start-immediately'), '/test/worktree', 'echo setup')
+ .waitForAgentStartup
+ ).toBeUndefined()
+ expect(
+ createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup')
+ .waitForAgentStartup
+ ).toBe(true)
+ })
+})
+
describe('shouldRunSetupForCreate', () => {
const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') =>
({
diff --git a/src/main/hooks.ts b/src/main/hooks.ts
index 17a87757f52..7cb4ba5a1e9 100644
--- a/src/main/hooks.ts
+++ b/src/main/hooks.ts
@@ -6,6 +6,7 @@ import { parse } from 'yaml'
import { getDefaultRepoHookSettings } from '../shared/constants'
import { getRuntimePathBasename } from '../shared/cross-platform-path'
import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
+import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy'
import { gitExecFileSync } from './git/runner'
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
import type {
@@ -517,7 +518,8 @@ export function createSetupRunnerScript(
worktreePath,
script,
'setup-runner',
- getHookRuntimeTarget(projectRuntime)
+ getHookRuntimeTarget(projectRuntime),
+ shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
)
}
@@ -575,7 +577,8 @@ function createWorktreeRunnerScript(
worktreePath: string,
script: string,
runnerBaseName: 'setup-runner' | 'issue-command-runner',
- runtimeTarget?: HookRuntimeTarget
+ runtimeTarget?: HookRuntimeTarget,
+ waitForAgentStartup?: boolean
): WorktreeSetupLaunch {
const envVars = getSetupEnvVars(repo, worktreePath)
// Why: WSL worktrees run on a Linux filesystem even though process.platform
@@ -618,7 +621,11 @@ function createWorktreeRunnerScript(
}
}
- return { runnerScriptPath, envVars }
+ return {
+ runnerScriptPath,
+ envVars,
+ ...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {})
+ }
}
/**
diff --git a/src/main/index.ts b/src/main/index.ts
index f8ab73b4aa2..6f27111f841 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -147,6 +147,7 @@ import {
type SyntheticAgentTitleProfile
} from '../shared/synthetic-agent-title'
import type { AgentStatusState } from '../shared/agent-status-types'
+import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
import { KeybindingService } from './keybindings/keybinding-service'
import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
@@ -855,7 +856,20 @@ function openMainWindow(): BrowserWindow {
// Why: some native OSC titles miss terminal idle/permission frames.
// Inject hook-derived frames so the renderer title tracker updates too.
const profile = getSyntheticAgentTitleProfile(payload.agentType)
- if (profile && shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state)) {
+ const suppressSyntheticCodexAutoApprovalTitle =
+ payload.agentType === 'codex' &&
+ (payload.state === 'waiting' || payload.state === 'blocked')
+ ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({
+ agentType: payload.agentType,
+ state: payload.state,
+ launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken })
+ })
+ : false
+ if (
+ profile &&
+ shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) &&
+ !suppressSyntheticCodexAutoApprovalTitle
+ ) {
driveSyntheticTitleFromHook(paneKey, payload.state, profile)
}
}
@@ -1282,6 +1296,32 @@ function driveSyntheticTitleFromHook(
})
}
+function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: {
+ agentType: string | null | undefined
+ state: AgentStatusState
+ launchConfig:
+ | {
+ agentArgs?: string | null
+ agentEnv?: Record | null
+ }
+ | null
+ | undefined
+}): boolean {
+ if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) {
+ return false
+ }
+ if (!args.launchConfig) {
+ return false
+ }
+ return (
+ resolveTuiAgentPermissionMode({
+ agent: 'codex',
+ agentArgs: args.launchConfig.agentArgs,
+ agentEnv: args.launchConfig.agentEnv
+ }) === 'yolo'
+ )
+}
+
app.whenReady().then(async () => {
logStartupMilestone('app-ready')
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts
new file mode 100644
index 00000000000..066357f0b4c
--- /dev/null
+++ b/src/main/ipc/native-chat.test.ts
@@ -0,0 +1,259 @@
+import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { handlers, listeners } = vi.hoisted(() => ({
+ handlers: new Map unknown>(),
+ listeners: new Map unknown>()
+}))
+
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
+ handlers.set(channel, handler)
+ }),
+ on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
+ listeners.set(channel, handler)
+ })
+ }
+}))
+
+import {
+ clearNativeChatSubscriptions,
+ clearNativeChatTranscriptCache,
+ registerNativeChatHandlers
+} from './native-chat'
+
+let tempRoots: string[] = []
+
+beforeEach(() => {
+ handlers.clear()
+ listeners.clear()
+ clearNativeChatTranscriptCache()
+ clearNativeChatSubscriptions()
+})
+
+afterEach(async () => {
+ await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
+ tempRoots = []
+})
+
+function jsonLines(records: unknown[]): string {
+ return records.map((record) => JSON.stringify(record)).join('\n')
+}
+
+async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise {
+ const start = Date.now()
+ while (!predicate()) {
+ if (Date.now() - start > timeoutMs) {
+ throw new Error('timed out waiting for condition')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ }
+}
+
+async function invokeReadSession(args: {
+ agent: string
+ sessionId: string
+ limit?: number
+}): Promise {
+ registerNativeChatHandlers()
+ const handler = handlers.get('nativeChat:readSession')
+ if (!handler) {
+ throw new Error('handler not registered')
+ }
+ return handler({}, args)
+}
+
+describe('nativeChat:readSession handler', () => {
+ it('resolves a Claude transcript and returns the full conversation', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-'))
+ tempRoots.push(root)
+ const projectsDir = join(root, '.claude', 'projects')
+ const projectDir = join(projectsDir, '-repo')
+ await mkdir(projectDir, { recursive: true })
+ await writeFile(
+ join(projectDir, 'sess-ipc.jsonl'),
+ jsonLines([
+ {
+ type: 'user',
+ uuid: 'u-1',
+ timestamp: '2026-06-01T10:00:00.000Z',
+ message: { role: 'user', content: 'Hi' }
+ },
+ {
+ type: 'assistant',
+ uuid: 'a-1',
+ timestamp: '2026-06-01T10:00:01.000Z',
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }
+ }
+ ])
+ )
+
+ // Point homedir-derived Claude root at our fixture via HOME so the resolver
+ // (which reads homedir() internally) finds the transcript.
+ const previousHome = process.env.HOME
+ process.env.HOME = root
+ try {
+ const result = (await invokeReadSession({ agent: 'claude', sessionId: 'sess-ipc' })) as {
+ messages?: unknown[]
+ error?: string
+ }
+ expect(result.error).toBeUndefined()
+ expect(result.messages).toHaveLength(2)
+ } finally {
+ if (previousHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = previousHome
+ }
+ }
+ })
+
+ it('windows to the most-recent `limit` turns and pages older history when raised', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-limit-'))
+ tempRoots.push(root)
+ const projectDir = join(root, '.claude', 'projects', '-repo')
+ await mkdir(projectDir, { recursive: true })
+ // Five user turns; reading with limit 2 returns only the last two, and a
+ // larger limit pages in older ones (chronological order preserved).
+ const records = [1, 2, 3, 4, 5].map((n) => ({
+ type: 'user',
+ uuid: `u-${n}`,
+ timestamp: `2026-06-01T10:00:0${n}.000Z`,
+ message: { role: 'user', content: `m${n}` }
+ }))
+ await writeFile(join(projectDir, 'sess-limit.jsonl'), jsonLines(records))
+
+ const previousHome = process.env.HOME
+ process.env.HOME = root
+ try {
+ const windowed = (await invokeReadSession({
+ agent: 'claude',
+ sessionId: 'sess-limit',
+ limit: 2
+ })) as { messages: { id: string }[] }
+ expect(windowed.messages.map((m) => m.id)).toEqual(['u-4', 'u-5'])
+
+ const wider = (await invokeReadSession({
+ agent: 'claude',
+ sessionId: 'sess-limit',
+ limit: 4
+ })) as { messages: { id: string }[] }
+ expect(wider.messages.map((m) => m.id)).toEqual(['u-2', 'u-3', 'u-4', 'u-5'])
+ } finally {
+ if (previousHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = previousHome
+ }
+ }
+ })
+
+ it('emits appended messages over nativeChat:appended and tears down on destroy', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-'))
+ tempRoots.push(root)
+ const projectsDir = join(root, '.claude', 'projects')
+ const projectDir = join(projectsDir, '-repo')
+ await mkdir(projectDir, { recursive: true })
+ const filePath = join(projectDir, 'sess-sub.jsonl')
+ await writeFile(
+ filePath,
+ `${jsonLines([
+ {
+ type: 'user',
+ uuid: 'u-1',
+ timestamp: '2026-06-01T10:00:00.000Z',
+ message: { role: 'user', content: 'Hi' }
+ }
+ ])}\n`
+ )
+
+ registerNativeChatHandlers()
+ const subscribe = listeners.get('nativeChat:subscribe')
+ expect(subscribe).toBeDefined()
+
+ const sent: { channel: string; payload: unknown }[] = []
+ let destroyedCb: (() => void) | undefined
+ const sender = {
+ id: 1,
+ isDestroyed: () => false,
+ once: (event: string, cb: () => void) => {
+ if (event === 'destroyed') {
+ destroyedCb = cb
+ }
+ },
+ send: (channel: string, payload: unknown) => sent.push({ channel, payload })
+ }
+
+ const previousHome = process.env.HOME
+ process.env.HOME = root
+ try {
+ subscribe!(
+ { sender },
+ {
+ subscriptionId: 'sub-1',
+ agent: 'claude',
+ sessionId: 'sess-sub'
+ }
+ )
+
+ // The listener dispatches handleSubscribe fire-and-forget; give it a beat
+ // to resolve the path and install the watcher before we append.
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ await appendFile(
+ filePath,
+ `${JSON.stringify({
+ type: 'assistant',
+ uuid: 'a-1',
+ timestamp: '2026-06-01T10:00:01.000Z',
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }
+ })}\n`
+ )
+
+ // Seed-at-0 means the first appended event carries the whole-file re-read;
+ // the new turn 'a-1' arrives across one of the appended events. Collect ids
+ // from every appended event and assert the new turn shows up.
+ const appendedIds = (): string[] =>
+ sent
+ .filter((s) => s.channel === 'nativeChat:appended')
+ .flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id))
+ await waitFor(() => appendedIds().includes('a-1'))
+ const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')!
+ const payload = appendedEvent.payload as { subscriptionId: string }
+ expect(payload.subscriptionId).toBe('sub-1')
+ expect(appendedIds()).toContain('a-1')
+
+ // Destroyed window tears down the watcher without error.
+ expect(destroyedCb).toBeDefined()
+ destroyedCb!()
+ } finally {
+ if (previousHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = previousHome
+ }
+ }
+ })
+
+ it('returns an error for an unknown session without throwing', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-'))
+ tempRoots.push(root)
+ const previousHome = process.env.HOME
+ process.env.HOME = root
+ try {
+ const result = (await invokeReadSession({ agent: 'claude', sessionId: 'nope' })) as {
+ error?: string
+ }
+ expect(result.error).toBeTruthy()
+ } finally {
+ if (previousHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = previousHome
+ }
+ }
+ })
+})
diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts
new file mode 100644
index 00000000000..ad6117db0c1
--- /dev/null
+++ b/src/main/ipc/native-chat.ts
@@ -0,0 +1,168 @@
+import { ipcMain, type IpcMainEvent, type WebContents } from 'electron'
+import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
+import {
+ clearNativeChatTranscriptCache,
+ readNativeChatTranscriptCached
+} from '../native-chat/transcript-read-cache'
+import type { ReadTranscriptResult } from '../native-chat/transcript-reader'
+import {
+ subscribeNativeChatTranscript,
+ type NativeChatTranscriptSubscription
+} from '../native-chat/transcript-watch'
+
+// Re-export so existing test imports of `clearNativeChatTranscriptCache` from
+// this module keep working after the cache moved to transcript-read-cache.ts.
+export { clearNativeChatTranscriptCache }
+
+export type NativeChatReadSessionArgs = {
+ agent: AgentType
+ sessionId: string
+ /** How many of the most-recent turns to return. The renderer starts at the
+ * default window and raises this to page in older history as it scrolls up. */
+ limit?: number
+ /** Authoritative transcript path from the agent hook (providerSession), used to
+ * locate the file when the session id no longer names it (recent Claude Code). */
+ transcriptPath?: string
+}
+
+// Why: render only the most recent turns so switching to chat view on a long
+// session (thousands of messages) doesn't stall on building that many message
+// components. The full transcript is still cached; only the returned slice is
+// capped. The renderer raises `limit` to page in older history; live appends
+// extend it from there.
+const DESKTOP_READ_WINDOW = 300
+
+function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult {
+ if (!('messages' in result) || result.messages.length <= limit) {
+ return result
+ }
+ return { ...result, messages: result.messages.slice(-limit) }
+}
+
+async function readSession(args: NativeChatReadSessionArgs): Promise {
+ const { agent, sessionId } = args
+ // Clamp to a positive window; default to the desktop window for the first page.
+ const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
+ // Desktop is full-class: window by count only, no char truncation.
+ const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath)
+ return windowTranscript(result, limit)
+}
+
+export type NativeChatSubscribeArgs = {
+ /** Renderer-minted id, unique per webContents, echoed back on every emit so
+ * the renderer can route appends to the right hook instance. */
+ subscriptionId: string
+ agent: AgentType
+ sessionId: string
+ /** Authoritative transcript path from the agent hook (providerSession). */
+ transcriptPath?: string
+}
+
+export type NativeChatAppendedPayload = {
+ subscriptionId: string
+ messages: NativeChatMessage[]
+}
+
+type LiveSubscription = {
+ subscription: NativeChatTranscriptSubscription
+}
+
+// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the
+// same renderer can watch several panes, and a destroyed window tears down all
+// of its watchers — strict teardown to avoid fd leaks (plan U4 risk).
+const liveSubscriptions = new Map>()
+const senderCleanupRegistered = new Set()
+
+function teardownSubscription(senderId: number, subscriptionId: string): void {
+ const bySubId = liveSubscriptions.get(senderId)
+ const live = bySubId?.get(subscriptionId)
+ if (!live || !bySubId) {
+ return
+ }
+ live.subscription.unsubscribe()
+ bySubId.delete(subscriptionId)
+ if (bySubId.size === 0) {
+ liveSubscriptions.delete(senderId)
+ }
+}
+
+function teardownAllForSender(senderId: number): void {
+ const bySubId = liveSubscriptions.get(senderId)
+ if (!bySubId) {
+ return
+ }
+ for (const live of bySubId.values()) {
+ live.subscription.unsubscribe()
+ }
+ liveSubscriptions.delete(senderId)
+ senderCleanupRegistered.delete(senderId)
+}
+
+function registerSenderCleanup(sender: WebContents): void {
+ if (senderCleanupRegistered.has(sender.id)) {
+ return
+ }
+ senderCleanupRegistered.add(sender.id)
+ // Strict teardown: a closed/reloaded window releases every watcher it owns.
+ sender.once('destroyed', () => teardownAllForSender(sender.id))
+}
+
+async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise {
+ const sender = event.sender
+ if (sender.isDestroyed()) {
+ return
+ }
+ const { subscriptionId, agent, sessionId, transcriptPath } = args
+ // Replace any prior subscription under the same id (session change/resubscribe).
+ teardownSubscription(sender.id, subscriptionId)
+ registerSenderCleanup(sender)
+
+ const subscription = await subscribeNativeChatTranscript({
+ agent,
+ sessionId,
+ transcriptPath,
+ onAppend: (messages) => {
+ if (sender.isDestroyed()) {
+ return
+ }
+ const payload: NativeChatAppendedPayload = { subscriptionId, messages }
+ sender.send('nativeChat:appended', payload)
+ }
+ })
+
+ // The window may have gone away (or the subscription been replaced) while we
+ // resolved the file path — don't register a now-orphaned watcher.
+ const stillCurrent = !sender.isDestroyed()
+ if (!stillCurrent) {
+ subscription.unsubscribe()
+ return
+ }
+ const bySubId = liveSubscriptions.get(sender.id) ?? new Map()
+ // A concurrent subscribe with the same id beat us here; honor the latest.
+ const existing = bySubId.get(subscriptionId)
+ if (existing) {
+ existing.subscription.unsubscribe()
+ }
+ bySubId.set(subscriptionId, { subscription })
+ liveSubscriptions.set(sender.id, bySubId)
+}
+
+/** Test-only: drop all live transcript subscriptions between runs. */
+export function clearNativeChatSubscriptions(): void {
+ const senderIds = Array.from(liveSubscriptions.keys())
+ for (const senderId of senderIds) {
+ teardownAllForSender(senderId)
+ }
+}
+
+export function registerNativeChatHandlers(): void {
+ ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) =>
+ readSession(args)
+ )
+ ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => {
+ void handleSubscribe(event, args)
+ })
+ ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => {
+ teardownSubscription(event.sender.id, args.subscriptionId)
+ })
+}
diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts
new file mode 100644
index 00000000000..4b807ba8afb
--- /dev/null
+++ b/src/main/ipc/preflight-command-exec.ts
@@ -0,0 +1,93 @@
+import { execFile } from 'child_process'
+import path from 'path'
+import { promisify } from 'util'
+import { buildLocalPreflightEnv } from './preflight-local-env'
+import { runPreflightCommandInWsl } from './preflight-wsl-command'
+import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
+
+const execFileAsync = promisify(execFile)
+export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
+
+export type PreflightCommandResult = { stdout: string; stderr: string }
+
+export function shellQuote(value: string): string {
+ return `'${value.replace(/'/g, "'\\''")}'`
+}
+
+async function withPreflightTimeout(command: string, commandPromise: Promise): Promise {
+ let timeout: ReturnType | null = null
+ try {
+ return await Promise.race([
+ commandPromise,
+ new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => {
+ const error = Object.assign(new Error(`Timed out running ${command}`), {
+ code: 'ETIMEDOUT'
+ })
+ reject(error)
+ }, PREFLIGHT_COMMAND_TIMEOUT_MS)
+ if (typeof timeout.unref === 'function') {
+ timeout.unref()
+ }
+ })
+ ])
+ } finally {
+ if (timeout) {
+ clearTimeout(timeout)
+ }
+ }
+}
+
+export async function execLocalPreflightCommand(
+ command: string,
+ args: string[]
+): Promise {
+ const env = buildLocalPreflightEnv()
+ const commandPromise = execFileAsync(command, args, {
+ encoding: 'utf-8',
+ timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
+ ...(env ? { env } : {})
+ }) as Promise
+
+ return withPreflightTimeout(command, commandPromise)
+}
+
+export async function execCommandInWsl(
+ target: WslPreflightTarget,
+ command: string
+): Promise {
+ const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
+ return withPreflightTimeout('wsl.exe', commandPromise)
+}
+
+export async function isCommandAvailable(
+ command: string,
+ wslTarget?: WslPreflightTarget
+): Promise {
+ try {
+ await (wslTarget
+ ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`)
+ : execLocalPreflightCommand(command, ['--version']))
+ return true
+ } catch {
+ return false
+ }
+}
+
+export async function isCommandOnPath(
+ command: string,
+ wslTarget?: WslPreflightTarget
+): Promise {
+ const finder = process.platform === 'win32' ? 'where' : 'which'
+ try {
+ const { stdout } = wslTarget
+ ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`)
+ : await execLocalPreflightCommand(finder, [command])
+ return stdout
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .some((line) => path.isAbsolute(line))
+ } catch {
+ return false
+ }
+}
diff --git a/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts
new file mode 100644
index 00000000000..f28886cdaec
--- /dev/null
+++ b/src/main/ipc/preflight-remote-windows-terminal-capabilities.ts
@@ -0,0 +1,30 @@
+import { getActiveMultiplexer } from './ssh'
+
+export type RemoteWindowsTerminalCapabilities = {
+ wslAvailable: boolean
+ wslDistros: string[]
+ pwshAvailable: boolean
+ gitBashAvailable: boolean
+ hostPlatform: NodeJS.Platform | null
+}
+
+const EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES: RemoteWindowsTerminalCapabilities = {
+ wslAvailable: false,
+ wslDistros: [],
+ pwshAvailable: false,
+ gitBashAvailable: false,
+ hostPlatform: null
+}
+
+export async function detectRemoteWindowsTerminalCapabilities(args: {
+ connectionId: string
+}): Promise {
+ const mux = getActiveMultiplexer(args.connectionId)
+ if (!mux || mux.isDisposed()) {
+ return EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES
+ }
+ const result = (await mux.request('preflight.detectWindowsTerminalCapabilities', {})) as
+ | RemoteWindowsTerminalCapabilities
+ | undefined
+ return result ?? EMPTY_REMOTE_WINDOWS_TERMINAL_CAPABILITIES
+}
diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts
index d3ffef854ef..944ee1b4418 100644
--- a/src/main/ipc/preflight.test.ts
+++ b/src/main/ipc/preflight.test.ts
@@ -708,6 +708,35 @@ describe('preflight', () => {
expect(request).not.toHaveBeenCalled()
})
+ it('sends remote Windows shell capability probes through the SSH preflight path', async () => {
+ const request = vi.fn().mockResolvedValue({
+ wslAvailable: true,
+ wslDistros: ['Ubuntu'],
+ pwshAvailable: true,
+ gitBashAvailable: true,
+ hostPlatform: 'win32'
+ })
+ getActiveMultiplexerMock.mockReturnValue({
+ isDisposed: () => false,
+ request
+ })
+
+ registerPreflightHandlers()
+
+ await expect(
+ handlers['preflight:detectRemoteWindowsTerminalCapabilities'](undefined, {
+ connectionId: 'ssh-1'
+ })
+ ).resolves.toEqual({
+ wslAvailable: true,
+ wslDistros: ['Ubuntu'],
+ pwshAvailable: true,
+ gitBashAvailable: true,
+ hostPlatform: 'win32'
+ })
+ expect(request).toHaveBeenCalledWith('preflight.detectWindowsTerminalCapabilities', {})
+ })
+
it('detects agents from the selected WSL distro for a WSL workspace', async () => {
Object.defineProperty(process, 'platform', {
configurable: true,
diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts
index fbe00db4605..cc14ca490b6 100644
--- a/src/main/ipc/preflight.ts
+++ b/src/main/ipc/preflight.ts
@@ -1,7 +1,4 @@
import { ipcMain } from 'electron'
-import { execFile } from 'child_process'
-import { promisify } from 'util'
-import path from 'path'
import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { PathSource, ShellHydrationFailureReason } from '../../shared/types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
@@ -11,13 +8,20 @@ import { getGiteaAuthStatus } from '../gitea/client'
import { _resetKnownHostsCache } from '../gitlab/gl-utils'
import { getActiveMultiplexer } from './ssh'
import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection'
-import { runPreflightCommandInWsl } from './preflight-wsl-command'
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
-import { buildLocalPreflightEnv } from './preflight-local-env'
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path'
-const execFileAsync = promisify(execFile)
-const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
+import {
+ execCommandInWsl,
+ execLocalPreflightCommand,
+ isCommandAvailable,
+ isCommandOnPath,
+ shellQuote
+} from './preflight-command-exec'
+import {
+ detectRemoteWindowsTerminalCapabilities,
+ type RemoteWindowsTerminalCapabilities
+} from './preflight-remote-windows-terminal-capabilities'
export type PreflightStatus = {
git: { installed: boolean }
@@ -44,6 +48,9 @@ export type PreflightStatus = {
}
}
+export { detectRemoteWindowsTerminalCapabilities }
+export type { RemoteWindowsTerminalCapabilities }
+
// Why: cache the result so repeated Landing mounts don't re-spawn processes.
// The check only runs once per app session — relaunch to re-check.
let cached: PreflightStatus | null = null
@@ -53,92 +60,6 @@ export function _resetPreflightCache(): void {
cached = null
}
-function shellQuote(value: string): string {
- return `'${value.replace(/'/g, "'\\''")}'`
-}
-
-type PreflightCommandResult = { stdout: string; stderr: string }
-
-// Why: a broken PATH shim or auth helper should not keep startup/settings
-// preflight IPC pending forever; WSL probes already use the same deadline.
-async function withPreflightTimeout(command: string, commandPromise: Promise): Promise {
- let timeout: ReturnType | null = null
- try {
- return await Promise.race([
- commandPromise,
- new Promise((_resolve, reject) => {
- timeout = setTimeout(() => {
- const error = Object.assign(new Error(`Timed out running ${command}`), {
- code: 'ETIMEDOUT'
- })
- reject(error)
- }, PREFLIGHT_COMMAND_TIMEOUT_MS)
- if (typeof timeout.unref === 'function') {
- timeout.unref()
- }
- })
- ])
- } finally {
- if (timeout) {
- clearTimeout(timeout)
- }
- }
-}
-
-async function execLocalPreflightCommand(
- command: string,
- args: string[]
-): Promise {
- const env = buildLocalPreflightEnv()
- const commandPromise = execFileAsync(command, args, {
- encoding: 'utf-8',
- timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
- ...(env ? { env } : {})
- }) as Promise
-
- return withPreflightTimeout(command, commandPromise)
-}
-
-async function execCommandInWsl(
- target: WslPreflightTarget,
- command: string
-): Promise<{ stdout: string; stderr: string }> {
- const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
- return withPreflightTimeout('wsl.exe', commandPromise)
-}
-
-async function isCommandAvailable(
- command: string,
- wslTarget?: WslPreflightTarget
-): Promise {
- try {
- await (wslTarget
- ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`)
- : execLocalPreflightCommand(command, ['--version']))
- return true
- } catch {
- return false
- }
-}
-
-// Why: `which`/`where` is faster than spawning the agent binary itself and avoids
-// triggering any agent-specific startup side-effects. This gives a reliable
-// PATH-based check without requiring `--version` support from each agent.
-async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): Promise {
- const finder = process.platform === 'win32' ? 'where' : 'which'
- try {
- const { stdout } = wslTarget
- ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`)
- : await execLocalPreflightCommand(finder, [command])
- return stdout
- .split(/\r?\n/)
- .map((line) => line.trim())
- .some((line) => path.isAbsolute(line))
- } catch {
- return false
- }
-}
-
const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) =>
getTuiAgentDetectCommands(config).map((cmd) => ({
id,
@@ -378,4 +299,11 @@ export function registerPreflightHandlers(): void {
return detectRemoteAgents(args)
}
)
+
+ ipcMain.handle(
+ 'preflight:detectRemoteWindowsTerminalCapabilities',
+ async (_event, args: { connectionId: string }): Promise => {
+ return detectRemoteWindowsTerminalCapabilities(args)
+ }
+ )
}
diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts
index 4b9ce9508b7..add52d56301 100644
--- a/src/main/ipc/pty.test.ts
+++ b/src/main/ipc/pty.test.ts
@@ -172,6 +172,7 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
}))
import { LocalPtyProvider } from '../providers/local-pty-provider'
import { makePaneKey } from '../../shared/stable-pane-id'
+import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
import {
registerPtyHandlers,
registerSshPtyProvider,
@@ -796,6 +797,20 @@ describe('registerPtyHandlers', () => {
}
)
+ it('uses sequenced startup env as the MiMo launch hint when command is a wrapper', async () => {
+ const env = await spawnAndGetEnv(
+ { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'mimo --prompt hi' },
+ undefined,
+ undefined,
+ undefined,
+ 'bash -lc wait-wrapper'
+ )
+
+ expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1)
+ expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
+ expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared')
+ })
+
it('does not inject MiMo overlay for non-mimo launches', async () => {
await spawnAndGetEnv()
@@ -897,6 +912,29 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
+ it('uses sequenced startup env as the OMP launch hint when command is a wrapper', async () => {
+ const env = await spawnAndGetEnv(
+ {
+ PI_CODING_AGENT_DIR: '/tmp/user-omp-agent',
+ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume'
+ },
+ undefined,
+ undefined,
+ undefined,
+ 'powershell wait-wrapper'
+ )
+
+ expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
+ expect.any(String),
+ '/tmp/user-omp-agent',
+ 'omp'
+ )
+ expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
+ '/tmp/user-omp-agent/extensions/orca-agent-status.ts'
+ )
+ expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
+ })
+
it('mirrors the original Pi source dir when launched from an Orca overlay shell', async () => {
const env = await spawnAndGetEnv({
PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay',
@@ -1347,6 +1385,29 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
+ it('uses sequenced startup env as the daemon OMP launch hint when command is a wrapper', async () => {
+ const env = await daemonSpawnAndGetEnv(
+ {
+ PI_CODING_AGENT_DIR: '/user/.omp/agent',
+ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume'
+ },
+ undefined,
+ undefined,
+ undefined,
+ { command: 'powershell wait-wrapper' }
+ )
+
+ expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
+ expect.any(String),
+ '/user/.omp/agent',
+ 'omp'
+ )
+ expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
+ '/user/.omp/agent/extensions/orca-agent-status.ts'
+ )
+ expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
+ })
+
it('injects the selected Codex home on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, () => TEST_CODEX_HOME)
expect(env.CODEX_HOME).toBe(TEST_CODEX_HOME)
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 65290d01177..09fb8f6906e 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -86,6 +86,7 @@ import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
+import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import {
assertFolderWorkspacePathUsable,
@@ -751,9 +752,10 @@ export function buildPtyHostEnv(
// in lock-step across spawn paths without pushing process.env onto the
// IPC wire unnecessarily.
const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv)
- const piAgentKind = detectPiAgentKindFromCommand(opts.launchCommand)
+ const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand)
+ const piAgentKind = detectPiAgentKindFromCommand(launchCommandHint)
const hasLaunchCommand =
- typeof opts.launchCommand === 'string' && opts.launchCommand.trim().length > 0
+ typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand
// Why: source shadows are agent-scoped. Trusting the other kind's source
// would reintroduce the exact Pi/OMP extension-state shadowing this PR fixes.
@@ -783,7 +785,7 @@ export function buildPtyHostEnv(
delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR
}
}
- if (isMimoLaunchCommand(opts.launchCommand)) {
+ if (isMimoLaunchCommand(launchCommandHint)) {
const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv)
Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome))
if (baseEnv.MIMOCODE_HOME) {
@@ -1880,6 +1882,9 @@ export function registerPtyHandlers(
if (args.command !== undefined) {
spawnOptions.command = args.command
}
+ if (args.commandDelivery !== undefined) {
+ spawnOptions.commandDelivery = args.commandDelivery
+ }
if (args.startupCommandDelivery !== undefined) {
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
}
@@ -2256,6 +2261,7 @@ export function registerPtyHandlers(
env?: Record
envToDelete?: string[]
command?: string
+ commandDelivery?: 'renderer' | 'provider'
launchConfig?: SleepingAgentLaunchConfig
launchAgent?: TuiAgent
startupCommandDelivery?: StartupCommandDelivery
@@ -2557,6 +2563,9 @@ export function registerPtyHandlers(
if (args.command !== undefined) {
spawnOptions.command = args.command
}
+ if (args.commandDelivery !== undefined) {
+ spawnOptions.commandDelivery = args.commandDelivery
+ }
if (args.startupCommandDelivery !== undefined) {
spawnOptions.startupCommandDelivery = args.startupCommandDelivery
}
diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts
index 72dd8bc8428..8bd2b511ac3 100644
--- a/src/main/ipc/register-core-handlers.test.ts
+++ b/src/main/ipc/register-core-handlers.test.ts
@@ -52,6 +52,7 @@ const {
registerSkillsHandlersMock,
registerWorkspaceSpaceHandlersMock,
registerWorkspacePortHandlersMock,
+ registerNativeChatHandlersMock,
registerEmulatorFrameStreamHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
@@ -103,6 +104,7 @@ const {
registerSkillsHandlersMock: vi.fn(),
registerWorkspaceSpaceHandlersMock: vi.fn(),
registerWorkspacePortHandlersMock: vi.fn(),
+ registerNativeChatHandlersMock: vi.fn(),
registerEmulatorFrameStreamHandlersMock: vi.fn()
}))
@@ -294,6 +296,10 @@ vi.mock('./hosted-review', () => ({
registerHostedReviewHandlers: registerHostedReviewHandlersMock
}))
+vi.mock('./native-chat', () => ({
+ registerNativeChatHandlers: registerNativeChatHandlersMock
+}))
+
import { registerCoreHandlers } from './register-core-handlers'
describe('registerCoreHandlers', () => {
@@ -346,6 +352,7 @@ describe('registerCoreHandlers', () => {
registerSkillsHandlersMock.mockReset()
registerWorkspaceSpaceHandlersMock.mockReset()
registerWorkspacePortHandlersMock.mockReset()
+ registerNativeChatHandlersMock.mockReset()
registerEmulatorFrameStreamHandlersMock.mockReset()
})
@@ -417,6 +424,7 @@ describe('registerCoreHandlers', () => {
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths
})
+ expect(registerNativeChatHandlersMock).toHaveBeenCalled()
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerPreflightHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalled()
diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts
index 7bcf9756c51..0f4cb4ca789 100644
--- a/src/main/ipc/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers.ts
@@ -24,6 +24,7 @@ import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { registerAiVaultHandlers } from './ai-vault'
+import { registerNativeChatHandlers } from './native-chat'
import { registerNotificationHandlers } from './notifications'
import { registerNotebookHandlers } from './notebook'
import { registerOnboardingHandlers } from './onboarding'
@@ -164,6 +165,7 @@ export function registerCoreHandlers(
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
})
+ registerNativeChatHandlers()
registerClipboardHandlers(store)
registerUpdaterHandlers(store)
registerSpeechHandlers(store)
diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts
index 2a166a4c5a1..540079a510e 100644
--- a/src/main/ipc/settings.test.ts
+++ b/src/main/ipc/settings.test.ts
@@ -285,6 +285,27 @@ describe('registerSettingsHandlers', () => {
)
})
+ it('normalizes terminal scrollback row updates and drops legacy byte updates', async () => {
+ store.getSettings.mockReturnValue({ terminalScrollbackRows: 5_000 })
+ store.updateSettings.mockReturnValue({ terminalScrollbackRows: 50_000 })
+ registerSettingsHandlers(store as never)
+
+ const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
+ _event: unknown,
+ args: unknown
+ ) => Promise
+
+ await handler(settingsInvokeEvent, {
+ terminalScrollbackRows: 75_000,
+ terminalScrollbackBytes: 250_000_000
+ })
+
+ expect(store.updateSettings).toHaveBeenCalledWith(
+ { terminalScrollbackRows: 50_000 },
+ { notifyListeners: true, originWebContentsId: 1 }
+ )
+ })
+
it('normalizes custom terminal themes from renderer settings IPC', async () => {
store.getSettings.mockReturnValue({ terminalCustomThemes: [] })
store.updateSettings.mockReturnValue({ terminalCustomThemes: [] })
diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts
index 585a3a6ee3c..712f49f6d43 100644
--- a/src/main/ipc/settings.ts
+++ b/src/main/ipc/settings.ts
@@ -17,6 +17,7 @@ import { normalizeAppIconId } from '../../shared/app-icon'
import { normalizeUiLanguage } from '../../shared/ui-language'
import { applyAppIcon } from '../app-icon'
import { normalizeTerminalCustomThemes } from '../../shared/terminal-custom-themes'
+import { normalizeDesktopTerminalScrollbackRows } from '../../shared/terminal-scrollback-policy'
import { prepareLocalWorktreeRootsForRepos } from '../worktree-root-preparation'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
@@ -25,6 +26,17 @@ import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base
// check stay O(1) without re-coercing the readonly tuple on every call.
const SETTINGS_CHANGED_WHITELIST_SET = new Set(SETTINGS_CHANGED_WHITELIST)
+type LegacyTerminalScrollbackSettingsUpdate = Partial & {
+ terminalScrollbackBytes?: unknown
+}
+
+function sanitizeRendererSettingsUpdate(args: Partial): Partial {
+ const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedArgs } =
+ args as LegacyTerminalScrollbackSettingsUpdate
+ void _legacyScrollbackBytes
+ return sanitizedArgs
+}
+
// Why: fields that appear in the View > Appearance submenu need the menu
// rebuilt after any update so the checkbox `checked` state stays in sync
// with the persisted value. Electron doesn't reactively re-render menu
@@ -55,7 +67,7 @@ export function registerSettingsHandlers(
})
ipcMain.handle('settings:set', async (event, args: Partial) => {
- const sanitizedArgs = { ...args }
+ const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
// Why: Floating Workspace grants are trusted only when written by the
// main-process directory picker, never by renderer-provided settings IPC.
delete sanitizedArgs.floatingTerminalTrustedCwds
@@ -78,6 +90,11 @@ export function registerSettingsHandlers(
if ('terminalCustomThemes' in args) {
sanitizedArgs.terminalCustomThemes = normalizeTerminalCustomThemes(args.terminalCustomThemes)
}
+ if ('terminalScrollbackRows' in args) {
+ sanitizedArgs.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
+ args.terminalScrollbackRows
+ )
+ }
if ('uiLanguage' in args) {
sanitizedArgs.uiLanguage = normalizeUiLanguage(args.uiLanguage)
}
diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts
index b75b7048b08..680163f8b5d 100644
--- a/src/main/ipc/skills.test.ts
+++ b/src/main/ipc/skills.test.ts
@@ -83,6 +83,14 @@ describe('registerSkillsHandlers', () => {
expect(getWslHomeMock).not.toHaveBeenCalled()
})
+ it('scopes host skill discovery to the active workspace cwd when provided', async () => {
+ const handler = getDiscoverHandler()
+
+ await handler(null, { cwd: '/repo/worktree' })
+
+ expect(discoverSkillsMock).toHaveBeenCalledWith({ repos: [], cwd: '/repo/worktree' })
+ })
+
it('uses the selected project WSL distro for skill discovery', async () => {
const handler = getDiscoverHandler()
diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts
index e3f2e6a570b..b81fd695790 100644
--- a/src/main/ipc/skills.ts
+++ b/src/main/ipc/skills.ts
@@ -51,7 +51,8 @@ export function registerSkillsHandlers(store: Store): void {
return discoverSkills({ repos: [], homeDir, cwd: homeDir })
}
- return discoverSkills({ repos: store.getRepos() })
+ const cwd = target?.cwd?.trim() || undefined
+ return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
}
)
}
diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts
index 74dfb7db9f8..ae445927837 100644
--- a/src/main/ipc/worktree-remote.ts
+++ b/src/main/ipc/worktree-remote.ts
@@ -93,7 +93,12 @@ import { createWorktreeLinkedPaths } from './worktree-symlinks'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
import type { IFilesystemProvider } from '../providers/types'
-import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
+import {
+ buildSetupRunnerCommand,
+ getSetupRunnerCommandPlatformForPath
+} from '../../shared/setup-runner-command'
+import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing'
+import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy'
import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing'
import {
markCodexProjectTrusted,
@@ -122,6 +127,7 @@ type RemoteWorktreeCreateBasePlan = {
type StagedStartupResult = {
startupTerminal?: CreateWorktreeResult['startupTerminal']
+ activationSetup?: CreateWorktreeResult['setup']
didSpawnSetup: boolean
warning?: string
}
@@ -230,6 +236,26 @@ async function spawnLocalStartupAndSetupTerminals(args: {
let startupTerminalHandle: string | null = null
let startupTerminal: CreateWorktreeResult['startupTerminal']
+ let sequencedStartup = startup
+ let wrappedSetupCommandStr: string | undefined
+ if (startup && setup?.waitForAgentStartup === true) {
+ const platform = getSetupRunnerCommandPlatformForPath(
+ setup.runnerScriptPath,
+ process.platform === 'win32' ? 'windows' : 'posix'
+ )
+ const sequenced = createSequencedSetupAgentCommands({
+ runnerScriptPath: setup.runnerScriptPath,
+ startupCommand: startup.command,
+ platform
+ })
+ sequencedStartup = {
+ ...startup,
+ command: sequenced.startupCommand,
+ ...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {})
+ }
+ wrappedSetupCommandStr = sequenced.setupCommand
+ }
+
try {
// Why: after `git worktree add` and metadata registration, a runtime-owned
// PTY can begin booting the selected agent while setup runs in a sibling
@@ -249,12 +275,13 @@ async function spawnLocalStartupAndSetupTerminals(args: {
}
}
const terminal = await runtime.createTerminal(`id:${worktree.id}`, {
- command: startup.command,
- env: startup.env,
- ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}),
+ command: sequencedStartup.command,
+ ...(setup ? { claudeAgentTeamsSourceCommand: startup.command } : {}),
+ env: sequencedStartup.env,
+ ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}),
- startupCommandDelivery: startup.startupCommandDelivery,
- telemetry: startup.telemetry,
+ startupCommandDelivery: sequencedStartup.startupCommandDelivery,
+ telemetry: sequencedStartup.telemetry,
activate: true
})
startupTerminalHandle = terminal.handle
@@ -272,10 +299,15 @@ async function spawnLocalStartupAndSetupTerminals(args: {
let didSpawnSetup = false
if (setup) {
try {
- const setupCommand = buildSetupRunnerCommand(
- setup.runnerScriptPath,
- process.platform === 'win32' ? 'windows' : 'posix'
- )
+ const setupCommand =
+ wrappedSetupCommandStr ??
+ buildSetupRunnerCommand(
+ setup.runnerScriptPath,
+ getSetupRunnerCommandPlatformForPath(
+ setup.runnerScriptPath,
+ process.platform === 'win32' ? 'windows' : 'posix'
+ )
+ )
const setupLaunchMode =
(settings as Partial>)
.setupScriptLaunchMode ?? 'new-tab'
@@ -307,6 +339,16 @@ async function spawnLocalStartupAndSetupTerminals(args: {
}
return {
+ ...(setup && !didSpawnSetup
+ ? {
+ activationSetup: {
+ ...setup,
+ ...(startupTerminalHandle && wrappedSetupCommandStr
+ ? { command: wrappedSetupCommandStr }
+ : {})
+ }
+ }
+ : {}),
...(startupTerminal ? { startupTerminal } : {}),
didSpawnSetup,
...(warning ? { warning } : {})
@@ -1072,7 +1114,10 @@ async function createRemoteSetupRunnerScript(
)
return {
runnerScriptPath,
- envVars: getSetupRunnerEnvVars(repo, worktreePath)
+ envVars: getSetupRunnerEnvVars(repo, worktreePath),
+ ...(shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
+ ? { waitForAgentStartup: true }
+ : {})
}
}
@@ -2390,7 +2435,11 @@ export async function createLocalWorktree(
return {
worktree: { ...worktree, workspaceLineage },
...(workspaceLineage ? { workspaceLineage } : {}),
- ...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}),
+ ...(stagedStartup.activationSetup
+ ? { setup: stagedStartup.activationSetup }
+ : setup && !stagedStartup.didSpawnSetup
+ ? { setup }
+ : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(addResult.localBaseRefRefresh
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts
index 51324c45c89..5ce5d0baba6 100644
--- a/src/main/ipc/worktrees.test.ts
+++ b/src/main/ipc/worktrees.test.ts
@@ -940,6 +940,7 @@ describe('registerWorktreeHandlers', () => {
1,
'id:repo-1::/workspace/improve-dashboard',
{
+ claudeAgentTeamsSourceCommand: 'claude --prefill test',
command: 'claude --prefill test',
env: { ORCA_AGENT_MODE: 'direct' },
launchAgent: 'claude',
@@ -957,7 +958,7 @@ describe('registerWorktreeHandlers', () => {
'id:repo-1::/workspace/improve-dashboard',
{
title: 'Setup',
- command: 'bash /workspace/repo/.git/orca/setup-runner.sh',
+ command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh'),
env: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
@@ -965,6 +966,15 @@ describe('registerWorktreeHandlers', () => {
activate: false
}
)
+ const startupCreateCall = runtimeStub.createTerminal.mock.calls[0]
+ const setupCreateCall = runtimeStub.createTerminal.mock.calls[1]
+ if (!startupCreateCall || !setupCreateCall) {
+ throw new Error('expected startup and setup terminal calls')
+ }
+ const startupCommand = (startupCreateCall[1] as { command: string }).command
+ const setupCommand = (setupCreateCall[1] as { command: string }).command
+ expect(startupCommand).toBe('claude --prefill test')
+ expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh')
expect(result.setup).toBeUndefined()
expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' })
expect(result.timing?.phases.map((phase) => phase.phase)).toEqual(
@@ -977,6 +987,57 @@ describe('registerWorktreeHandlers', () => {
)
})
+ it('returns the wrapped setup command when startup spawned but setup creation failed', async () => {
+ addWorktreeMock.mockResolvedValue({})
+ listWorktreesMock.mockResolvedValueOnce([
+ {
+ path: '/workspace/improve-dashboard',
+ head: 'def',
+ branch: 'improve-dashboard',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+ loadHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
+ getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
+ getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
+ shouldRunSetupForCreateMock.mockReturnValue(true)
+ createSetupRunnerScriptMock.mockReturnValueOnce({
+ runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
+ envVars: {
+ ORCA_ROOT_PATH: '/workspace/repo',
+ ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
+ },
+ waitForAgentStartup: true
+ })
+ runtimeStub.createTerminal
+ .mockResolvedValueOnce({ handle: 'term-startup', surface: 'visible' })
+ .mockRejectedValueOnce(new Error('setup creation failed'))
+
+ const result = (await handlers['worktrees:create'](null, {
+ repoId: 'repo-1',
+ name: 'improve-dashboard',
+ createdWithAgent: 'claude',
+ startup: {
+ command: 'claude --prefill test',
+ env: { ORCA_AGENT_MODE: 'direct' },
+ telemetry: {
+ agent_kind: 'claude',
+ launch_source: 'new_workspace_composer',
+ request_kind: 'new'
+ }
+ }
+ })) as { setup?: { command?: string; runnerScriptPath: string } }
+
+ expect(result.setup).toEqual(
+ expect.objectContaining({
+ runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
+ command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh')
+ })
+ )
+ expect(result.setup?.command).toContain('printf')
+ })
+
it('checks out a selected existing local branch exactly', async () => {
listWorktreesMock
.mockResolvedValueOnce([
diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts
index db7660ce09e..39b266b6846 100644
--- a/src/main/menu/register-app-menu.test.ts
+++ b/src/main/menu/register-app-menu.test.ts
@@ -179,17 +179,21 @@ describe('registerAppMenu', () => {
expect(paletteItem?.accelerator).toBeUndefined()
})
- it('keeps Edit > Paste on the native Electron paste role in this split', () => {
+ it('routes Edit > Paste through Orca coordinated paste ownership', () => {
const send = vi.fn()
getFocusedWindowMock.mockReturnValue({ webContents: { send } })
registerAppMenu(buildMenuOptions())
const editSubmenu = getSubmenu(getTemplate(), 'Edit')
- const pasteItem = editSubmenu.find((item) => item.role === 'paste')
+ const pasteItem = editSubmenu.find((item) => item.label === 'Paste')
expect(pasteItem).toBeDefined()
- expect(pasteItem?.click).toBeUndefined()
- expect(send).not.toHaveBeenCalled()
+ expect(pasteItem?.role).toBeUndefined()
+ expect(pasteItem?.accelerator).toBe('CmdOrCtrl+V')
+
+ pasteItem?.click?.({} as never, {} as never, {} as never)
+
+ expect(send).toHaveBeenCalledWith('ui:appMenuPaste')
})
it.runIf(!isMac)('puts Settings and Exit under File on Windows/Linux', () => {
diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts
index eda524d44a6..a5390e36afc 100644
--- a/src/main/menu/register-app-menu.ts
+++ b/src/main/menu/register-app-menu.ts
@@ -162,7 +162,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
- { role: 'paste' },
+ {
+ label: translateMain('menu.paste', 'Paste'),
+ accelerator: 'CmdOrCtrl+V',
+ click: () => {
+ // Why: a focused terminal/native-chat pane is not a native editable
+ // control, so raw Electron paste cannot know which Orca surface owns it.
+ BrowserWindow.getFocusedWindow()?.webContents.send('ui:appMenuPaste')
+ }
+ },
{ role: 'selectAll' }
]
}
diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts
new file mode 100644
index 00000000000..9e41174d169
--- /dev/null
+++ b/src/main/native-chat/session-file-resolver.test.ts
@@ -0,0 +1,163 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { resolveSessionFilePath } from './session-file-resolver'
+
+let tempRoots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
+ tempRoots = []
+})
+
+async function makeRoot(prefix: string): Promise {
+ const root = await mkdtemp(join(tmpdir(), prefix))
+ tempRoots.push(root)
+ return root
+}
+
+function restoreEnv(key: string, previous: string | undefined): void {
+ if (previous === undefined) {
+ delete process.env[key]
+ } else {
+ process.env[key] = previous
+ }
+}
+
+describe('resolveSessionFilePath', () => {
+ it('globs Claude project subdirs for .jsonl', async () => {
+ const root = await makeRoot('orca-native-chat-resolve-claude-')
+ const claudeProjectsDir = join(root, 'claude-projects')
+ const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
+ await mkdir(projectDir, { recursive: true })
+ const target = join(projectDir, 'sess-123.jsonl')
+ await writeFile(target, '{}\n')
+
+ const resolved = await resolveSessionFilePath('claude', 'sess-123', { claudeProjectsDir })
+ expect(resolved).toBe(target)
+ })
+
+ it('matches Codex rollout files by session id suffix', async () => {
+ const root = await makeRoot('orca-native-chat-resolve-codex-')
+ const codexSessionsDir = join(root, 'codex-sessions')
+ const dayDir = join(codexSessionsDir, '2026', '06', '04')
+ await mkdir(dayDir, { recursive: true })
+ const target = join(dayDir, 'rollout-2026-06-04T10-00-00-abc-session.jsonl')
+ await writeFile(target, '{}\n')
+
+ const resolved = await resolveSessionFilePath('codex', 'abc-session', {
+ codexSessionsDirs: [codexSessionsDir]
+ })
+ expect(resolved).toBe(target)
+ })
+
+ it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => {
+ // Orca launches Codex with its own managed CODEX_HOME, so rollout files land
+ // under /codex-runtime-home/home/sessions, NOT ~/.codex/sessions.
+ const root = await makeRoot('orca-native-chat-resolve-managed-')
+ const managedSessionsDir = join(root, 'codex-runtime-home', 'home', 'sessions')
+ const dayDir = join(managedSessionsDir, '2026', '06', '19')
+ await mkdir(dayDir, { recursive: true })
+ const target = join(dayDir, 'rollout-2026-06-19T04-20-39-019edf9c-managed.jsonl')
+ await writeFile(target, '{}\n')
+
+ const previous = process.env.ORCA_USER_DATA_PATH
+ process.env.ORCA_USER_DATA_PATH = root
+ try {
+ const resolved = await resolveSessionFilePath('codex', '019edf9c-managed')
+ expect(resolved).toBe(target)
+ } finally {
+ if (previous === undefined) {
+ delete process.env.ORCA_USER_DATA_PATH
+ } else {
+ process.env.ORCA_USER_DATA_PATH = previous
+ }
+ }
+ })
+
+ it('falls back to CODEX_HOME when the managed home has no match', async () => {
+ const root = await makeRoot('orca-native-chat-resolve-codex-home-')
+ const managedRoot = join(root, 'managed-userdata')
+ await mkdir(managedRoot, { recursive: true })
+ const codexHome = join(root, 'custom-codex-home')
+ const dayDir = join(codexHome, 'sessions', '2026', '06', '05')
+ await mkdir(dayDir, { recursive: true })
+ const target = join(dayDir, 'rollout-xyz-session.jsonl')
+ await writeFile(target, '{}\n')
+
+ const previousCodex = process.env.CODEX_HOME
+ const previousUserData = process.env.ORCA_USER_DATA_PATH
+ process.env.CODEX_HOME = codexHome
+ // Point the managed home at an empty dir so the fallback is exercised.
+ process.env.ORCA_USER_DATA_PATH = managedRoot
+ try {
+ const resolved = await resolveSessionFilePath('codex', 'xyz-session')
+ expect(resolved).toBe(target)
+ } finally {
+ restoreEnv('CODEX_HOME', previousCodex)
+ restoreEnv('ORCA_USER_DATA_PATH', previousUserData)
+ }
+ })
+
+ it('returns null when no transcript matches', async () => {
+ const root = await makeRoot('orca-native-chat-resolve-missing-')
+ const claudeProjectsDir = join(root, 'claude-projects')
+ await mkdir(claudeProjectsDir, { recursive: true })
+ expect(await resolveSessionFilePath('claude', 'nope', { claudeProjectsDir })).toBeNull()
+ })
+
+ it('returns null for unsupported agents', async () => {
+ expect(await resolveSessionFilePath('gemini', 'whatever')).toBeNull()
+ })
+
+ it('prefers the hook transcriptPath when it exists (Claude id != file name)', async () => {
+ // Recent Claude Code names the file with a UUID that differs from the hook
+ // session_id, so the id glob would miss it — but transcript_path is exact.
+ const root = await makeRoot('orca-native-chat-resolve-path-')
+ const claudeProjectsDir = join(root, 'claude-projects')
+ const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
+ await mkdir(projectDir, { recursive: true })
+ // The real transcript is named by a DIFFERENT id than the hook session id.
+ const realFile = join(projectDir, 'real-file-uuid.jsonl')
+ await writeFile(realFile, '{}\n')
+
+ const resolved = await resolveSessionFilePath('claude', 'hook-session-id', {
+ claudeProjectsDir,
+ transcriptPath: realFile
+ })
+ expect(resolved).toBe(realFile)
+ })
+
+ it('falls back to the id glob when the hook transcriptPath does not exist', async () => {
+ const root = await makeRoot('orca-native-chat-resolve-path-stale-')
+ const claudeProjectsDir = join(root, 'claude-projects')
+ const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
+ await mkdir(projectDir, { recursive: true })
+ const target = join(projectDir, 'sess-xyz.jsonl')
+ await writeFile(target, '{}\n')
+
+ const resolved = await resolveSessionFilePath('claude', 'sess-xyz', {
+ claudeProjectsDir,
+ transcriptPath: join(projectDir, 'does-not-exist.jsonl')
+ })
+ expect(resolved).toBe(target)
+ })
+
+ it('ignores a non-jsonl transcriptPath and falls back to the glob', async () => {
+ const root = await makeRoot('orca-native-chat-resolve-path-ext-')
+ const claudeProjectsDir = join(root, 'claude-projects')
+ const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
+ await mkdir(projectDir, { recursive: true })
+ const bogus = join(projectDir, 'not-a-transcript.txt')
+ await writeFile(bogus, 'x')
+ const target = join(projectDir, 'sess-ok.jsonl')
+ await writeFile(target, '{}\n')
+
+ const resolved = await resolveSessionFilePath('claude', 'sess-ok', {
+ claudeProjectsDir,
+ transcriptPath: bogus
+ })
+ expect(resolved).toBe(target)
+ })
+})
diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts
new file mode 100644
index 00000000000..7e94969408f
--- /dev/null
+++ b/src/main/native-chat/session-file-resolver.ts
@@ -0,0 +1,117 @@
+import { existsSync } from 'fs'
+import { homedir } from 'os'
+import { basename, extname, join } from 'path'
+import type { AgentType } from '../../shared/native-chat-types'
+import { walkSessionFiles } from '../ai-vault/session-scanner-discovery'
+import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths'
+
+// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads
+// run in the main process against the runtime's own home directory; over SSH
+// the remote main resolves its local home, so we never hardcode an absolute
+// user path — homedir()/CODEX_HOME resolution stays runtime-relative and is
+// computed per call (not at module load) so it tracks the live home.
+function claudeProjectsDir(): string {
+ return join(homedir(), '.claude', 'projects')
+}
+
+// Why: Orca launches Codex with ORCA_CODEX_HOME pointing at its own managed
+// runtime home, so Orca-started Codex rollout files land under
+// `/sessions`, NOT `~/.codex/sessions`. Search the managed home
+// first (that's where this main process's Codex sessions actually live), then
+// fall back to CODEX_HOME/~/.codex so a non-Orca Codex transcript still resolves.
+// Duplicates are filtered so a managed-home symlink to ~/.codex isn't scanned twice.
+function codexSessionsDirs(): string[] {
+ const candidates = [
+ join(getOrcaManagedCodexHomePath(), 'sessions'),
+ join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions')
+ ]
+ return candidates.filter((dir, index) => candidates.indexOf(dir) === index)
+}
+
+export type ResolveSessionFileOptions = {
+ /** Override the Claude projects root (used by tests / isolated scans). */
+ claudeProjectsDir?: string
+ /** Override the Codex sessions roots, searched in order (tests / isolated
+ * scans). Defaults to the orca-managed home then CODEX_HOME/~/.codex. */
+ codexSessionsDirs?: string[]
+ /** Authoritative transcript path reported by the agent hook
+ * (`providerSession.transcriptPath`). When set and the file exists, it is used
+ * directly — recent Claude Code names the transcript with a UUID that differs
+ * from the hook session_id, so the id-based glob below would miss it. */
+ transcriptPath?: string
+}
+
+/**
+ * Resolve the on-disk JSONL transcript path for a given agent + session id.
+ *
+ * Prefers the hook-reported `transcriptPath` when it exists on disk (authoritative).
+ * Otherwise: Claude nests transcripts by project slug
+ * (`~/.claude/projects//.jsonl`), so we glob the projects subdirs for
+ * `.jsonl`. Codex stores rollout files under date-nested dirs whose file name
+ * embeds the session id, so we match by the session id appearing in the file name.
+ * Returns null when no matching transcript exists.
+ */
+export async function resolveSessionFilePath(
+ agent: AgentType,
+ sessionId: string,
+ options: ResolveSessionFileOptions = {}
+): Promise {
+ // Why: the hook's transcript_path is the exact file the agent is writing, so it
+ // beats reconstructing a path from the session id. Guard with existsSync so a
+ // stale/remote path falls through to the id-based search rather than returning
+ // a non-existent file.
+ const hookPath = options.transcriptPath?.trim()
+ if (hookPath && extname(hookPath) === '.jsonl' && existsSync(hookPath)) {
+ return hookPath
+ }
+
+ const trimmedId = sessionId.trim()
+ if (!trimmedId) {
+ return null
+ }
+
+ if (agent === 'claude') {
+ return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir())
+ }
+ if (agent === 'codex') {
+ return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs())
+ }
+ return null
+}
+
+async function resolveClaudeSessionFile(
+ sessionId: string,
+ projectsDir: string
+): Promise {
+ const targetName = `${sessionId}.jsonl`
+ const files = await walkSessionFiles(projectsDir, 'claude', [], {
+ extensions: new Set(['.jsonl']),
+ filePredicate: (path) => basename(path) === targetName
+ })
+ return files[0] ?? null
+}
+
+async function resolveCodexSessionFile(
+ sessionId: string,
+ sessionsDirs: string[]
+): Promise {
+ // Codex rollout file names embed the session id (rollout--.jsonl), so
+ // match the id as a suffix of the file's base name rather than an exact name.
+ // Search each candidate root (managed home first) and stop at the first match.
+ for (const sessionsDir of sessionsDirs) {
+ if (!existsSync(sessionsDir)) {
+ continue
+ }
+ const files = await walkSessionFiles(sessionsDir, 'codex', [], {
+ extensions: new Set(['.jsonl']),
+ filePredicate: (path) => {
+ const name = basename(path, extname(path))
+ return name === sessionId || name.endsWith(`-${sessionId}`)
+ }
+ })
+ if (files[0]) {
+ return files[0]
+ }
+ }
+ return null
+}
diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts
new file mode 100644
index 00000000000..1a428cbb98a
--- /dev/null
+++ b/src/main/native-chat/transcript-line-decoders.ts
@@ -0,0 +1,185 @@
+// Per-line record→NativeChatMessage decoders, shared by the full transcript
+// reader (transcript-reader.ts) and the live tailer (transcript-watch.ts) so
+// both paths apply identical record-shape mapping. Each decoder is stateless:
+// it takes a single JSONL line plus a stable fallback id and returns one message
+// or null (unknown/empty records are skipped, never thrown — plan KTD risk:
+// schema drift). `fallbackId` is used only when the record carries no intrinsic
+// id; the caller supplies a value unique per line.
+
+import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types'
+import {
+ asRecord,
+ extractString,
+ parseJsonObject,
+ timestampMs
+} from '../ai-vault/session-scanner-values'
+import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks'
+
+export function decodeClaudeTranscriptLine(
+ line: string,
+ fallbackId: string
+): NativeChatMessage | null {
+ const record = parseJsonObject(line)
+ if (!record) {
+ return null
+ }
+ const role = record.type
+ if (role !== 'user' && role !== 'assistant') {
+ return null
+ }
+ const message = asRecord(record.message)
+ const blocks = claudeContentBlocks(message?.content)
+ if (blocks.length === 0) {
+ return null
+ }
+ const messageId = extractString(record.uuid) ?? extractString(message?.id)
+ return {
+ id: messageId ?? fallbackId,
+ role: claudeMessageRole(role, blocks),
+ blocks,
+ timestamp: parseTimestamp(record.timestamp),
+ source: 'transcript'
+ }
+}
+
+// Claude marks reasoning via `thinking` content blocks; when a message is made
+// up solely of reasoning, surface it as a reasoning-role message.
+function claudeMessageRole(
+ role: 'user' | 'assistant',
+ blocks: NativeChatBlock[]
+): NativeChatMessage['role'] {
+ if (role === 'user') {
+ const onlyToolResults = blocks.every((block) => block.type === 'tool-result')
+ return onlyToolResults && blocks.length > 0 ? 'tool' : 'user'
+ }
+ return role
+}
+
+export function decodeCodexTranscriptLine(
+ line: string,
+ fallbackId: string
+): NativeChatMessage | null {
+ const record = parseJsonObject(line)
+ if (!record) {
+ return null
+ }
+ const payload = asRecord(record.payload)
+ if (!payload) {
+ return null
+ }
+ const timestamp = parseTimestamp(record.timestamp)
+ const baseId = extractString(payload.id) ?? fallbackId
+
+ if (record.type === 'response_item') {
+ return codexResponseItem(payload, baseId, timestamp)
+ }
+ if (record.type === 'event_msg') {
+ return codexEventMessage(payload, baseId, timestamp)
+ }
+ return null
+}
+
+function codexResponseItem(
+ payload: Record,
+ id: string,
+ timestamp: number | null
+): NativeChatMessage | null {
+ if (payload.type === 'message') {
+ const blocks = claudeContentBlocks(payload.content)
+ if (blocks.length === 0) {
+ return null
+ }
+ const role =
+ payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system'
+ return { id, role, blocks, timestamp, source: 'transcript' }
+ }
+ if (payload.type === 'reasoning') {
+ const text = extractString(payload.text) ?? codexSummaryText(payload.summary)
+ if (!text) {
+ return null
+ }
+ return {
+ id,
+ role: 'reasoning',
+ blocks: [{ type: 'text', text }],
+ timestamp,
+ source: 'transcript'
+ }
+ }
+ if (payload.type === 'function_call' || payload.type === 'local_shell_call') {
+ const name = extractString(payload.name) ?? 'tool'
+ return {
+ id,
+ role: 'assistant',
+ blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }],
+ timestamp,
+ source: 'transcript'
+ }
+ }
+ if (payload.type === 'function_call_output') {
+ return {
+ id,
+ role: 'tool',
+ blocks: [codexToolResult(payload.output)],
+ timestamp,
+ source: 'transcript'
+ }
+ }
+ return null
+}
+
+function codexEventMessage(
+ payload: Record,
+ id: string,
+ timestamp: number | null
+): NativeChatMessage | null {
+ if (payload.type === 'user_message') {
+ const text = extractString(payload.message)
+ return text
+ ? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
+ : null
+ }
+ if (payload.type === 'agent_message') {
+ const text = extractString(payload.message)
+ return text
+ ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
+ : null
+ }
+ return null
+}
+
+function codexCallInput(payload: Record): unknown {
+ if (payload.arguments !== undefined) {
+ return payload.arguments
+ }
+ return payload.input ?? payload.action ?? null
+}
+
+function codexToolResult(output: unknown): NativeChatBlock {
+ const record = asRecord(output)
+ const isError = record?.success === false || record?.is_error === true
+ return {
+ type: 'tool-result',
+ output: toolResultOutput(record?.content ?? record?.output ?? output),
+ ...(isError ? { isError: true } : {})
+ }
+}
+
+function codexSummaryText(summary: unknown): string | null {
+ if (!Array.isArray(summary)) {
+ return null
+ }
+ const parts: string[] = []
+ for (const item of summary) {
+ const text = extractString(asRecord(item)?.text) ?? extractString(item)
+ if (text) {
+ parts.push(text)
+ }
+ }
+ return parts.length ? parts.join('\n') : null
+}
+
+function parseTimestamp(value: unknown): number | null {
+ const parsed = timestampMs(value)
+ return Number.isFinite(parsed) ? parsed : null
+}
diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts
new file mode 100644
index 00000000000..04fd40355aa
--- /dev/null
+++ b/src/main/native-chat/transcript-read-cache.test.ts
@@ -0,0 +1,92 @@
+import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as TranscriptReader from './transcript-reader'
+
+// Spy on the underlying reader so we can assert cache hits issue zero reads.
+const readSpy = vi.hoisted(() => vi.fn())
+vi.mock('./transcript-reader', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ readNativeChatTranscript: (...args: Parameters) => {
+ readSpy(...args)
+ return actual.readNativeChatTranscript(...args)
+ }
+ }
+})
+
+import {
+ clearNativeChatTranscriptCache,
+ readNativeChatTranscriptCached
+} from './transcript-read-cache'
+
+let tempRoots: string[] = []
+
+function jsonLines(records: unknown[]): string {
+ return records.map((record) => JSON.stringify(record)).join('\n')
+}
+
+async function seedSession(sessionId: string, turns: number): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-'))
+ tempRoots.push(root)
+ const projectDir = join(root, '.claude', 'projects', '-repo')
+ await mkdir(projectDir, { recursive: true })
+ const records = Array.from({ length: turns }, (_unused, n) => ({
+ type: 'user',
+ uuid: `u-${n}`,
+ timestamp: `2026-06-01T10:00:0${n}.000Z`,
+ message: { role: 'user', content: `m${n}` }
+ }))
+ const filePath = join(projectDir, `${sessionId}.jsonl`)
+ await writeFile(filePath, jsonLines(records))
+ process.env.HOME = root
+ return filePath
+}
+
+beforeEach(() => {
+ clearNativeChatTranscriptCache()
+ readSpy.mockClear()
+})
+
+afterEach(async () => {
+ await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
+ tempRoots = []
+})
+
+describe('readNativeChatTranscriptCached', () => {
+ it('returns the same cached object on an mtime hit without re-reading', async () => {
+ await seedSession('sess-hit', 3)
+ const first = await readNativeChatTranscriptCached('claude', 'sess-hit')
+ const second = await readNativeChatTranscriptCached('claude', 'sess-hit')
+ expect(readSpy).toHaveBeenCalledTimes(1)
+ // Same reference: the second call served the cached parse.
+ expect(second).toBe(first)
+ })
+
+ it('re-reads when the file mtime changes', async () => {
+ const filePath = await seedSession('sess-mtime', 2)
+ await readNativeChatTranscriptCached('claude', 'sess-mtime')
+ expect(readSpy).toHaveBeenCalledTimes(1)
+ // Bump mtime into the future to invalidate without changing content shape.
+ const future = new Date(Date.now() + 5_000)
+ await utimes(filePath, future, future)
+ await readNativeChatTranscriptCached('claude', 'sess-mtime')
+ expect(readSpy).toHaveBeenCalledTimes(2)
+ })
+
+ it('clear() empties the cache so the next read re-reads', async () => {
+ await seedSession('sess-clear', 1)
+ await readNativeChatTranscriptCached('claude', 'sess-clear')
+ clearNativeChatTranscriptCache()
+ await readNativeChatTranscriptCached('claude', 'sess-clear')
+ expect(readSpy).toHaveBeenCalledTimes(2)
+ })
+
+ it('returns an error result for an unknown session without throwing', async () => {
+ await seedSession('present', 1)
+ const result = await readNativeChatTranscriptCached('claude', 'absent')
+ expect('error' in result && result.error).toBeTruthy()
+ })
+})
diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts
new file mode 100644
index 00000000000..84151f1d590
--- /dev/null
+++ b/src/main/native-chat/transcript-read-cache.ts
@@ -0,0 +1,90 @@
+import { stat } from 'fs/promises'
+import type { AgentType } from '../../shared/native-chat-types'
+import { resolveSessionFilePath } from './session-file-resolver'
+import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
+
+// Why: both the desktop IPC handler and the runtime RPC handler read the same
+// host-filesystem transcript, so a single process-global cache keyed by
+// agent:sessionId maximizes the hit rate across desktop + every paired
+// web/mobile client. Keying by connection instead would defeat the multi-client
+// case this feature targets and multiply memory by the connection count.
+// The cache stores ONE canonical, unwindowed parse; windowing and per-surface
+// truncation stay in the callers so the same parse is reused across all `limit`
+// values and every client kind.
+
+type CachedTranscript = {
+ result: ReadTranscriptResult
+ /** mtime of the resolved file when cached; a newer mtime invalidates it. */
+ mtimeMs: number
+}
+
+const cache = new Map()
+
+// Why: cap the cache so a long-lived process browsing many sessions can't grow
+// it unbounded. Map preserves insertion order, so evicting the first key drops
+// the oldest entry (a simple LRU once re-inserts bump recency; see setCached).
+// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling
+// shows RSS pressure now that one process serves many remote clients.
+const MAX_CACHE_ENTRIES = 50
+
+function setCached(key: string, value: CachedTranscript): void {
+ // Re-insert moves the key to the most-recent position for LRU eviction.
+ cache.delete(key)
+ cache.set(key, value)
+ while (cache.size > MAX_CACHE_ENTRIES) {
+ const oldest = cache.keys().next().value
+ if (oldest === undefined) {
+ break
+ }
+ cache.delete(oldest)
+ }
+}
+
+function cacheKey(agent: AgentType, sessionId: string): string {
+ return `${agent}:${sessionId}`
+}
+
+async function fileMtimeMs(filePath: string): Promise {
+ try {
+ return (await stat(filePath)).mtimeMs
+ } catch {
+ return Number.NaN
+ }
+}
+
+/**
+ * Read the full transcript for an agent + session, returning the cached parse on
+ * an mtime hit and re-reading (and re-caching) when the file changed. Returns the
+ * canonical, unwindowed result; callers apply their own windowing/truncation.
+ */
+export async function readNativeChatTranscriptCached(
+ agent: AgentType,
+ sessionId: string,
+ /** Hook-reported authoritative transcript path, preferred over the id glob. */
+ transcriptPath?: string
+): Promise {
+ const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
+ if (!filePath) {
+ return { error: `No transcript found for ${agent} session ${sessionId}` }
+ }
+
+ const key = cacheKey(agent, sessionId)
+ const mtimeMs = await fileMtimeMs(filePath)
+ const cached = cache.get(key)
+ if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
+ // Bump recency so a frequently-read session survives eviction.
+ setCached(key, cached)
+ return cached.result
+ }
+
+ const result = await readNativeChatTranscript(agent, sessionId, { filePath })
+ if (Number.isFinite(mtimeMs)) {
+ setCached(key, { result, mtimeMs })
+ }
+ return result
+}
+
+/** Test-only: drop the per-session transcript cache between runs. */
+export function clearNativeChatTranscriptCache(): void {
+ cache.clear()
+}
diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts
new file mode 100644
index 00000000000..864715011d1
--- /dev/null
+++ b/src/main/native-chat/transcript-reader.test.ts
@@ -0,0 +1,192 @@
+import { mkdtemp, rm, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { readNativeChatTranscript } from './transcript-reader'
+
+let tempRoots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
+ tempRoots = []
+})
+
+function jsonLines(records: unknown[]): string {
+ return records.map((record) => JSON.stringify(record)).join('\n')
+}
+
+async function writeFixture(prefix: string, records: unknown[]): Promise {
+ const root = await mkdtemp(join(tmpdir(), prefix))
+ tempRoots.push(root)
+ const filePath = join(root, 'transcript.jsonl')
+ await writeFile(filePath, jsonLines(records))
+ return filePath
+}
+
+describe('readNativeChatTranscript (claude)', () => {
+ it('returns ordered user/assistant/tool messages with no 5-message cap', async () => {
+ const records: unknown[] = []
+ // 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap.
+ for (let turn = 0; turn < 4; turn++) {
+ records.push({
+ type: 'user',
+ uuid: `u-${turn}`,
+ timestamp: `2026-06-01T10:0${turn}:00.000Z`,
+ message: { role: 'user', content: `Prompt **${turn}**` }
+ })
+ records.push({
+ type: 'assistant',
+ uuid: `a-${turn}`,
+ timestamp: `2026-06-01T10:0${turn}:30.000Z`,
+ message: { role: 'assistant', content: [{ type: 'text', text: `Reply _${turn}_` }] }
+ })
+ }
+ // A tool_use then a tool_result (carried on a user record).
+ records.push({
+ type: 'assistant',
+ uuid: 'a-tool',
+ timestamp: '2026-06-01T10:05:00.000Z',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }]
+ }
+ })
+ records.push({
+ type: 'user',
+ uuid: 'u-toolresult',
+ timestamp: '2026-06-01T10:05:01.000Z',
+ message: {
+ role: 'user',
+ content: [{ type: 'tool_result', content: 'file-a\nfile-b', is_error: false }]
+ }
+ })
+
+ const filePath = await writeFixture('orca-native-chat-claude-', records)
+ const result = await readNativeChatTranscript('claude', 'sess', { filePath })
+ expect('messages' in result).toBe(true)
+ if (!('messages' in result)) {
+ return
+ }
+
+ expect(result.messages.length).toBe(10)
+ expect(result.messages.length).toBeGreaterThan(5)
+ expect(result.messages[0]).toMatchObject({ role: 'user', source: 'transcript' })
+ // Markdown text preserved verbatim.
+ expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'Prompt **0**' })
+ expect(result.messages[1].blocks[0]).toEqual({ type: 'text', text: 'Reply _0_' })
+
+ const toolCall = result.messages.find((m) => m.blocks[0]?.type === 'tool-call')
+ expect(toolCall?.blocks[0]).toEqual({
+ type: 'tool-call',
+ name: 'Bash',
+ input: { command: 'ls' }
+ })
+
+ const toolResult = result.messages.at(-1)
+ expect(toolResult?.role).toBe('tool')
+ expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'file-a\nfile-b' })
+ })
+
+ it('marks thinking-only assistant content as a reasoning surface', async () => {
+ const filePath = await writeFixture('orca-native-chat-claude-think-', [
+ {
+ type: 'assistant',
+ uuid: 'a-think',
+ timestamp: '2026-06-01T10:00:00.000Z',
+ message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'pondering' }] }
+ }
+ ])
+ const result = await readNativeChatTranscript('claude', 'sess', { filePath })
+ if (!('messages' in result)) {
+ throw new Error('expected messages')
+ }
+ expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'pondering' })
+ })
+})
+
+describe('readNativeChatTranscript (codex)', () => {
+ it('maps tool calls and results to tool-call/tool-result blocks', async () => {
+ const filePath = await writeFixture('orca-native-chat-codex-', [
+ {
+ type: 'session_meta',
+ timestamp: '2026-06-01T10:00:00.000Z',
+ payload: { id: 'codex-sess', cwd: '/repo' }
+ },
+ {
+ type: 'response_item',
+ timestamp: '2026-06-01T10:00:01.000Z',
+ payload: {
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'text', text: 'Run the build' }]
+ }
+ },
+ {
+ type: 'response_item',
+ timestamp: '2026-06-01T10:00:02.000Z',
+ payload: { type: 'reasoning', summary: [{ type: 'summary_text', text: 'I will run it' }] }
+ },
+ {
+ type: 'response_item',
+ timestamp: '2026-06-01T10:00:03.000Z',
+ payload: {
+ type: 'function_call',
+ name: 'shell',
+ arguments: '{"command":["bash","-lc","make"]}'
+ }
+ },
+ {
+ type: 'response_item',
+ timestamp: '2026-06-01T10:00:04.000Z',
+ payload: {
+ type: 'function_call_output',
+ output: { content: 'build ok', success: true }
+ }
+ },
+ {
+ type: 'response_item',
+ timestamp: '2026-06-01T10:00:05.000Z',
+ payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'Done.' }] }
+ }
+ ])
+
+ const result = await readNativeChatTranscript('codex', 'codex-sess', { filePath })
+ if (!('messages' in result)) {
+ throw new Error(`expected messages, got error`)
+ }
+
+ const roles = result.messages.map((m) => m.role)
+ expect(roles).toEqual(['user', 'reasoning', 'assistant', 'tool', 'assistant'])
+
+ const call = result.messages.find((m) => m.blocks[0]?.type === 'tool-call')
+ expect(call?.blocks[0]).toEqual({
+ type: 'tool-call',
+ name: 'shell',
+ input: '{"command":["bash","-lc","make"]}'
+ })
+
+ const toolResult = result.messages.find((m) => m.blocks[0]?.type === 'tool-result')
+ expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'build ok' })
+
+ const reasoning = result.messages.find((m) => m.role === 'reasoning')
+ expect(reasoning?.blocks[0]).toEqual({ type: 'text', text: 'I will run it' })
+ })
+})
+
+describe('readNativeChatTranscript (errors)', () => {
+ it('returns an error for an unreadable/missing file without throwing', async () => {
+ const result = await readNativeChatTranscript('claude', 'sess', {
+ filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl')
+ })
+ expect('error' in result).toBe(true)
+ })
+
+ it('returns an error when no transcript can be resolved', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-'))
+ tempRoots.push(root)
+ const result = await readNativeChatTranscript('claude', 'missing', {
+ claudeProjectsDir: join(root, 'empty')
+ })
+ expect('error' in result).toBe(true)
+ })
+})
diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts
new file mode 100644
index 00000000000..7fe13f0aadf
--- /dev/null
+++ b/src/main/native-chat/transcript-reader.ts
@@ -0,0 +1,66 @@
+import { createReadStream } from 'fs'
+import { createInterface } from 'readline'
+import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
+import { errorMessage } from '../ai-vault/session-scanner-values'
+import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
+import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders'
+
+export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string }
+
+export type ReadTranscriptOptions = ResolveSessionFileOptions & {
+ /** Resolve directly to this file, skipping path discovery (used by tests). */
+ filePath?: string
+}
+
+/**
+ * Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into
+ * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies
+ * NO message cap. Unknown record types are skipped rather than throwing, so a
+ * single malformed/unrecognized line cannot fail the whole read. The per-line
+ * record→message mapping is shared with the live tailer (transcript-watch.ts)
+ * via transcript-line-decoders.ts.
+ */
+export async function readNativeChatTranscript(
+ agent: AgentType,
+ sessionId: string,
+ options: ReadTranscriptOptions = {}
+): Promise {
+ const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
+ if (!filePath) {
+ return { error: `No transcript found for ${agent} session ${sessionId}` }
+ }
+ try {
+ if (agent === 'claude') {
+ return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) }
+ }
+ if (agent === 'codex') {
+ return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) }
+ }
+ return { error: `Unsupported agent for native chat transcript: ${agent}` }
+ } catch (err) {
+ return { error: errorMessage(err) }
+ }
+}
+
+async function readTranscript(
+ filePath: string,
+ decode: (line: string, fallbackId: string) => NativeChatMessage | null
+): Promise {
+ const reader = createInterface({
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
+ crlfDelay: Infinity
+ })
+ const messages: NativeChatMessage[] = []
+ let index = 0
+ for await (const line of reader) {
+ // Why: fallback id embeds start offset 0 so it matches the live tailer's id
+ // for the same record (the tailer's first drain reads from offset 0 too).
+ // Records that re-emit then collapse by id in the assembler — no dup, no drop.
+ const message = decode(line, `${filePath}:0:${index}`)
+ if (message) {
+ messages.push(message)
+ }
+ index++
+ }
+ return messages
+}
diff --git a/src/main/native-chat/transcript-record-blocks.ts b/src/main/native-chat/transcript-record-blocks.ts
new file mode 100644
index 00000000000..6355df277e5
--- /dev/null
+++ b/src/main/native-chat/transcript-record-blocks.ts
@@ -0,0 +1,117 @@
+// Centralized record→block mapping for native-chat transcripts. Kept separate
+// from the reader so the Claude and Codex per-record decoders share one place
+// to evolve as CLI transcript schemas drift (plan KTD risk: schema drift).
+
+import type {
+ NativeChatBlock,
+ NativeChatImageRefBlock,
+ NativeChatToolResultBlock
+} from '../../shared/native-chat-types'
+import { asRecord, extractString } from '../ai-vault/session-scanner-values'
+
+/** Coerce an arbitrary tool-result payload into a single output string. */
+export function toolResultOutput(value: unknown): string {
+ if (typeof value === 'string') {
+ return value
+ }
+ if (!Array.isArray(value)) {
+ const record = asRecord(value)
+ if (record) {
+ const text = extractString(record.text) ?? extractString(record.content)
+ if (text) {
+ return text
+ }
+ }
+ return value === undefined || value === null ? '' : JSON.stringify(value)
+ }
+ const parts: string[] = []
+ for (const item of value) {
+ if (typeof item === 'string') {
+ parts.push(item)
+ continue
+ }
+ const record = asRecord(item)
+ const text = extractString(record?.text) ?? extractString(record?.content)
+ if (text) {
+ parts.push(text)
+ }
+ }
+ return parts.join('\n')
+}
+
+/** Build the blocks for one Claude content array (string or block[]). */
+export function claudeContentBlocks(content: unknown): NativeChatBlock[] {
+ if (typeof content === 'string') {
+ const text = content.trim()
+ return text ? [{ type: 'text', text: content }] : []
+ }
+ if (!Array.isArray(content)) {
+ return []
+ }
+ const blocks: NativeChatBlock[] = []
+ for (const item of content) {
+ if (typeof item === 'string') {
+ if (item.trim()) {
+ blocks.push({ type: 'text', text: item })
+ }
+ continue
+ }
+ const record = asRecord(item)
+ if (!record) {
+ continue
+ }
+ const block = claudeContentBlock(record)
+ if (block) {
+ blocks.push(block)
+ }
+ }
+ return blocks
+}
+
+function claudeContentBlock(record: Record): NativeChatBlock | null {
+ switch (record.type) {
+ case 'text': {
+ const text = extractString(record.text)
+ return text ? { type: 'text', text } : null
+ }
+ case 'thinking': {
+ // Reasoning surfaces as a text block; the message role marks it as reasoning.
+ const text = extractString(record.thinking) ?? extractString(record.text)
+ return text ? { type: 'text', text } : null
+ }
+ case 'tool_use': {
+ const name = extractString(record.name) ?? 'tool'
+ return { type: 'tool-call', name, input: record.input }
+ }
+ case 'tool_result':
+ return toolResultBlock(record)
+ case 'image':
+ return imageRefBlock(record)
+ default:
+ return null
+ }
+}
+
+function toolResultBlock(record: Record): NativeChatToolResultBlock {
+ return {
+ type: 'tool-result',
+ output: toolResultOutput(record.content),
+ ...(record.is_error === true ? { isError: true } : {})
+ }
+}
+
+function imageRefBlock(record: Record): NativeChatImageRefBlock | null {
+ const source = asRecord(record.source)
+ const url = extractString(source?.url) ?? extractString(record.url)
+ const path = extractString(record.path)
+ const alt = extractString(record.alt) ?? undefined
+ if (!url && !path) {
+ return null
+ }
+ return {
+ type: 'image-ref',
+ ...(path ? { path } : {}),
+ ...(url ? { url } : {}),
+ ...(alt ? { alt } : {})
+ }
+}
diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts
new file mode 100644
index 00000000000..9c1be087cbf
--- /dev/null
+++ b/src/main/native-chat/transcript-watch.test.ts
@@ -0,0 +1,239 @@
+import { appendFile, mkdtemp, rm, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import type { NativeChatMessage } from '../../shared/native-chat-types'
+import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch'
+
+let tempRoots: string[] = []
+
+beforeEach(() => {
+ tempRoots = []
+})
+
+afterEach(async () => {
+ await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
+ tempRoots = []
+})
+
+async function tempFile(initial: string): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-'))
+ tempRoots.push(root)
+ const filePath = join(root, 'rollout.jsonl')
+ await writeFile(filePath, initial)
+ return filePath
+}
+
+function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string {
+ return `${JSON.stringify({
+ type: role,
+ uuid,
+ timestamp: '2026-06-01T10:00:00.000Z',
+ message: { role, content: role === 'user' ? text : [{ type: 'text', text }] }
+ })}\n`
+}
+
+async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise {
+ const start = Date.now()
+ while (!predicate()) {
+ if (Date.now() - start > timeoutMs) {
+ throw new Error('timed out waiting for condition')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ }
+}
+
+describe('subscribeNativeChatTranscript', () => {
+ it('re-emits from the top on first drain so appended turns are never dropped', async () => {
+ const filePath = await tempFile(claudeLine('u-1', 'user', 'first'))
+ const batches: NativeChatMessage[][] = []
+
+ const sub = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: 'ignored',
+ filePath,
+ onAppend: (messages) => batches.push(messages),
+ debounceMs: 5
+ })
+
+ await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply'))
+ await waitFor(() => batches.flat().some((m) => m.id === 'a-1'))
+
+ sub.unsubscribe()
+
+ // Seed-at-0 means the first drain re-reads the whole file; the assembler
+ // dedups by id. The appended turn must appear; the pre-existing line may
+ // appear too (collapsed downstream by id).
+ const ids = batches.flat().map((m) => m.id)
+ expect(ids).toContain('a-1')
+ })
+
+ it('appends a turn in the gap between initial read and first watcher drain exactly once', async () => {
+ // Simulate the read/subscribe race: a turn lands after the caller's
+ // readSession EOF but before the watcher's first drain. Seeding at 0 means
+ // the first drain reads it; the assembler later dedups by deterministic id.
+ const filePath = await tempFile(claudeLine('u-1', 'user', 'first'))
+ const seen: NativeChatMessage[] = []
+
+ // The gap turn is written BEFORE subscribe completes its first drain.
+ await appendFile(filePath, claudeLine('a-gap', 'assistant', 'raced reply'))
+
+ const sub = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: 'ignored',
+ filePath,
+ onAppend: (messages) => seen.push(...messages),
+ debounceMs: 5
+ })
+
+ await waitFor(() => seen.some((m) => m.id === 'a-gap'))
+ sub.unsubscribe()
+
+ // The raced turn is present, and not duplicated within a single drain pass.
+ expect(seen.filter((m) => m.id === 'a-gap')).toHaveLength(1)
+ })
+
+ it('recovers cleanly when a read throws (subscription not left deaf)', async () => {
+ const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
+ const seen: NativeChatMessage[] = []
+
+ const sub = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: 'ignored',
+ filePath,
+ onAppend: (messages) => seen.push(...messages),
+ debounceMs: 5
+ })
+
+ // Make the file unreadable mid-flight (EACCES on the read path). The drain's
+ // try/catch must break and reset `reading` in finally so a later append
+ // still tails once permissions are restored.
+ await waitFor(() => seen.some((m) => m.id === 'u-1'))
+ const { chmod } = await import('fs/promises')
+ await chmod(filePath, 0o000)
+ await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')).catch(() => {})
+ // Give the watcher a chance to attempt (and fail) a drain.
+ await new Promise((resolve) => setTimeout(resolve, 40))
+ await chmod(filePath, 0o644)
+ await appendFile(filePath, claudeLine('a-2', 'assistant', 'recovered'))
+
+ await waitFor(() => seen.some((m) => m.id === 'a-2'))
+ sub.unsubscribe()
+ expect(seen.some((m) => m.id === 'a-2')).toBe(true)
+ })
+
+ it('releases the watcher on unsubscribe (no leak)', async () => {
+ const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
+ const before = getActiveNativeChatWatcherCount()
+
+ const sub = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: 'ignored',
+ filePath,
+ onAppend: () => {},
+ debounceMs: 5
+ })
+ expect(getActiveNativeChatWatcherCount()).toBe(before + 1)
+
+ sub.unsubscribe()
+ expect(getActiveNativeChatWatcherCount()).toBe(before)
+
+ // Idempotent: a second unsubscribe must not under-count.
+ sub.unsubscribe()
+ expect(getActiveNativeChatWatcherCount()).toBe(before)
+ })
+
+ it('coalesces rapid successive appends without dropping messages', async () => {
+ const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
+ const seen: NativeChatMessage[] = []
+
+ const sub = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: 'ignored',
+ filePath,
+ onAppend: (messages) => seen.push(...messages),
+ debounceMs: 10
+ })
+
+ // Fire several appends back-to-back within the debounce window.
+ await appendFile(filePath, claudeLine('a-1', 'assistant', 'one'))
+ await appendFile(filePath, claudeLine('a-2', 'assistant', 'two'))
+ await appendFile(filePath, claudeLine('a-3', 'assistant', 'three'))
+
+ await waitFor(() => ['a-1', 'a-2', 'a-3'].every((id) => seen.some((m) => m.id === id)))
+ sub.unsubscribe()
+
+ // Order is preserved for the appended turns (the seed re-read may also carry
+ // the pre-existing u-1, which the assembler dedups downstream).
+ const appendedIds = seen.map((m) => m.id).filter((id) => id !== 'u-1')
+ expect(appendedIds).toEqual(['a-1', 'a-2', 'a-3'])
+ })
+
+ it('waits for an incomplete trailing JSONL line before advancing the offset', async () => {
+ const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
+ const seen: NativeChatMessage[] = []
+
+ const sub = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: 'ignored',
+ filePath,
+ onAppend: (messages) => seen.push(...messages),
+ debounceMs: 5
+ })
+
+ await waitFor(() => seen.some((m) => m.id === 'u-1'))
+
+ const line = claudeLine('a-partial', 'assistant', 'split reply')
+ const splitAt = Math.floor(line.length / 2)
+ await appendFile(filePath, line.slice(0, splitAt))
+ await new Promise((resolve) => setTimeout(resolve, 40))
+ expect(seen.some((m) => m.id === 'a-partial')).toBe(false)
+
+ await appendFile(filePath, line.slice(splitAt))
+ await waitFor(() => seen.some((m) => m.id === 'a-partial'))
+
+ sub.unsubscribe()
+ expect(seen.filter((m) => m.id === 'a-partial')).toHaveLength(1)
+ })
+
+ it('survives file replacement / rotation (offset reset on shrink)', async () => {
+ const filePath = await tempFile(
+ claudeLine('u-1', 'user', 'old') + claudeLine('a-1', 'assistant', 'old-reply')
+ )
+ const seen: NativeChatMessage[] = []
+
+ const sub = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: 'ignored',
+ filePath,
+ onAppend: (messages) => seen.push(...messages),
+ debounceMs: 5
+ })
+
+ // Replace the file with shorter content (simulates rotation to a new,
+ // smaller session file at the same resolved path).
+ await writeFile(filePath, claudeLine('u-2', 'user', 'fresh'))
+ await waitFor(() => seen.some((m) => m.id === 'u-2'))
+
+ // A subsequent append on the rotated file is still tailed.
+ await appendFile(filePath, claudeLine('a-2', 'assistant', 'fresh-reply'))
+ await waitFor(() => seen.some((m) => m.id === 'a-2'))
+
+ sub.unsubscribe()
+ const ids = seen.map((m) => m.id)
+ expect(ids).toContain('u-2')
+ expect(ids).toContain('a-2')
+ })
+
+ it('returns a no-op unsubscribe when the file cannot be resolved', async () => {
+ const before = getActiveNativeChatWatcherCount()
+ const sub = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: '',
+ onAppend: () => {}
+ })
+ expect(getActiveNativeChatWatcherCount()).toBe(before)
+ // Must not throw.
+ sub.unsubscribe()
+ })
+})
diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts
new file mode 100644
index 00000000000..ab202f59147
--- /dev/null
+++ b/src/main/native-chat/transcript-watch.ts
@@ -0,0 +1,254 @@
+// Live transcript tailing: watch a resolved session JSONL file and emit only
+// the messages parsed from bytes appended since the last read. Modeled on the
+// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's
+// skipInitialBytes), but specialized to the NativeChatMessage record decoders.
+//
+// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription
+// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes
+// the watcher and clears the timer synchronously, and the module tracks the live
+// watcher count so tests can assert no watcher survives teardown.
+
+import { watch, type FSWatcher } from 'fs'
+import { open, stat } from 'fs/promises'
+import type { Readable } from 'stream'
+import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
+import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
+import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders'
+
+export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
+ agent: AgentType
+ sessionId: string
+ /** Called with the newly-appended messages whenever the file grows. Never
+ * called with an empty array. */
+ onAppend: (messages: NativeChatMessage[]) => void
+ /** Resolve directly to this file, skipping path discovery (used by tests). */
+ filePath?: string
+ /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */
+ debounceMs?: number
+}
+
+export type NativeChatTranscriptSubscription = {
+ /** Closes the watcher and releases the file handle. Idempotent. */
+ unsubscribe: () => void
+}
+
+// Why: a single watch event can fire several times for one append; we read from
+// the last byte offset so re-entrant reads never re-emit prior messages. Each
+// decoder is stateless per-line, so tailing reuses the same record→message
+// mapping the full reader uses.
+const DEFAULT_DEBOUNCE_MS = 40
+
+// Why: process-wide count of live FSWatchers opened by this module. The U4 leak
+// test asserts this returns to zero after unsubscribe so a forgotten handle is
+// caught deterministically rather than relying on OS fd inspection.
+let activeWatcherCount = 0
+
+/** Test-only: number of fs watchers this module currently holds open. */
+export function getActiveNativeChatWatcherCount(): number {
+ return activeWatcherCount
+}
+
+function lineDecoderForAgent(
+ agent: AgentType
+): ((line: string, fallbackId: string) => NativeChatMessage | null) | null {
+ if (agent === 'claude') {
+ return decodeClaudeTranscriptLine
+ }
+ if (agent === 'codex') {
+ return decodeCodexTranscriptLine
+ }
+ return null
+}
+
+async function fileSize(filePath: string): Promise {
+ try {
+ return (await stat(filePath)).size
+ } catch {
+ return 0
+ }
+}
+
+/**
+ * Read bytes [start, end) of the file and decode each complete line into a
+ * NativeChatMessage. Opens its own fd and always closes it (no leak on the read
+ * path, distinct from the long-lived watcher). Returns the messages plus the
+ * byte offset actually consumed so a partially-written trailing line is re-read
+ * on the next append rather than dropped.
+ */
+async function readAppendedMessages(
+ filePath: string,
+ start: number,
+ decode: (line: string, fallbackId: string) => NativeChatMessage | null
+): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> {
+ const end = await fileSize(filePath)
+ if (end <= start) {
+ // File shrank (rotation/replacement) or unchanged — caller resets offset.
+ return { messages: [], consumedTo: end }
+ }
+
+ const handle = await open(filePath, 'r')
+ try {
+ const stream = handle.createReadStream({
+ encoding: 'utf-8',
+ start,
+ end: end - 1,
+ autoClose: false
+ })
+ const { messages, consumedBytes } = await decodeStreamLines(stream, filePath, start, decode)
+ return { messages, consumedTo: start + consumedBytes }
+ } finally {
+ await handle.close()
+ }
+}
+
+async function decodeStreamLines(
+ stream: Readable,
+ filePath: string,
+ start: number,
+ decode: (line: string, fallbackId: string) => NativeChatMessage | null
+): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> {
+ const text = await readStreamText(stream)
+ const completeEnd = text.lastIndexOf('\n')
+ if (completeEnd === -1) {
+ return { messages: [], consumedBytes: 0 }
+ }
+
+ // Why: transcript writers can flush mid-record. Only advance through
+ // newline-terminated JSONL so invalid partial JSON is retried on the next
+ // append instead of being lost forever.
+ const completeText = text.slice(0, completeEnd + 1)
+ const lines = completeText.split('\n')
+ const messages: NativeChatMessage[] = []
+ for (const [index, line] of lines.entries()) {
+ if (!line) {
+ continue
+ }
+ // Fallback id embeds the byte offset so ids stay stable+unique across
+ // appends even when a record carries no intrinsic id.
+ const message = decode(line, `${filePath}:${start}:${index}`)
+ if (message) {
+ messages.push(message)
+ }
+ }
+ return { messages, consumedBytes: Buffer.byteLength(completeText, 'utf8') }
+}
+
+async function readStreamText(stream: Readable): Promise {
+ const chunks: string[] = []
+ for await (const chunk of stream) {
+ chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
+ }
+ return chunks.join('')
+}
+
+/**
+ * Subscribe to live appends on an agent's transcript file. Returns an
+ * unsubscribe fn that tears the watcher down completely.
+ *
+ * Handles file rotation/replacement: when the file shrinks (a new session id
+ * resolved to a smaller/newer file, or the file was truncated), the offset is
+ * reset to 0 so the replacement's content is read from the top.
+ */
+export async function subscribeNativeChatTranscript(
+ args: SubscribeNativeChatTranscriptArgs
+): Promise {
+ const { agent, sessionId, onAppend, debounceMs } = args
+ const decode = lineDecoderForAgent(agent)
+ const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args))
+
+ if (!filePath || !decode) {
+ // Nothing watchable — return a no-op teardown so callers can unconditionally
+ // unsubscribe without null-checks.
+ return { unsubscribe: () => {} }
+ }
+
+ // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This
+ // closes the read/subscribe race — a turn appended between the caller's
+ // readSession EOF and the watcher install is still emitted. Re-emitted lines
+ // collapse by deterministic id in the assembler (no dup, no drop). Subsequent
+ // drains use the incremental offset so the full re-read happens only once.
+ let offset = 0
+ let closed = false
+ let reading = false
+ let pendingReadRequested = false
+ let debounceTimer: ReturnType | null = null
+
+ async function drain(): Promise {
+ if (closed) {
+ return
+ }
+ if (reading) {
+ // A read is already in flight; mark that another pass is needed so rapid
+ // successive appends coalesce without dropping the trailing one.
+ pendingReadRequested = true
+ return
+ }
+ reading = true
+ try {
+ do {
+ pendingReadRequested = false
+ try {
+ const currentSize = await fileSize(filePath!)
+ if (currentSize < offset) {
+ // Rotation/replacement/truncation: re-read from the top.
+ offset = 0
+ }
+ const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!)
+ offset = consumedTo
+ if (!closed && messages.length > 0) {
+ onAppend(messages)
+ }
+ } catch {
+ // Why: a transient read failure (EACCES/EIO/ENOENT during rotation)
+ // must not leave the subscription permanently deaf. Stop this drain;
+ // the finally resets `reading` so a later fs event re-arms the read.
+ break
+ }
+ } while (pendingReadRequested && !closed)
+ } finally {
+ reading = false
+ }
+ }
+
+ function scheduleDrain(): void {
+ if (closed) {
+ return
+ }
+ if (debounceTimer) {
+ clearTimeout(debounceTimer)
+ }
+ debounceTimer = setTimeout(() => {
+ debounceTimer = null
+ void drain()
+ }, debounceMs ?? DEFAULT_DEBOUNCE_MS)
+ }
+
+ let watcher: FSWatcher
+ try {
+ watcher = watch(filePath, scheduleDrain)
+ } catch {
+ // File vanished between resolve and watch — return a no-op teardown.
+ return { unsubscribe: () => {} }
+ }
+ activeWatcherCount++
+
+ // Why: on some platforms fs.watch can miss the very first append that lands
+ // between offset-seed and watcher install. Kick one debounced drain so a
+ // turn written immediately after subscribe is still picked up.
+ scheduleDrain()
+
+ return {
+ unsubscribe: () => {
+ if (closed) {
+ return
+ }
+ closed = true
+ if (debounceTimer) {
+ clearTimeout(debounceTimer)
+ debounceTimer = null
+ }
+ watcher.close()
+ activeWatcherCount--
+ }
+ }
+}
diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts
index 44c9a9caa69..b016e679b61 100644
--- a/src/main/persistence.test.ts
+++ b/src/main/persistence.test.ts
@@ -1366,6 +1366,51 @@ describe('Store', () => {
expect(reloaded.listAutomations()[0].reuseSession).toBe(false)
})
+ it('persists setup decisions only for new-per-run automations', async () => {
+ const store = await createStore()
+ store.addRepo(makeRepo())
+
+ const newPerRun = store.createAutomation({
+ name: 'Fresh',
+ prompt: 'Run checks',
+ agentId: 'claude',
+ projectId: 'r1',
+ workspaceMode: 'new_per_run',
+ setupDecision: 'run',
+ timezone: 'UTC',
+ rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
+ dtstart: new Date('2026-05-13T00:00:00Z').getTime()
+ })
+ const existing = store.createAutomation({
+ name: 'Reuse',
+ prompt: 'Summarize changes',
+ agentId: 'claude',
+ projectId: 'r1',
+ workspaceMode: 'existing',
+ workspaceId: 'wt1',
+ setupDecision: 'run',
+ timezone: 'UTC',
+ rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
+ dtstart: new Date('2026-05-13T00:00:00Z').getTime()
+ })
+
+ expect(newPerRun.setupDecision).toBe('run')
+ expect(existing.setupDecision).toBeUndefined()
+
+ const skipped = store.updateAutomation(newPerRun.id, { setupDecision: 'skip' })
+ const switchedToExisting = store.updateAutomation(newPerRun.id, {
+ workspaceMode: 'existing',
+ workspaceId: 'wt1'
+ })
+
+ expect(skipped.setupDecision).toBe('skip')
+ expect(switchedToExisting.setupDecision).toBeUndefined()
+ expect(
+ store.updateAutomation(existing.id, { workspaceMode: 'new_per_run', setupDecision: 'run' })
+ .setupDecision
+ ).toBe('run')
+ })
+
it('derives automation source and run contexts from the project host setup', async () => {
const store = await createStore()
store.addRepo(
@@ -3813,6 +3858,102 @@ describe('Store', () => {
expect(listener).not.toHaveBeenCalled()
})
+ it('migrates missing terminal scrollback rows to the row default and writes back rows only', async () => {
+ writeDataFile({ settings: {} })
+
+ const store = await createStore()
+
+ expect(store.getSettings().terminalScrollbackRows).toBe(5_000)
+
+ store.flush()
+ const persisted = readDataFile() as { settings?: Record }
+ expect(persisted.settings?.terminalScrollbackRows).toBe(5_000)
+ expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
+ })
+
+ it('migrates legacy terminal scrollback byte presets by intent', async () => {
+ writeDataFile({
+ settings: {
+ terminalScrollbackBytes: 25_000_000
+ }
+ })
+
+ const store = await createStore()
+
+ expect(store.getSettings().terminalScrollbackRows).toBe(10_000)
+
+ store.flush()
+ const persisted = readDataFile() as { settings?: Record }
+ expect(persisted.settings?.terminalScrollbackRows).toBe(10_000)
+ expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
+ })
+
+ it('lets persisted terminal scrollback rows win over legacy bytes', async () => {
+ writeDataFile({
+ settings: {
+ terminalScrollbackRows: 25_000,
+ terminalScrollbackBytes: 100_000_000
+ }
+ })
+
+ const store = await createStore()
+
+ expect(store.getSettings().terminalScrollbackRows).toBe(25_000)
+
+ store.flush()
+ const persisted = readDataFile() as { settings?: Record }
+ expect(persisted.settings?.terminalScrollbackRows).toBe(25_000)
+ expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
+ })
+
+ it('normalizes invalid and clamped terminal scrollback rows on load', async () => {
+ writeDataFile({
+ settings: {
+ terminalScrollbackRows: '50000'
+ }
+ })
+
+ const invalidStore = await createStore()
+ expect(invalidStore.getSettings().terminalScrollbackRows).toBe(5_000)
+ invalidStore.flush()
+
+ writeDataFile({
+ settings: {
+ terminalScrollbackRows: 75_000
+ }
+ })
+
+ const clampedStore = await createStore()
+ expect(clampedStore.getSettings().terminalScrollbackRows).toBe(50_000)
+ })
+
+ it('normalizes terminal scrollback row updates and ignores stale byte updates', async () => {
+ const store = await createStore()
+ const listener = vi.fn()
+ store.onSettingsChanged(listener)
+
+ const updated = store.updateSettings(
+ {
+ terminalScrollbackRows: 75_000,
+ terminalScrollbackBytes: 250_000_000
+ } as never,
+ { notifyListeners: true }
+ )
+
+ expect(updated.terminalScrollbackRows).toBe(50_000)
+ expect(listener).toHaveBeenCalledWith(
+ { terminalScrollbackRows: 50_000 },
+ expect.objectContaining({ terminalScrollbackRows: 50_000 }),
+ undefined
+ )
+
+ store.updateSettings({ terminalScrollbackBytes: 10_000_000 } as never)
+ store.flush()
+ const persisted = readDataFile() as { settings?: Record }
+ expect(persisted.settings?.terminalScrollbackRows).toBe(50_000)
+ expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
+ })
+
it('normalizes disabled TUI agents on load and update', async () => {
writeFileSync(
join(testState.dir, 'orca-data.json'),
@@ -8576,3 +8717,85 @@ describe('Store host-partitioned workspace sessions', () => {
expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull()
})
})
+
+describe('Store native-chat tab viewMode persistence', () => {
+ beforeEach(() => {
+ testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
+ })
+
+ afterEach(() => {
+ rmSync(testState.dir, { recursive: true, force: true })
+ })
+
+ // Why: a tab persisted in 'chat' must restore to 'chat' (R1), and a tab
+ // persisted before the field existed must default to 'terminal' — i.e. the
+ // field is absent on restore — so older sessions stay backward-compatible.
+ it('round-trips viewMode for unified tabs and defaults legacy tabs to terminal', async () => {
+ const WORKTREE = 'repo1::/worktree'
+ writeDataFile({
+ schemaVersion: 1,
+ repos: [makeRepo()],
+ worktreeMeta: {},
+ settings: {},
+ ui: {},
+ githubCache: { pr: {}, issue: {} },
+ workspaceSession: {
+ activeRepoId: 'r1',
+ activeWorktreeId: WORKTREE,
+ activeTabId: 'chat-tab',
+ tabsByWorktree: {},
+ terminalLayoutsByTabId: {},
+ sleepingAgentSessionsByPaneKey: {},
+ unifiedTabs: {
+ [WORKTREE]: [
+ {
+ id: 'chat-tab',
+ entityId: 'chat-tab',
+ groupId: 'g1',
+ worktreeId: WORKTREE,
+ contentType: 'terminal',
+ label: 'Agent',
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 1,
+ viewMode: 'chat'
+ },
+ {
+ // Legacy tab persisted before viewMode existed — no field at all.
+ id: 'legacy-tab',
+ entityId: 'legacy-tab',
+ groupId: 'g1',
+ worktreeId: WORKTREE,
+ contentType: 'terminal',
+ label: 'Legacy',
+ customLabel: null,
+ color: null,
+ sortOrder: 1,
+ createdAt: 2
+ }
+ ]
+ },
+ tabGroups: {
+ [WORKTREE]: [
+ {
+ id: 'g1',
+ worktreeId: WORKTREE,
+ activeTabId: 'chat-tab',
+ tabOrder: ['chat-tab', 'legacy-tab']
+ }
+ ]
+ }
+ }
+ })
+
+ const store = await createStore()
+ const restored = store.getWorkspaceSession().unifiedTabs?.[WORKTREE] ?? []
+ const chatTab = restored.find((tab) => tab.id === 'chat-tab')
+ const legacyTab = restored.find((tab) => tab.id === 'legacy-tab')
+
+ expect(chatTab?.viewMode).toBe('chat')
+ // Missing on a legacy tab; renderer hydration treats absent as 'terminal'.
+ expect(legacyTab?.viewMode).toBeUndefined()
+ })
+})
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index ce475f0487f..99ebeb1f792 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -132,6 +132,10 @@ import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order'
import { normalizeAppIconId } from '../shared/app-icon'
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
+import {
+ legacyTerminalScrollbackBytesToRows,
+ normalizeDesktopTerminalScrollbackRows
+} from '../shared/terminal-scrollback-policy'
import {
compareFeatureInteractionUsageBuckets,
getFeatureInteractionCategory,
@@ -392,6 +396,46 @@ function buildWorkspaceDirHistoryForUpdate(
return next
}
+type LegacyTerminalScrollbackSettings = {
+ terminalScrollbackRows?: unknown
+ terminalScrollbackBytes?: unknown
+}
+
+function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminalScrollbackSettings {
+ return settings && typeof settings === 'object'
+ ? (settings as LegacyTerminalScrollbackSettings)
+ : {}
+}
+
+function stripLegacyTerminalScrollbackBytes(
+ settings: Partial | undefined
+): Partial {
+ const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
+ {}) as Partial & { terminalScrollbackBytes?: unknown }
+ void _legacyScrollbackBytes
+ return rest
+}
+
+function migrateTerminalScrollbackRows(settings: unknown): {
+ rows: number
+ needsSave: boolean
+} {
+ const legacySettings = readLegacyTerminalScrollbackSettings(settings)
+ const hasRows = Object.prototype.hasOwnProperty.call(legacySettings, 'terminalScrollbackRows')
+ const hasLegacyBytes = Object.prototype.hasOwnProperty.call(
+ legacySettings,
+ 'terminalScrollbackBytes'
+ )
+ const rows = hasRows
+ ? normalizeDesktopTerminalScrollbackRows(legacySettings.terminalScrollbackRows)
+ : legacyTerminalScrollbackBytesToRows(legacySettings.terminalScrollbackBytes)
+
+ return {
+ rows,
+ needsSave: !hasRows || hasLegacyBytes || legacySettings.terminalScrollbackRows !== rows
+ }
+}
+
function getWorkspaceLayoutHistoryKey(layout: OrcaWorkspaceLayout): string {
return `${normalizeRuntimePathForComparison(layout.path)}:${layout.nestWorkspaces}`
}
@@ -731,13 +775,27 @@ function normalizeAutomationPrecheckResult(
}
function normalizeAutomationSessionReuse(automation: Automation): Automation {
+ const setupDecision = normalizeAutomationSetupDecisionForWorkspaceMode(
+ automation.workspaceMode,
+ automation.setupDecision
+ )
return {
...automation,
precheck: normalizeAutomationPrecheck(automation.precheck),
+ setupDecision,
reuseSession: automation.workspaceMode === 'existing' && automation.reuseSession === true
}
}
+function normalizeAutomationSetupDecisionForWorkspaceMode(
+ workspaceMode: Automation['workspaceMode'],
+ setupDecision: unknown
+): Automation['setupDecision'] {
+ return workspaceMode === 'new_per_run' && (setupDecision === 'run' || setupDecision === 'skip')
+ ? setupDecision
+ : undefined
+}
+
function getAutomationContextsForRepo(
repo: Repo | undefined,
projectHostSetups: readonly ProjectHostSetup[]
@@ -2522,6 +2580,10 @@ export class Store {
// Merge with defaults in case new fields were added
const homeDir = homedir()
const defaults = getDefaultPersistedState(homeDir)
+ const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings)
+ if (migratedTerminalScrollback.needsSave) {
+ this.loadNeedsSave = true
+ }
const rawSourceControlAi = parsed.settings?.sourceControlAi
const rawSourceControlAiMissing = rawSourceControlAi === undefined
const rawSourceControlAiActionsMissing =
@@ -2744,7 +2806,7 @@ export class Store {
),
settings: {
...defaults.settings,
- ...parsed.settings,
+ ...stripLegacyTerminalScrollbackBytes(parsed.settings),
// Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry
// the old persisted flag forward once so enabled users don't lose it.
experimentalPet:
@@ -2780,6 +2842,7 @@ export class Store {
floatingTerminalCwd: migratedFloatingTerminalCwd,
floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds,
floatingTerminalCwdMigratedToAppWorkspace: true,
+ terminalScrollbackRows: migratedTerminalScrollback.rows,
terminalQuickCommands: normalizeTerminalQuickCommands(
parsed.settings?.terminalQuickCommands
),
@@ -4061,6 +4124,10 @@ export class Store {
workspaceMode: input.workspaceMode,
workspaceId: input.workspaceMode === 'existing' ? (input.workspaceId ?? null) : null,
baseBranch: input.workspaceMode === 'new_per_run' ? (input.baseBranch ?? null) : null,
+ setupDecision: normalizeAutomationSetupDecisionForWorkspaceMode(
+ input.workspaceMode,
+ input.setupDecision
+ ),
reuseSession: input.workspaceMode === 'existing' ? (input.reuseSession ?? false) : false,
timezone: input.timezone,
rrule: input.rrule,
@@ -4128,6 +4195,12 @@ export class Store {
? (updates.baseBranch ?? null)
: (current.baseBranch ?? null)
: null,
+ setupDecision:
+ workspaceMode === 'new_per_run'
+ ? Object.hasOwn(updates, 'setupDecision')
+ ? normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, updates.setupDecision)
+ : normalizeAutomationSetupDecisionForWorkspaceMode(workspaceMode, current.setupDecision)
+ : undefined,
reuseSession:
workspaceMode === 'existing'
? (updates.reuseSession ?? current.reuseSession ?? false)
@@ -4612,7 +4685,7 @@ export class Store {
updates: Partial,
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
): GlobalSettings {
- const sanitizedUpdates = { ...updates }
+ const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates)
// Why: coerce strictly to boolean here (not at the IPC edge) so every write
// path is covered and a non-bool renderer payload can never persist a
// truthy non-bool that later reads as "tray-minimize on".
@@ -4640,6 +4713,11 @@ export class Store {
updates.terminalCustomThemes
)
}
+ if ('terminalScrollbackRows' in updates) {
+ sanitizedUpdates.terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
+ updates.terminalScrollbackRows
+ )
+ }
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
const taskProviderSettings = normalizeTaskProviderSettings({
visibleTaskProviders:
diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts
index 5adfdecf23b..1523f38f02f 100644
--- a/src/main/providers/ssh-pty-provider.test.ts
+++ b/src/main/providers/ssh-pty-provider.test.ts
@@ -67,6 +67,26 @@ describe('SshPtyProvider', () => {
})
})
+ it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-2' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ shellOverride: 'powershell.exe',
+ terminalWindowsWslDistro: 'Ubuntu'
+ })
+
+ expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
+ shellOverride: 'powershell.exe',
+ terminalWindowsWslDistro: 'Ubuntu'
+ })
+ })
+
it('preserves an explicit remote Powerlevel10k wizard env value', async () => {
mux.request.mockResolvedValue({ id: 'pty-2' })
@@ -102,6 +122,28 @@ describe('SshPtyProvider', () => {
})
})
+ it('forwards provider command delivery to the relay', async () => {
+ mux.request.mockResolvedValue({ id: 'pty-provider-command' })
+
+ await provider.spawn({
+ cols: 120,
+ rows: 40,
+ command: 'echo from-runtime',
+ commandDelivery: 'provider',
+ startupCommandDelivery: 'shell-ready'
+ })
+
+ expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
+ cols: 120,
+ rows: 40,
+ cwd: undefined,
+ env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
+ command: 'echo from-runtime',
+ commandDelivery: 'provider',
+ startupCommandDelivery: 'shell-ready'
+ })
+ })
+
it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => {
mux.request.mockResolvedValue({ id: 'pty-bridge' })
provider = new SshPtyProvider('conn-1', mux as never, {
diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts
index 54c2ce5824f..7284f2353ba 100644
--- a/src/main/providers/ssh-pty-provider.ts
+++ b/src/main/providers/ssh-pty-provider.ts
@@ -133,12 +133,14 @@ export class SshPtyProvider implements IPtyProvider {
cwd: opts.cwd,
env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete),
// Why: the relay's plugin-overlay env augmenter needs to know which
- // Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors
- // the right `~/./agent` source dir on the remote disk. The
- // relay does not execute `command` itself — the user types it into
- // the shell — but receiving it as a hint lets overlay resolution be
- // per-launch instead of always-Pi.
+ // Pi-compatible agent is being launched, while commandDelivery tells it
+ // whether to submit the command itself for runtime-owned background PTYs.
...(opts.command ? { command: opts.command } : {}),
+ ...(opts.shellOverride !== undefined ? { shellOverride: opts.shellOverride } : {}),
+ ...(opts.terminalWindowsWslDistro !== undefined
+ ? { terminalWindowsWslDistro: opts.terminalWindowsWslDistro }
+ : {}),
+ ...(opts.commandDelivery ? { commandDelivery: opts.commandDelivery } : {}),
...(opts.startupCommandDelivery
? { startupCommandDelivery: opts.startupCommandDelivery }
: {})
diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts
index 3a3ebdd94a4..6561d120240 100644
--- a/src/main/providers/types.ts
+++ b/src/main/providers/types.ts
@@ -30,6 +30,7 @@ export type PtySpawnOptions = {
env?: Record
envToDelete?: string[]
command?: string
+ commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: StartupCommandDelivery
/** Orca worktree identity. When present, the local provider scopes shell
* history to this worktree so ArrowUp only surfaces local commands. */
diff --git a/src/main/runtime/orca-runtime-automations.test.ts b/src/main/runtime/orca-runtime-automations.test.ts
index 9920e5c12ff..0c6d32d215d 100644
--- a/src/main/runtime/orca-runtime-automations.test.ts
+++ b/src/main/runtime/orca-runtime-automations.test.ts
@@ -82,6 +82,7 @@ describe('OrcaRuntimeService automation methods', () => {
agentId: 'codex',
repo: 'repo-1',
workspaceMode: 'new_per_run',
+ setupDecision: 'skip',
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1
})
@@ -94,7 +95,8 @@ describe('OrcaRuntimeService automation methods', () => {
agentId: 'codex',
projectId: 'repo-1',
workspaceMode: 'new_per_run',
- workspaceId: null
+ workspaceId: null,
+ setupDecision: 'skip'
})
)
expect(automation.id).toBe('auto-1')
@@ -127,6 +129,15 @@ describe('OrcaRuntimeService automation methods', () => {
expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { baseBranch: null })
})
+ it('passes setup decision updates through the shared store', async () => {
+ const store = makeStore([existingAutomation])
+ const runtime = new OrcaRuntimeService(store as never)
+
+ await runtime.updateAutomation('auto-1', { setupDecision: 'run' })
+
+ expect(store.updateAutomation).toHaveBeenCalledWith('auto-1', { setupDecision: 'run' })
+ })
+
it('passes session reuse updates for existing-workspace automations', async () => {
const existing = {
...existingAutomation,
diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts
index 9197c1694ab..9a2f611cfaf 100644
--- a/src/main/runtime/orca-runtime-browser.test.ts
+++ b/src/main/runtime/orca-runtime-browser.test.ts
@@ -505,4 +505,27 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => {
})
expect(closeTab).not.toHaveBeenCalled()
})
+
+ it('forwards an unresolved worktree to the bridge unchanged for keyboard inserttext', async () => {
+ const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
+ // Why: when no --worktree is passed (or cwd is outside a managed worktree),
+ // worktreeId arrives undefined. The bridge — not the runtime — owns the
+ // cross-worktree guard, so verify the undefined scope is threaded through
+ // intact rather than silently widened here.
+ const keyboardInsertText = vi.fn().mockResolvedValue({ inserted: true })
+ const bridge = {
+ getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])),
+ keyboardInsertText
+ } as unknown as AgentBrowserBridge
+ const commands = new RuntimeBrowserCommands(
+ createHost({
+ getAgentBrowserBridge: () => bridge,
+ getAuthoritativeWindow: vi.fn(() => ({ webContents: { send: vi.fn() } }) as never)
+ })
+ )
+
+ await commands.browserKeyboardInsertText({ text: 'hello' })
+
+ expect(keyboardInsertText).toHaveBeenCalledWith('hello', undefined, undefined)
+ })
})
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 3487ea9830c..0c11b668578 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -30,6 +30,7 @@ import {
createSetupRunnerScript,
getEffectiveHooks,
getEffectiveHooksFromConfig,
+ getDefaultTabsLaunch,
hasHooksFile,
loadHooks,
parseOrcaYaml,
@@ -65,6 +66,7 @@ import {
} from '../../shared/constants'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { makePaneKey } from '../../shared/stable-pane-id'
+import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest } from './rpc/core'
@@ -525,6 +527,7 @@ function resetRuntimeTestMocks(): void {
vi.mocked(createSetupRunnerScript).mockReset()
vi.mocked(getEffectiveHooks).mockReset()
vi.mocked(getEffectiveHooksFromConfig).mockReset()
+ vi.mocked(getDefaultTabsLaunch).mockReset()
vi.mocked(loadHooks).mockReset()
vi.mocked(hasHooksFile).mockReset()
vi.mocked(parseOrcaYaml).mockReset()
@@ -533,6 +536,7 @@ function resetRuntimeTestMocks(): void {
vi.mocked(shouldRunSetupForCreate).mockImplementation((_repo, decision) => decision === 'run')
vi.mocked(getEffectiveHooks).mockReturnValue(null)
vi.mocked(getEffectiveHooksFromConfig).mockReturnValue(null)
+ vi.mocked(getDefaultTabsLaunch).mockReturnValue(undefined)
vi.mocked(loadHooks).mockReturnValue(null)
vi.mocked(hasHooksFile).mockReturnValue(false)
vi.mocked(parseOrcaYaml).mockReturnValue(null)
@@ -2908,7 +2912,13 @@ describe('OrcaRuntimeService', () => {
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
- connectionId: 'ssh-1'
+ connectionId: 'ssh-1',
+ hookSettings: {
+ mode: 'auto' as const,
+ setupRunPolicy: 'run-by-default' as const,
+ setupAgentStartupPolicy: 'wait-for-setup' as const,
+ scripts: { setup: '', archive: '' }
+ }
}
const parent = {
path: '/remote/repo-parent',
@@ -3075,7 +3085,13 @@ describe('OrcaRuntimeService', () => {
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
- connectionId: 'ssh-1'
+ connectionId: 'ssh-1',
+ hookSettings: {
+ mode: 'auto' as const,
+ setupRunPolicy: 'run-by-default' as const,
+ setupAgentStartupPolicy: 'wait-for-setup' as const,
+ scripts: { setup: '', archive: '' }
+ }
}
const metaById: Record = {}
const remoteStore = {
@@ -3285,7 +3301,13 @@ describe('OrcaRuntimeService', () => {
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
- connectionId: 'ssh-1'
+ connectionId: 'ssh-1',
+ hookSettings: {
+ mode: 'auto' as const,
+ setupRunPolicy: 'run-by-default' as const,
+ setupAgentStartupPolicy: 'wait-for-setup' as const,
+ scripts: { setup: '', archive: '' }
+ }
}
const metaById: Record = {}
const remoteStore = {
@@ -3372,14 +3394,16 @@ describe('OrcaRuntimeService', () => {
startup: { command: 'claude' }
})
- expect(result.setup).toMatchObject({
- runnerScriptPath: '/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh'
- })
+ // Why: runtime now provisions setup itself (fire-and-forget) and omits it
+ // from the RPC result so the caller does not double-spawn. The async spawn
+ // means we wait for both the agent and setup PTYs before asserting.
+ expect(result.setup).toBeUndefined()
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
expect(spawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cwd: '/remote/mobile-setup',
- command: 'claude',
+ command: expect.stringContaining('exec claude'),
worktreeId: result.worktree.id
})
)
@@ -3387,10 +3411,21 @@ describe('OrcaRuntimeService', () => {
2,
expect.objectContaining({
cwd: '/remote/mobile-setup',
- command: 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh',
+ command: expect.stringContaining(
+ 'bash /remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh'
+ ),
worktreeId: result.worktree.id
})
)
+ const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
+ const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
+ const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
+ expect(nonceMatch?.[1]).toBeTruthy()
+ const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done`
+ expect(setupCommand).toContain('printf')
+ expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
+ expect(startupCommand).toContain(markerPath)
+ expect(setupCommand).toContain(markerPath)
expect(revealTerminalSession).toHaveBeenLastCalledWith(
result.worktree.id,
expect.objectContaining({
@@ -3511,7 +3546,7 @@ describe('OrcaRuntimeService', () => {
setupDecision: 'run'
})
- expect(spawn).toHaveBeenCalledTimes(2)
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
const initialEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {}
expect(setupEnv.ORCA_TAB_ID).toBe(initialEnv.ORCA_TAB_ID)
@@ -6420,6 +6455,7 @@ describe('OrcaRuntimeService', () => {
expect.objectContaining({
cwd: TEST_WORKTREE_PATH,
command: 'codex',
+ commandDelivery: 'provider',
worktreeId: TEST_WORKTREE_ID,
preAllocatedHandle: expect.stringMatching(/^term_/)
})
@@ -6700,6 +6736,49 @@ describe('OrcaRuntimeService', () => {
})
})
+ it('preserves Claude Agent Teams for sequenced Claude launches', async () => {
+ const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
+ const runtimeStore = {
+ ...store,
+ getSettings: () => ({
+ ...store.getSettings(),
+ claudeAgentTeamsMode: 'in-process' as const
+ })
+ }
+ const runtime = new OrcaRuntimeService(runtimeStore)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
+ command:
+ 'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\'',
+ claudeAgentTeamsSourceCommand: 'claude "hello"',
+ launchAgent: 'claude',
+ launchConfig: {
+ agentCommand: 'claude',
+ agentArgs: '',
+ agentEnv: { CLAUDE_PROFILE: 'captured' }
+ }
+ })
+
+ const sequencedClaude = spawn.mock.calls[0]?.[0] as {
+ command?: string
+ env?: Record
+ }
+
+ expect(sequencedClaude.command).toBe(
+ 'bash -lc \'echo Waiting for setup to finish before starting agent... >&2; exec claude "hello"\''
+ )
+ expect(sequencedClaude.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1')
+ expect(sequencedClaude.env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe(
+ 'claude --teammate-mode in-process "hello"'
+ )
+ })
+
it('restores captured native Claude Agent Teams mode with fresh service env', async () => {
setPlatform('linux')
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
@@ -11032,7 +11111,12 @@ describe('OrcaRuntimeService', () => {
title: 'claude agents'
})
)
- expect(result.tabs[0]).not.toHaveProperty('agentStatus')
+ // The stale "working" status is suppressed (no spinner), but agent identity
+ // is retained so native chat can still address the idle agent's transcript.
+ const suppressed = result.tabs[0]
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('suppresses saved mobile agent status when the current terminal title is neutral', async () => {
@@ -11099,7 +11183,11 @@ describe('OrcaRuntimeService', () => {
title: 'bash'
})
)
- expect(result.tabs[0]).not.toHaveProperty('agentStatus')
+ // Stale "working" suppressed; agent identity retained for native chat.
+ const suppressed = result.tabs[0]
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('suppresses saved mobile agent status when fresh live OSC title is Claude agents', async () => {
@@ -11172,7 +11260,11 @@ describe('OrcaRuntimeService', () => {
title: 'claude agents'
})
)
- expect(result.tabs[0]).not.toHaveProperty('agentStatus')
+ // Stale "working" suppressed; agent identity retained for native chat.
+ const suppressed = result.tabs[0]
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('keeps saved PTY bindings pending until the runtime knows the PTY is connected', async () => {
@@ -12714,7 +12806,11 @@ describe('OrcaRuntimeService', () => {
title: 'claude agents'
})
)
- expect(result.tabs[0]).not.toHaveProperty('agentStatus')
+ // Stale "working" suppressed; agent identity retained for native chat.
+ const suppressed = result.tabs[0]
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('uses fresh neutral PTY titles over stale mobile snapshot and OSC titles', async () => {
@@ -12785,7 +12881,11 @@ describe('OrcaRuntimeService', () => {
title: 'zsh'
})
)
- expect(result.tabs[0]).not.toHaveProperty('agentStatus')
+ // Stale "working" suppressed; agent identity retained for native chat.
+ const suppressed = result.tabs[0]
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
+ expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => {
@@ -13312,6 +13412,36 @@ describe('OrcaRuntimeService', () => {
expect(clearedSurface?.type === 'browser' && clearedSurface.isPinned).toBe(false)
})
+ it('persists headless tab viewMode and surfaces it through a cold rehydrate', async () => {
+ const session = makeWorkspaceSessionWithHeadlessTerminal()
+ const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
+ const runtime = new OrcaRuntimeService(runtimeStore as never)
+
+ await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, {
+ tabId: 'host-tab',
+ viewMode: 'chat'
+ })
+
+ const persisted = getSession().tabsByWorktree[TEST_WORKTREE_ID]!.find(
+ (tab) => tab.id === 'host-tab'
+ )!
+ expect(persisted.viewMode).toBe('chat')
+
+ const live = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
+ const liveSurface = live.tabs.find(
+ (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab'
+ )
+ expect(liveSurface?.type === 'terminal' && liveSurface.viewMode).toBe('chat')
+
+ runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
+ runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID)
+ const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
+ const surface = rehydrated.tabs.find(
+ (tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab'
+ )
+ expect(surface?.type === 'terminal' && surface.viewMode).toBe('chat')
+ })
+
it('still persists tab props in serve mode after syncWindowGraph(0) (gate does not fire)', async () => {
// Why: the renderer-authoritative gate uses getAvailableAuthoritativeWindow,
// and serve startup calls syncWindowGraph(0,...) which sets authoritativeWindowId=0.
@@ -18700,16 +18830,11 @@ describe('OrcaRuntimeService', () => {
repoId: 'repo-1',
path: '/tmp/workspaces/runtime-hook-skip',
branch: 'runtime-hook-skip'
- }),
- setup: {
- runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
- envVars: {
- ORCA_ROOT_PATH: '/tmp/repo',
- ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-hook-skip'
- }
- }
+ })
})
+ expect(result.setup).toBeUndefined()
expect(activateWorktree).not.toHaveBeenCalled()
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
expect(spawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
@@ -18749,6 +18874,166 @@ describe('OrcaRuntimeService', () => {
})
})
+ it('sequences setup before startup for opted-in local headless worktree creates', async () => {
+ const waitRepo = {
+ ...store.getRepo('repo-1')!,
+ hookSettings: {
+ mode: 'auto' as const,
+ setupRunPolicy: 'run-by-default' as const,
+ setupAgentStartupPolicy: 'wait-for-setup' as const,
+ scripts: { setup: '', archive: '' }
+ }
+ }
+ const runtimeStore = {
+ ...store,
+ getRepos: () => [waitRepo],
+ getRepo: (id: string) => (id === 'repo-1' ? waitRepo : undefined)
+ }
+ const runtime = new OrcaRuntimeService(runtimeStore as never)
+ const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' })
+ const spawn = vi
+ .fn()
+ .mockResolvedValueOnce({ id: 'pty-headless-startup' })
+ .mockResolvedValueOnce({ id: 'pty-headless-setup' })
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+ runtime.setNotifier({
+ worktreesChanged: vi.fn(),
+ reposChanged: vi.fn(),
+ activateWorktree: vi.fn(),
+ createTerminal: vi.fn(),
+ revealTerminalSession,
+ splitTerminal: vi.fn(),
+ renameTerminal: vi.fn(),
+ focusTerminal: vi.fn(),
+ closeTerminal: vi.fn(),
+ sleepWorktree: vi.fn(),
+ terminalFitOverrideChanged: vi.fn(),
+ terminalDriverChanged: vi.fn()
+ })
+
+ computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup')
+ ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-startup-setup')
+ vi.mocked(getEffectiveHooks).mockReturnValue({
+ scripts: {
+ setup: 'pnpm worktree:setup'
+ }
+ })
+ vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
+ vi.mocked(createSetupRunnerScript).mockReturnValue({
+ runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
+ envVars: {
+ ORCA_ROOT_PATH: '/tmp/repo',
+ ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup'
+ },
+ waitForAgentStartup: true
+ })
+ vi.mocked(listWorktrees).mockResolvedValue([
+ {
+ path: '/tmp/workspaces/runtime-headless-startup-setup',
+ head: 'def',
+ branch: 'runtime-headless-startup-setup',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+
+ const result = await runtime.createManagedWorktree({
+ repoSelector: 'id:repo-1',
+ name: 'runtime-headless-startup-setup',
+ setupDecision: 'run',
+ startup: { command: 'claude' }
+ })
+
+ expect(createSetupRunnerScript).toHaveBeenCalled()
+ expect(runHook).not.toHaveBeenCalled()
+ // Why: runtime now provisions setup fire-and-forget, so the setup PTY spawns
+ // on a later tick. The wait-for-setup guarantee is enforced by the shell
+ // nonce/marker in the wrapped commands below, not by JS spawn ordering.
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
+ const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
+ const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
+ const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
+ expect(nonceMatch?.[1]).toBeTruthy()
+ expect(startupCommand).toContain('exec claude')
+ expect(setupCommand).toContain('printf')
+ expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
+ expect(result.setup).toBeUndefined()
+ })
+
+ it('starts setup and startup side by side by default for local headless worktree creates', async () => {
+ const runtime = new OrcaRuntimeService(store)
+ const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-parallel' })
+ const spawn = vi
+ .fn()
+ .mockResolvedValueOnce({ id: 'pty-headless-parallel-startup' })
+ .mockResolvedValueOnce({ id: 'pty-headless-parallel-setup' })
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+ runtime.setNotifier({
+ worktreesChanged: vi.fn(),
+ reposChanged: vi.fn(),
+ activateWorktree: vi.fn(),
+ createTerminal: vi.fn(),
+ revealTerminalSession,
+ splitTerminal: vi.fn(),
+ renameTerminal: vi.fn(),
+ focusTerminal: vi.fn(),
+ closeTerminal: vi.fn(),
+ sleepWorktree: vi.fn(),
+ terminalFitOverrideChanged: vi.fn(),
+ terminalDriverChanged: vi.fn()
+ })
+
+ computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel')
+ ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-headless-parallel')
+ vi.mocked(getEffectiveHooks).mockReturnValue({
+ scripts: {
+ setup: 'pnpm worktree:setup'
+ }
+ })
+ vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
+ vi.mocked(createSetupRunnerScript).mockReturnValue({
+ runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
+ envVars: {
+ ORCA_ROOT_PATH: '/tmp/repo',
+ ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-parallel'
+ }
+ })
+ vi.mocked(listWorktrees).mockResolvedValue([
+ {
+ path: '/tmp/workspaces/runtime-headless-parallel',
+ head: 'def',
+ branch: 'runtime-headless-parallel',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+
+ await runtime.createManagedWorktree({
+ repoSelector: 'id:repo-1',
+ name: 'runtime-headless-parallel',
+ setupDecision: 'run',
+ startup: { command: 'claude' }
+ })
+
+ // Why: setup now spawns fire-and-forget on a later tick; wait for both PTYs.
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
+ expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' }))
+ expect(spawn).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ command: 'bash /tmp/repo/.git/orca/setup-runner.sh' })
+ )
+ })
+
it('creates the first terminal for CLI-created worktrees without activating them', async () => {
const runtime = new OrcaRuntimeService(store)
const activateWorktree = vi.fn()
@@ -18886,7 +19171,7 @@ describe('OrcaRuntimeService', () => {
})
expect(activateWorktree).not.toHaveBeenCalled()
- expect(spawn).toHaveBeenCalledTimes(2)
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {}
expectStablePaneKeyEnv(mainEnv)
@@ -18966,6 +19251,94 @@ describe('OrcaRuntimeService', () => {
expect(spawn).toHaveBeenCalledTimes(1)
})
+ it('materializes default tabs for inactive local managed worktree creates', async () => {
+ const metaById: Record = {}
+ const runtimeStore = {
+ ...store,
+ getAllWorktreeMeta: () => metaById,
+ getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
+ setWorktreeMeta: (worktreeId: string, meta: Partial) => {
+ metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
+ return metaById[worktreeId]
+ }
+ }
+ const runtime = new OrcaRuntimeService(runtimeStore as never)
+ const spawn = vi
+ .fn()
+ .mockResolvedValueOnce({ id: 'pty-default-dev' })
+ .mockResolvedValueOnce({ id: 'pty-default-test' })
+ const revealTerminalSession = vi
+ .fn()
+ .mockResolvedValueOnce({ tabId: 'tab-default-dev' })
+ .mockResolvedValueOnce({ tabId: 'tab-default-test' })
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+ runtime.setNotifier({
+ worktreesChanged: vi.fn(),
+ reposChanged: vi.fn(),
+ activateWorktree: vi.fn(),
+ createTerminal: vi.fn(),
+ revealTerminalSession,
+ splitTerminal: vi.fn(),
+ renameTerminal: vi.fn(),
+ focusTerminal: vi.fn(),
+ closeTerminal: vi.fn(),
+ sleepWorktree: vi.fn(),
+ terminalFitOverrideChanged: vi.fn(),
+ terminalDriverChanged: vi.fn()
+ })
+
+ computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-default-tabs')
+ ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-default-tabs')
+ vi.mocked(getDefaultTabsLaunch).mockReturnValue({
+ runCommands: true,
+ tabs: [
+ { title: 'Dev', command: 'pnpm dev' },
+ { title: 'Test', command: 'pnpm test' }
+ ]
+ })
+ vi.mocked(listWorktrees).mockResolvedValue([
+ {
+ path: '/tmp/workspaces/runtime-default-tabs',
+ head: 'def',
+ branch: 'runtime-default-tabs',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+
+ const result = await runtime.createManagedWorktree({
+ repoSelector: 'id:repo-1',
+ name: 'runtime-default-tabs',
+ setupDecision: 'run'
+ })
+
+ expect(result.defaultTabs).toEqual({
+ runCommands: true,
+ tabs: [
+ { title: 'Dev', command: 'pnpm dev' },
+ { title: 'Test', command: 'pnpm test' }
+ ]
+ })
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
+ expect(spawn.mock.calls[0]![0]).toMatchObject({ command: 'pnpm dev' })
+ expect(spawn.mock.calls[1]![0]).toMatchObject({ command: 'pnpm test' })
+ expect(revealTerminalSession).toHaveBeenNthCalledWith(
+ 1,
+ result.worktree.id,
+ expect.objectContaining({ title: 'Dev', activate: false })
+ )
+ expect(revealTerminalSession).toHaveBeenNthCalledWith(
+ 2,
+ result.worktree.id,
+ expect.objectContaining({ title: 'Test', activate: false })
+ )
+ })
+
it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => {
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude'])
const metaById: Record = {}
@@ -19356,7 +19729,7 @@ describe('OrcaRuntimeService', () => {
expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' })
})
- it('honors split setup placement for local startup-draft worktrees', async () => {
+ it('honors split setup placement for opted-in local startup-draft worktrees', async () => {
const metaById: Record = {}
const runtimeStore = {
...store,
@@ -19413,7 +19786,8 @@ describe('OrcaRuntimeService', () => {
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split'
- }
+ },
+ waitForAgentStartup: true
})
vi.mocked(listWorktrees).mockResolvedValue([
{
@@ -19433,12 +19807,12 @@ describe('OrcaRuntimeService', () => {
activate: true
})
- expect(spawn).toHaveBeenCalledTimes(2)
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
expect(spawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
cwd: '/tmp/workspaces/runtime-startup-setup-split',
- command: "codex '--dangerously-bypass-approvals-and-sandbox'",
+ command: expect.stringContaining('codex'),
worktreeId: result.worktree.id
})
)
@@ -19446,7 +19820,7 @@ describe('OrcaRuntimeService', () => {
2,
expect.objectContaining({
cwd: '/tmp/workspaces/runtime-startup-setup-split',
- command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
+ command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'),
env: expect.objectContaining({
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split',
@@ -19455,8 +19829,19 @@ describe('OrcaRuntimeService', () => {
worktreeId: result.worktree.id
})
)
+ const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command
+ const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
+ const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
+ expect(nonceMatch?.[1]).toBeTruthy()
+ const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done`
+ expect(startupCommand).toContain('--dangerously-bypass-approvals-and-sandbox')
+ expect(setupCommand).toContain('printf')
+ expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
+ expect(startupCommand).toContain(markerPath)
+ expect(setupCommand).toContain(markerPath)
const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {}
+ expect(result.setup).toBeUndefined()
expect(mainEnv.ORCA_TAB_ID).toBeDefined()
expect(mainEnv.ORCA_PANE_KEY).toBeDefined()
expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID)
@@ -19473,6 +19858,84 @@ describe('OrcaRuntimeService', () => {
)
})
+ it('passes the wrapped setup command to activation when startup spawned but setup did not', async () => {
+ const runtime = new OrcaRuntimeService(store)
+ const spawn = vi
+ .fn()
+ .mockResolvedValueOnce({ id: 'pty-startup-main' })
+ .mockRejectedValueOnce(new Error('setup spawn failed'))
+ const activateWorktree = vi.fn()
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+ runtime.setNotifier({
+ worktreesChanged: vi.fn(),
+ reposChanged: vi.fn(),
+ activateWorktree,
+ createTerminal: vi.fn(),
+ revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-startup-main' }),
+ splitTerminal: vi.fn(),
+ renameTerminal: vi.fn(),
+ focusTerminal: vi.fn(),
+ closeTerminal: vi.fn(),
+ sleepWorktree: vi.fn(),
+ terminalFitOverrideChanged: vi.fn(),
+ terminalDriverChanged: vi.fn()
+ })
+ runtime.attachWindow(1)
+
+ computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry')
+ ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup-retry')
+ vi.mocked(getEffectiveHooks).mockReturnValue({
+ scripts: {
+ setup: 'pnpm worktree:setup'
+ }
+ })
+ vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
+ vi.mocked(createSetupRunnerScript).mockReturnValue({
+ runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
+ envVars: {
+ ORCA_ROOT_PATH: '/tmp/repo',
+ ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry'
+ },
+ waitForAgentStartup: true
+ })
+ vi.mocked(listWorktrees).mockResolvedValue([
+ {
+ path: '/tmp/workspaces/runtime-startup-setup-retry',
+ head: 'def',
+ branch: 'runtime-startup-setup-retry',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+
+ await runtime.createManagedWorktree({
+ repoSelector: 'id:repo-1',
+ name: 'runtime-startup-setup-retry',
+ setupDecision: 'run',
+ activate: true,
+ startup: { command: 'claude' }
+ })
+
+ expect(spawn).toHaveBeenCalledTimes(2)
+ expect(activateWorktree).toHaveBeenCalledWith(
+ 'repo-1',
+ expect.any(String),
+ expect.objectContaining({
+ runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
+ command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh')
+ }),
+ undefined,
+ undefined
+ )
+ const activationSetup = activateWorktree.mock.calls[0]?.[2] as { command?: string } | undefined
+ expect(activationSetup?.command).toContain('printf')
+ })
+
it('lets explicit startup draft agents override the desktop default', async () => {
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
const metaById: Record = {}
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 94716e89400..c5fc22bf8ce 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -154,13 +154,21 @@ import { parsePtySessionId } from '../../shared/pty-session-id-format'
import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits'
import { isFolderRepo } from '../../shared/repo-kind'
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
-import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
+import {
+ buildSetupRunnerCommand,
+ getSetupRunnerCommandPlatformForPath
+} from '../../shared/setup-runner-command'
+import {
+ createSequencedSetupAgentCommands,
+ SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
+} from '../../shared/setup-agent-sequencing'
import { TASK_PROVIDERS } from '../../shared/task-providers'
import { FIRST_PANE_ID } from '../../shared/pane-key'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
+import { repoIsRemote } from '../../shared/agent-launch-remote'
import {
isAgentForegroundWrapperProcess,
isExpectedAgentProcess,
@@ -991,6 +999,7 @@ type RuntimePtyController = {
rows: number
cwd?: string
command?: string
+ commandDelivery?: 'renderer' | 'provider'
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
env?: Record
envToDelete?: string[]
@@ -2297,6 +2306,7 @@ export class OrcaRuntimeService {
workspaceMode: target.workspaceMode,
workspaceId: target.workspaceId,
baseBranch: input.baseBranch,
+ setupDecision: input.setupDecision,
reuseSession: input.reuseSession,
timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
rrule: input.rrule,
@@ -2333,6 +2343,9 @@ export class OrcaRuntimeService {
if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) {
patch.baseBranch = updates.baseBranch
}
+ if (hasRuntimeAutomationUpdateValue(updates, 'setupDecision')) {
+ patch.setupDecision = updates.setupDecision
+ }
if (hasRuntimeAutomationUpdateValue(updates, 'reuseSession')) {
patch.reuseSession = updates.reuseSession
}
@@ -3281,6 +3294,7 @@ export class OrcaRuntimeService {
...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}),
...(tab.color != null ? { color: tab.color } : {}),
...(tab.isPinned ? { isPinned: true } : {}),
+ ...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout)
}
})
@@ -4196,7 +4210,12 @@ export class OrcaRuntimeService {
// was never persisted. Persist to the workspace session + live snapshot.
async setMobileSessionTabProps(
worktreeSelector: string,
- args: { tabId: string; color?: string | null; isPinned?: boolean }
+ args: {
+ tabId: string
+ color?: string | null
+ isPinned?: boolean
+ viewMode?: 'terminal' | 'chat'
+ }
): Promise<{ updated: true }> {
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
@@ -4218,7 +4237,7 @@ export class OrcaRuntimeService {
private persistHeadlessSessionTabProps(
worktreeId: string,
tabId: string,
- props: { color?: string | null; isPinned?: boolean }
+ props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' }
): void {
const session = this.store?.getWorkspaceSession?.()
if (!session || !this.store?.setWorkspaceSession) {
@@ -4236,7 +4255,8 @@ export class OrcaRuntimeService {
? {
...tab,
...(props.color !== undefined ? { color: props.color } : {}),
- ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {})
+ ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}),
+ ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {})
}
: tab
)
@@ -4269,7 +4289,7 @@ export class OrcaRuntimeService {
private applyHeadlessSessionTabPropsToSnapshot(
worktreeId: string,
tabId: string,
- props: { color?: string | null; isPinned?: boolean }
+ props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' }
): void {
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (!snapshot) {
@@ -4284,7 +4304,8 @@ export class OrcaRuntimeService {
return {
...tab,
...(props.color !== undefined ? { color: props.color } : {}),
- ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {})
+ ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}),
+ ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {})
}
})
if (!changed) {
@@ -11689,13 +11710,15 @@ export class OrcaRuntimeService {
// Why: a mobile client can run on Windows while the workspace shell is
// Linux over SSH. Startup command quoting must target the shell that runs it.
const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo)
+ const isRemote = repoIsRemote(repo)
const draftLaunchPlan = buildAgentDraftLaunchPlan({
agent,
draft: content,
cmdOverrides: settings.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
- platform: agentLaunchPlatform
+ platform: agentLaunchPlatform,
+ isRemote
})
if (draftLaunchPlan) {
return {
@@ -11718,6 +11741,7 @@ export class OrcaRuntimeService {
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
platform: agentLaunchPlatform,
+ isRemote,
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
@@ -11759,6 +11783,7 @@ export class OrcaRuntimeService {
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
platform: agentLaunchPlatform,
+ isRemote: repoIsRemote(repo),
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
@@ -11929,6 +11954,102 @@ export class OrcaRuntimeService {
})
}
+ private async createDefaultTabTerminals(
+ worktreeSelector: string,
+ worktreeId: string,
+ defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined
+ ): Promise {
+ if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) {
+ return []
+ }
+ const handles: string[] = []
+ for (const template of defaultTabs.tabs) {
+ try {
+ const command = template.command?.trim()
+ const terminal = await this.createTerminal(worktreeSelector, {
+ ...(template.title ? { title: template.title } : {}),
+ ...(command && defaultTabs.runCommands ? { command } : {})
+ })
+ handles.push(terminal.handle)
+ if (template.color && terminal.tabId) {
+ await this.setMobileSessionTabProps(`id:${worktreeId}`, {
+ tabId: terminal.tabId,
+ color: template.color
+ })
+ }
+ } catch (error) {
+ console.warn(`[worktree-create] Failed to create default tab for ${worktreeId}:`, error)
+ }
+ }
+ return handles
+ }
+
+ private async provisionManagedWorktreeTerminals(args: {
+ worktreeSelector: string
+ worktreeId: string
+ worktreePath: string
+ setup?: CreateWorktreeResult['setup']
+ defaultTabs?: CreateWorktreeResult['defaultTabs']
+ primaryTerminalHandle?: string | null
+ hasStartupTerminal: boolean
+ setupCommandPlatform: 'windows' | 'posix'
+ // Why: when the agent startup is sequenced to wait for setup
+ // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds
+ // the setup command. Pass that wrapped command through so the Setup tab runs
+ // the same script the agent is waiting on instead of a bare runner.
+ wrappedSetupCommand?: string
+ }): Promise<{ setupSpawned: boolean }> {
+ if (!this.ptyController?.spawn) {
+ return { setupSpawned: false }
+ }
+ let setupSpawned = false
+ try {
+ const defaultTabHandles = await this.createDefaultTabTerminals(
+ args.worktreeSelector,
+ args.worktreeId,
+ args.defaultTabs
+ )
+ let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null
+ const setupLaunchMode =
+ (
+ this.requireStore().getSettings() as Partial<
+ Pick
+ >
+ ).setupScriptLaunchMode ?? 'new-tab'
+ if (!args.hasStartupTerminal && !primaryTerminalHandle) {
+ const terminal = await this.createTerminal(args.worktreeSelector)
+ primaryTerminalHandle = terminal.handle
+ }
+ if (args.setup) {
+ const setupCommand =
+ args.wrappedSetupCommand ??
+ buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform)
+ const shouldSplitSetup =
+ primaryTerminalHandle &&
+ (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal')
+ await (shouldSplitSetup
+ ? this.splitTerminal(primaryTerminalHandle!, {
+ direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
+ command: setupCommand,
+ env: args.setup.envVars,
+ activate: false
+ })
+ : this.createTerminal(args.worktreeSelector, {
+ title: 'Setup',
+ command: setupCommand,
+ env: args.setup.envVars
+ }))
+ setupSpawned = true
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ console.warn(
+ `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}`
+ )
+ }
+ return { setupSpawned }
+ }
+
private async waitForStartupFollowupReady(
handle: string,
expectedProcess: string
@@ -12717,12 +12838,11 @@ export class OrcaRuntimeService {
}
const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision)
if (shouldRunSetup && hooks?.scripts.setup) {
- if (this.authoritativeWindowId !== null) {
+ const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup)
+ if (shouldUseSetupRunner) {
try {
- // Why: CLI-created worktrees must use the same runner-script path as the
- // renderer create flow so repo-committed `orca.yaml` setup hooks run in
- // the visible first terminal instead of a hidden background shell with
- // different failure and prompt behavior.
+ // Why: setup+startup must share the terminal runner path even without
+ // a renderer window, so the startup shell can wait on setup completion.
setup = createSetupRunnerScript(
repo,
worktreePath,
@@ -12765,12 +12885,39 @@ export class OrcaRuntimeService {
this.notifyWorktreesChanged(repo.id)
const shouldActivate = args.activate === true || args.runHooks === true
let didSpawnStartup = false
+ // Why: tracks whether runtime itself launched the setup script (via
+ // provisionManagedWorktreeTerminals). When true, renderer activation and the
+ // RPC return value must omit setup so the client does not spawn it a second
+ // time. Mirrors the wait-for-agent setup contract from #6298.
let didSpawnSetup = false
let startupTerminalHandle: string | null = null
let startupTerminalTabId: string | null = null
let startupTerminalPaneKey: string | null = null
let startupTerminalPtyId: string | null = null
- if (effectiveStartup && this.ptyController?.spawn) {
+
+ let sequencedStartup = effectiveStartup
+ let wrappedSetupCommandStr: string | undefined
+ if (effectiveStartup && setup?.waitForAgentStartup === true) {
+ const platform = getSetupRunnerCommandPlatformForPath(
+ setup.runnerScriptPath,
+ process.platform === 'win32' ? 'windows' : 'posix'
+ )
+ const sequenced = createSequencedSetupAgentCommands({
+ runnerScriptPath: setup.runnerScriptPath,
+ startupCommand: effectiveStartup.command,
+ platform
+ })
+ sequencedStartup = {
+ ...effectiveStartup,
+ command: sequenced.startupCommand,
+ ...(sequenced.startupEnv
+ ? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } }
+ : {})
+ }
+ wrappedSetupCommandStr = sequenced.setupCommand
+ }
+
+ if (sequencedStartup && this.ptyController?.spawn) {
try {
// Why: automation startup must not depend on a renderer TerminalPane
// mounting. Runtime-spawned PTYs run immediately and the UI adopts the
@@ -12780,12 +12927,15 @@ export class OrcaRuntimeService {
this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath)
}
const terminal = await this.createTerminal(`id:${worktree.id}`, {
- command: effectiveStartup.command,
- env: effectiveStartup.env,
- ...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}),
+ command: sequencedStartup.command,
+ ...(setup && effectiveStartup
+ ? { claudeAgentTeamsSourceCommand: effectiveStartup.command }
+ : {}),
+ env: sequencedStartup.env,
+ ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
- startupCommandDelivery: effectiveStartup.startupCommandDelivery,
- telemetry: effectiveStartup.telemetry
+ startupCommandDelivery: sequencedStartup.startupCommandDelivery,
+ telemetry: sequencedStartup.telemetry
})
if (effectiveDraftPaste) {
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
@@ -12806,92 +12956,93 @@ export class OrcaRuntimeService {
console.warn(`[worktree-create] ${warning}`)
}
}
- if (didSpawnStartup && setup && this.ptyController?.spawn) {
- try {
- // Why: reveal-on-adopt can create the startup tab before renderer
- // activation handles setup. Honor the same split-vs-tab setting here
- // because renderer activation will skip setup once the startup tab exists.
- const setupCommand = buildSetupRunnerCommand(
- setup.runnerScriptPath,
- process.platform === 'win32' ? 'windows' : 'posix'
- )
- const setupLaunchMode =
- (this.store.getSettings() as Partial>)
- .setupScriptLaunchMode ?? 'new-tab'
- if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') {
- if (!startupTerminalHandle) {
- throw new Error('startup_terminal_missing')
- }
- await this.splitTerminal(startupTerminalHandle, {
- direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
- command: setupCommand,
- env: setup.envVars,
- activate: false
- })
- } else {
- await this.createTerminal(`id:${worktree.id}`, {
- title: 'Setup',
- command: setupCommand,
- env: setup.envVars
- })
- }
- didSpawnSetup = true
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- warning = warning
- ? `${warning} Also failed to create the setup terminal for ${worktreePath}: ${message}`
- : `Failed to create the setup terminal for ${worktreePath}: ${message}`
- console.warn(`[worktree-create] ${warning}`)
- }
- }
if (shouldActivate) {
// Why: plain CLI creates should not steal the user's current workspace.
// Explicit activation and hook-running still use renderer activation so
// the user can watch prompts/output in a visible pane.
- const activationSetup = didSpawnSetup ? undefined : setup
+ const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs)
+ if (runtimeWillProvisionTerminals) {
+ // Why: once runtime spawned the startup PTY, renderer activation may see
+ // an existing terminal and skip setup/default tabs. Await provisioning so
+ // a failed setup spawn falls back to renderer activation (which still
+ // carries the wrapped command for retry); #6298's wait-for-setup
+ // guarantee is enforced by the shell marker, not by spawn timing.
+ const provisioned = await this.provisionManagedWorktreeTerminals({
+ worktreeSelector: `id:${worktree.id}`,
+ worktreeId: worktree.id,
+ worktreePath,
+ ...(setup ? { setup } : {}),
+ ...(defaultTabs ? { defaultTabs } : {}),
+ primaryTerminalHandle: startupTerminalHandle,
+ hasStartupTerminal: didSpawnStartup,
+ setupCommandPlatform: setup
+ ? isWindowsAbsolutePathLike(setup.runnerScriptPath)
+ ? 'windows'
+ : 'posix'
+ : 'posix',
+ // Why: carry the wait-for-agent wrapped setup command (#6298) so the
+ // Setup tab runs the same script the sequenced agent waits on.
+ ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
+ })
+ didSpawnSetup = provisioned.setupSpawned
+ }
+ // Why: when runtime spawned setup, omit it from activation. When setup
+ // spawn failed, fall through with the wrapped command so renderer
+ // activation retries it.
+ const activationSetup = didSpawnSetup
+ ? undefined
+ : setup
+ ? {
+ ...setup,
+ ...(didSpawnStartup && wrappedSetupCommandStr
+ ? { command: wrappedSetupCommandStr }
+ : {})
+ }
+ : undefined
+ const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs
if (effectiveStartup && !didSpawnStartup) {
this.notifyActivateWorktree(
repo.id,
worktree.id,
activationSetup,
effectiveStartup,
- defaultTabs
+ activationDefaultTabs
)
} else {
- this.notifyActivateWorktree(repo.id, worktree.id, activationSetup, undefined, defaultTabs)
+ this.notifyActivateWorktree(
+ repo.id,
+ worktree.id,
+ activationSetup,
+ undefined,
+ activationDefaultTabs
+ )
+ }
+ } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) {
+ // Why: inactive terminal materialization matches normal worktree creation,
+ // but setup/default tab failures must not gate automation dispatch.
+ void this.provisionManagedWorktreeTerminals({
+ worktreeSelector: `id:${worktree.id}`,
+ worktreeId: worktree.id,
+ worktreePath,
+ ...(setup ? { setup } : {}),
+ ...(defaultTabs ? { defaultTabs } : {}),
+ primaryTerminalHandle: startupTerminalHandle,
+ hasStartupTerminal: didSpawnStartup,
+ setupCommandPlatform: setup
+ ? isWindowsAbsolutePathLike(setup.runnerScriptPath)
+ ? 'windows'
+ : 'posix'
+ : 'posix',
+ ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
+ })
+ // Why: runtime owns setup spawning here, so the RPC result must omit setup
+ // to keep the headless/mobile caller from launching it a second time.
+ if (setup) {
+ didSpawnSetup = true
}
} else if (this.ptyController?.spawn) {
try {
- let initialTerminalHandle: string | null = null
- if (!didSpawnStartup) {
- const terminal = await this.createTerminal(`id:${worktree.id}`)
- initialTerminalHandle = terminal.handle
- }
- if (setup && !didSpawnSetup) {
- const setupCommand = buildSetupRunnerCommand(
- setup.runnerScriptPath,
- process.platform === 'win32' ? 'windows' : 'posix'
- )
- const setupLaunchMode =
- (this.store.getSettings() as Partial>)
- .setupScriptLaunchMode ?? 'new-tab'
- const shouldSplitSetup =
- initialTerminalHandle &&
- (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal')
- await (shouldSplitSetup
- ? this.splitTerminal(initialTerminalHandle!, {
- direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
- command: setupCommand,
- env: setup.envVars,
- activate: false
- })
- : this.createTerminal(`id:${worktree.id}`, {
- title: 'Setup',
- command: setupCommand,
- env: setup.envVars
- }))
- didSpawnSetup = true
- }
+ await this.createTerminal(`id:${worktree.id}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = warning
@@ -12900,6 +13051,16 @@ export class OrcaRuntimeService {
console.warn(`[worktree-create] ${warning}`)
}
}
+ const returnedSetup = didSpawnSetup
+ ? undefined
+ : setup
+ ? {
+ ...setup,
+ ...(didSpawnStartup && wrappedSetupCommandStr
+ ? { command: wrappedSetupCommandStr }
+ : {})
+ }
+ : undefined
return {
worktree: {
...worktree,
@@ -12910,7 +13071,7 @@ export class OrcaRuntimeService {
git: created
},
...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}),
- ...(setup ? { setup } : {}),
+ ...(returnedSetup ? { setup: returnedSetup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(warning ? { warning } : {}),
...(addResult.localBaseRefRefresh
@@ -13031,12 +13192,32 @@ export class OrcaRuntimeService {
let warning = result.warning
let didSpawnStartup = false
+ // Why: same no-double-spawn contract as the local path — once runtime
+ // provisions setup, omit it from activation and the RPC result.
let didSpawnSetup = false
let startupTerminalHandle: string | null = null
let startupTerminalTabId: string | null = null
let startupTerminalPaneKey: string | null = null
let startupTerminalPtyId: string | null = null
- if (args.startup && this.ptyController?.spawn) {
+
+ let sequencedStartup = args.startup
+ let wrappedSetupCommandStr: string | undefined
+ if (args.startup && result.setup?.waitForAgentStartup === true) {
+ const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix')
+ const sequenced = createSequencedSetupAgentCommands({
+ runnerScriptPath: result.setup.runnerScriptPath,
+ startupCommand: args.startup.command,
+ platform
+ })
+ sequencedStartup = {
+ ...args.startup,
+ command: sequenced.startupCommand,
+ ...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {})
+ }
+ wrappedSetupCommandStr = sequenced.setupCommand
+ }
+
+ if (sequencedStartup && this.ptyController?.spawn) {
try {
const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent
if (startupTrustAgent) {
@@ -13047,12 +13228,15 @@ export class OrcaRuntimeService {
)
}
const terminal = await this.createTerminal(`path:${result.worktree.path}`, {
- command: args.startup.command,
- env: args.startup.env,
- ...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}),
+ command: sequencedStartup.command,
+ ...(result.setup && args.startup
+ ? { claudeAgentTeamsSourceCommand: args.startup.command }
+ : {}),
+ env: sequencedStartup.env,
+ ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
- startupCommandDelivery: args.startup.startupCommandDelivery,
- telemetry: args.startup.telemetry
+ startupCommandDelivery: sequencedStartup.startupCommandDelivery,
+ telemetry: sequencedStartup.telemetry
})
if (args.startupDraftPaste) {
this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste)
@@ -13073,54 +13257,53 @@ export class OrcaRuntimeService {
}
}
- if (didSpawnStartup && result.setup && this.ptyController?.spawn) {
- try {
- // Why: remote/mobile task creates spawn the agent terminal in runtime,
- // so renderer activation never receives the setup payload. Runtime
- // must apply the same user-selected split-vs-tab setup placement.
- const setupCommand = buildSetupRunnerCommand(
- result.setup.runnerScriptPath,
- isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix'
- )
- const setupLaunchMode =
- (this.store.getSettings() as Partial>)
- .setupScriptLaunchMode ?? 'new-tab'
- if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') {
- if (!startupTerminalHandle) {
- throw new Error('startup_terminal_missing')
- }
- await this.splitTerminal(startupTerminalHandle, {
- direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
- command: setupCommand,
- env: result.setup.envVars,
- activate: false
- })
- } else {
- await this.createTerminal(`path:${result.worktree.path}`, {
- title: 'Setup',
- command: setupCommand,
- env: result.setup.envVars
- })
- }
- didSpawnSetup = true
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- warning = warning
- ? `${warning} Also failed to create the setup terminal for ${result.worktree.path}: ${message}`
- : `Failed to create the setup terminal for ${result.worktree.path}: ${message}`
- }
- }
-
const shouldActivate = args.activate === true || args.runHooks === true
if (shouldActivate) {
- const activationSetup = didSpawnSetup ? undefined : result.setup
+ const runtimeWillProvisionTerminals =
+ didSpawnStartup && Boolean(result.setup || result.defaultTabs)
+ if (runtimeWillProvisionTerminals) {
+ // Why: remote/mobile task creates spawn the agent terminal in runtime,
+ // so renderer activation may not materialize setup/default tabs. Await so
+ // a failed setup spawn falls back to renderer activation for retry.
+ const provisioned = await this.provisionManagedWorktreeTerminals({
+ worktreeSelector: `path:${result.worktree.path}`,
+ worktreeId: result.worktree.id,
+ worktreePath: result.worktree.path,
+ ...(result.setup ? { setup: result.setup } : {}),
+ ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
+ primaryTerminalHandle: startupTerminalHandle,
+ hasStartupTerminal: didSpawnStartup,
+ setupCommandPlatform: result.setup
+ ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath)
+ ? 'windows'
+ : 'posix'
+ : 'posix',
+ // Why: carry the wait-for-agent wrapped setup command (#6298) so the
+ // remote Setup tab runs the same script the sequenced agent waits on.
+ ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
+ })
+ didSpawnSetup = provisioned.setupSpawned
+ }
+ // Why: omit setup from activation when runtime spawned it; on spawn
+ // failure fall through with the wrapped command so renderer retries.
+ const activationSetup = didSpawnSetup
+ ? undefined
+ : result.setup
+ ? {
+ ...result.setup,
+ ...(didSpawnStartup && wrappedSetupCommandStr
+ ? { command: wrappedSetupCommandStr }
+ : {})
+ }
+ : undefined
+ const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs
if (args.startup && !didSpawnStartup) {
this.notifyActivateWorktree(
repo.id,
result.worktree.id,
activationSetup,
args.startup,
- result.defaultTabs
+ activationDefaultTabs
)
} else {
this.notifyActivateWorktree(
@@ -13128,36 +13311,41 @@ export class OrcaRuntimeService {
result.worktree.id,
activationSetup,
undefined,
- result.defaultTabs
+ activationDefaultTabs
)
}
}
- if (!args.startup && !shouldActivate && this.ptyController?.spawn) {
+ if (
+ !shouldActivate &&
+ this.ptyController?.spawn &&
+ (result.setup || result.defaultTabs || didSpawnStartup)
+ ) {
+ // Why: inactive terminal materialization matches normal worktree creation,
+ // but setup/default tab failures must not gate automation dispatch.
+ void this.provisionManagedWorktreeTerminals({
+ worktreeSelector: `path:${result.worktree.path}`,
+ worktreeId: result.worktree.id,
+ worktreePath: result.worktree.path,
+ ...(result.setup ? { setup: result.setup } : {}),
+ ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
+ primaryTerminalHandle: startupTerminalHandle,
+ hasStartupTerminal: didSpawnStartup,
+ setupCommandPlatform: result.setup
+ ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath)
+ ? 'windows'
+ : 'posix'
+ : 'posix',
+ ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
+ })
+ // Why: runtime owns setup spawning here, so omit setup from the RPC result
+ // to keep the headless/mobile caller from launching it a second time.
+ if (result.setup) {
+ didSpawnSetup = true
+ }
+ } else if (!shouldActivate && this.ptyController?.spawn) {
try {
- const terminal = await this.createTerminal(`path:${result.worktree.path}`)
- if (result.setup && !didSpawnSetup) {
- const setupCommand = buildSetupRunnerCommand(
- result.setup.runnerScriptPath,
- isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix'
- )
- const setupLaunchMode =
- (this.store.getSettings() as Partial>)
- .setupScriptLaunchMode ?? 'new-tab'
- await (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal'
- ? this.splitTerminal(terminal.handle, {
- direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
- command: setupCommand,
- env: result.setup.envVars,
- activate: false
- })
- : this.createTerminal(`path:${result.worktree.path}`, {
- title: 'Setup',
- command: setupCommand,
- env: result.setup.envVars
- }))
- didSpawnSetup = true
- }
+ await this.createTerminal(`path:${result.worktree.path}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = warning
@@ -13166,10 +13354,27 @@ export class OrcaRuntimeService {
}
}
+ const returnedSetup = didSpawnSetup
+ ? undefined
+ : result.setup
+ ? {
+ ...result.setup,
+ ...(didSpawnStartup && wrappedSetupCommandStr
+ ? { command: wrappedSetupCommandStr }
+ : {})
+ }
+ : undefined
+ const resultForRenderer = returnedSetup
+ ? { ...result, setup: returnedSetup }
+ : (() => {
+ const { setup: _setup, ...resultWithoutSetup } = result
+ return resultWithoutSetup
+ })()
+
const resultWithStartupTerminal =
didSpawnStartup && startupTerminalHandle
? {
- ...result,
+ ...resultForRenderer,
startupTerminal: {
spawned: true,
handle: startupTerminalHandle,
@@ -13179,7 +13384,7 @@ export class OrcaRuntimeService {
surface: 'background' as const
}
}
- : result
+ : resultForRenderer
return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal
}
@@ -14678,6 +14883,7 @@ export class OrcaRuntimeService {
worktreeSelector?: string,
opts: {
command?: string
+ claudeAgentTeamsSourceCommand?: string
env?: Record
launchConfig?: WorktreeStartupLaunch['launchConfig']
launchToken?: string
@@ -14739,14 +14945,16 @@ export class OrcaRuntimeService {
...opts.env,
...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {})
}
+ const claudeAgentTeamsSourceCommand =
+ opts.claudeAgentTeamsSourceCommand?.trim() || opts.command?.trim() || undefined
const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode
const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode(
opts.launchConfig,
- opts.command,
+ claudeAgentTeamsSourceCommand,
claudeAgentTeamsMode
)
const agentTeamsPlan = await buildClaudeAgentTeamsLaunchPlan({
- command: opts.command,
+ command: claudeAgentTeamsSourceCommand,
mode: effectiveClaudeAgentTeamsMode,
baseEnv: {
...process.env,
@@ -14763,6 +14971,13 @@ export class OrcaRuntimeService {
shimBin
}).env
})
+ const sequencedStartupCommand =
+ agentTeamsPlan &&
+ claudeAgentTeamsSourceCommand &&
+ opts.command &&
+ claudeAgentTeamsSourceCommand !== opts.command
+ ? agentTeamsPlan.command
+ : undefined
const effectiveLaunchConfig =
opts.launchConfig && agentTeamsPlan
? {
@@ -14778,9 +14993,17 @@ export class OrcaRuntimeService {
}
}
: opts.launchConfig
+ // Why: setup/agent sequencing wraps the PTY launch in a wait shell before
+ // Claude Agent Teams runs. Preserve the direct Claude command separately
+ // so the wrapper can exec the teammate-mode variant after setup completes.
const env = this.buildTerminalWorkspaceEnv(
workspace,
- baseEnv,
+ {
+ ...baseEnv,
+ ...(sequencedStartupCommand
+ ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand }
+ : {})
+ },
paneKey,
tabId,
agentTeamsPlan?.env
@@ -14789,7 +15012,8 @@ export class OrcaRuntimeService {
cols: 120,
rows: 40,
cwd: workspace.path,
- command: agentTeamsPlan?.command ?? opts.command,
+ command: sequencedStartupCommand ? opts.command : (agentTeamsPlan?.command ?? opts.command),
+ commandDelivery: 'provider',
startupCommandDelivery: opts.startupCommandDelivery,
env,
envToDelete: agentTeamsPlan?.envToDelete,
@@ -15156,6 +15380,9 @@ export class OrcaRuntimeService {
// Why: mobile may be running on iOS while the actual terminal shell is
// Windows/macOS/Linux or an SSH Linux host; quote for the host shell.
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
+ // Why: an SSH workspace runs the CLI through the relay shim (plain `orca`),
+ // so the Linux-only `orca-ide` rename must not be applied.
+ const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : repoIsRemote(workspace)
const startupPlan = buildAgentStartupPlan({
agent: opts.agent,
prompt: '',
@@ -15163,6 +15390,7 @@ export class OrcaRuntimeService {
agentArgs: resolveTuiAgentLaunchArgs(opts.agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(opts.agent, settings.agentDefaultEnv),
platform,
+ isRemote,
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
@@ -15634,6 +15862,7 @@ export class OrcaRuntimeService {
rows: 40,
cwd: workspace.path,
command: opts.command,
+ commandDelivery: 'provider',
env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId),
envToDelete: opts.envToDelete,
connectionId: workspace.connectionId,
@@ -17606,10 +17835,41 @@ export class OrcaRuntimeService {
const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title
const liveTitleEvidence = leafTitle ?? ptyTitle
const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence)
- const agentStatus =
+ // Why: keep the rich hook-driven status when the agent has a live
+ // interactive prompt or an active tool — those are authoritative agent
+ // activity even if the terminal's title isn't agent-classified (e.g. it
+ // shows a task/branch name). Otherwise the mobile/web client falls back to
+ // the OSC-title-only status and never sees interactivePrompt (the question
+ // card never renders).
+ const hasLiveAgentSignal =
+ tab.agentStatus?.interactivePrompt != null || tab.agentStatus?.toolName != null
+ const keepFullAgentStatus =
tab.agentStatus &&
- (liveTitleEvidence === null || liveTitleEvidenceClassification === 'agent')
- ? { agentStatus: tab.agentStatus }
+ (liveTitleEvidence === null ||
+ liveTitleEvidenceClassification === 'agent' ||
+ hasLiveAgentSignal)
+ const agentStatus = keepFullAgentStatus
+ ? { agentStatus: tab.agentStatus }
+ : // Why: when live title evidence says the pane is idle (e.g. the Claude
+ // agents picker or a neutral shell title), suppress the stale "working"
+ // state so the client shows no spinner — but retain agent identity
+ // (agentType + providerSession) so native chat can still address an
+ // idle agent's transcript. Reset the transient state to 'done'.
+ tab.agentStatus?.agentType != null
+ ? {
+ agentStatus: {
+ state: 'done' as const,
+ prompt: '',
+ updatedAt: tab.agentStatus.updatedAt,
+ stateStartedAt: tab.agentStatus.stateStartedAt,
+ paneKey: tab.agentStatus.paneKey,
+ stateHistory: [],
+ agentType: tab.agentStatus.agentType,
+ ...(tab.agentStatus.providerSession
+ ? { providerSession: tab.agentStatus.providerSession }
+ : {})
+ }
+ }
: null
// Why: web/mobile clients hold these handles across renderer graph syncs;
// leaf handles are graph-epoch-bound, but PTY handles remain streamable.
@@ -17637,6 +17897,7 @@ export class OrcaRuntimeService {
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
...(tab.color != null ? { color: tab.color } : {}),
...(tab.isPinned ? { isPinned: true } : {}),
+ ...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
isActive: tab.isActive,
...(terminalHandle
? { status: 'ready' as const, terminal: terminalHandle }
@@ -17808,6 +18069,20 @@ export class OrcaRuntimeService {
return this.getTerminalHandleForPaneKey(paneKey) ?? undefined
}
+ getAgentStatusLaunchConfigForPaneKey(
+ paneKey: string,
+ args?: { launchToken?: string }
+ ): SleepingAgentLaunchConfig | undefined {
+ const pty = this.getPtyRecordForPaneKey(paneKey)
+ if (!pty?.launchConfig) {
+ return undefined
+ }
+ if (pty.launchToken === null || pty.launchToken !== args?.launchToken) {
+ return undefined
+ }
+ return copySleepingAgentLaunchConfig(pty.launchConfig)
+ }
+
private buildAgentOrchestrationByPaneKey():
| Record
| undefined {
@@ -17918,6 +18193,23 @@ export class OrcaRuntimeService {
return null
}
+ private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null {
+ const parsed = parsePaneKey(paneKey)
+ if (parsed) {
+ const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId))
+ const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined
+ if (pty) {
+ return pty
+ }
+ }
+ for (const pty of this.ptysById.values()) {
+ if (pty.paneKey === paneKey) {
+ return pty
+ }
+ }
+ return null
+ }
+
private getPaneKeyForTerminalHandle(handle: string): string | null {
const livePty = this.getLivePtyForHandle(handle)
if (livePty?.pty.paneKey) {
diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts
index bac75923dc4..1ef817bf552 100644
--- a/src/main/runtime/rpc/core.ts
+++ b/src/main/runtime/rpc/core.ts
@@ -59,6 +59,11 @@ export type RpcContext = {
// Why: WebSocket RPCs authenticate by mobile device token. State-owning
// handlers use this to clean up when that paired device disconnects.
clientId?: string
+ // Why: payload windowing/truncation tuned for the constrained mobile payload
+ // (e.g. native-chat block char cap) must not clip full-screen web/desktop
+ // clients. Carries the paired device's scope so handlers can gate the diet to
+ // phones only. Undefined for in-process callers → treat as full-class (no clip).
+ clientKind?: 'mobile' | 'runtime'
// Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming
// responses after the binary terminal cutover. Undefined on Unix/socket
// transports and non-E2EE WebSocket paths.
diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts
index 61b58e3aab0..e1c2fcd38f3 100644
--- a/src/main/runtime/rpc/dispatcher.ts
+++ b/src/main/runtime/rpc/dispatcher.ts
@@ -93,6 +93,7 @@ export class RpcDispatcher {
connectionId?: string
signal?: AbortSignal
clientId?: string
+ clientKind?: 'mobile' | 'runtime'
sendBinary?: (bytes: Uint8Array) => void
registerBinaryStreamHandler?: (
streamId: number,
@@ -125,6 +126,7 @@ export class RpcDispatcher {
requestId: request.id,
connectionId: options?.connectionId,
clientId: options?.clientId,
+ clientKind: options?.clientKind,
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
})
@@ -158,6 +160,7 @@ export class RpcDispatcher {
requestId: request.id,
connectionId: options?.connectionId,
clientId: options?.clientId,
+ clientKind: options?.clientKind,
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
},
diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts
index fe918875d22..c5a8cf37440 100644
--- a/src/main/runtime/rpc/methods/automations.test.ts
+++ b/src/main/runtime/rpc/methods/automations.test.ts
@@ -48,6 +48,7 @@ describe('automation RPC methods', () => {
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }
},
repo: 'repo-1',
+ setupDecision: 'skip',
reuseSession: true,
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
dtstart: 1
@@ -58,6 +59,7 @@ describe('automation RPC methods', () => {
id: 'auto-1',
updates: {
enabled: false,
+ setupDecision: 'run',
reuseSession: false,
rrule: '0 9 * * 1-5',
dtstart: 2
@@ -79,12 +81,18 @@ describe('automation RPC methods', () => {
runContext: expect.objectContaining({ hostId: 'runtime:gpu' }),
sourceContext: expect.objectContaining({ hostId: 'local' }),
repo: 'repo-1',
+ setupDecision: 'skip',
reuseSession: true
})
)
expect(runtime.updateAutomation).toHaveBeenCalledWith(
'auto-1',
- expect.objectContaining({ enabled: false, reuseSession: false, rrule: '0 9 * * 1-5' })
+ expect.objectContaining({
+ enabled: false,
+ setupDecision: 'run',
+ reuseSession: false,
+ rrule: '0 9 * * 1-5'
+ })
)
expect(runtime.deleteAutomation).toHaveBeenCalledWith('auto-1')
expect(runtime.runAutomationNow).toHaveBeenCalledWith('auto-1')
diff --git a/src/main/runtime/rpc/methods/automations.ts b/src/main/runtime/rpc/methods/automations.ts
index 858e80596d0..d4e74b719fe 100644
--- a/src/main/runtime/rpc/methods/automations.ts
+++ b/src/main/runtime/rpc/methods/automations.ts
@@ -22,6 +22,7 @@ const TuiAgent = requiredString('Missing provider').refine(isTuiAgent, {
})
const AutomationWorkspaceMode = z.enum(['existing', 'new_per_run']).optional()
+const SetupDecision = z.enum(['inherit', 'run', 'skip']).optional()
const ExecutionHostId = requiredString('Missing host id').transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
@@ -109,6 +110,7 @@ const AutomationCreate = z.object({
workspace: OptionalString,
workspaceMode: AutomationWorkspaceMode,
baseBranch: OptionalPlainString,
+ setupDecision: SetupDecision,
reuseSession: OptionalBoolean,
timezone: OptionalString,
rrule: AutomationSchedule,
@@ -129,6 +131,7 @@ const AutomationUpdateFields = z.object({
workspaceMode: AutomationWorkspaceMode,
// Why: update patches distinguish omitted from null so callers can clear a saved base branch.
baseBranch: OptionalNullablePlainString,
+ setupDecision: SetupDecision,
reuseSession: OptionalBoolean,
timezone: OptionalString,
rrule: AutomationSchedule.optional(),
diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts
index bff744016c3..8018375a8a0 100644
--- a/src/main/runtime/rpc/methods/index.ts
+++ b/src/main/runtime/rpc/methods/index.ts
@@ -15,6 +15,7 @@ import { ACCOUNT_METHODS } from './accounts'
import { PREFLIGHT_METHODS } from './preflight'
import { COMPUTER_METHODS } from './computer'
import { SESSION_TAB_METHODS } from './session-tabs'
+import { NATIVE_CHAT_METHODS } from './native-chat'
import { FILE_METHODS } from './files'
import { GIT_METHODS } from './git'
import { GITHUB_METHODS } from './github'
@@ -53,6 +54,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...PREFLIGHT_METHODS,
...COMPUTER_METHODS,
...SESSION_TAB_METHODS,
+ ...NATIVE_CHAT_METHODS,
...FILE_METHODS,
...GIT_METHODS,
...GITHUB_METHODS,
diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts
new file mode 100644
index 00000000000..4d428c9e5f5
--- /dev/null
+++ b/src/main/runtime/rpc/methods/native-chat.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { NativeChatMessage } from '../../../../shared/native-chat-types'
+import type { RpcContext } from '../core'
+
+// Stub the shared cache so the handler returns a deterministic transcript with
+// one oversized tool-result block; the test then asserts clip behavior per client.
+const OVERSIZED = 'x'.repeat(5000)
+const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } }))
+vi.mock('../../../native-chat/transcript-read-cache', () => ({
+ readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value)
+}))
+
+import { NATIVE_CHAT_METHODS } from './native-chat'
+
+function makeMessage(text: string): NativeChatMessage {
+ return {
+ id: 'a-1',
+ role: 'assistant',
+ timestamp: 1_717_236_000_000,
+ source: 'transcript',
+ blocks: [{ type: 'tool-result', output: text, isError: false }]
+ }
+}
+
+function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise {
+ const method = NATIVE_CHAT_METHODS.find((m) => m.name === 'nativeChat.readSession')
+ if (!method) {
+ throw new Error('readSession method not registered')
+ }
+ return method.handler as (params: unknown, ctx: RpcContext) => Promise
+}
+
+function ctxWith(clientKind: RpcContext['clientKind']): RpcContext {
+ return { runtime: {} as RpcContext['runtime'], clientKind }
+}
+
+function firstOutput(result: unknown): string {
+ const messages = (result as { messages: NativeChatMessage[] }).messages
+ const block = messages[0].blocks[0] as { output: string }
+ return block.output
+}
+
+describe('nativeChat.readSession clientKind truncation gating', () => {
+ it('clips oversized tool output for mobile clients', async () => {
+ cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
+ const result = await readSessionHandler()(
+ { agent: 'claude', sessionId: 's' },
+ ctxWith('mobile')
+ )
+ const output = firstOutput(result)
+ expect(output.length).toBeLessThan(OVERSIZED.length)
+ expect(output).toContain('truncated')
+ })
+
+ it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => {
+ cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
+ const result = await readSessionHandler()(
+ { agent: 'claude', sessionId: 's' },
+ ctxWith('runtime')
+ )
+ expect(firstOutput(result)).toBe(OVERSIZED)
+ })
+
+ it('defaults to no clip when clientKind is undefined (in-process callers)', async () => {
+ cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
+ const result = await readSessionHandler()(
+ { agent: 'claude', sessionId: 's' },
+ ctxWith(undefined)
+ )
+ expect(firstOutput(result)).toBe(OVERSIZED)
+ })
+
+ it('windows by count for all client kinds', async () => {
+ const many = Array.from({ length: 60 }, (_unused, n) => {
+ const message = makeMessage('small')
+ return { ...message, id: `m-${n}` }
+ })
+ cachedResult.value = { messages: many }
+ const result = await readSessionHandler()(
+ { agent: 'claude', sessionId: 's', limit: 40 },
+ ctxWith('runtime')
+ )
+ const messages = (result as { messages: NativeChatMessage[] }).messages
+ expect(messages).toHaveLength(40)
+ // Tail-only: the last id survives, the first is dropped.
+ expect(messages.at(-1)?.id).toBe('m-59')
+ expect(messages[0].id).toBe('m-20')
+ })
+})
diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts
new file mode 100644
index 00000000000..f434df1d4ed
--- /dev/null
+++ b/src/main/runtime/rpc/methods/native-chat.ts
@@ -0,0 +1,180 @@
+import { z } from 'zod'
+import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types'
+import type { AgentType } from '../../../../shared/native-chat-types'
+import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache'
+import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch'
+import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
+
+// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
+// desktop reaches the readers via Electron IPC; mobile/web clients reach the
+// same pure readers through these runtime RPC methods so the native chat view
+// works over the paired connection, not just in the desktop renderer.
+
+const NativeChatSession = z.object({
+ agent: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing agent'))
+ .transform((v) => v as AgentType),
+ sessionId: z
+ .unknown()
+ .transform((v) => (typeof v === 'string' ? v : ''))
+ .pipe(z.string().min(1, 'Missing session id')),
+ // How many of the most-recent messages to return. Clients start small for a
+ // fast first paint and raise it to page older history in as the user scrolls.
+ limit: z.number().int().positive().max(2000).optional(),
+ // Optional client-supplied cleanup token. When present, the subscribe handler
+ // keys the fs-watcher cleanup under it so registration and unsubscribe derive
+ // from the SAME token (back-compat: falls back to `agent:sessionId` when absent,
+ // which is exactly what existing mobile clients rely on).
+ subscriptionId: z.string().min(1).optional(),
+ // Authoritative transcript path from the agent hook (providerSession), used to
+ // locate the file directly when the session id no longer names it (recent
+ // Claude Code). Optional for back-compat with older clients.
+ transcriptPath: z.string().min(1).optional()
+})
+
+const NativeChatUnsubscribe = z.object({
+ subscriptionId: z.string().min(1).optional()
+})
+
+// Why: a long agent session can hold thousands of turns (with full tool I/O).
+// Shipping all of them over the paired connection and rendering them at once
+// freezes the mobile app, so the runtime RPC windows to the most recent slice —
+// the conversation tail is what the chat view shows first. The desktop IPC path
+// is unaffected (it reads locally with a virtualized list).
+// Small first page for a fast initial paint; the client raises `limit` to load
+// older history as the user scrolls back.
+const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
+const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
+// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
+// The mobile view only previews block bodies, so truncate them on the wire to
+// keep the payload small; the marker tells the user content was clipped.
+const MOBILE_BLOCK_CHAR_CAP = 4000
+const TRUNCATION_MARKER = '\n… (truncated)'
+
+function clip(text: string): string {
+ return text.length > MOBILE_BLOCK_CHAR_CAP
+ ? text.slice(0, MOBILE_BLOCK_CHAR_CAP) + TRUNCATION_MARKER
+ : text
+}
+
+function clipBlock(block: NativeChatBlock): NativeChatBlock {
+ if (block.type === 'text') {
+ return block.text.length > MOBILE_BLOCK_CHAR_CAP ? { ...block, text: clip(block.text) } : block
+ }
+ if (block.type === 'tool-result') {
+ return block.output.length > MOBILE_BLOCK_CHAR_CAP
+ ? { ...block, output: clip(block.output) }
+ : block
+ }
+ return block
+}
+
+function sanitizeMessage(message: NativeChatMessage): NativeChatMessage {
+ return { ...message, blocks: message.blocks.map(clipBlock) }
+}
+
+/** Window a transcript to its most recent `limit` messages so a long session
+ * can't freeze the client. Windowing by count applies to ALL RPC clients —
+ * shipping thousands of turns over the paired link is bad for web and mobile
+ * alike. Char-clipping (the mobile-only payload diet) is applied separately. */
+function windowTranscript(
+ messages: readonly NativeChatMessage[],
+ limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
+): NativeChatMessage[] {
+ const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW)
+ return messages.length > window ? messages.slice(-window) : messages.slice()
+}
+
+/** Apply the windowed slice plus, for `mobile` clients only, oversized-block
+ * char truncation. Web/desktop (`runtime`, or undefined for in-process callers)
+ * are full-class surfaces and pass block bodies through untruncated — matching
+ * the desktop IPC path, which never clips. */
+function windowForClient(
+ messages: readonly NativeChatMessage[],
+ clientKind: RpcContext['clientKind'],
+ limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
+): NativeChatMessage[] {
+ const windowed = windowTranscript(messages, limit)
+ return clientKind === 'mobile' ? windowed.map(sanitizeMessage) : windowed
+}
+
+export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
+ defineMethod({
+ name: 'nativeChat.readSession',
+ params: NativeChatSession,
+ handler: async (params, { clientKind }) => {
+ const result = await readNativeChatTranscriptCached(
+ params.agent,
+ params.sessionId,
+ params.transcriptPath
+ )
+ // Window to the conversation tail (all clients); clip blocks for mobile only.
+ return 'messages' in result
+ ? { messages: windowForClient(result.messages, clientKind, params.limit) }
+ : result
+ }
+ }),
+ defineStreamingMethod({
+ name: 'nativeChat.subscribe',
+ params: NativeChatSession,
+ handler: async (params, { runtime, connectionId, clientKind }, emit) => {
+ let closed = false
+ let unsubscribe = (): void => {}
+ // Why: the subscriber seeds its read offset at 0, so the first drain emits
+ // the whole transcript and later drains emit only appended turns. The first
+ // batch is windowed to the tail (a full transcript would freeze mobile);
+ // later incremental batches are smaller than the window so they pass through.
+ // Clients merge by message id, so the initial windowed batch doubles as the
+ // snapshot. Keyed by the client-supplied subscriptionId when present so
+ // registration and unsubscribe derive from the same token; otherwise by
+ // agent:sessionId, which is exactly the token existing mobile clients send to
+ // unsubscribe (no wire break).
+ const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}`
+ const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}`
+ runtime.registerSubscriptionCleanup(
+ subscriptionId,
+ () => {
+ closed = true
+ unsubscribe()
+ emit({ type: 'end' })
+ },
+ connectionId
+ )
+ if (closed) {
+ return
+ }
+ const subscription = await subscribeNativeChatTranscript({
+ agent: params.agent,
+ sessionId: params.sessionId,
+ transcriptPath: params.transcriptPath,
+ onAppend: (messages) => {
+ if (closed) {
+ return
+ }
+ emit({ type: 'appended', messages: windowForClient(messages, clientKind) })
+ }
+ })
+ // The connection may have closed while the file was being resolved.
+ if (closed) {
+ subscription.unsubscribe()
+ return
+ }
+ unsubscribe = subscription.unsubscribe
+ }
+ }),
+ defineMethod({
+ name: 'nativeChat.unsubscribe',
+ params: NativeChatUnsubscribe,
+ handler: async (params, { runtime, connectionId }) => {
+ const connection = connectionId ?? 'local'
+ if (params.subscriptionId) {
+ runtime.cleanupSubscription(`nativeChat:${connection}:${params.subscriptionId}`)
+ return { unsubscribed: true }
+ }
+ runtime.cleanupSubscriptionsByPrefix(`nativeChat:${connection}:`)
+ return { unsubscribed: true }
+ }
+ })
+]
diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts
index d28b97b8e53..e8f1ef82930 100644
--- a/src/main/runtime/rpc/methods/preflight.test.ts
+++ b/src/main/runtime/rpc/methods/preflight.test.ts
@@ -7,11 +7,13 @@ import { PREFLIGHT_METHODS } from './preflight'
const {
detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgentsMock,
+ detectRemoteWindowsTerminalCapabilitiesMock,
refreshShellPathAndDetectAgentsMock,
runPreflightCheckMock
} = vi.hoisted(() => ({
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
detectRemoteAgentsMock: vi.fn(),
+ detectRemoteWindowsTerminalCapabilitiesMock: vi.fn(),
refreshShellPathAndDetectAgentsMock: vi.fn(),
runPreflightCheckMock: vi.fn()
}))
@@ -19,6 +21,7 @@ const {
vi.mock('../../../ipc/preflight', () => ({
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgents: detectRemoteAgentsMock,
+ detectRemoteWindowsTerminalCapabilities: detectRemoteWindowsTerminalCapabilitiesMock,
refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock,
runPreflightCheck: runPreflightCheckMock
}))
@@ -81,4 +84,36 @@ describe('preflight RPC methods', () => {
expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
expect(response).toMatchObject({ ok: true, result: ['claude'] })
})
+
+ it('detects remote Windows terminal capabilities through runtime RPC', async () => {
+ detectRemoteWindowsTerminalCapabilitiesMock.mockResolvedValueOnce({
+ wslAvailable: true,
+ wslDistros: ['Ubuntu'],
+ pwshAvailable: true,
+ gitBashAvailable: true,
+ hostPlatform: 'win32'
+ })
+ const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
+ const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS })
+
+ const response = await dispatcher.dispatch(
+ makeRequest('preflight.detectRemoteWindowsTerminalCapabilities', {
+ connectionId: 'ssh-1'
+ })
+ )
+
+ expect(detectRemoteWindowsTerminalCapabilitiesMock).toHaveBeenCalledWith({
+ connectionId: 'ssh-1'
+ })
+ expect(response).toMatchObject({
+ ok: true,
+ result: {
+ wslAvailable: true,
+ wslDistros: ['Ubuntu'],
+ pwshAvailable: true,
+ gitBashAvailable: true,
+ hostPlatform: 'win32'
+ }
+ })
+ })
})
diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts
index 4e0f06201e9..aafbb96319c 100644
--- a/src/main/runtime/rpc/methods/preflight.ts
+++ b/src/main/runtime/rpc/methods/preflight.ts
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import {
detectRemoteAgents,
+ detectRemoteWindowsTerminalCapabilities,
detectInstalledAgentsWithShellPathHydration,
refreshShellPathAndDetectAgents,
runPreflightCheck
@@ -13,6 +14,9 @@ const PreflightCheck = z.object({
const PreflightDetectRemoteAgents = z.object({
connectionId: z.string().min(1)
})
+const PreflightDetectRemoteWindowsTerminalCapabilities = z.object({
+ connectionId: z.string().min(1)
+})
export const PREFLIGHT_METHODS: RpcMethod[] = [
defineMethod({
@@ -30,6 +34,11 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [
params: PreflightDetectRemoteAgents,
handler: async (params) => detectRemoteAgents(params)
}),
+ defineMethod({
+ name: 'preflight.detectRemoteWindowsTerminalCapabilities',
+ params: PreflightDetectRemoteWindowsTerminalCapabilities,
+ handler: async (params) => detectRemoteWindowsTerminalCapabilities(params)
+ }),
defineMethod({
name: 'preflight.refreshAgents',
params: null,
diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts
index 275514a39f5..2c7c3183946 100644
--- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts
+++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts
@@ -104,7 +104,9 @@ export const SetTabProps = WorktreeTabSelector.extend({
.pipe(z.string().min(1, 'Missing tab id')),
// undefined = leave unchanged; null = clear color / unset.
color: z.string().max(64).nullable().optional(),
- isPinned: z.boolean().optional()
+ isPinned: z.boolean().optional(),
+ // undefined = leave unchanged; no "clear" semantic (absence means default 'terminal').
+ viewMode: z.enum(['terminal', 'chat']).optional()
})
export const CreateTerminalTab = WorktreeTabSelector.extend({
diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts
index 59eb3d04ecc..ebdcc2a7f2c 100644
--- a/src/main/runtime/rpc/methods/session-tabs.ts
+++ b/src/main/runtime/rpc/methods/session-tabs.ts
@@ -103,7 +103,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
runtime.setMobileSessionTabProps(params.worktree, {
tabId: params.tabId,
...(params.color !== undefined ? { color: params.color } : {}),
- ...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {})
+ ...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}),
+ ...(params.viewMode !== undefined ? { viewMode: params.viewMode } : {})
})
}),
defineStreamingMethod({
diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts
index 0c157779068..8719cc528ab 100644
--- a/src/main/runtime/rpc/methods/skills.ts
+++ b/src/main/runtime/rpc/methods/skills.ts
@@ -1,10 +1,20 @@
+import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { discoverSkills } from '../../../skills/discovery'
+const SkillDiscoveryParams = z.object({
+ cwd: z.string().optional().nullable()
+})
+
export const SKILL_METHODS: RpcMethod[] = [
defineMethod({
name: 'skills.discover',
- params: null,
- handler: async (_params, { runtime }) => discoverSkills({ repos: runtime.listRepos() })
+ params: SkillDiscoveryParams,
+ handler: async (params, { runtime }) => {
+ const cwd = params.cwd?.trim() || undefined
+ return cwd
+ ? discoverSkills({ repos: [], cwd })
+ : discoverSkills({ repos: runtime.listRepos() })
+ }
})
]
diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts
index aebeaf9baad..154f0926145 100644
--- a/src/main/runtime/runtime-rpc.ts
+++ b/src/main/runtime/runtime-rpc.ts
@@ -297,6 +297,9 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'session.tabs.subscribeAll',
'session.tabs.unsubscribe',
'session.tabs.unsubscribeAll',
+ 'nativeChat.readSession',
+ 'nativeChat.subscribe',
+ 'nativeChat.unsubscribe',
'settings.get',
'settings.update',
'ssh.connect',
@@ -971,6 +974,9 @@ export class OrcaRuntimeRpcServer {
await this.dispatcher.dispatchStreaming(request, reply, {
connectionId,
clientId: token,
+ // Why: gates the mobile-only payload diet (native-chat char clipping) so
+ // full-screen web/desktop runtime clients aren't truncated.
+ clientKind: device.scope,
signal: abortRegistration?.signal,
sendBinary,
registerBinaryStreamHandler: (streamId, handler) =>
diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts
index 93a8e59aafc..dcee058f8e2 100644
--- a/src/main/skills/discovery.test.ts
+++ b/src/main/skills/discovery.test.ts
@@ -77,6 +77,33 @@ describe('skill discovery', () => {
expect(skill?.directoryPath).toBe(linkedSkill)
})
+ it('discovers worktree .agents skill symlinks from the requested cwd', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
+ const home = join(root, 'home')
+ const worktree = join(root, 'worktree')
+ const realSkill = join(root, 'central-skills', 'ref-oss')
+ const linkedSkill = join(worktree, '.agents', 'skills', 'ref-oss')
+ await mkdir(realSkill, { recursive: true })
+ await mkdir(join(worktree, '.agents', 'skills'), { recursive: true })
+ await writeFile(join(realSkill, 'SKILL.md'), '# ref-oss\n\nUse local OSS reference repos.')
+ await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir')
+
+ const result = await discoverSkills({
+ homeDir: home,
+ cwd: worktree,
+ repos: []
+ })
+
+ expect(result.skills.filter((entry) => entry.name === 'ref-oss')).toMatchObject([
+ {
+ sourceKind: 'repo',
+ sourceLabel: 'Repo worktree .agents',
+ directoryPath: linkedSkill,
+ providers: ['agent-skills']
+ }
+ ])
+ })
+
it('keeps home classification when cwd points at the same directory as home', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts
index 8d6d2ba81dc..4ba541aad45 100644
--- a/src/main/window/createMainWindow.ts
+++ b/src/main/window/createMainWindow.ts
@@ -92,6 +92,18 @@ function nativeZoomCommandMatchesKeybindings(
)
}
+function isMacAppPasteInput(input: Electron.Input): boolean {
+ return (
+ process.platform === 'darwin' &&
+ input.type === 'keyDown' &&
+ input.meta &&
+ !input.control &&
+ !input.alt &&
+ !input.shift &&
+ (input.code === 'KeyV' || input.key.toLowerCase() === 'v')
+ )
+}
+
// Why: the titlebar is 36px (border-box, 1px border-bottom). The visual
// center of the CSS-centered content sits at ~18 CSS px from the top.
// At zoom factor z that becomes 18·z window px. Traffic lights are
@@ -844,6 +856,14 @@ export function createMainWindow(
return
}
+ if (isMacAppPasteInput(input)) {
+ // Why: native chat/terminal panes can own focus without being native
+ // editable controls, so route Cmd+V through Orca's paste ownership first.
+ event.preventDefault()
+ mainWindow.webContents.send('ui:appMenuPaste')
+ return
+ }
+
const keybindings = opts?.getKeybindings?.()
const terminalShortcutContext: KeybindingMatchOptions = {
context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app',
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 7a485016fe6..00514423fc5 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -362,6 +362,7 @@ import type {
OpenCodeUsageSummary
} from '../shared/opencode-usage-types'
import type { AiVaultListArgs, AiVaultListResult } from '../shared/ai-vault-types'
+import type { AgentType, NativeChatMessage } from '../shared/native-chat-types'
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
import type { AppStarSource } from '../shared/gh-star-source'
@@ -548,6 +549,13 @@ export type PreflightApi = {
detectAgents: (args?: PreflightRuntimeContext) => Promise
refreshAgents: (args?: PreflightRuntimeContext) => Promise
detectRemoteAgents: (args: { connectionId: string }) => Promise
+ detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{
+ wslAvailable: boolean
+ wslDistros: string[]
+ pwshAvailable: boolean
+ gitBashAvailable: boolean
+ hostPlatform: NodeJS.Platform | null
+ }>
}
// Why: renderer-facing mirror of the daemon's `SessionInfo` + protocolVersion
@@ -713,6 +721,46 @@ export type AiVaultApi = {
listSessions: (args?: AiVaultListArgs) => Promise
}
+export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string }
+
+/** Messages appended to a live-tailed transcript since the previous emit. */
+export type NativeChatAppendedMessages = NativeChatMessage[]
+
+/** Wire payload for the `nativeChat:appended` push channel. */
+export type NativeChatAppendedPayload = {
+ subscriptionId: string
+ messages: NativeChatAppendedMessages
+}
+
+export type NativeChatSubscribeArgs = {
+ /** Unique per-caller id, echoed on every append so multiple live panes in
+ * one renderer don't cross-talk. */
+ subscriptionId: string
+ agent: AgentType
+ sessionId: string
+ /** Authoritative transcript path from the agent hook (providerSession). */
+ transcriptPath?: string
+}
+
+export type NativeChatApi = {
+ /** Read the on-disk transcript for an agent + session id, windowed to the most
+ * recent `limit` turns (defaults to the desktop window). The renderer raises
+ * `limit` to page in older history as it scrolls to the top. `transcriptPath`
+ * is the hook-reported authoritative file path, preferred over the id glob. */
+ readSession: (
+ agent: AgentType,
+ sessionId: string,
+ limit?: number,
+ transcriptPath?: string
+ ) => Promise
+ /** Live-tail a transcript: `onAppended` fires with only newly-appended
+ * messages. Returns an unsubscribe fn that closes the main-process watcher. */
+ subscribe: (
+ args: NativeChatSubscribeArgs,
+ onAppended: (messages: NativeChatAppendedMessages) => void
+ ) => () => void
+}
+
export type AppApi = {
/** Returns the app identity currently exposed to native chrome and the titlebar. */
getIdentity: () => Promise
@@ -2007,6 +2055,7 @@ export type PreloadApi = {
codexUsage: CodexUsageApi
openCodeUsage: OpenCodeUsageApi
aiVault: AiVaultApi
+ nativeChat: NativeChatApi
fs: {
readDir: (args: { dirPath: string; connectionId?: string }) => Promise
readFile: (args: {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index d29a1b701c7..bac40a93a90 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -153,6 +153,12 @@ import type {
} from '../shared/automations-types'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
import type { AiVaultListArgs } from '../shared/ai-vault-types'
+import type { AgentType } from '../shared/native-chat-types'
+import type {
+ NativeChatAppendedMessages,
+ NativeChatAppendedPayload,
+ NativeChatReadSessionResult
+} from './api-types'
import {
ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT,
type EditorPrepareHotExitDetail
@@ -1771,7 +1777,16 @@ const api = {
refreshAgents: (args?: PreflightRuntimeContext): Promise =>
ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise =>
- ipcRenderer.invoke('preflight:detectRemoteAgents', args)
+ ipcRenderer.invoke('preflight:detectRemoteAgents', args),
+ detectRemoteWindowsTerminalCapabilities: (args: {
+ connectionId: string
+ }): Promise<{
+ wslAvailable: boolean
+ wslDistros: string[]
+ pwshAvailable: boolean
+ gitBashAvailable: boolean
+ hostPlatform: NodeJS.Platform | null
+ }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args)
},
notifications: {
@@ -3480,6 +3495,40 @@ const api = {
ipcRenderer.invoke('aiVault:listSessions', args)
},
+ nativeChat: {
+ readSession: (
+ agent: AgentType,
+ sessionId: string,
+ limit?: number,
+ transcriptPath?: string
+ ): Promise =>
+ ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }),
+ /** Start live tailing for a transcript. `onAppended` fires with only the
+ * newly-appended messages. Returns an unsubscribe fn that closes the
+ * main-process watcher (subscriptionId routes appends to this caller). */
+ subscribe: (
+ args: {
+ subscriptionId: string
+ agent: AgentType
+ sessionId: string
+ transcriptPath?: string
+ },
+ onAppended: (messages: NativeChatAppendedMessages) => void
+ ): (() => void) => {
+ const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => {
+ if (payload.subscriptionId === args.subscriptionId) {
+ onAppended(payload.messages)
+ }
+ }
+ ipcRenderer.on('nativeChat:appended', listener)
+ ipcRenderer.send('nativeChat:subscribe', args)
+ return () => {
+ ipcRenderer.removeListener('nativeChat:appended', listener)
+ ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId })
+ }
+ }
+ },
+
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise =>
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts
index 6d2f9339b9e..668354b03db 100644
--- a/src/relay/preflight-handler.test.ts
+++ b/src/relay/preflight-handler.test.ts
@@ -4,6 +4,14 @@ const { execFileAsyncMock } = vi.hoisted(() => ({
execFileAsyncMock: vi.fn()
}))
+const { isPwshAvailableMock, isWslAvailableMock, listWslDistrosMock, isGitBashAvailableMock } =
+ vi.hoisted(() => ({
+ isPwshAvailableMock: vi.fn(),
+ isWslAvailableMock: vi.fn(),
+ listWslDistrosMock: vi.fn(),
+ isGitBashAvailableMock: vi.fn()
+ }))
+
vi.mock('child_process', () => {
const execFileWithPromisify = Object.assign(vi.fn(), {
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
@@ -11,11 +19,19 @@ vi.mock('child_process', () => {
return { execFile: execFileWithPromisify }
})
+vi.mock('../main/pwsh', () => ({ isPwshAvailable: isPwshAvailableMock }))
+vi.mock('../main/wsl', () => ({
+ isWslAvailable: isWslAvailableMock,
+ listWslDistros: listWslDistrosMock
+}))
+vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock }))
+
import {
buildCommandLookupSpec,
buildCommandLookupSpecs,
hasAbsoluteCommandPath,
- isCommandOnPathForRelay
+ isCommandOnPathForRelay,
+ PreflightHandler
} from './preflight-handler'
function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] {
@@ -43,6 +59,10 @@ function fishLookupArgs(command: string): string[] {
beforeEach(() => {
execFileAsyncMock.mockReset()
+ isPwshAvailableMock.mockReset()
+ isWslAvailableMock.mockReset()
+ listWslDistrosMock.mockReset()
+ isGitBashAvailableMock.mockReset()
})
describe('buildCommandLookupSpec', () => {
@@ -214,3 +234,45 @@ describe('hasAbsoluteCommandPath', () => {
).toBe(true)
})
})
+
+describe('PreflightHandler', () => {
+ it('reports remote Windows shell capabilities through the SSH preflight path', async () => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: 'win32'
+ })
+ isWslAvailableMock.mockReturnValue(true)
+ listWslDistrosMock.mockReturnValue(['Ubuntu'])
+ isPwshAvailableMock.mockReturnValue(true)
+ isGitBashAvailableMock.mockReturnValue(true)
+
+ const requestHandlers = new Map) => Promise>()
+ const dispatcher = {
+ onRequest: vi.fn(
+ (method: string, handler: (params: Record) => Promise) => {
+ requestHandlers.set(method, handler)
+ }
+ )
+ }
+
+ new PreflightHandler(dispatcher as never)
+
+ try {
+ const handler = requestHandlers.get('preflight.detectWindowsTerminalCapabilities')
+ expect(handler).toBeDefined()
+ await expect(handler!({})).resolves.toEqual({
+ wslAvailable: true,
+ wslDistros: ['Ubuntu'],
+ pwshAvailable: true,
+ gitBashAvailable: true,
+ hostPlatform: 'win32'
+ })
+ } finally {
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
+ }
+ })
+})
diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts
index a0382d77275..cdfc42c2b21 100644
--- a/src/relay/preflight-handler.ts
+++ b/src/relay/preflight-handler.ts
@@ -4,6 +4,9 @@ import { promisify } from 'util'
import path, { win32 } from 'path'
import type { RelayDispatcher } from './dispatcher'
import { buildRelayCommandEnv } from './relay-command-env'
+import { isPwshAvailable } from '../main/pwsh'
+import { isWslAvailable, listWslDistros } from '../main/wsl'
+import { isGitBashAvailable } from '../main/git-bash'
const execFileAsync = promisify(execFile)
@@ -33,6 +36,9 @@ export class PreflightHandler {
private registerHandlers(): void {
this.dispatcher.onRequest('preflight.detectAgents', (p) => this.detectAgents(p))
+ this.dispatcher.onRequest('preflight.detectWindowsTerminalCapabilities', () =>
+ this.detectWindowsTerminalCapabilities()
+ )
}
// Why: the client sends the command list rather than importing TUI_AGENT_CONFIG
@@ -54,6 +60,28 @@ export class PreflightHandler {
return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] }
}
+ private async detectWindowsTerminalCapabilities(): Promise<{
+ wslAvailable: boolean
+ wslDistros: string[]
+ pwshAvailable: boolean
+ gitBashAvailable: boolean
+ hostPlatform: NodeJS.Platform | null
+ }> {
+ const [wslAvailable, pwshAvailable, gitBashAvailable] = await Promise.all([
+ Promise.resolve(isWslAvailable()).catch(() => false),
+ Promise.resolve(isPwshAvailable()).catch(() => false),
+ Promise.resolve(isGitBashAvailable()).catch(() => false)
+ ])
+ const wslDistros = wslAvailable ? await Promise.resolve(listWslDistros()).catch(() => []) : []
+ return {
+ wslAvailable,
+ wslDistros,
+ pwshAvailable,
+ gitBashAvailable,
+ hostPlatform: process.platform
+ }
+ }
+
// Why: SSH exec channels give the relay a minimal environment without shell
// startup files sourced. Ask the user's configured shell so agent dirs added
// by zsh/bash/fish startup hooks match the remote terminal experience.
diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts
index 983ac234848..321458c65da 100644
--- a/src/relay/pty-handler.test.ts
+++ b/src/relay/pty-handler.test.ts
@@ -4,6 +4,12 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
+import * as gitBash from '../main/git-bash'
+import * as ptyShellUtils from './pty-shell-utils'
+import {
+ resolveSetupAgentSequenceLaunchCommand,
+ SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
+} from '../shared/setup-agent-sequencing'
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -165,8 +171,183 @@ describe('PtyHandler', () => {
expect(handler.activePtyCount).toBe(1)
})
+ it('uses an explicit shell override and falls back to the default shell otherwise', async () => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: 'win32'
+ })
+ const resolveDefaultShellSpy = vi
+ .spyOn(ptyShellUtils, 'resolveDefaultShell')
+ .mockReturnValue('/default-shell')
+ try {
+ await dispatcher.callRequest('pty.spawn', {
+ cols: 80,
+ rows: 24,
+ shellOverride: 'powershell.exe'
+ })
+ expect(mockPtySpawn).toHaveBeenCalledWith(
+ 'powershell.exe',
+ expect.any(Array),
+ expect.any(Object)
+ )
+
+ mockPtySpawn.mockClear()
+
+ await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
+ expect(mockPtySpawn).toHaveBeenCalledWith(
+ '/default-shell',
+ expect.any(Array),
+ expect.any(Object)
+ )
+ } finally {
+ resolveDefaultShellSpy.mockRestore()
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
+ }
+ })
+
+ it('ignores Windows shell overrides on non-Windows relay hosts', async () => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: 'linux'
+ })
+ const resolveDefaultShellSpy = vi
+ .spyOn(ptyShellUtils, 'resolveDefaultShell')
+ .mockReturnValue('/default-shell')
+ try {
+ await dispatcher.callRequest('pty.spawn', {
+ cols: 80,
+ rows: 24,
+ shellOverride: 'powershell.exe'
+ })
+
+ expect(mockPtySpawn).toHaveBeenCalledWith(
+ '/default-shell',
+ expect.any(Array),
+ expect.any(Object)
+ )
+ } finally {
+ resolveDefaultShellSpy.mockRestore()
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
+ }
+ })
+
+ it('rejects unsupported shell overrides on Windows relay hosts', async () => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: 'win32'
+ })
+ try {
+ await expect(
+ dispatcher.callRequest('pty.spawn', {
+ cols: 80,
+ rows: 24,
+ shellOverride: 'notepad.exe'
+ })
+ ).rejects.toThrow('Unsupported Windows shell override')
+ expect(mockPtySpawn).not.toHaveBeenCalled()
+ } finally {
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
+ }
+ })
+
+ it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: 'win32'
+ })
+ const resolveGitBashSpy = vi
+ .spyOn(gitBash, 'resolveWindowsGitBashShellPath')
+ .mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe')
+ try {
+ await dispatcher.callRequest('pty.spawn', {
+ cols: 80,
+ rows: 24,
+ shellOverride: 'git-bash'
+ })
+
+ expect(resolveGitBashSpy).toHaveBeenCalledWith('git-bash')
+ expect(mockPtySpawn).toHaveBeenCalledWith(
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
+ expect.any(Array),
+ expect.any(Object)
+ )
+ } finally {
+ resolveGitBashSpy.mockRestore()
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
+ }
+ })
+
+ it('passes the selected WSL distro to relay launches on Windows', async () => {
+ const originalPlatform = process.platform
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: 'win32'
+ })
+ try {
+ await dispatcher.callRequest('pty.spawn', {
+ cols: 80,
+ rows: 24,
+ shellOverride: 'wsl.exe',
+ terminalWindowsWslDistro: 'Ubuntu-24.04'
+ })
+
+ expect(mockPtySpawn).toHaveBeenCalledWith(
+ 'wsl.exe',
+ ['-d', 'Ubuntu-24.04'],
+ expect.any(Object)
+ )
+ } finally {
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
+ }
+ })
+
+ it('keeps SSH spawn commands as hints unless provider delivery is requested', async () => {
+ await dispatcher.callRequest('pty.spawn', { command: 'echo renderer-owned' })
+
+ vi.advanceTimersByTime(50)
+
+ const term = mockPtySpawn.mock.results[0]?.value
+ expect(term.write).not.toHaveBeenCalled()
+ })
+
+ it('submits provider-delivered spawn commands to the relay shell', async () => {
+ await dispatcher.callRequest('pty.spawn', {
+ command: 'echo provider-owned',
+ commandDelivery: 'provider'
+ })
+
+ vi.advanceTimersByTime(49)
+ const term = mockPtySpawn.mock.results[0]?.value
+ expect(handler.retainedStartupCommandCount).toBe(1)
+ expect(term.write).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(1)
+ const submit = process.platform === 'win32' ? '\r' : '\n'
+ expect(term.write).toHaveBeenCalledWith(`echo provider-owned${submit}`)
+ expect(handler.retainedStartupCommandCount).toBe(0)
+ })
+
it.skipIf(process.platform === 'win32')(
- 'enables shell-ready marker env for delivery-hinted startup commands',
+ 'emits shell-ready markers for renderer-delivered startup commands',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
@@ -177,6 +358,7 @@ describe('PtyHandler', () => {
try {
await dispatcher.callRequest('pty.spawn', {
env: { HOME: homeDir },
+ command: 'echo renderer-owned',
startupCommandDelivery: 'shell-ready'
})
} finally {
@@ -197,11 +379,12 @@ describe('PtyHandler', () => {
| { env?: Record }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
+ expect(handler.retainedStartupCommandCount).toBe(0)
}
)
it.skipIf(process.platform === 'win32')(
- 'enables shell-ready marker env for Codex native prefill commands',
+ 'emits shell-ready markers for renderer-delivered Codex native prefill commands',
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
@@ -232,6 +415,197 @@ describe('PtyHandler', () => {
| { env?: Record }
| undefined
expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
+ expect(handler.retainedStartupCommandCount).toBe(0)
+ }
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'enables shell-ready marker env for provider-delivered startup commands',
+ async () => {
+ const oldShell = process.env.SHELL
+ const oldHome = process.env.HOME
+ const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-env-'))
+
+ process.env.SHELL = '/bin/bash'
+ process.env.HOME = homeDir
+ try {
+ await dispatcher.callRequest('pty.spawn', {
+ env: { HOME: homeDir },
+ command: 'echo provider-owned',
+ commandDelivery: 'provider',
+ startupCommandDelivery: 'shell-ready'
+ })
+ } finally {
+ if (oldShell === undefined) {
+ delete process.env.SHELL
+ } else {
+ process.env.SHELL = oldShell
+ }
+ if (oldHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = oldHome
+ }
+ rmSync(homeDir, { recursive: true, force: true })
+ }
+
+ const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
+ | { env?: Record }
+ | undefined
+ expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
+ }
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'uses the sequenced startup command hint for provider shell-ready detection',
+ async () => {
+ const oldShell = process.env.SHELL
+ const oldHome = process.env.HOME
+ const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-sequenced-ready-env-'))
+
+ process.env.SHELL = '/bin/bash'
+ process.env.HOME = homeDir
+ try {
+ await dispatcher.callRequest('pty.spawn', {
+ env: {
+ HOME: homeDir,
+ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --prefill 'linked issue context'"
+ },
+ command: 'bash -lc wait-for-setup-wrapper',
+ commandDelivery: 'provider'
+ })
+ } finally {
+ if (oldShell === undefined) {
+ delete process.env.SHELL
+ } else {
+ process.env.SHELL = oldShell
+ }
+ if (oldHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = oldHome
+ }
+ rmSync(homeDir, { recursive: true, force: true })
+ }
+
+ const spawnOptions = mockPtySpawn.mock.calls[0]?.[2] as
+ | { env?: Record }
+ | undefined
+ expect(spawnOptions?.env?.ORCA_SHELL_READY_MARKER).toBe('1')
+ }
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'waits for the shell-ready marker before provider-delivered startup commands',
+ async () => {
+ let dataCallback: ((data: string) => void) | undefined
+ const term = {
+ ...mockPtyInstance,
+ onData: vi.fn((cb: (data: string) => void) => {
+ dataCallback = cb
+ }),
+ onExit: vi.fn()
+ }
+ mockPtySpawn.mockReturnValue(term)
+ const oldShell = process.env.SHELL
+ const oldHome = process.env.HOME
+ const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-ready-spawn-'))
+
+ process.env.SHELL = '/bin/bash'
+ process.env.HOME = homeDir
+ try {
+ await dispatcher.callRequest('pty.spawn', {
+ env: { HOME: homeDir },
+ command: 'echo after-ready',
+ commandDelivery: 'provider',
+ startupCommandDelivery: 'shell-ready'
+ })
+ } finally {
+ if (oldShell === undefined) {
+ delete process.env.SHELL
+ } else {
+ process.env.SHELL = oldShell
+ }
+ if (oldHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = oldHome
+ }
+ rmSync(homeDir, { recursive: true, force: true })
+ }
+
+ vi.advanceTimersByTime(1499)
+ expect(term.write).not.toHaveBeenCalled()
+
+ dataCallback?.('\x1b]777;orca-shell-ready\x07user@remote $ ')
+ vi.advanceTimersByTime(49)
+ expect(term.write).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(1)
+
+ expect(term.write).toHaveBeenCalledWith('echo after-ready\n')
+ expect(handler.retainedStartupCommandCount).toBe(0)
+ vi.advanceTimersByTime(8)
+ expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
+ id: 'pty-1',
+ data: 'user@remote $ '
+ })
+ }
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'flushes held shell-ready marker bytes when provider delivery falls back',
+ async () => {
+ let dataCallback: ((data: string) => void) | undefined
+ const term = {
+ ...mockPtyInstance,
+ onData: vi.fn((cb: (data: string) => void) => {
+ dataCallback = cb
+ }),
+ onExit: vi.fn()
+ }
+ mockPtySpawn.mockReturnValue(term)
+ const oldShell = process.env.SHELL
+ const oldHome = process.env.HOME
+ const homeDir = mkdtempSync(join(tmpdir(), 'relay-provider-fallback-spawn-'))
+
+ process.env.SHELL = '/bin/bash'
+ process.env.HOME = homeDir
+ try {
+ await dispatcher.callRequest('pty.spawn', {
+ env: { HOME: homeDir },
+ command: 'echo fallback',
+ commandDelivery: 'provider',
+ startupCommandDelivery: 'shell-ready'
+ })
+ } finally {
+ if (oldShell === undefined) {
+ delete process.env.SHELL
+ } else {
+ process.env.SHELL = oldShell
+ }
+ if (oldHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = oldHome
+ }
+ rmSync(homeDir, { recursive: true, force: true })
+ }
+
+ dataCallback?.('\x1b]777;orca-shell-ready')
+ vi.advanceTimersByTime(1500)
+
+ expect(term.write).toHaveBeenCalledWith('echo fallback\n')
+ vi.advanceTimersByTime(8)
+ expect(dispatcher.notify).toHaveBeenCalledWith('pty.data', {
+ id: 'pty-1',
+ data: '\x1b]777;orca-shell-ready'
+ })
+
+ const result = await dispatcher.callRequest('pty.attach', {
+ id: 'pty-1',
+ suppressReplayNotification: true
+ })
+ expect(result).toEqual({ replay: '\x1b]777;orca-shell-ready' })
}
)
@@ -252,6 +626,42 @@ describe('PtyHandler', () => {
expect(killSpy).toHaveBeenCalledWith('SIGKILL')
})
+ it('does not submit provider-delivered commands for stale spawn responses', async () => {
+ const killSpy = vi.fn()
+ const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
+ mockPtySpawn.mockReturnValue(term)
+
+ await dispatcher.callRequest(
+ 'pty.spawn',
+ { command: 'echo stale', commandDelivery: 'provider' },
+ { isStale: () => true }
+ )
+
+ vi.advanceTimersByTime(50)
+ expect(term.write).not.toHaveBeenCalled()
+ expect(handler.retainedStartupCommandCount).toBe(0)
+ expect(killSpy).toHaveBeenCalledWith('SIGTERM')
+ })
+
+ it('releases pending provider-delivered commands on shutdown before delivery', async () => {
+ const killSpy = vi.fn()
+ const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
+ mockPtySpawn.mockReturnValue(term)
+
+ await dispatcher.callRequest('pty.spawn', {
+ command: 'echo stop-before-run',
+ commandDelivery: 'provider'
+ })
+ expect(handler.retainedStartupCommandCount).toBe(1)
+
+ await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
+ vi.advanceTimersByTime(50)
+
+ expect(handler.retainedStartupCommandCount).toBe(0)
+ expect(term.write).not.toHaveBeenCalled()
+ expect(killSpy).toHaveBeenCalledWith('SIGKILL')
+ })
+
it('increments PTY ids on each spawn', async () => {
const r1 = await dispatcher.callRequest('pty.spawn', {})
const r2 = await dispatcher.callRequest('pty.spawn', {})
@@ -759,6 +1169,20 @@ describe('PtyHandler', () => {
expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi')
})
+ it('lets relay env augmenters resolve the original sequenced startup command hint', async () => {
+ handler.addEnvAugmenter((ctx) => ({
+ SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? ''
+ }))
+
+ await dispatcher.callRequest('pty.spawn', {
+ command: 'powershell wait-wrapper',
+ env: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'omp --resume' }
+ })
+
+ const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record }
+ expect(spawnEnv.env.SEEN_LAUNCH_COMMAND_HINT).toBe('omp --resume')
+ })
+
it.skipIf(process.platform === 'win32')(
'wraps bash spawns to restore overlay env after remote startup files',
async () => {
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index b1419e2facb..5f4fff86610 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -1,6 +1,8 @@
/* oxlint-disable max-lines */
import type { IPty } from 'node-pty'
import type * as NodePty from 'node-pty'
+import { resolveWindowsGitBashShellPath } from '../main/git-bash'
+import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
resolveDefaultShell,
@@ -13,6 +15,13 @@ import {
import { getRelayShellLaunchConfig } from './pty-shell-launch'
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery'
+import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
+import {
+ createShellReadyScanState,
+ drainShellReadyHeldBytes,
+ scanForShellReady,
+ type ShellReadyScanState
+} from '../main/shell-ready-marker-scanner'
// Why: node-pty is a native addon that may not be installed on the remote.
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
@@ -53,12 +62,21 @@ type ManagedPty = {
paneKey?: string
tabId?: string
worktreeId?: string
+ startupCommand?: ManagedStartupCommand
}
type PendingPtyOutput = {
data: string
}
+type ManagedStartupCommand = {
+ command: string
+ delivered: boolean
+ waitForShellReady: boolean
+ scanState: ShellReadyScanState | null
+ timer: ReturnType | null
+}
+
function disposeManagedPty(managed: ManagedPty): void {
if (managed.disposed) {
return
@@ -98,6 +116,8 @@ const INTERACTIVE_OUTPUT_WINDOW_MS = 100
const INTERACTIVE_OUTPUT_MAX_CHARS = 1024
const INTERACTIVE_REDRAW_MAX_CHARS = PTY_OUTPUT_FLUSH_CHUNK_CHARS
const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024
+const STARTUP_COMMAND_WRITE_DELAY_MS = 50
+const STARTUP_COMMAND_SHELL_READY_FALLBACK_MS = 1500
const ALLOWED_SIGNALS = new Set([
'SIGINT',
'SIGTERM',
@@ -110,6 +130,32 @@ const ALLOWED_SIGNALS = new Set([
'SIGUSR2'
])
+const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([
+ 'powershell.exe',
+ 'powershell',
+ 'pwsh.exe',
+ 'pwsh',
+ 'cmd.exe',
+ 'cmd',
+ 'wsl.exe',
+ 'wsl',
+ WINDOWS_GIT_BASH_SHELL
+])
+
+function resolvePtyShellOverride(shellOverride: string): string {
+ if (!shellOverride) {
+ return ''
+ }
+ if (process.platform !== 'win32') {
+ return ''
+ }
+ const normalized = shellOverride.toLowerCase()
+ if (!ALLOWED_WINDOWS_SHELL_OVERRIDES.has(normalized)) {
+ throw new Error(`Unsupported Windows shell override: ${shellOverride}`)
+ }
+ return resolveWindowsGitBashShellPath(shellOverride) ?? shellOverride
+}
+
type SerializedPtyEntry = {
id: string
pid: number
@@ -222,14 +268,71 @@ export class PtyHandler {
return { ...baseEnv, ...augmented }
}
+ private clearStartupCommandTimer(managed: ManagedPty): void {
+ if (managed.startupCommand?.timer) {
+ clearTimeout(managed.startupCommand.timer)
+ managed.startupCommand.timer = null
+ }
+ }
+
+ private appendReplayBuffer(managed: ManagedPty, data: string): void {
+ managed.buffered += data
+ if (managed.buffered.length > REPLAY_BUFFER_MAX) {
+ managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX)
+ }
+ }
+
+ private releaseStartupCommand(managed: ManagedPty): void {
+ this.clearStartupCommandTimer(managed)
+ managed.startupCommand = undefined
+ }
+
+ private scheduleStartupCommandDelivery(managed: ManagedPty, delayMs: number): void {
+ const startup = managed.startupCommand
+ if (!startup || startup.delivered || managed.disposed) {
+ return
+ }
+ this.clearStartupCommandTimer(managed)
+ startup.timer = setTimeout(() => {
+ startup.timer = null
+ this.deliverStartupCommand(managed)
+ }, delayMs)
+ }
+
+ private deliverStartupCommand(managed: ManagedPty): void {
+ const startup = managed.startupCommand
+ if (!startup || startup.delivered || managed.disposed) {
+ return
+ }
+ startup.delivered = true
+ this.clearStartupCommandTimer(managed)
+ if (startup.scanState) {
+ const heldBytes = drainShellReadyHeldBytes(startup.scanState)
+ if (heldBytes) {
+ this.appendReplayBuffer(managed, heldBytes)
+ this.enqueuePtyOutput(managed.id, heldBytes)
+ }
+ }
+ const submit = process.platform === 'win32' ? '\r' : '\n'
+ const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n')
+ const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}`
+ managed.startupCommand = undefined
+ managed.pty.write(payload)
+ }
+
/** Wire onData/onExit listeners for a managed PTY and store it. */
private wireAndStore(managed: ManagedPty): void {
this.ptys.set(managed.id, managed)
managed.pty.onData((data: string) => {
- managed.buffered += data
- if (managed.buffered.length > REPLAY_BUFFER_MAX) {
- managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX)
+ const startup = managed.startupCommand
+ if (startup?.waitForShellReady && startup.scanState && !startup.delivered) {
+ const scanned = scanForShellReady(startup.scanState, data)
+ data = scanned.output
+ if (scanned.matched) {
+ this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS)
+ }
}
+ this.appendReplayBuffer(managed, data)
this.enqueuePtyOutput(managed.id, data)
})
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
@@ -253,6 +356,7 @@ export class PtyHandler {
clearTimeout(managed.killTimer)
managed.killTimer = undefined
}
+ this.clearStartupCommandTimer(managed)
this.flushPtyOutput(managed.id)
this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
this.notifyExitListener(managed)
@@ -421,27 +525,39 @@ export class PtyHandler {
const rows = (params.rows as number) || 24
const cwd = (params.cwd as string) || resolveDefaultCwd()
const env = params.env as Record | undefined
- const shell = resolveDefaultShell()
+ const shellOverride =
+ typeof params.shellOverride === 'string' ? params.shellOverride.trim() : ''
+ const resolvedShellOverride = resolvePtyShellOverride(shellOverride)
+ const shell = resolvedShellOverride || resolveDefaultShell()
const id = `pty-${this.nextId++}`
// Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay
// dirs) override renderer-supplied env so live remote paths and hook coords
// win over local userData paths. The context lets overlay augmenters derive
// per-PTY OpenCode/Pi directories from the stable paneKey when present.
- // `command` is forwarded by ssh-pty-provider.ts only as a hint for
- // overlay resolution — the relay still launches a login shell and the
- // command is typed in via pty.data writes.
+ // `command` is usually forwarded by ssh-pty-provider.ts only as a hint
+ // for overlay resolution; runtime-owned PTYs opt into relay delivery
+ // because no renderer TerminalPane exists to type the command.
const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
const command = typeof params.command === 'string' ? params.command : undefined
+ const terminalWindowsWslDistro =
+ typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null
+ const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer'
+ const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined
const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command })
- // Why: only explicit shell-ready hints are trusted here; native Codex
- // prefill detection still auto-enables readiness through the predicate.
- const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
- emitReadyMarker: shouldUseShellReadyStartupDelivery({
- command,
+ const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command)
+ const shouldEmitShellReadyMarker =
+ launchCommandHint !== undefined &&
+ shouldUseShellReadyStartupDelivery({
+ command: launchCommandHint,
startupCommandDelivery:
params.startupCommandDelivery === 'shell-ready' ? 'shell-ready' : undefined
})
+ // Why: renderer- and provider-delivered startup commands both use this
+ // marker; the side responsible for delivery also strips it from output.
+ const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv, process.platform, {
+ terminalWindowsWslDistro,
+ emitReadyMarker: shouldEmitShellReadyMarker
})
// Why: SSH exec channels give the relay a minimal environment without
@@ -454,7 +570,9 @@ export class PtyHandler {
cols,
rows,
cwd,
- env: { ...spawnEnv, ...shellLaunch.env }
+ // Why: relay shells inherit process.env; never let an ambient Orca marker
+ // enable shell-ready behavior unless this spawn explicitly requested it.
+ env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
// Why: capture the renderer-supplied paneKey on the managed entry so the
@@ -470,13 +588,28 @@ export class PtyHandler {
buffered: '',
paneKey,
tabId,
- worktreeId
+ worktreeId,
+ ...(shouldProviderDeliverCommand
+ ? {
+ startupCommand: {
+ command,
+ delivered: false,
+ waitForShellReady: shellLaunch.env.ORCA_SHELL_READY_MARKER === '1',
+ scanState:
+ shellLaunch.env.ORCA_SHELL_READY_MARKER === '1'
+ ? createShellReadyScanState()
+ : null,
+ timer: null
+ }
+ }
+ : {})
}
this.wireAndStore(managed)
if (context?.isStale()) {
// Why: if the client reconnected while pty.spawn was in flight, the
// response is discarded and no renderer can own this PTY. Shut it down
// immediately so it does not linger as an unreachable remote shell.
+ this.releaseStartupCommand(managed)
term.kill('SIGTERM')
managed.killTimer = setTimeout(() => {
const still = this.ptys.get(id)
@@ -491,6 +624,13 @@ export class PtyHandler {
this.ptys.delete(id)
}
}, 5000)
+ } else if (managed.startupCommand) {
+ this.scheduleStartupCommandDelivery(
+ managed,
+ managed.startupCommand.waitForShellReady
+ ? STARTUP_COMMAND_SHELL_READY_FALLBACK_MS
+ : STARTUP_COMMAND_WRITE_DELAY_MS
+ )
}
return { id }
}
@@ -562,6 +702,7 @@ export class PtyHandler {
}
if (immediate) {
+ this.releaseStartupCommand(managed)
this.flushPtyOutput(id)
managed.pty.kill('SIGKILL')
// Why: SIGKILL has already reaped the child; release the ptmx fd on the
@@ -581,6 +722,7 @@ export class PtyHandler {
this.ptys.delete(id)
this.clearPtyFlowState(id)
} else {
+ this.releaseStartupCommand(managed)
managed.pty.kill('SIGTERM')
// Why: Some processes ignore SIGTERM (e.g. a hung child, a custom signal
@@ -761,7 +903,9 @@ export class PtyHandler {
cols: entry.cols,
rows: entry.rows,
cwd: entry.cwd,
- env: { ...spawnEnv, ...shellLaunch.env }
+ // Why: revived shells should not inherit an ambient shell-ready marker
+ // because no provider-delivered startup command is waiting on it.
+ env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env }
})
this.wireAndStore({
id: entry.id,
@@ -821,6 +965,7 @@ export class PtyHandler {
clearTimeout(managed.killTimer)
managed.killTimer = undefined
}
+ this.clearStartupCommandTimer(managed)
// Why: SIGKILL (not SIGTERM) before destroy. The relay process is
// exiting; any SIGTERM-ignoring remote shell (editor with unsaved
// buffers, a hung child with a bad handler, a process in
@@ -843,6 +988,16 @@ export class PtyHandler {
return this.ptys.size
}
+ get retainedStartupCommandCount(): number {
+ let count = 0
+ for (const managed of this.ptys.values()) {
+ if (managed.startupCommand) {
+ count += 1
+ }
+ }
+ return count
+ }
+
get graceTimerActive(): boolean {
return this.graceTimer !== null
}
diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts
index d2ee25e2d41..935f48d46d4 100644
--- a/src/relay/pty-shell-launch.ts
+++ b/src/relay/pty-shell-launch.ts
@@ -25,7 +25,10 @@ function shellBasename(shellPath: string): string {
return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? ''
}
-function windowsShellArgs(shellName: string): string[] | null {
+function windowsShellArgs(
+ shellName: string,
+ options: { terminalWindowsWslDistro?: string | null } = {}
+): string[] | null {
if (shellName === 'powershell.exe' || shellName === 'powershell') {
return ['-NoLogo']
}
@@ -35,6 +38,10 @@ function windowsShellArgs(shellName: string): string[] | null {
if (shellName === 'cmd.exe' || shellName === 'cmd') {
return []
}
+ if (shellName === 'wsl.exe' || shellName === 'wsl') {
+ const distro = options.terminalWindowsWslDistro?.trim()
+ return distro ? ['-d', distro] : []
+ }
return null
}
@@ -247,14 +254,20 @@ export function getRelayShellLaunchConfig(
shellPath: string,
env: Record,
platform: NodeJS.Platform = process.platform,
- options: { emitReadyMarker?: boolean } = {}
+ options: { emitReadyMarker?: boolean; terminalWindowsWslDistro?: string | null } = {}
): RelayShellLaunchConfig {
const shellName = shellBasename(shellPath)
const emitReadyMarker = options.emitReadyMarker === true
if (platform === 'win32') {
// Why: pwsh also exists on POSIX remotes; Windows-specific shell args must
// only apply when the relay itself is running on native Windows.
- return { args: windowsShellArgs(shellName) ?? [], env: {} }
+ return {
+ args:
+ windowsShellArgs(shellName, {
+ terminalWindowsWslDistro: options.terminalWindowsWslDistro
+ }) ?? [],
+ env: {}
+ }
}
if (shellName !== 'zsh' && shellName !== 'bash') {
diff --git a/src/relay/relay.ts b/src/relay/relay.ts
index 356d778125b..7f1e6212019 100644
--- a/src/relay/relay.ts
+++ b/src/relay/relay.ts
@@ -57,6 +57,7 @@ import {
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind'
+import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
import { pickRemoteCliEnv } from './remote-cli-env'
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
@@ -536,8 +537,10 @@ async function main(): Promise {
// Why: source-dir defaulting is keyed on which Pi-compatible agent is
// being launched (Pi vs OMP). Install Orca's guarded extension into that
// real remote agent dir without redirecting PI_CODING_AGENT_DIR.
- const kind = detectPiAgentKindFromCommand(ctx.command)
- const hasLaunchCommand = typeof ctx.command === 'string' && ctx.command.trim().length > 0
+ const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command)
+ const kind = detectPiAgentKindFromCommand(launchCommandHint)
+ const hasLaunchCommand =
+ typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0
const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand
if (kind === 'pi') {
const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi')
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx
index 899524a11dc..43d07dc285a 100644
--- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx
+++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx
@@ -150,6 +150,8 @@ function renderCard(
requiresExplicitSetupChoice={false}
setupDecision={null}
onSetupDecisionChange={() => {}}
+ setupAgentStartupPolicy="start-immediately"
+ onSetupAgentStartupPolicyChange={() => {}}
shouldWaitForSetupCheck={false}
resolvedSetupDecision={null}
createError={null}
@@ -266,6 +268,58 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
expect(onChanges).toEqual([true])
})
+ it('shows the setup startup policy toggle only when setup is available', () => {
+ current = renderCard({
+ advancedOpen: true,
+ setupControlsEnabled: true,
+ setupConfig: {
+ source: 'yaml',
+ command: '# defaultTabs[1]\npnpm dev',
+ kind: 'default-tabs'
+ }
+ })
+ expect(current.container.textContent).not.toContain(
+ 'Wait for setup to complete before starting agent'
+ )
+
+ act(() => current?.root.unmount())
+ current?.container.remove()
+
+ current = renderCard({
+ advancedOpen: true,
+ setupControlsEnabled: true,
+ setupConfig: {
+ source: 'yaml',
+ command: 'pnpm install',
+ kind: 'setup'
+ }
+ })
+ expect(current.container.textContent).toContain(
+ 'Wait for setup to complete before starting agent'
+ )
+ })
+
+ it('emits the setup startup policy toggle value', () => {
+ const changes: string[] = []
+ current = renderCard({
+ advancedOpen: true,
+ setupControlsEnabled: true,
+ setupConfig: {
+ source: 'yaml',
+ command: 'pnpm install',
+ kind: 'setup'
+ },
+ onSetupAgentStartupPolicyChange: (next) => changes.push(next)
+ })
+
+ const waitSwitch = current.container.querySelector(
+ '[role="switch"][aria-label="Wait for setup to complete before starting agent"]'
+ )
+ expect(waitSwitch).toBeTruthy()
+ act(() => waitSwitch?.click())
+ expect(changes).toEqual(['wait-for-setup'])
+ })
+
it('does not disable folder workspace creation when only source lookup needs SSH', () => {
current = renderCard({
eligibleRepos: [
diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
index 6f68d168bb2..aa3bd8a60c2 100644
--- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx
+++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx
@@ -16,6 +16,7 @@ import {
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { SettingsSwitch } from '@/components/settings/SettingsFormControls'
import type RepoCombobox from '@/components/repo/RepoCombobox'
import AgentCombobox from '@/components/agent/AgentCombobox'
import { getAgentCatalog } from '@/lib/agent-catalog'
@@ -35,6 +36,7 @@ import type {
GitHubWorkItem,
GitLabWorkItem,
LinearIssue,
+ SetupAgentStartupPolicy,
SparsePreset,
TuiAgent
} from '../../../shared/types'
@@ -116,6 +118,8 @@ type NewWorkspaceComposerCardProps = {
requiresExplicitSetupChoice: boolean
setupDecision: 'run' | 'skip' | null
onSetupDecisionChange: (value: 'run' | 'skip') => void
+ setupAgentStartupPolicy: SetupAgentStartupPolicy
+ onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: WorkspaceCreateErrorDisplay | null
@@ -354,6 +358,8 @@ export default function NewWorkspaceComposerCard({
requiresExplicitSetupChoice,
setupDecision,
onSetupDecisionChange,
+ setupAgentStartupPolicy,
+ onSetupAgentStartupPolicyChange,
shouldWaitForSetupCheck,
resolvedSetupDecision,
createError,
@@ -421,6 +427,10 @@ export default function NewWorkspaceComposerCard({
? 'Run commands now'
: 'Run setup now'
const setupSkipButtonLabel = setupConfig?.kind === 'setup' ? 'Skip for now' : 'Skip commands'
+ // Why: defaultTabs launch commands can be long-running too, but they are not
+ // the setup command this setting gates agent startup on.
+ const showSetupAgentStartupPolicy =
+ setupControlsEnabled && setupConfig !== null && setupConfig.kind !== 'default-tabs'
const handleSetDefaultAgent = React.useCallback(
(next: TuiAgent | 'blank' | null) => {
@@ -1004,6 +1014,39 @@ export default function NewWorkspaceComposerCard({
) : null}
) : null}
+
+ {showSetupAgentStartupPolicy ? (
+
+
+
+ {translate(
+ 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
+ 'Wait for setup to complete before starting agent'
+ )}
+
+
+ {translate(
+ 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp',
+ 'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
+ )}
+
+
+
+ onSetupAgentStartupPolicyChange(
+ setupAgentStartupPolicy === 'wait-for-setup'
+ ? 'start-immediately'
+ : 'wait-for-setup'
+ )
+ }
+ ariaLabel={translate(
+ 'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
+ 'Wait for setup to complete before starting agent'
+ )}
+ />
+
+ ) : null}
) : null}
diff --git a/src/renderer/src/components/automations/AutomationEditorDialog.tsx b/src/renderer/src/components/automations/AutomationEditorDialog.tsx
index 6d113de78a4..f595290d2a2 100644
--- a/src/renderer/src/components/automations/AutomationEditorDialog.tsx
+++ b/src/renderer/src/components/automations/AutomationEditorDialog.tsx
@@ -6,7 +6,15 @@ import type {
AutomationSchedulePreset,
AutomationWorkspaceMode
} from '../../../../shared/automations-types'
-import type { GlobalSettings, Repo, TuiAgent, Worktree } from '../../../../shared/types'
+import type {
+ GlobalSettings,
+ OrcaHooks,
+ ProjectHostSetup,
+ Repo,
+ SetupDecision,
+ TuiAgent,
+ Worktree
+} from '../../../../shared/types'
import {
isValidAutomationCronSchedule,
isValidAutomationSchedule
@@ -32,6 +40,7 @@ export type AutomationDraft = {
workspaceMode: AutomationWorkspaceMode
workspaceId: string
baseBranch: string
+ setupDecision?: Extract
reuseSession: boolean
precheckCommand: string
precheckTimeoutSeconds: string
@@ -53,6 +62,9 @@ type AutomationEditorDialogProps = {
canSave: boolean
createTarget: AutomationCreateTarget
repos: Repo[]
+ projectHostSetups: ProjectHostSetup[]
+ automationYamlHooksByRepoKey: Record
+ getAutomationHooksCacheKey: (repoId: string) => string
repoMap: Map
worktrees: Worktree[]
settings: GlobalSettings | null
@@ -62,6 +74,7 @@ type AutomationEditorDialogProps = {
onCreateTargetChange: (target: AutomationCreateTarget) => void
onOpenChange: (open: boolean) => void
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
+ onSetupDecisionTouched: () => void
onApplyTemplate: (template: AutomationTemplate) => void
onSave: () => void
}
@@ -74,6 +87,9 @@ export function AutomationEditorDialog({
canSave,
createTarget,
repos,
+ projectHostSetups,
+ automationYamlHooksByRepoKey,
+ getAutomationHooksCacheKey,
repoMap,
worktrees,
settings,
@@ -83,6 +99,7 @@ export function AutomationEditorDialog({
onCreateTargetChange,
onOpenChange,
onDraftChange,
+ onSetupDecisionTouched,
onApplyTemplate,
onSave
}: AutomationEditorDialogProps): React.JSX.Element {
@@ -159,6 +176,9 @@ export function AutomationEditorDialog({
isSaving={isSaving}
canSave={canSave}
repos={repos}
+ projectHostSetups={projectHostSetups}
+ automationYamlHooksByRepoKey={automationYamlHooksByRepoKey}
+ getAutomationHooksCacheKey={getAutomationHooksCacheKey}
repoMap={repoMap}
worktrees={worktrees}
settings={settings}
@@ -170,6 +190,7 @@ export function AutomationEditorDialog({
onProjectChange={onProjectChange}
getRepoHostLabel={getRepoHostLabel}
onDraftChange={onDraftChange}
+ onSetupDecisionTouched={onSetupDecisionTouched}
onOpenChange={onOpenChange}
onSave={onSave}
/>
diff --git a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx
index 22edb5f40a2..62a7be7aab7 100644
--- a/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx
+++ b/src/renderer/src/components/automations/AutomationEditorDialogFooter.tsx
@@ -7,11 +7,18 @@ import AgentCombobox from '@/components/agent/AgentCombobox'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { AutomationWorkspaceMode } from '../../../../shared/automations-types'
-import type { GlobalSettings, Repo, Worktree } from '../../../../shared/types'
+import type {
+ GlobalSettings,
+ OrcaHooks,
+ ProjectHostSetup,
+ Repo,
+ Worktree
+} from '../../../../shared/types'
import type { AgentCatalogEntry } from '@/lib/agent-catalog'
import { Field } from './automation-page-parts'
import { AutomationMissedRunGraceField } from './AutomationMissedRunGraceField'
import { AutomationSessionField } from './AutomationSessionField'
+import { AutomationSetupDecisionField } from './AutomationSetupDecisionField'
import { CreateFromPicker } from './CreateFromPicker'
import { WorkspaceCombobox } from './WorkspaceCombobox'
import AutomationProjectCombobox from './AutomationProjectCombobox'
@@ -25,6 +32,9 @@ type AutomationEditorDialogFooterProps = {
isSaving: boolean
canSave: boolean
repos: Repo[]
+ projectHostSetups: ProjectHostSetup[]
+ automationYamlHooksByRepoKey: Record
+ getAutomationHooksCacheKey: (repoId: string) => string
repoMap: Map
worktrees: Worktree[]
settings: GlobalSettings | null
@@ -36,6 +46,7 @@ type AutomationEditorDialogFooterProps = {
onProjectChange: (projectId: string) => void
getRepoHostLabel?: (repo: Repo) => string | null | undefined
onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
+ onSetupDecisionTouched: () => void
onOpenChange: (open: boolean) => void
onSave: () => void
}
@@ -48,6 +59,9 @@ export function AutomationEditorDialogFooter({
isSaving,
canSave,
repos,
+ projectHostSetups,
+ automationYamlHooksByRepoKey,
+ getAutomationHooksCacheKey,
repoMap,
worktrees,
settings,
@@ -59,6 +73,7 @@ export function AutomationEditorDialogFooter({
onProjectChange,
getRepoHostLabel,
onDraftChange,
+ onSetupDecisionTouched,
onOpenChange,
onSave
}: AutomationEditorDialogFooterProps): React.JSX.Element {
@@ -233,6 +248,18 @@ export function AutomationEditorDialogFooter({
onDraftChange={onDraftChange}
/>
+ {/* Why: a full-width row below the columned controls — the setup
+ switch is a per-automation preference, not a peer of the compact
+ column pickers, so it reads cleaner spanning the dialog. */}
+
diff --git a/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx b/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx
new file mode 100644
index 00000000000..2731dba8164
--- /dev/null
+++ b/src/renderer/src/components/automations/AutomationSetupDecisionField.tsx
@@ -0,0 +1,117 @@
+import React from 'react'
+import { Check, ChevronDown } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import { translate } from '@/i18n/i18n'
+import { getVisibleAutomationSetupDecision } from './automation-setup-decision'
+import type { AutomationCreateTarget, AutomationDraft } from './AutomationEditorDialog'
+import type { OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types'
+
+type AutomationSetupDecisionFieldProps = {
+ createTarget: AutomationCreateTarget
+ draft: AutomationDraft
+ repos: readonly Repo[]
+ projectHostSetups: readonly ProjectHostSetup[]
+ yamlHooks?: OrcaHooks | null
+ onDraftChange: (updater: (current: AutomationDraft) => AutomationDraft) => void
+ onSetupDecisionTouched: () => void
+}
+
+export function AutomationSetupDecisionField({
+ createTarget,
+ draft,
+ repos,
+ projectHostSetups,
+ yamlHooks,
+ onDraftChange,
+ onSetupDecisionTouched
+}: AutomationSetupDecisionFieldProps): React.JSX.Element | null {
+ const [advancedOpen, setAdvancedOpen] = React.useState(false)
+ const defaultDecision = getVisibleAutomationSetupDecision({
+ createTarget,
+ workspaceMode: draft.workspaceMode,
+ repoId: draft.projectId,
+ repos,
+ projectHostSetups,
+ yamlHooks
+ })
+ if (!defaultDecision) {
+ return null
+ }
+ const checked = (draft.setupDecision ?? defaultDecision) === 'run'
+ const label = translate(
+ 'auto.components.automations.AutomationSetupDecisionField.5a7863909c',
+ 'Run setup for each new workspace'
+ )
+ return (
+ // Why: the setup choice is a power-user knob, so tuck it behind the same
+ // Advanced disclosure grammar the New Workspace composer uses.
+
+
+
+
+
+
+
+ {translate(
+ 'auto.components.automations.AutomationSetupDecisionField.874b72195b',
+ "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs."
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx
index eec62ea8413..322f2170f4d 100644
--- a/src/renderer/src/components/automations/AutomationsPage.tsx
+++ b/src/renderer/src/components/automations/AutomationsPage.tsx
@@ -64,7 +64,7 @@ import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/proto
import type { PreflightStatus } from '../../../../preload/api-types'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
-import type { Repo, Worktree } from '../../../../shared/types'
+import type { OrcaHooks, Repo, Worktree } from '../../../../shared/types'
import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id'
import {
buildAutomationCronSchedule,
@@ -107,9 +107,17 @@ import {
} from './AutomationEditorDialog'
import { AutomationRunPageFrame } from './AutomationRunPageFrame'
import { AutomationRunHistory } from './AutomationRunHistory'
+import {
+ getAutomationSetupDecisionDraftValue,
+ getVisibleAutomationSetupDecision,
+ resolveAutomationSetupDecisionForSave
+} from './automation-setup-decision'
import { getAutomationTemplates, type AutomationTemplate } from './automation-templates'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { buildAutomationRunContextForRepo } from './automation-run-context'
+import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
+import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner'
+import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import {
getRepoBackedProviderAvailability,
type RuntimeProviderPreflightStatus
@@ -167,6 +175,16 @@ function getAutomationHostTargetKey(target: AutomationHostTarget): string {
return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local'
}
+function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null {
+ if (!key) {
+ return null
+ }
+ if (key.startsWith('environment:')) {
+ return { kind: 'environment', environmentId: key.slice('environment:'.length) }
+ }
+ return { kind: 'local' }
+}
+
function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null {
return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null
}
@@ -446,6 +464,15 @@ export default function AutomationsPage(): React.JSX.Element {
const completionInFlightRef = useRef>(new Set())
const rerunRunIdsInFlightRef = useRef>(new Set())
const workspaceNameCacheRef = useRef