Merge origin/main into floating add worktree

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-28 16:38:13 -07:00
co-authored by Orca
343 changed files with 25814 additions and 1310 deletions
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+217
View File
@@ -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 <rest>`.
- 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.
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+2 -2
View File
@@ -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)
</td>
<td width="50%">
@@ -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)
---
+1 -1
View File
@@ -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",
+1
View File
@@ -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
)
@@ -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')
})
})
@@ -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,
@@ -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 () => {
+95 -40
View File
@@ -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<BrowserTypeResult> {
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<unknown> {
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<T>,
options: EnqueueTargetedCommandOptions = {}
): Promise<T> {
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<string | undefined>()
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,
@@ -81,7 +81,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
terminalScrollbackRows: 5_000,
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
+1 -1
View File
@@ -85,7 +85,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
setupScriptLaunchMode: 'split-vertical',
terminalScrollbackBytes: 10_000_000,
terminalScrollbackRows: 5_000,
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
@@ -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')
+35
View File
@@ -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' })
+210
View File
@@ -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
+54 -12
View File
@@ -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 {
+6 -1
View File
@@ -22,7 +22,9 @@ const conclusionMap: Record<string, PRCheckDetail['conclusion']> = {
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'
}
+34
View File
@@ -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') =>
({
+10 -3
View File
@@ -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 } : {})
}
}
/**
+41 -1
View File
@@ -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<string, string> | 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)
+259
View File
@@ -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<string, (_event: unknown, args?: unknown) => unknown>(),
listeners: new Map<string, (_event: unknown, args?: unknown) => 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<void> {
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<unknown> {
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
}
}
})
})
+168
View File
@@ -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<ReadTranscriptResult> {
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<number, Map<string, LiveSubscription>>()
const senderCleanupRegistered = new Set<number>()
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<void> {
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<string, LiveSubscription>()
// 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)
})
}
+93
View File
@@ -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<T>(command: string, commandPromise: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
commandPromise,
new Promise<never>((_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<PreflightCommandResult> {
const env = buildLocalPreflightEnv()
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
...(env ? { env } : {})
}) as Promise<PreflightCommandResult>
return withPreflightTimeout(command, commandPromise)
}
export async function execCommandInWsl(
target: WslPreflightTarget,
command: string
): Promise<PreflightCommandResult> {
const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
return withPreflightTimeout('wsl.exe', commandPromise)
}
export async function isCommandAvailable(
command: string,
wslTarget?: WslPreflightTarget
): Promise<boolean> {
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<boolean> {
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
}
}
@@ -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<RemoteWindowsTerminalCapabilities> {
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
}
+29
View File
@@ -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,
+21 -93
View File
@@ -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<T>(command: string, commandPromise: Promise<T>): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
commandPromise,
new Promise<never>((_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<PreflightCommandResult> {
const env = buildLocalPreflightEnv()
const commandPromise = execFileAsync(command, args, {
encoding: 'utf-8',
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS,
...(env ? { env } : {})
}) as Promise<PreflightCommandResult>
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<boolean> {
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<boolean> {
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<RemoteWindowsTerminalCapabilities> => {
return detectRemoteWindowsTerminalCapabilities(args)
}
)
}
+61
View File
@@ -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)
+12 -3
View File
@@ -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<string, string>
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
}
@@ -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()
+2
View File
@@ -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)
+21
View File
@@ -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<unknown>
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: [] })
+18 -1
View File
@@ -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<string>(SETTINGS_CHANGED_WHITELIST)
type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
terminalScrollbackBytes?: unknown
}
function sanitizeRendererSettingsUpdate(args: Partial<GlobalSettings>): Partial<GlobalSettings> {
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<GlobalSettings>) => {
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)
}
+8
View File
@@ -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()
+2 -1
View File
@@ -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() })
}
)
}
+61 -12
View File
@@ -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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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 }
+62 -1
View File
@@ -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([
+8 -4
View File
@@ -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', () => {
+9 -1
View File
@@ -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' }
]
}
@@ -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<string> {
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 <sessionId>.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 <userData>/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)
})
})
@@ -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
// `<managed home>/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/<slug>/<id>.jsonl`), so we glob the projects subdirs for
* `<id>.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<string | null> {
// 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<string | null> {
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<string | null> {
// Codex rollout file names embed the session id (rollout-<ts>-<id>.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
}
@@ -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<string, unknown>,
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<string, unknown>,
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<string, unknown>): 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
}
@@ -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<typeof TranscriptReader>()
return {
...actual,
readNativeChatTranscript: (...args: Parameters<typeof actual.readNativeChatTranscript>) => {
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<string> {
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()
})
})
@@ -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<string, CachedTranscript>()
// 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<number> {
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<ReadTranscriptResult> {
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()
}
@@ -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<string> {
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)
})
})
+66
View File
@@ -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<ReadTranscriptResult> {
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<NativeChatMessage[]> {
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
}
@@ -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<string, unknown>): 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<string, unknown>): NativeChatToolResultBlock {
return {
type: 'tool-result',
output: toolResultOutput(record.content),
...(record.is_error === true ? { isError: true } : {})
}
}
function imageRefBlock(record: Record<string, unknown>): 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 } : {})
}
}
@@ -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<string> {
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<void> {
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()
})
})
+254
View File
@@ -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<number> {
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<string> {
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<NativeChatTranscriptSubscription> {
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<typeof setTimeout> | null = null
async function drain(): Promise<void> {
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--
}
}
}
+223
View File
@@ -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<string, unknown> }
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<string, unknown> }
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<string, unknown> }
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<string, unknown> }
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()
})
})
+80 -2
View File
@@ -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<GlobalSettings> | undefined
): Partial<GlobalSettings> {
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
{}) as Partial<GlobalSettings> & { 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<GlobalSettings>,
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:
@@ -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, {
+7 -5
View File
@@ -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 `~/.<kind>/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 }
: {})
+1
View File
@@ -30,6 +30,7 @@ export type PtySpawnOptions = {
env?: Record<string, string>
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. */
@@ -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,
@@ -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)
})
})
+491 -28
View File
@@ -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<string, WorktreeMeta> = {}
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<string, WorktreeMeta> = {}
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<string, string> }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).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<string, string>
}
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<string, string> }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).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<string, WorktreeMeta> = {}
const runtimeStore = {
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
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<string, WorktreeMeta> = {}
@@ -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<string, WorktreeMeta> = {}
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<string, string> }).env ?? {}
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).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<string, WorktreeMeta> = {}
+462 -170
View File
@@ -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<string, string>
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<string[]> {
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<GlobalSettings, 'setupScriptLaunchMode'>
>
).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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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<string, string>
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<string, AgentStatusOrchestrationContext>
| 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) {
+5
View File
@@ -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.
+3
View File
@@ -93,6 +93,7 @@ export class RpcDispatcher {
connectionId?: string
signal?: AbortSignal
clientId?: string
clientKind?: 'mobile' | 'runtime'
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => 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
},
@@ -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')
@@ -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(),
+2
View File
@@ -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,
@@ -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<unknown> {
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<unknown>
}
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')
})
})
+180
View File
@@ -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 }
}
})
]
@@ -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'
}
})
})
})
@@ -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,
@@ -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({
+2 -1
View File
@@ -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({
+12 -2
View File
@@ -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() })
}
})
]
+6
View File
@@ -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) =>
+27
View File
@@ -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')
+20
View File
@@ -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',
+49
View File
@@ -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<string[]>
refreshAgents: (args?: PreflightRuntimeContext) => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
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<AiVaultListResult>
}
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<NativeChatReadSessionResult>
/** 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<AppIdentity>
@@ -2007,6 +2055,7 @@ export type PreloadApi = {
codexUsage: CodexUsageApi
openCodeUsage: OpenCodeUsageApi
aiVault: AiVaultApi
nativeChat: NativeChatApi
fs: {
readDir: (args: { dirPath: string; connectionId?: string }) => Promise<DirEntry[]>
readFile: (args: {
+50 -1
View File
@@ -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<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
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<NativeChatReadSessionResult> =>
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<RuntimeSyncWindowGraphResult> =>
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
+63 -1
View File
@@ -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<string, (params: Record<string, unknown>) => Promise<unknown>>()
const dispatcher = {
onRequest: vi.fn(
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
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
})
}
})
})
+28
View File
@@ -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.
+426 -2
View File
@@ -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<string, string> }
| 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<string, string> }
| 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<string, string> }
| 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<string, string> }
| 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<string, string> }
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 () => {
+170 -15
View File
@@ -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<typeof setTimeout> | 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<string, string> | 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
}
+16 -3
View File
@@ -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<string, string>,
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') {
+5 -2
View File
@@ -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<void> {
// 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')
@@ -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<HTMLElement>(
'[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: [
@@ -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}
</div>
) : null}
{showSetupAgentStartupPolicy ? (
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-muted/25 p-3">
<span className="min-w-0 space-y-1">
<span className="block text-xs font-medium text-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
</span>
<span className="block text-[11px] text-muted-foreground">
{translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgentHelp',
'Turn this on when setup installs dependencies, MCP servers, or config files the agent needs during startup.'
)}
</span>
</span>
<SettingsSwitch
checked={setupAgentStartupPolicy === 'wait-for-setup'}
onChange={() =>
onSetupAgentStartupPolicyChange(
setupAgentStartupPolicy === 'wait-for-setup'
? 'start-immediately'
: 'wait-for-setup'
)
}
ariaLabel={translate(
'auto.components.NewWorkspaceComposerCard.waitForSetupBeforeAgent',
'Wait for setup to complete before starting agent'
)}
/>
</div>
) : null}
</div>
) : null}
@@ -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<SetupDecision, 'run' | 'skip'>
reuseSession: boolean
precheckCommand: string
precheckTimeoutSeconds: string
@@ -53,6 +62,9 @@ type AutomationEditorDialogProps = {
canSave: boolean
createTarget: AutomationCreateTarget
repos: Repo[]
projectHostSetups: ProjectHostSetup[]
automationYamlHooksByRepoKey: Record<string, OrcaHooks | null>
getAutomationHooksCacheKey: (repoId: string) => string
repoMap: Map<string, Repo>
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}
/>
@@ -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<string, OrcaHooks | null>
getAutomationHooksCacheKey: (repoId: string) => string
repoMap: Map<string, Repo>
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}
/>
</div>
{/* 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. */}
<AutomationSetupDecisionField
createTarget={isHermesTarget ? 'hermes' : 'orca'}
draft={draft}
repos={repos}
projectHostSetups={projectHostSetups}
yamlHooks={automationYamlHooksByRepoKey[getAutomationHooksCacheKey(draft.projectId)]}
onDraftChange={onDraftChange}
onSetupDecisionTouched={onSetupDecisionTouched}
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
@@ -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.
<div className="mt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setAdvancedOpen((open) => !open)}
className="-ml-2 text-xs"
>
{translate(
'auto.components.automations.AutomationSetupDecisionField.18f000ad4e',
'Advanced'
)}
<ChevronDown className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')} />
</Button>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!advancedOpen}
>
<div className="min-h-0">
<div
className={cn(
'space-y-1 px-1 pt-2 transition-[opacity,transform] duration-150 ease-out',
advancedOpen
? 'translate-y-0 opacity-100 delay-200'
: '-translate-y-1 opacity-0 delay-0'
)}
>
<label className="group flex items-center gap-2 text-xs text-foreground">
<span
className={cn(
'flex size-4 items-center justify-center rounded-[3px] border shadow-sm transition',
checked
? 'border-emerald-500/60 bg-emerald-500 text-white'
: 'border-foreground/20 bg-background dark:border-white/20 dark:bg-muted/10'
)}
>
<Check
className={cn('size-3 transition-opacity', checked ? 'opacity-100' : 'opacity-0')}
/>
</span>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
onSetupDecisionTouched()
onDraftChange((current) => ({
...current,
setupDecision: event.target.checked ? 'run' : 'skip'
}))
}}
className="sr-only"
/>
<span>{label}</span>
</label>
<p className="pl-6 text-xs text-muted-foreground">
{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."
)}
</p>
</div>
</div>
</div>
</div>
)
}
@@ -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<Set<string>>(new Set())
const rerunRunIdsInFlightRef = useRef<Set<string>>(new Set())
const workspaceNameCacheRef = useRef<Map<string, string>>(new Map())
const setupDecisionPolicyDefaultRef = useRef<AutomationDraft['setupDecision']>(undefined)
const setupDecisionDefaultSignatureRef = useRef<string | null>(null)
const setupDecisionTouchedRef = useRef(false)
const automationHookCheckPromisesRef = useRef<
Map<string, Promise<{ hooks: OrcaHooks | null; ok: boolean }>>
>(new Map())
const [automationYamlHooksByRepoKey, setAutomationYamlHooksByRepoKey] = useState<
Record<string, OrcaHooks | null>
>({})
const [draft, setDraft] = useState<AutomationDraft>({
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, 'projectId' | 'workspaceMode'>
): 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<OrcaHooks | null> => {
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<AutomationDraft, 'projectId' | 'workspaceMode'>): 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<void> => {
await updateAutomationForTarget(automation, { enabled: !automation.enabled })
await updateAutomationForTarget(
automation,
{ enabled: !automation.enabled },
automationHostTarget
)
await refresh()
}
const deleteAutomation = async (automation: Automation): Promise<void> => {
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 =
@@ -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: () => <div data-testid="run-table" />
}))
// 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> = {}): 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> = {}
): 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(
<ExternalAutomationManagers
managers={managers}
now={0}
runningActionKey={options.runningActionKey ?? null}
onAction={onAction}
onEdit={onEdit}
/>
)
})
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')
})
})
@@ -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 (
<div
key={job.id}
className="grid grid-cols-[minmax(0,1fr)_minmax(8rem,auto)_auto] items-center gap-3 px-3 py-2 text-sm"
className="relative grid grid-cols-[minmax(0,1fr)_minmax(8rem,auto)_auto] items-center gap-3 px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{job.name}</span>
<span
id={`automation-name-${manager.id}-${job.id}`}
className="truncate font-medium"
>
{job.name}
</span>
<Badge variant={job.enabled ? 'secondary' : 'outline'}>
{job.enabled
? translate(
@@ -187,6 +193,40 @@ export function ExternalAutomationManagers({
'Paused'
)}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
{/* 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. */}
<span className="absolute right-3 top-2 inline-flex shrink-0 items-center gap-1.5">
{/* 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') ? (
<RefreshCw className="size-3.5 animate-spin" />
) : null}
<SettingsSwitch
checked={job.enabled}
onChange={() =>
onAction(manager, job, job.enabled ? 'pause' : 'resume')
}
disabled={disabledMessage !== null}
ariaLabelledBy={`automation-name-${manager.id}-${job.id}`}
/>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{disabledMessage ??
(job.enabled
? translate(
'auto.components.automations.ExternalAutomationManagers.0def1693bb',
'Pause external automation'
)
: translate(
'auto.components.automations.ExternalAutomationManagers.1c3bfd38fe',
'Resume external automation'
))}
</TooltipContent>
</Tooltip>
</div>
<div className="mt-1 truncate text-xs font-medium text-foreground/80">
{scheduleDisplay.label}
@@ -264,31 +304,6 @@ export function ExternalAutomationManagers({
<Pencil className="size-3.5" />
</ExternalActionButton>
) : null}
<ExternalActionButton
label={
disabledMessage ??
(job.enabled
? translate(
'auto.components.automations.ExternalAutomationManagers.0def1693bb',
'Pause external automation'
)
: translate(
'auto.components.automations.ExternalAutomationManagers.1c3bfd38fe',
'Resume external automation'
))
}
disabled={disabledMessage !== null}
onClick={() => onAction(manager, job, job.enabled ? 'pause' : 'resume')}
>
{runningActionKey ===
actionKey(manager, job, job.enabled ? 'pause' : 'resume') ? (
<RefreshCw className="size-3.5 animate-spin" />
) : job.enabled ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
</ExternalActionButton>
<ExternalActionButton
label={
disabledMessage ??
@@ -4,7 +4,8 @@ import {
createAutomationForTarget,
getAutomationListTarget,
listAutomationsForTarget,
runAutomationNowForTarget
runAutomationNowForTarget,
updateAutomationForTarget
} from './automation-host-client'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
@@ -94,6 +95,7 @@ describe('automation host client', () => {
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 }
)
})
})
@@ -43,8 +43,12 @@ export function getAutomationListTarget(
}
export function getAutomationOwnerTarget(
automation: Pick<Automation, 'runContext'>
automation: Pick<Automation, 'runContext'>,
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<Automation> {
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<void> {
const target = getAutomationOwnerTarget(automation)
export async function deleteAutomationForTarget(
automation: Automation,
sourceTarget?: AutomationHostTarget | null
): Promise<void> {
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<AutomationRun> {
const target = getAutomationOwnerTarget(automation)
export async function runAutomationNowForTarget(
automation: Automation,
sourceTarget?: AutomationHostTarget | null
): Promise<AutomationRun> {
const target = getAutomationOwnerTarget(automation, sourceTarget)
if (target.kind === 'local') {
return await window.api.automations.runNow({ id: automation.id })
}
@@ -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> = {}): 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()
})
})
@@ -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<AutomationSetupSource, 'setupRunPolicy'> | null
): Extract<SetupDecision, 'run' | 'skip'> | 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<SetupDecision, 'run' | 'skip'> | 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<SetupDecision, 'run' | 'skip'> | undefined
}): Extract<SetupDecision, 'run' | 'skip'> | 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<SetupDecision, 'run' | 'skip'> | 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
}
@@ -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: {
@@ -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:<env>,
// 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:<env> 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
@@ -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<DictationControlAction>).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,
@@ -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<DictationControlAction>(DICTATION_CONTROL_EVENT, { detail: action })
)
}
@@ -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) => {
@@ -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) {

Some files were not shown because too many files have changed in this diff Show More