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 22e17d96b67..c8f437619a5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.104-rc.6",
+ "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/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/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/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/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 a898997a8d3..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(
@@ -8672,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 d8d8f065d1a..99ebeb1f792 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -775,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[]
@@ -4110,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,
@@ -4177,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)
diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts
index 7a6af3da39a..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' })
diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts
index c33910bad95..7284f2353ba 100644
--- a/src/main/providers/ssh-pty-provider.ts
+++ b/src/main/providers/ssh-pty-provider.ts
@@ -136,6 +136,10 @@ export class SshPtyProvider implements IPtyProvider {
// 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/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.test.ts b/src/main/runtime/orca-runtime.test.ts
index 5f8cd7fc52b..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,
@@ -526,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()
@@ -534,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)
@@ -3391,7 +3394,11 @@ describe('OrcaRuntimeService', () => {
startup: { command: 'claude' }
})
+ // 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({
@@ -3539,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)
@@ -11104,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 () => {
@@ -11171,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 () => {
@@ -11244,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 () => {
@@ -12786,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 () => {
@@ -12857,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 () => {
@@ -13384,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.
@@ -18776,6 +18834,7 @@ describe('OrcaRuntimeService', () => {
})
expect(result.setup).toBeUndefined()
expect(activateWorktree).not.toHaveBeenCalled()
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
expect(spawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
@@ -18892,7 +18951,10 @@ describe('OrcaRuntimeService', () => {
expect(createSetupRunnerScript).toHaveBeenCalled()
expect(runHook).not.toHaveBeenCalled()
- expect(spawn).toHaveBeenCalledTimes(2)
+ // 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-]+) \]/)
@@ -18963,7 +19025,8 @@ describe('OrcaRuntimeService', () => {
startup: { command: 'claude' }
})
- expect(spawn).toHaveBeenCalledTimes(2)
+ // 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,
@@ -19108,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)
@@ -19188,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 = {}
@@ -19656,7 +19807,7 @@ describe('OrcaRuntimeService', () => {
activate: true
})
- expect(spawn).toHaveBeenCalledTimes(2)
+ await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
expect(spawn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index f8dc05fcfa7..8595f783e4a 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -2305,6 +2305,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,
@@ -2341,6 +2342,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
}
@@ -3289,6 +3293,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)
}
})
@@ -4204,7 +4209,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 =
@@ -4226,7 +4236,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) {
@@ -4244,7 +4254,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
)
@@ -4277,7 +4288,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) {
@@ -4292,7 +4303,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) {
@@ -11937,6 +11949,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
@@ -12772,6 +12880,10 @@ 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
@@ -12839,53 +12951,39 @@ 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 =
- wrappedSetupCommandStr ??
- buildSetupRunnerCommand(
- setup.runnerScriptPath,
- getSetupRunnerCommandPlatformForPath(
- 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 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
@@ -12896,54 +12994,50 @@ export class OrcaRuntimeService {
: {})
}
: 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 =
- wrappedSetupCommandStr ??
- buildSetupRunnerCommand(
- setup.runnerScriptPath,
- getSetupRunnerCommandPlatformForPath(
- 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
@@ -13093,6 +13187,8 @@ 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
@@ -13156,48 +13252,35 @@ 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 =
- wrappedSetupCommandStr ??
- buildSetupRunnerCommand(
- result.setup.runnerScriptPath,
- getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, '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 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
@@ -13208,13 +13291,14 @@ export class OrcaRuntimeService {
: {})
}
: 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(
@@ -13222,36 +13306,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,
- getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, '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
@@ -17737,10 +17826,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.
@@ -17768,6 +17888,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 }
@@ -17939,6 +18060,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 {
@@ -18049,6 +18184,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 3c9b0597030..321458c65da 100644
--- a/src/relay/pty-handler.test.ts
+++ b/src/relay/pty-handler.test.ts
@@ -4,6 +4,8 @@ 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
@@ -169,6 +171,155 @@ 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' })
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index 8ac4259d3ee..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,
@@ -128,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
@@ -497,7 +525,10 @@ 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
@@ -509,6 +540,8 @@ export class PtyHandler {
// 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 })
@@ -523,6 +556,7 @@ export class PtyHandler {
// 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
})
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/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