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

+ {translate( + 'auto.components.automations.AutomationSetupDecisionField.874b72195b', + "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." + )} +

+
+
+
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index eec62ea8413..322f2170f4d 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -64,7 +64,7 @@ import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/proto import type { PreflightStatus } from '../../../../preload/api-types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { Repo, Worktree } from '../../../../shared/types' +import type { OrcaHooks, Repo, Worktree } from '../../../../shared/types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' import { buildAutomationCronSchedule, @@ -107,9 +107,17 @@ import { } from './AutomationEditorDialog' import { AutomationRunPageFrame } from './AutomationRunPageFrame' import { AutomationRunHistory } from './AutomationRunHistory' +import { + getAutomationSetupDecisionDraftValue, + getVisibleAutomationSetupDecision, + resolveAutomationSetupDecisionForSave +} from './automation-setup-decision' import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' import { buildAutomationRunContextForRepo } from './automation-run-context' +import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { getRepoBackedProviderAvailability, type RuntimeProviderPreflightStatus @@ -167,6 +175,16 @@ function getAutomationHostTargetKey(target: AutomationHostTarget): string { return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' } +function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { + if (!key) { + return null + } + if (key.startsWith('environment:')) { + return { kind: 'environment', environmentId: key.slice('environment:'.length) } + } + return { kind: 'local' } +} + function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null } @@ -446,6 +464,15 @@ export default function AutomationsPage(): React.JSX.Element { const completionInFlightRef = useRef>(new Set()) const rerunRunIdsInFlightRef = useRef>(new Set()) const workspaceNameCacheRef = useRef>(new Map()) + const setupDecisionPolicyDefaultRef = useRef(undefined) + const setupDecisionDefaultSignatureRef = useRef(null) + const setupDecisionTouchedRef = useRef(false) + const automationHookCheckPromisesRef = useRef< + Map> + >(new Map()) + const [automationYamlHooksByRepoKey, setAutomationYamlHooksByRepoKey] = useState< + Record + >({}) const [draft, setDraft] = useState({ name: '', prompt: '', @@ -454,6 +481,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId: '', baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -531,6 +559,76 @@ export default function AutomationsPage(): React.JSX.Element { }), [selectedAutomationRuns.runs, worktreeMap] ) + const getDraftSetupDecisionDefault = useCallback( + ( + candidate: Pick + ): AutomationDraft['setupDecision'] => { + const settingsForRepo = getSettingsForRepoRuntimeOwner( + { repos, settings }, + candidate.projectId + ) + const hookKey = `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}` + return getVisibleAutomationSetupDecision({ + createTarget, + workspaceMode: candidate.workspaceMode, + repoId: candidate.projectId, + repos, + projectHostSetups, + yamlHooks: automationYamlHooksByRepoKey[hookKey] + }) + }, + [automationYamlHooksByRepoKey, createTarget, projectHostSetups, repos, settings] + ) + const getAutomationHooksCacheKey = useCallback( + (repoId: string): string => { + const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) + return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}` + }, + [repos, settings] + ) + const loadAutomationYamlHooksForRepo = useCallback( + async (repoId: string): Promise => { + const key = getAutomationHooksCacheKey(repoId) + if (Object.prototype.hasOwnProperty.call(automationYamlHooksByRepoKey, key)) { + return automationYamlHooksByRepoKey[key] ?? null + } + const existingPromise = automationHookCheckPromisesRef.current.get(key) + if (existingPromise) { + return (await existingPromise).hooks + } + const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) + const promise = checkRuntimeHooks(settingsForRepo, repoId) + .then((result) => ({ + hooks: result.status === 'error' ? null : ((result.hooks as OrcaHooks | null) ?? null), + ok: result.status !== 'error' + })) + .catch(() => ({ hooks: null, ok: false })) + automationHookCheckPromisesRef.current.set(key, promise) + const { hooks, ok } = await promise + automationHookCheckPromisesRef.current.delete(key) + if (!ok) { + return hooks + } + setAutomationYamlHooksByRepoKey((current) => + Object.prototype.hasOwnProperty.call(current, key) ? current : { ...current, [key]: hooks } + ) + return hooks + }, + [automationYamlHooksByRepoKey, getAutomationHooksCacheKey, repos, settings] + ) + const getDraftSetupDecisionDefaultSignature = useCallback( + (candidate: Pick): string => + [ + createTarget, + candidate.workspaceMode, + candidate.projectId, + getDraftSetupDecisionDefault(candidate) ?? 'none' + ].join(':'), + [createTarget, getDraftSetupDecisionDefault] + ) + const markSetupDecisionTouched = useCallback((): void => { + setupDecisionTouchedRef.current = true + }, []) // Why: keep the detail tab scoped even while the selected-run fetch catches up. const selectedRunsSource = selected && selectedAutomationRuns.automationId === selected.id @@ -547,6 +645,10 @@ export default function AutomationsPage(): React.JSX.Element { () => worktreesByRepo[draft.projectId] ?? [], [draft.projectId, worktreesByRepo] ) + const automationHostTarget = useMemo( + () => getAutomationHostTargetFromKey(automationHostTargetKey), + [automationHostTargetKey] + ) useEffect(() => { for (const [workspaceId, worktree] of worktreeMap) { @@ -801,6 +903,7 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(selected.id) }) : null @@ -969,7 +1072,7 @@ export default function AutomationsPage(): React.JSX.Element { pendingAutomationRunNavigation.hostId ? getAutomationTargetFromHostId(pendingAutomationRunNavigation.hostId) : selected - ? getAutomationOwnerTarget(selected) + ? getAutomationOwnerTarget(selected, automationHostTarget) : getAutomationListTarget(settings) void listAutomationRunsForTarget(target, automationId).then((nextRuns) => { if (!cancelled) { @@ -979,7 +1082,7 @@ export default function AutomationsPage(): React.JSX.Element { return () => { cancelled = true } - }, [pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) + }, [automationHostTarget, pendingAutomationRunNavigation, selected, selected?.id, runs, settings]) useEffect(() => { const onAutomationsChanged = (): void => { @@ -1087,6 +1190,50 @@ export default function AutomationsPage(): React.JSX.Element { } }, [draft.projectId, draft.workspaceId, worktreesByRepo]) + useEffect(() => { + if ( + !createOpen || + createTarget !== 'orca' || + draft.workspaceMode !== 'new_per_run' || + !draft.projectId + ) { + return + } + void loadAutomationYamlHooksForRepo(draft.projectId) + }, [ + createOpen, + createTarget, + draft.projectId, + draft.workspaceMode, + loadAutomationYamlHooksForRepo + ]) + + useEffect(() => { + if (!createOpen) { + setupDecisionPolicyDefaultRef.current = undefined + setupDecisionDefaultSignatureRef.current = null + setupDecisionTouchedRef.current = false + return + } + const nextDefault = getDraftSetupDecisionDefault(draft) + const nextSignature = getDraftSetupDecisionDefaultSignature(draft) + if (setupDecisionDefaultSignatureRef.current !== nextSignature) { + setupDecisionDefaultSignatureRef.current = nextSignature + setupDecisionTouchedRef.current = false + } + const previousDefault = setupDecisionPolicyDefaultRef.current + setupDecisionPolicyDefaultRef.current = nextDefault + const shouldApplyPolicyDefault = + !setupDecisionTouchedRef.current && + (nextDefault === undefined || + draft.setupDecision === undefined || + draft.setupDecision === previousDefault) + if (!shouldApplyPolicyDefault || draft.setupDecision === nextDefault) { + return + } + setDraft((current) => ({ ...current, setupDecision: nextDefault })) + }, [createOpen, draft, getDraftSetupDecisionDefault, getDraftSetupDecisionDefaultSignature]) + const applyTemplateToDraft = useCallback((template: AutomationTemplate): void => { setDraft((current) => ({ ...current, @@ -1109,6 +1256,7 @@ export default function AutomationsPage(): React.JSX.Element { ...current, agentId: 'hermes', workspaceMode: 'existing', + setupDecision: undefined, reuseSession: false })) } @@ -1128,6 +1276,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId: target.workspaceId, baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -1182,6 +1331,10 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: latest.workspaceMode, workspaceId: latest.workspaceId ?? '', baseBranch: latest.baseBranch ?? '', + setupDecision: getAutomationSetupDecisionDraftValue({ + workspaceMode: latest.workspaceMode, + persistedSetupDecision: latest.setupDecision + }), reuseSession: latest.workspaceMode === 'existing' && latest.reuseSession, precheckCommand: latest.precheck?.command ?? '', precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60), @@ -1229,6 +1382,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: 'existing', workspaceId, baseBranch: '', + setupDecision: undefined, reuseSession: false, precheckCommand: '', precheckTimeoutSeconds: '60', @@ -1435,6 +1589,28 @@ export default function AutomationsPage(): React.JSX.Element { repos, projectHostSetups }) + let setupDecision = resolveAutomationSetupDecisionForSave({ + createTarget, + workspaceMode: draft.workspaceMode, + repoId: draft.projectId, + repos, + projectHostSetups, + yamlHooks: + createTarget === 'orca' && draft.workspaceMode === 'new_per_run' + ? await loadAutomationYamlHooksForRepo(draft.projectId) + : null, + draftSetupDecision: draft.setupDecision + }) + if (setupDecision === 'run') { + const trustDecision = await ensureHooksConfirmed( + useAppStore.getState(), + draft.projectId, + 'setup' + ) + if (trustDecision === 'skip') { + setupDecision = 'skip' + } + } if (!runContext) { toast.error( translate( @@ -1467,6 +1643,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, baseBranch: draft.baseBranch.trim() || null, + setupDecision, reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, timezone, missedRunGraceMinutes @@ -1478,7 +1655,7 @@ export default function AutomationsPage(): React.JSX.Element { } const automation = editingAutomationId ? currentAutomation - ? await updateAutomationForTarget(currentAutomation, updates) + ? await updateAutomationForTarget(currentAutomation, updates, automationHostTarget) : await window.api.automations.update({ id: editingAutomationId, updates @@ -1493,6 +1670,7 @@ export default function AutomationsPage(): React.JSX.Element { workspaceMode: draft.workspaceMode, workspaceId: draft.workspaceId, baseBranch: draft.baseBranch.trim() || null, + setupDecision, reuseSession: draft.workspaceMode === 'existing' && draft.reuseSession, timezone, rrule, @@ -1539,12 +1717,16 @@ export default function AutomationsPage(): React.JSX.Element { } const toggleAutomation = async (automation: Automation): Promise => { - await updateAutomationForTarget(automation, { enabled: !automation.enabled }) + await updateAutomationForTarget( + automation, + { enabled: !automation.enabled }, + automationHostTarget + ) await refresh() } const deleteAutomation = async (automation: Automation): Promise => { - await deleteAutomationForTarget(automation) + await deleteAutomationForTarget(automation, automationHostTarget) if (useAppStore.getState().selectedAutomationId === automation.id) { selectAutomationId(null) } @@ -1616,13 +1798,14 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) if (!availability.canRunNow) { toast.error(availability.message) return } - await runAutomationNowForTarget(automation) + await runAutomationNowForTarget(automation, automationHostTarget) useAppStore.getState().recordFeatureInteraction('automation-run') await hydratePersistedUIState() await refresh() @@ -1640,7 +1823,7 @@ export default function AutomationsPage(): React.JSX.Element { rerunRunIdsInFlightRef.current.add(runId) setRerunRunIdsInFlight(new Set(rerunRunIdsInFlightRef.current)) try { - await runAutomationNowForTarget(automation) + await runAutomationNowForTarget(automation, automationHostTarget) await hydratePersistedUIState() await refresh() toast.message( @@ -2015,6 +2198,9 @@ export default function AutomationsPage(): React.JSX.Element { isEditingExternal={editingExternalTarget !== null} createTarget={createTarget} repos={repos} + projectHostSetups={projectHostSetups} + automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} + getAutomationHooksCacheKey={getAutomationHooksCacheKey} repoMap={repoMap} worktrees={worktrees} settings={settings} @@ -2024,6 +2210,7 @@ export default function AutomationsPage(): React.JSX.Element { onCreateTargetChange={handleCreateTargetChange} onOpenChange={setCreateOpen} onDraftChange={setDraft} + onSetupDecisionTouched={markSetupDecisionTouched} onApplyTemplate={applyTemplateToDraft} onSave={() => void saveAutomation()} /> @@ -2217,6 +2404,7 @@ export default function AutomationsPage(): React.JSX.Element { projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) const workspaceLabel = diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx new file mode 100644 index 00000000000..8d21b62da6f --- /dev/null +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.test.tsx @@ -0,0 +1,204 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationProvider +} from '../../../../shared/automations-types' +import { ExternalAutomationManagers } from './ExternalAutomationManagers' + +// Why: act(...) warnings are silenced by opting this module into the React act +// environment, matching how the renderer mounts under test. +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// Why: the run table fetches from the external automation store; stub it so the +// test stays focused on the row controls (switch + action cluster). +vi.mock('./ExternalAutomationRunTable', () => ({ + ExternalAutomationRunTable: () =>
+})) + +// Why: Tooltip needs a TooltipProvider mounted higher in the real app; stub the +// primitives so the row renders standalone and the span trigger stays inspectable. +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +let container: HTMLDivElement +let root: Root + +function makeJob(overrides: Partial = {}): ExternalAutomationJob { + return { + id: 'job-1', + managerId: 'manager-1', + provider: 'hermes', + name: 'Nightly backup', + schedule: '0 0 * * *', + rawSchedule: null, + enabled: true, + state: 'enabled', + prompt: null, + promptPreview: '', + nextRunAt: null, + lastRunAt: null, + lastStatus: null, + lastError: null, + workdir: null, + runCount: 0, + runs: [], + ...overrides + } +} + +function makeManager( + overrides: Partial = {} +): ExternalAutomationManager { + const provider: ExternalAutomationProvider = overrides.provider ?? 'hermes' + return { + id: 'manager-1', + provider, + label: 'Local Hermes', + targetLabel: 'Local', + target: { type: 'local' }, + status: 'available', + error: null, + canManage: true, + jobs: [makeJob({ provider })], + ...overrides + } +} + +type OnActionMock = ReturnType< + typeof vi.fn< + ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + > +> +type OnEditMock = ReturnType< + typeof vi.fn<(manager: ExternalAutomationManager, job: ExternalAutomationJob) => void> +> + +type RenderOptions = { + runningActionKey?: string | null + onAction?: OnActionMock + onEdit?: OnEditMock +} + +function renderManagers( + managers: ExternalAutomationManager[], + options: RenderOptions = {} +): { onAction: OnActionMock; onEdit: OnEditMock } { + const onAction = options.onAction ?? vi.fn() + const onEdit = options.onEdit ?? vi.fn() + act(() => { + root.render( + + ) + }) + return { onAction, onEdit } +} + +function getSwitch(): HTMLButtonElement { + const node = container.querySelector('button[role="switch"]') + if (!(node instanceof HTMLButtonElement)) { + throw new Error('expected a role="switch" control') + } + return node +} + +function actionButtonLabels(): string[] { + return Array.from(container.querySelectorAll('button[aria-label]')).map( + (button) => button.getAttribute('aria-label') ?? '' + ) +} + +describe('ExternalAutomationManagers toggle', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.clearAllMocks() + }) + + it('renders a switch reflecting the enabled state via aria-checked', () => { + renderManagers([makeManager({ jobs: [makeJob({ enabled: true })] })]) + expect(getSwitch().getAttribute('aria-checked')).toBe('true') + }) + + it('renders aria-checked=false for a paused job', () => { + renderManagers([makeManager({ jobs: [makeJob({ enabled: false })] })]) + expect(getSwitch().getAttribute('aria-checked')).toBe('false') + }) + + it('dispatches pause when toggling an enabled job', () => { + const manager = makeManager({ jobs: [makeJob({ enabled: true })] }) + const { onAction } = renderManagers([manager]) + act(() => { + getSwitch().click() + }) + expect(onAction).toHaveBeenCalledWith(manager, manager.jobs[0], 'pause') + }) + + it('dispatches resume when toggling a paused job', () => { + const manager = makeManager({ jobs: [makeJob({ enabled: false })] }) + const { onAction } = renderManagers([manager]) + act(() => { + getSwitch().click() + }) + expect(onAction).toHaveBeenCalledWith(manager, manager.jobs[0], 'resume') + }) + + it('disables the switch when the manager cannot be managed', () => { + renderManagers([makeManager({ canManage: false })]) + expect(getSwitch().disabled).toBe(true) + }) + + it('shows the sibling spinner only while a pause/resume targets this row', () => { + const manager = makeManager({ jobs: [makeJob({ id: 'job-1', enabled: true })] }) + // Keyed for the resume action even though the job is enabled — the spinner + // must match either pause or resume so it does not vanish when enabled flips. + renderManagers([manager], { runningActionKey: 'manager-1:job-1:resume' }) + expect(container.querySelector('.animate-spin')).not.toBeNull() + }) + + it('keeps the Run/Edit/Delete actions and removes the pause/resume button on hermes', () => { + renderManagers([makeManager({ provider: 'hermes' })]) + const labels = actionButtonLabels() + expect(labels).toContain('Run external automation') + expect(labels).toContain('Edit external automation') + expect(labels).toContain('Delete external automation') + expect(labels).not.toContain('Pause external automation') + expect(labels).not.toContain('Resume external automation') + }) + + it('keeps Run/Delete (no Edit) for openclaw and removes the pause/resume button', () => { + renderManagers([makeManager({ provider: 'openclaw' })]) + const labels = actionButtonLabels() + expect(labels).toContain('Run external automation') + expect(labels).toContain('Delete external automation') + expect(labels).not.toContain('Edit external automation') + expect(labels).not.toContain('Pause external automation') + expect(labels).not.toContain('Resume external automation') + }) +}) diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index 27259681b3b..bd6e1b67549 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -1,7 +1,8 @@ import React from 'react' -import { Pause, Pencil, Play, RefreshCw, Trash2 } from 'lucide-react' +import { Pencil, Play, RefreshCw, Trash2 } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { SettingsSwitch } from '@/components/settings/SettingsFormControls' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { ExternalAutomationAction, @@ -171,11 +172,16 @@ export function ExternalAutomationManagers({ return (
- {job.name} + + {job.name} + {job.enabled ? translate( @@ -187,6 +193,40 @@ export function ExternalAutomationManagers({ 'Paused' )} + + + {/* Absolutely pinned to the row's top-right corner (above the action + icons) so it reaches the true right edge, not just the grid column. */} + + {/* Match either key: job.enabled flips mid-flight, so a single-action + match would drop the spinner a render early. */} + {runningActionKey === actionKey(manager, job, 'pause') || + runningActionKey === actionKey(manager, job, 'resume') ? ( + + ) : null} + + onAction(manager, job, job.enabled ? 'pause' : 'resume') + } + disabled={disabledMessage !== null} + ariaLabelledBy={`automation-name-${manager.id}-${job.id}`} + /> + + + + {disabledMessage ?? + (job.enabled + ? translate( + 'auto.components.automations.ExternalAutomationManagers.0def1693bb', + 'Pause external automation' + ) + : translate( + 'auto.components.automations.ExternalAutomationManagers.1c3bfd38fe', + 'Resume external automation' + ))} + +
{scheduleDisplay.label} @@ -264,31 +304,6 @@ export function ExternalAutomationManagers({ ) : null} - onAction(manager, job, job.enabled ? 'pause' : 'resume')} - > - {runningActionKey === - actionKey(manager, job, job.enabled ? 'pause' : 'resume') ? ( - - ) : job.enabled ? ( - - ) : ( - - )} - { projectId: automation.projectId, workspaceMode: automation.workspaceMode, workspaceId: null, + setupDecision: 'run', timezone: automation.timezone, rrule: automation.rrule, dtstart: automation.dtstart @@ -114,6 +116,7 @@ describe('automation host client', () => { expect.objectContaining({ repo: 'repo-1', workspace: undefined, + setupDecision: 'run', runContext: automation.runContext }), { timeoutMs: 15_000 } @@ -126,4 +129,41 @@ describe('automation host client', () => { { timeoutMs: 15_000 } ) }) + + it('updates and manually runs SSH-host automations through the remote server that listed them', async () => { + const automation = makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'github:stablyai/orca', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-devbox', + repoId: 'repo-1', + path: '/srv/orca' + } + }) + const sourceTarget = { kind: 'environment' as const, environmentId: 'gpu' } + vi.mocked(callRuntimeRpc) + .mockResolvedValueOnce({ automation: { ...automation, name: 'Updated' } }) + .mockResolvedValueOnce({ run: { id: 'run-1', automationId: automation.id } }) + + await updateAutomationForTarget(automation, { name: 'Updated' }, sourceTarget) + await runAutomationNowForTarget(automation, sourceTarget) + + expect(mockApi.automations.update).not.toHaveBeenCalled() + expect(mockApi.automations.runNow).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + sourceTarget, + 'automation.update', + { id: automation.id, updates: { name: 'Updated' } }, + { timeoutMs: 15_000 } + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + sourceTarget, + 'automation.runNow', + { id: automation.id }, + { timeoutMs: 15_000 } + ) + }) }) diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index a657c88ad8f..b3569b4b7b2 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -43,8 +43,12 @@ export function getAutomationListTarget( } export function getAutomationOwnerTarget( - automation: Pick + automation: Pick, + sourceTarget?: AutomationHostTarget | null ): AutomationHostTarget { + if (sourceTarget?.kind === 'environment') { + return sourceTarget + } return getAutomationTargetFromHostId(automation.runContext?.hostId) } @@ -121,9 +125,10 @@ export async function createAutomationForTarget(input: AutomationCreateInput): P export async function updateAutomationForTarget( automation: Automation, - updates: AutomationUpdateInput + updates: AutomationUpdateInput, + sourceTarget?: AutomationHostTarget | null ): Promise { - const target = getAutomationOwnerTarget(automation) + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { return await window.api.automations.update({ id: automation.id, updates }) } @@ -136,8 +141,11 @@ export async function updateAutomationForTarget( return result.automation } -export async function deleteAutomationForTarget(automation: Automation): Promise { - const target = getAutomationOwnerTarget(automation) +export async function deleteAutomationForTarget( + automation: Automation, + sourceTarget?: AutomationHostTarget | null +): Promise { + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { await window.api.automations.delete({ id: automation.id }) return @@ -145,8 +153,11 @@ export async function deleteAutomationForTarget(automation: Automation): Promise await callRuntimeRpc(target, 'automation.delete', { id: automation.id }, { timeoutMs: 15_000 }) } -export async function runAutomationNowForTarget(automation: Automation): Promise { - const target = getAutomationOwnerTarget(automation) +export async function runAutomationNowForTarget( + automation: Automation, + sourceTarget?: AutomationHostTarget | null +): Promise { + const target = getAutomationOwnerTarget(automation, sourceTarget) if (target.kind === 'local') { return await window.api.automations.runNow({ id: automation.id }) } diff --git a/src/renderer/src/components/automations/automation-setup-decision.test.ts b/src/renderer/src/components/automations/automation-setup-decision.test.ts new file mode 100644 index 00000000000..b46d76da980 --- /dev/null +++ b/src/renderer/src/components/automations/automation-setup-decision.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetup, Repo } from '../../../../shared/types' +import { + getAutomationSetupDecisionDraftValue, + getVisibleAutomationSetupDecision, + resolveAutomationSetupDecisionForSave +} from './automation-setup-decision' + +const repo: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: 'pnpm install', archive: '' } + } +} + +function setup(overrides: Partial = {}): ProjectHostSetup { + return { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/repo', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('automation setup decision defaults', () => { + it.each([ + ['run-by-default', 'run'], + ['skip-by-default', 'skip'], + ['ask', 'skip'] + ] as const)('maps %s setup policy to %s', (setupRunPolicy, expectedDecision) => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy, + scripts: { setup: 'pnpm install', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null + }) + ).toBe(expectedDecision) + }) + + it('hides setup choice outside Orca new-run automations with setup', () => { + const baseArgs = { + repoId: 'repo-1', + repos: [repo], + projectHostSetups: [], + yamlHooks: null + } + + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'orca', + workspaceMode: 'existing' + }) + ).toBeUndefined() + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'hermes', + workspaceMode: 'new_per_run' + }) + ).toBeUndefined() + expect( + getVisibleAutomationSetupDecision({ + ...baseArgs, + createTarget: 'orca', + workspaceMode: 'new_per_run', + repos: [ + { + ...repo, + hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } + } + ] + }) + ).toBeUndefined() + }) + + it('uses ready project-host setup hook settings before repo fallback', () => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [repo], + projectHostSetups: [ + setup({ + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: 'bun install', archive: '' } + } + }) + ], + yamlHooks: null + }) + ).toBe('skip') + }) + + it('shows setup choice for shared orca.yaml setup and default tabs', () => { + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: { + scripts: { setup: 'pnpm install' }, + defaultTabs: [] + } + }) + ).toBe('run') + + expect( + getVisibleAutomationSetupDecision({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: { + scripts: {}, + defaultTabs: [{ title: 'Dev', command: 'pnpm dev' }] + } + }) + ).toBe('skip') + }) + + it('omits setup decision when no setup source is visible', () => { + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'ask', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'skip-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + yamlHooks: null, + draftSetupDecision: undefined + }) + ).toBeUndefined() + }) + + it('fails closed when saving before shared hook inspection is available', () => { + expect( + resolveAutomationSetupDecisionForSave({ + createTarget: 'orca', + workspaceMode: 'new_per_run', + repoId: 'repo-1', + repos: [ + { + ...repo, + hookSettings: { + mode: 'override', + setupRunPolicy: 'run-by-default', + scripts: { setup: '', archive: '' } + } + } + ], + projectHostSetups: [], + draftSetupDecision: undefined + }) + ).toBe('skip') + }) + + it('loads legacy new-run automations as skip when editing', () => { + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'new_per_run', + persistedSetupDecision: undefined + }) + ).toBe('skip') + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'new_per_run', + persistedSetupDecision: 'run' + }) + ).toBe('run') + expect( + getAutomationSetupDecisionDraftValue({ + workspaceMode: 'existing', + persistedSetupDecision: undefined + }) + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/automations/automation-setup-decision.ts b/src/renderer/src/components/automations/automation-setup-decision.ts new file mode 100644 index 00000000000..f565e439f34 --- /dev/null +++ b/src/renderer/src/components/automations/automation-setup-decision.ts @@ -0,0 +1,102 @@ +import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' +import type { + OrcaHooks, + ProjectHostSetup, + Repo, + SetupDecision, + SetupRunPolicy +} from '../../../../shared/types' +import { getSetupConfig } from '@/lib/new-workspace' + +type AutomationSetupSource = { + setupScript: string + setupRunPolicy: SetupRunPolicy +} + +function getAutomationSetupSource( + repoId: string, + repos: readonly Repo[], + projectHostSetups: readonly ProjectHostSetup[], + yamlHooks: OrcaHooks | null | undefined +): AutomationSetupSource | null { + const setup = projectHostSetups.find( + (candidate) => candidate.repoId === repoId && candidate.setupState === 'ready' + ) + const repo = repos.find((candidate) => candidate.id === repoId) + const hookSettings = setup?.hookSettings ?? repo?.hookSettings + const setupConfig = getSetupConfig( + hookSettings ? { hookSettings } : repo, + yamlHooks === undefined ? null : yamlHooks + ) + if (!setupConfig) { + return null + } + return { + setupScript: setupConfig.command, + setupRunPolicy: hookSettings?.setupRunPolicy ?? 'run-by-default' + } +} + +export function getAutomationSetupDefaultDecision( + source: Pick | null +): Extract | undefined { + if (!source) { + return undefined + } + return source.setupRunPolicy === 'run-by-default' ? 'run' : 'skip' +} + +export function getVisibleAutomationSetupDecision(args: { + createTarget: 'orca' | 'hermes' + workspaceMode: AutomationWorkspaceMode + repoId: string + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] + yamlHooks?: OrcaHooks | null +}): Extract | undefined { + if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') { + return undefined + } + return getAutomationSetupDefaultDecision( + getAutomationSetupSource(args.repoId, args.repos, args.projectHostSetups, args.yamlHooks) + ) +} + +export function resolveAutomationSetupDecisionForSave(args: { + createTarget: 'orca' | 'hermes' + workspaceMode: AutomationWorkspaceMode + repoId: string + repos: readonly Repo[] + projectHostSetups: readonly ProjectHostSetup[] + yamlHooks?: OrcaHooks | null + draftSetupDecision: Extract | undefined +}): Extract | undefined { + if (args.createTarget !== 'orca' || args.workspaceMode !== 'new_per_run') { + return undefined + } + + const visibleDefault = getVisibleAutomationSetupDecision(args) + if (visibleDefault) { + return args.draftSetupDecision ?? visibleDefault + } + + if (args.yamlHooks === undefined) { + // Why: automations cannot pause later for an orca.yaml trust prompt; when + // hook inspection is unavailable, fail closed instead of inheriting setup. + return 'skip' + } + + return undefined +} + +export function getAutomationSetupDecisionDraftValue(args: { + workspaceMode: AutomationWorkspaceMode + persistedSetupDecision: SetupDecision | undefined +}): Extract | undefined { + if (args.persistedSetupDecision === 'run' || args.persistedSetupDecision === 'skip') { + return args.persistedSetupDecision + } + // Why: legacy new-run automations had no saved choice and now dispatch as + // skip for compatibility; editing them must not silently opt into setup. + return args.workspaceMode === 'new_per_run' ? 'skip' : undefined +} diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index a640de7d7b5..cbc9b029c92 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -140,6 +140,66 @@ describe('automation target availability', () => { ).toBe('host-mismatch') }) + it('allows remote-listed SSH automations whose repo is projected through a runtime server', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + executionTargetType: 'ssh', + executionTargetId: 'devbox', + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'ssh:devbox', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo({ + connectionId: 'devbox', + executionHostId: 'runtime:gpu' + }), + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'ssh:devbox', + connectionId: 'devbox', + executionHostId: 'ssh:devbox' + }) + ], + sshConnectionStates: new Map([['devbox', { status: 'connected' }]]), + automationHostTarget: { kind: 'environment', environmentId: 'gpu' } + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + + it('allows remote-listed server-local automations whose setup is projected through a runtime server', () => { + expect( + getAutomationTargetAvailability({ + automation: makeAutomation({ + runContext: { + kind: 'workspace-run', + projectId: 'project-1', + hostId: 'local', + projectHostSetupId: 'setup-1', + repoId: 'repo-1', + path: '/repo' + } + }), + repo: makeRepo({ executionHostId: 'runtime:gpu' }), + workspace: makeWorkspace(), + projectHostSetups: [ + makeProjectHostSetup({ + hostId: 'runtime:gpu', + executionHostId: 'runtime:gpu' + }) + ], + sshConnectionStates: new Map(), + automationHostTarget: { kind: 'environment', environmentId: 'gpu' } + }) + ).toEqual({ canRunNow: true, reason: 'available', message: null }) + }) + it('blocks saved run contexts whose project host setup is missing or not ready', () => { const automation = makeAutomation({ runContext: { diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index f2e994758db..008b4c9eb48 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -8,6 +8,7 @@ import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { AutomationHostTarget } from './automation-host-client' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { RuntimeStatus } from '../../../../shared/runtime-types' @@ -52,6 +53,7 @@ type AutomationTargetAvailabilityArgs = { string, { status: RuntimeStatus | null; checkedAt: number } > + automationHostTarget?: AutomationHostTarget | null sourceHostAvailability?: readonly TaskSourceHostAvailability[] } @@ -62,6 +64,7 @@ export function getAutomationTargetAvailability({ projectHostSetups, sshConnectionStates, runtimeStatusByEnvironmentId, + automationHostTarget, sourceHostAvailability }: AutomationTargetAvailabilityArgs): AutomationTargetAvailability { if (!repo) { @@ -93,15 +96,16 @@ export function getAutomationTargetAvailability({ `Project setup on the selected automation host is ${setup.setupState}.` ) } - if ( - setup.projectId !== automation.runContext.projectId || - setup.hostId !== automation.runContext.hostId || - setup.repoId !== automation.runContext.repoId || - setup.path !== automation.runContext.path || - automation.runContext.repoId !== repo.id || - automation.runContext.path !== repo.path || - automation.runContext.hostId !== getRepoExecutionHostId(repo) - ) { + const setupMatchesContext = + setup.projectId === automation.runContext.projectId && + setup.repoId === automation.runContext.repoId && + setup.path === automation.runContext.path && + setupHostMatchesRunContext(setup.hostId, automation.runContext.hostId, automationHostTarget) + const repoMatchesContext = + automation.runContext.repoId === repo.id && + automation.runContext.path === repo.path && + repoHostMatchesRunContext(repo, automation.runContext.hostId, automationHostTarget) + if (!setupMatchesContext || !repoMatchesContext) { return unavailable( 'host-mismatch', 'The saved run host no longer matches this project setup.' @@ -142,6 +146,40 @@ export function getAutomationTargetAvailability({ } } +function getRuntimeTargetHostId(target: AutomationHostTarget | null | undefined): string | null { + return target?.kind === 'environment' + ? `runtime:${encodeURIComponent(target.environmentId)}` + : null +} + +function setupHostMatchesRunContext( + setupHostId: string, + runHostId: string, + target: AutomationHostTarget | null | undefined +): boolean { + if (setupHostId === runHostId) { + return true + } + const targetHostId = getRuntimeTargetHostId(target) + // Why: remote-runtime project lists project the server-local host as runtime:, + // while CLI-created automations can preserve the server's durable local run host. + return targetHostId !== null && setupHostId === targetHostId && runHostId === 'local' +} + +function repoHostMatchesRunContext( + repo: Repo, + runHostId: string, + target: AutomationHostTarget | null | undefined +): boolean { + if (runHostId === getRepoExecutionHostId(repo)) { + return true + } + const targetHostId = getRuntimeTargetHostId(target) + // Why: repos fetched from a remote runtime are owned by runtime: in the + // renderer, but saved automations still target the host setup that runs there. + return targetHostId !== null && getRepoExecutionHostId(repo) === targetHostId +} + function getAutomationSourceAvailability( sourceContext: TaskSourceContext | null | undefined, sourceHostAvailability: readonly TaskSourceHostAvailability[] | undefined diff --git a/src/renderer/src/components/dictation/DictationController.tsx b/src/renderer/src/components/dictation/DictationController.tsx index 4781ec4f4f8..1d8486becf5 100644 --- a/src/renderer/src/components/dictation/DictationController.tsx +++ b/src/renderer/src/components/dictation/DictationController.tsx @@ -13,6 +13,7 @@ import { recordStoppedSession, waitForStoppedSession } from './dictation-stopped import { translate } from '@/i18n/i18n' import { showDictationStartErrorToast } from './dictation-start-error-toast' import { useHoldDictationGesture } from './use-hold-dictation-gesture' +import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events' export function DictationController() { const dictationState = useAppStore((s) => s.dictationState) @@ -265,6 +266,35 @@ export function DictationController() { stopDictation ]) + useEffect(() => { + const canDictate = (): boolean => Boolean(settings?.voice?.enabled && settings.voice.sttModel) + const handleControl = (event: Event): void => { + if (!canDictate() || dictationStateRef.current === 'stopping') { + return + } + const action = (event as CustomEvent).detail + if (action === 'start') { + if (dictationStateRef.current === 'idle') { + void startDictation() + } + return + } + if (action === 'stop') { + if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { + void stopDictation() + } + return + } + if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') { + void stopDictation() + } else { + void startDictation() + } + } + document.addEventListener(DICTATION_CONTROL_EVENT, handleControl) + return () => document.removeEventListener(DICTATION_CONTROL_EVENT, handleControl) + }, [settings?.voice?.enabled, settings?.voice?.sttModel, startDictation, stopDictation]) + useHoldDictationGesture({ dictationStateRef, holdGestureActiveRef, diff --git a/src/renderer/src/components/dictation/dictation-control-events.ts b/src/renderer/src/components/dictation/dictation-control-events.ts new file mode 100644 index 00000000000..14c40e8483b --- /dev/null +++ b/src/renderer/src/components/dictation/dictation-control-events.ts @@ -0,0 +1,9 @@ +export const DICTATION_CONTROL_EVENT = 'dictation:control' + +export type DictationControlAction = 'toggle' | 'start' | 'stop' + +export function dispatchDictationControl(action: DictationControlAction): void { + document.dispatchEvent( + new CustomEvent(DICTATION_CONTROL_EVENT, { detail: action }) + ) +} diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx index ddac7b33e1f..80752aeee23 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx @@ -26,7 +26,12 @@ function formatCheckTimestamp(value: string | null | undefined): string | null { }) } -function getCheckStatusLabel(check: PRCheckDetail): string { +type CheckStatusLike = { + status: PRCheckDetail['status'] | string | null | undefined + conclusion: PRCheckDetail['conclusion'] | string | null | undefined +} + +function getCheckStatusLabel(check: CheckStatusLike): string { const conclusion = check.conclusion ?? 'pending' switch (conclusion) { case 'success': @@ -43,11 +48,23 @@ function getCheckStatusLabel(check: PRCheckDetail): string { return translate('auto.components.editor.CheckRunDetailsPanel.5a1c8e3d67', 'Neutral') case 'pending': return translate('auto.components.editor.CheckRunDetailsPanel.3d9f2b8e14', 'Pending') + default: + return isFailureState(conclusion) + ? translate('auto.components.editor.CheckRunDetailsPanel.4c8e1b2d73', 'Failed') + : translate('auto.components.editor.CheckRunDetailsPanel.3d9f2b8e14', 'Pending') } } function isFailureState(state: string | null | undefined): boolean { - return state === 'failure' || state === 'cancelled' || state === 'timed_out' + return ( + state === 'failure' || + state === 'failed' || + state === 'action_required' || + state === 'cancelled' || + state === 'stale' || + state === 'startup_failure' || + state === 'timed_out' + ) } export function CheckRunDetailsPanel({ @@ -88,10 +105,10 @@ export function CheckRunDetailsPanel({ }) const startedAt = formatCheckTimestamp(details?.startedAt) const completedAt = formatCheckTimestamp(details?.completedAt) - const detailsStatusCheck: PRCheckDetail = { + const detailsStatusCheck: CheckStatusLike = { ...check, - status: (details?.status as PRCheckDetail['status'] | undefined) ?? check.status, - conclusion: (details?.conclusion as PRCheckDetail['conclusion'] | undefined) ?? check.conclusion + status: details?.status ?? check.status, + conclusion: details?.conclusion ?? check.conclusion } const failedJobs = details?.jobs.filter((job) => { diff --git a/src/renderer/src/components/editor/CheckRunJobs.tsx b/src/renderer/src/components/editor/CheckRunJobs.tsx index 0aa0f4884e4..4818aafcd3c 100644 --- a/src/renderer/src/components/editor/CheckRunJobs.tsx +++ b/src/renderer/src/components/editor/CheckRunJobs.tsx @@ -39,17 +39,18 @@ function StepRow({ step }: { step: PRCheckStep }): React.JSX.Element { function JobCard({ job, index }: { job: PRCheckJob; index: number }): React.JSX.Element { const breakdown = summarizeJobSteps(job) - const jobState = job.conclusion ?? job.status - const jobFailed = - jobState === 'failure' || - jobState === 'failed' || - jobState === 'cancelled' || - jobState === 'timed_out' + const jobFailed = resolveStepOutcome(job) === 'failure' // Failures matter most, so surface them and collapse the passing/skipped noise // behind a one-line summary. With no failures there is nothing to prioritize, // so expand by default rather than hiding the job's only content. const collapsible = [...breakdown.succeeded, ...breakdown.skipped, ...breakdown.pending] + const failedStepKey = breakdown.failed + .map((step) => `${step.name}:${step.status ?? ''}:${step.conclusion ?? ''}`) + .join('\0') const [showRest, setShowRest] = React.useState(breakdown.failed.length === 0) + React.useEffect(() => { + setShowRest(breakdown.failed.length === 0) + }, [breakdown.failed.length, failedStepKey]) const summaryParts: string[] = [] if (breakdown.succeeded.length > 0) { diff --git a/src/renderer/src/components/editor/check-annotation-open.ts b/src/renderer/src/components/editor/check-annotation-open.ts index 70f16288b88..44dcc9983cf 100644 --- a/src/renderer/src/components/editor/check-annotation-open.ts +++ b/src/renderer/src/components/editor/check-annotation-open.ts @@ -1,30 +1,13 @@ import { detectLanguage } from '@/lib/language-detect' -import { joinPath } from '@/lib/path' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { findWorktreeById } from '@/store/slices/worktree-helpers' -import type { PRCheckAnnotation } from '../../../../shared/types' +import { + getOpenableAnnotationLine, + resolveAnnotationPathInsideWorktree +} from './check-annotation-path' -/** - * A CI annotation only links to the editor when its path names a real repo file - * at a line — not a workflow-level reference like `.github` (no extension), which - * has no on-disk location to reveal. Requiring a file extension keeps the - * affordance honest: links that wouldn't open never render. - */ -export function getOpenableAnnotationLine( - annotation: PRCheckAnnotation -): { path: string; line: number } | null { - const path = annotation.path?.trim() - if (!path || !annotation.startLine) { - return null - } - const basename = path.split(/[\\/]/).pop() ?? '' - const hasExtension = basename.lastIndexOf('.') > 0 - if (!hasExtension) { - return null - } - return { path, line: annotation.startLine } -} +export { getOpenableAnnotationLine } export function openAnnotationLocation(params: { worktreeId: string @@ -39,7 +22,11 @@ export function openAnnotationLocation(params: { if (!worktree) { return } - const absolutePath = joinPath(worktree.path, path) + const resolvedPath = resolveAnnotationPathInsideWorktree(worktree.path, path) + if (!resolvedPath) { + return + } + const { absolutePath, relativePath } = resolvedPath // Why: reuse the shared activation path so an annotation jump lands in the // same history stack as sidebar, palette, and terminal-link navigation. @@ -48,9 +35,9 @@ export function openAnnotationLocation(params: { store.openFile( { filePath: absolutePath, - relativePath: path, + relativePath, worktreeId, - language: detectLanguage(path), + language: detectLanguage(relativePath), mode: 'edit' }, { forceContentReload: true } diff --git a/src/renderer/src/components/editor/check-annotation-path.test.ts b/src/renderer/src/components/editor/check-annotation-path.test.ts new file mode 100644 index 00000000000..95f23378473 --- /dev/null +++ b/src/renderer/src/components/editor/check-annotation-path.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + getOpenableAnnotationLine, + resolveAnnotationPathInsideWorktree +} from './check-annotation-path' +import type { PRCheckAnnotation } from '../../../../shared/types' + +function annotation(path: string | null, startLine = 1): PRCheckAnnotation { + return { + path, + startLine, + endLine: startLine, + annotationLevel: null, + title: null, + message: '', + rawDetails: null + } +} + +describe('getOpenableAnnotationLine', () => { + it('allows extensionless repo files and dotfiles', () => { + expect(getOpenableAnnotationLine(annotation('Dockerfile'))).toEqual({ + path: 'Dockerfile', + line: 1 + }) + expect(getOpenableAnnotationLine(annotation('LICENSE'))).toEqual({ path: 'LICENSE', line: 1 }) + expect(getOpenableAnnotationLine(annotation('.eslintrc'))).toEqual({ + path: '.eslintrc', + line: 1 + }) + }) + + it('rejects workflow pseudo-paths without blocking workflow files', () => { + expect(getOpenableAnnotationLine(annotation('.github'))).toBeNull() + expect(getOpenableAnnotationLine(annotation('.github/workflows/ci.yml'))).toEqual({ + path: '.github/workflows/ci.yml', + line: 1 + }) + }) +}) + +describe('resolveAnnotationPathInsideWorktree', () => { + it('normalizes safe relative paths inside the worktree', () => { + expect(resolveAnnotationPathInsideWorktree('/repo', 'src/../Dockerfile')).toEqual({ + absolutePath: '/repo/Dockerfile', + relativePath: 'Dockerfile' + }) + expect(resolveAnnotationPathInsideWorktree('/repo', ' LICENSE ')).toEqual({ + absolutePath: '/repo/LICENSE', + relativePath: 'LICENSE' + }) + }) + + it('rejects paths that escape or start outside the worktree', () => { + expect(resolveAnnotationPathInsideWorktree('/repo', '.github')).toBeNull() + expect(resolveAnnotationPathInsideWorktree('/repo', '../../tmp/secret.txt')).toBeNull() + expect(resolveAnnotationPathInsideWorktree('/repo', '/tmp/secret.txt')).toBeNull() + expect(resolveAnnotationPathInsideWorktree('C:\\Repo', 'D:\\secret.txt')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/editor/check-annotation-path.ts b/src/renderer/src/components/editor/check-annotation-path.ts new file mode 100644 index 00000000000..b18baea1475 --- /dev/null +++ b/src/renderer/src/components/editor/check-annotation-path.ts @@ -0,0 +1,60 @@ +import type { PRCheckAnnotation } from '../../../../shared/types' +import { relativePathInsideRoot, resolveRuntimePath } from '../../../../shared/cross-platform-path' + +const WORKFLOW_PSEUDO_ANNOTATION_PATH = '.github' + +function isRootedAnnotationPath(path: string): boolean { + return path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:/.test(path) +} + +function normalizeAnnotationRelativePath(path: string): string | null { + const trimmedPath = path.trim() + if (!trimmedPath || trimmedPath.includes('\0') || isRootedAnnotationPath(trimmedPath)) { + return null + } + + const segments: string[] = [] + for (const segment of trimmedPath.replace(/[\\/]+/g, '/').split('/')) { + if (!segment || segment === '.') { + continue + } + if (segment === '..') { + if (segments.length === 0) { + return null + } + segments.pop() + continue + } + segments.push(segment) + } + + return segments.length > 0 ? segments.join('/') : null +} + +export function getOpenableAnnotationLine( + annotation: PRCheckAnnotation +): { path: string; line: number } | null { + const path = normalizeAnnotationRelativePath(annotation.path?.trim() ?? '') + const line = annotation.startLine + if (!path || path === WORKFLOW_PSEUDO_ANNOTATION_PATH || !line || line < 1) { + return null + } + return { path, line } +} + +export function resolveAnnotationPathInsideWorktree( + worktreePath: string, + path: string +): { absolutePath: string; relativePath: string } | null { + const relativePath = normalizeAnnotationRelativePath(path) + if (!relativePath || relativePath === WORKFLOW_PSEUDO_ANNOTATION_PATH) { + return null + } + + const absolutePath = resolveRuntimePath(worktreePath, relativePath) + if (relativePathInsideRoot(worktreePath, absolutePath) !== relativePath) { + return null + } + + return { absolutePath, relativePath } +} diff --git a/src/renderer/src/components/editor/check-job-step-status.test.ts b/src/renderer/src/components/editor/check-job-step-status.test.ts index ba0590d32ac..fd6695f43ee 100644 --- a/src/renderer/src/components/editor/check-job-step-status.test.ts +++ b/src/renderer/src/components/editor/check-job-step-status.test.ts @@ -7,7 +7,15 @@ describe('resolveStepOutcome', () => { }) it('maps failure-like states to failure', () => { - for (const conclusion of ['failure', 'failed', 'cancelled', 'timed_out']) { + for (const conclusion of [ + 'failure', + 'failed', + 'action_required', + 'cancelled', + 'stale', + 'startup_failure', + 'timed_out' + ]) { expect(resolveStepOutcome({ status: null, conclusion })).toBe('failure') } }) diff --git a/src/renderer/src/components/editor/check-job-step-status.ts b/src/renderer/src/components/editor/check-job-step-status.ts index 0bd1c7a5aaf..a6d4d07007d 100644 --- a/src/renderer/src/components/editor/check-job-step-status.ts +++ b/src/renderer/src/components/editor/check-job-step-status.ts @@ -8,7 +8,10 @@ export function resolveStepOutcome(step: Pick = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.17/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx new file mode 100644 index 00000000000..cbf041f62e3 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -0,0 +1,55 @@ +import { ShieldQuestion } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { ChatApproval } from './native-chat-interactive-prompt' + +export type NativeChatApprovalCardProps = { + approval: ChatApproval + /** Send the chosen option's literal string to the agent's PTY. */ + onChoose: (send: string) => void +} + +/** + * Native renderer for an agent tool-approval (PermissionRequest) as an + * Allow/Deny card. Each button writes its option's literal `send` string back + * to the agent (a number to allow; ESC to deny). The first option reads as the + * affirmative action and gets the primary styling. + */ +export function NativeChatApprovalCard({ + approval, + onChoose +}: NativeChatApprovalCardProps): React.JSX.Element { + return ( +
+
+
+ +
+

{approval.title}

+ {approval.detail ? ( +

+ {approval.detail} +

+ ) : null} +
+
+
+ {approval.options.map((opt, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx new file mode 100644 index 00000000000..f0662c52acd --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from 'react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { SlashCommandSuggestion } from './native-chat-composer-state' +import type { DiscoveredSkill } from '../../../../shared/skills' + +export function NativeChatSlashMenu({ + suggestions, + activeIndex, + onChoose +}: { + suggestions: SlashCommandSuggestion[] + activeIndex: number + onChoose: (command: SlashCommandSuggestion) => void +}): React.JSX.Element { + return ( +
+ {suggestions.map((command, index) => ( + + ))} +
+ ) +} + +export function NativeChatMentionHint({ + query, + onAccept +}: { + query: string + onAccept: () => void +}): React.JSX.Element { + return ( + + ) +} + +export function NativeChatSkillMenu({ + suggestions, + activeIndex, + onChoose +}: { + suggestions: DiscoveredSkill[] + activeIndex: number + onChoose: (skill: DiscoveredSkill) => void +}): React.JSX.Element { + const activeItemRef = useRef(null) + + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, suggestions]) + + return ( +
+ {suggestions.length === 0 ? ( +
+ {translate('components.native-chat.composer.noSkills', 'No matching skills')} +
+ ) : null} + {suggestions.map((skill, index) => ( + + ))} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx new file mode 100644 index 00000000000..f5a2f2c7100 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -0,0 +1,424 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '../../store' +import type { AgentType } from '../../../../shared/agent-status-types' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' +import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { sendNativeChatMessage, submitNativeChatPrompt } from './native-chat-runtime-send' +import { getAgentSlashCommands } from './native-chat-agent-commands' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + applyMentionSuggestion, + applySkillSuggestion, + applySlashSuggestion, + deriveComposerAutocomplete, + EMPTY_HISTORY, + isSlashCommandDraft, + pushHistory, + slashCommandDispatchText, + type HistoryState, + type SlashCommandSuggestion +} from './native-chat-composer-state' +import { resolveImagePaste } from './native-chat-image-paste' +import { NativeChatComposerField } from './NativeChatComposerField' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { useNativeChatSkills } from './use-native-chat-skills' +import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' +import { dispatchDictationControl } from '../dictation/dictation-control-events' +import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' + +// Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a +// PTY (matching how xterm forwards Escape). The richer interrupt-intent +// inference (agent-interrupt-intent.ts) is driven by the existing PTY input +// observers, so writing ESC through the same send path feeds that machinery. +const ESC = '\x1b' + +export type NativeChatComposerProps = { + /** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */ + terminalTabId: string + /** Specific split-pane PTY this chat view owns. */ + targetPtyId: string | null + agent: AgentType + /** + * Mobile presence-lock seam (R8): when a mobile client holds the pty, desktop + * sends must be guarded rather than silently dropped. U9 wires the real lock + * state in; until then this defaults to `true` (sendable) and the composer + * already renders the guarded/disabled affordance when it is `false`. + */ + canSend?: boolean + /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ + isWorking?: boolean + /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ + onStop?: () => void + /** Optional optimistic-send hook: called with the sent text so the view can + * render a "queued" echo until the real transcript turn lands (mobile parity). */ + onOptimisticSend?: (text: string, imagePaths?: string[]) => void + /** Called with a dispatched slash command (e.g. `/clear`) so the view can show + * a small "Ran /clear" system line — slash commands aren't chat turns and + * otherwise leave no visible trace that anything happened. */ + onSlashCommand?: (command: string) => void +} + +export type NativeChatComposerHandle = { + focus: () => boolean + insertTypedText: (text: string) => boolean +} + +/** + * Rich native input for the chat view. Sends prompts into the running agent + * through the same verified runtime path as typed input (KTD4), so the agent + * cannot distinguish native input from keystrokes. Enter sends; Shift+Enter + * inserts a newline; multi-line is bracketed-paste wrapped; Esc interrupts. + * Slash-command and `@file` autocomplete are agent-aware; image paste persists a + * temp file and injects the agent-appropriate path (or reports unsupported). + */ +export const NativeChatComposer = forwardRef( + function NativeChatComposer( + { + terminalTabId, + targetPtyId, + agent, + canSend = true, + isWorking = false, + onStop, + onOptimisticSend, + onSlashCommand + }, + ref + ): React.JSX.Element { + const [draft, setDraft] = useState('') + const [caret, setCaret] = useState(0) + const [history, setHistory] = useState(EMPTY_HISTORY) + const [activeSuggestion, setActiveSuggestion] = useState(0) + const [notice, setNotice] = useState(null) + const [dictationPressed, setDictationPressed] = useState(false) + const skills = useNativeChatSkills(agent, terminalTabId) + const textareaRef = useRef(null) + const dictationState = useAppStore((store) => store.dictationState) + const voiceSettings = useAppStore((store) => store.settings?.voice) + const isDictationHoldMode = voiceSettings?.dictationMode === 'hold' + const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel + const isDictating = + dictationPressed || + dictationState === 'starting' || + dictationState === 'listening' || + dictationState === 'stopping' + + const agentCommands = useMemo(() => getAgentSlashCommands(agent), [agent]) + const autocomplete = useMemo( + () => + deriveComposerAutocomplete(draft, caret, agentCommands, agent === 'codex' ? skills : []), + [draft, caret, agentCommands, agent, skills] + ) + + // Resolve the live ptyId for this chat leaf; runtime owner settings route + // local vs remote (SSH) sends. + const resolveTarget = useCallback((): NativeChatResolvedTarget | null => { + if (!targetPtyId) { + return null + } + return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) } + }, [targetPtyId, terminalTabId]) + + const hasPty = targetPtyId !== null + const disabled = !hasPty || !canSend + + const syncCaret = useCallback((el: HTMLTextAreaElement) => { + setCaret(el.selectionStart ?? el.value.length) + }, []) + + const { imageAttachments, attachLocalPaths, clearImageAttachments, removeImageAttachment } = + useNativeChatComposerAttachments({ + attachmentScopeKey: targetPtyId ?? terminalTabId, + caret, + resolveTarget, + textareaRef, + setCaret, + setDraft, + setNotice + }) + const sendButtonDisabled = isWorking + ? !hasPty || !onStop + : disabled || (draft.trim() === '' && imageAttachments.length === 0) + + const insertTypedText = useCallback( + (text: string): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + const selectionStart = textarea.selectionStart ?? caret + const selectionEnd = textarea.selectionEnd ?? selectionStart + const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}` + const nextCaret = selectionStart + text.length + textarea.focus() + setDraft(next) + setCaret(nextCaret) + setHistory((prev) => ({ entries: prev.entries, index: null })) + setActiveSuggestion(0) + requestAnimationFrame(() => { + textarea.setSelectionRange(nextCaret, nextCaret) + }) + return true + }, + [caret, draft] + ) + + const focus = useCallback((): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + textarea.focus() + return true + }, []) + + useImperativeHandle(ref, () => ({ focus, insertTypedText }), [focus, insertTypedText]) + + useEffect(() => { + return window.api.ui.onFileDrop((payload) => { + if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) { + return + } + attachLocalPaths(payload.paths) + }) + }, [attachLocalPaths]) + + const pickAttachment = useCallback(() => { + void (async () => { + const filePath = await window.api.shell.pickAttachment() + if (!filePath) { + return + } + attachLocalPaths([filePath]) + })() + }, [attachLocalPaths]) + + const focusForDictation = useCallback(() => { + textareaRef.current?.focus() + }, []) + + const toggleDictation = useCallback(() => { + focusForDictation() + dispatchDictationControl('toggle') + }, [focusForDictation]) + + const startHoldDictation = useCallback(() => { + setDictationPressed(true) + focusForDictation() + dispatchDictationControl('start') + }, [focusForDictation]) + + const stopHoldDictation = useCallback(() => { + setDictationPressed(false) + dispatchDictationControl('stop') + }, []) + + const send = useCallback(() => { + const text = draft + const imagePaths = imageAttachments.map((attachment) => attachment.path) + if ((text.trim() === '' && imagePaths.length === 0) || disabled) { + return + } + const target = resolveTarget() + if (!target) { + return + } + // Images are pasted into the hosted TUI as soon as they are attached, so the + // Send action only submits the text body (if any) plus Enter. + if (text.trim().length > 0) { + sendNativeChatMessage(target.settings, target.ptyId, text) + } else { + submitNativeChatPrompt(target.settings, target.ptyId) + } + // Slash commands are TUI controls, not chat turns: don't echo a user bubble, + // but DO surface a small "Ran /clear" system line so the command leaves a + // visible trace instead of seeming to do nothing. + if (isSlashCommandDraft(text)) { + onSlashCommand?.(text.trim()) + } else { + onOptimisticSend?.(text, imagePaths) + } + // Why: U10 telemetry — record adoption + local-vs-remote runtime split. The + // agent prop is the loose AgentType; the emitter narrows unknowns to 'other'. + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((prev) => pushHistory(prev, text)) + setDraft('') + setCaret(0) + clearImageAttachments() + setNotice(null) + }, [ + agent, + clearImageAttachments, + draft, + imageAttachments, + disabled, + resolveTarget, + onOptimisticSend, + onSlashCommand + ]) + + const interrupt = useCallback(() => { + if (isWorking && onStop) { + onStop() + return + } + const target = resolveTarget() + if (!target) { + return + } + sendRuntimePtyInput(target.settings, target.ptyId, ESC) + }, [isWorking, onStop, resolveTarget]) + + const chooseSlash = useCallback((command: SlashCommandSuggestion) => { + const next = applySlashSuggestion(command) + setDraft(next) + setCaret(next.length) + setActiveSuggestion(0) + textareaRef.current?.focus() + }, []) + + const dispatchSlash = useCallback( + (command: SlashCommandSuggestion) => { + const next = slashCommandDispatchText(command) + const target = resolveTarget() + if (!target || disabled) { + return + } + sendNativeChatMessage(target.settings, target.ptyId, next) + // Surface the command as a system line (this is the autocomplete-menu + // dispatch path; the typed-Enter path in `send` does the same). + onSlashCommand?.(next.trim()) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((prev) => pushHistory(prev, next)) + setDraft('') + setCaret(0) + setActiveSuggestion(0) + setNotice(null) + }, + [agent, disabled, resolveTarget, onSlashCommand] + ) + + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + const hasImage = Array.from(event.clipboardData.items).some((item) => + item.type.startsWith('image/') + ) + if (!hasImage) { + return + } + event.preventDefault() + // Why: snapshot the caret before the async temp-file round-trip — `caret` + // state can move (further typing/selection) while the await is in flight. + const caretAtPaste = caret + void (async () => { + const tempPath = await window.api.ui.saveClipboardImageAsTempFile() + if (!tempPath) { + return + } + const result = resolveImagePaste(agent, tempPath) + if (result.kind === 'unsupported') { + setNotice( + translate( + 'components.native-chat.composer.imageUnsupported', + 'Image paste is not supported for this agent.' + ) + ) + return + } + attachLocalPaths([result.path]) + setCaret(caretAtPaste) + setNotice(null) + })() + }, + [agent, attachLocalPaths, caret] + ) + + const handleKeyDown = useNativeChatComposerKeyDown({ + autocomplete, + activeSuggestion, + draft, + caret, + history, + chooseSlash, + dispatchSlash, + interrupt, + send, + setActiveSuggestion, + setDraft, + setCaret, + setHistory + }) + + return ( + { + setDraft(value) + setHistory((prev) => ({ entries: prev.entries, index: null })) + syncCaret(element) + setActiveSuggestion(0) + }} + onTextareaSelect={syncCaret} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + onChooseSlash={chooseSlash} + onAcceptMention={() => { + if (autocomplete.mode !== 'mention') { + return + } + const result = applyMentionSuggestion(draft, caret, autocomplete.query) + setDraft(result.draft) + setCaret(result.caret) + textareaRef.current?.focus() + }} + onChooseSkill={(skill) => { + const result = applySkillSuggestion(draft, caret, skill.name) + setDraft(result.draft) + setCaret(result.caret) + setActiveSuggestion(0) + textareaRef.current?.focus() + }} + onRemoveImageAttachment={(id) => removeImageAttachment(id)} + onAttach={pickAttachment} + onDictationToggle={toggleDictation} + onDictationHoldStart={startHoldDictation} + onDictationHoldEnd={stopHoldDictation} + onSend={send} + onStop={onStop} + /> + ) + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx new file mode 100644 index 00000000000..fb67917b332 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -0,0 +1,125 @@ +import { ArrowUp, Mic, Plus, Square } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' + +export type NativeChatComposerActionsProps = { + attachDisabled: boolean + dictationDisabled: boolean + sendDisabled: boolean + isWorking: boolean + isDictating: boolean + isDictationHoldMode: boolean + onAttach: () => void + onDictationToggle: () => void + onDictationHoldStart: () => void + onDictationHoldEnd: () => void + onSend: () => void + onStop?: () => void +} + +export function NativeChatComposerActions({ + attachDisabled, + dictationDisabled, + sendDisabled, + isWorking, + isDictating, + isDictationHoldMode, + onAttach, + onDictationToggle, + onDictationHoldStart, + onDictationHoldEnd, + onSend, + onStop +}: NativeChatComposerActionsProps): React.JSX.Element { + const dictationLabel = isDictating + ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') + : translate('components.native-chat.composer.startDictation', 'Start dictation') + return ( +
+ + + + + + {translate('components.native-chat.composer.attach', 'Attach file')} + + +
+ + + + + + {dictationLabel} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx new file mode 100644 index 00000000000..40cb7b6adf7 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -0,0 +1,181 @@ +import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import { Image as ImageIcon, ImageOff, X } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' +import { basename } from '@/lib/path' +import type { ComposerAutocomplete, SlashCommandSuggestion } from './native-chat-composer-state' +import { + NativeChatMentionHint, + NativeChatSkillMenu, + NativeChatSlashMenu +} from './NativeChatAutocompleteMenus' +import { NativeChatComposerActions } from './NativeChatComposerActions' +import { nativeChatComposerPlaceholder } from './native-chat-composer-target' +import type { DiscoveredSkill } from '../../../../shared/skills' + +export type NativeChatComposerFieldProps = { + textareaRef: RefObject + draft: string + disabled: boolean + hasPty: boolean + canSend: boolean + autocomplete: ComposerAutocomplete + activeSuggestion: number + notice: string | null + imageAttachments: readonly NativeChatComposerImageAttachment[] + sendButtonDisabled: boolean + isWorking: boolean + attachDisabled: boolean + dictationDisabled: boolean + isDictating: boolean + isDictationHoldMode: boolean + onDraftChange: (value: string, element: HTMLTextAreaElement) => void + onTextareaSelect: (element: HTMLTextAreaElement) => void + onKeyDown: KeyboardEventHandler + onPaste: ClipboardEventHandler + onChooseSlash: (command: SlashCommandSuggestion) => void + onAcceptMention: () => void + onChooseSkill: (skill: DiscoveredSkill) => void + onRemoveImageAttachment: (id: string) => void + onAttach: () => void + onDictationToggle: () => void + onDictationHoldStart: () => void + onDictationHoldEnd: () => void + onSend: () => void + onStop?: () => void +} + +export type NativeChatComposerImageAttachment = { + id: string + path: string +} + +export function NativeChatComposerField({ + textareaRef, + draft, + disabled, + hasPty, + canSend, + autocomplete, + activeSuggestion, + notice, + imageAttachments, + sendButtonDisabled, + isWorking, + attachDisabled, + dictationDisabled, + isDictating, + isDictationHoldMode, + onDraftChange, + onTextareaSelect, + onKeyDown, + onPaste, + onChooseSlash, + onAcceptMention, + onChooseSkill, + onRemoveImageAttachment, + onAttach, + onDictationToggle, + onDictationHoldStart, + onDictationHoldEnd, + onSend, + onStop +}: NativeChatComposerFieldProps): React.JSX.Element { + return ( +
+
+
+ {autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? ( + + ) : null} + {autocomplete.mode === 'mention' ? ( + + ) : null} + {autocomplete.mode === 'skill' ? ( + + ) : null} + {notice ? ( +
+ + {notice} +
+ ) : null} +
+ {imageAttachments.length > 0 ? ( +
+ {imageAttachments.map((attachment) => ( +
+ + {basename(attachment.path)} + +
+ ))} +
+ ) : null} +