Skip to content

Commit f37a030

Browse files
committed
Add Side Quest conversations
1 parent 20ebe85 commit f37a030

74 files changed

Lines changed: 4430 additions & 38 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Side Quests: viability and delivery plan
2+
3+
## Outcome
4+
5+
Side Quests are viable on Orca's existing terminal-group and native-chat foundations. The delivered
6+
vertical slice opens a durable provider-owned Codex conversation beside the source terminal,
7+
optionally carries selected terminal output into the native composer as quoted context, and leaves
8+
the original process running and interactive.
9+
10+
Local Codex Side Quests use one warm `codex app-server` process and do not create a hidden PTY.
11+
Claude and SSH worktrees retain the read-only terminal-backed compatibility path. Runtime-owned web,
12+
paired, and headless worktrees need a host API improvement before they can bind context safely.
13+
14+
## User flow
15+
16+
1. Select terminal output and choose **Add Selection to Side Quest** from the floating action, or
17+
right-click the terminal and choose the same action.
18+
2. To start without context, right-click and choose **New Side Quest**.
19+
3. Orca creates a split to the right and starts a durable, read-only Codex provider thread for local
20+
worktrees. Compatibility environments launch the matching/default terminal agent.
21+
4. The new pane opens directly in native chat and is labeled **Side Quest**.
22+
5. Selected output appears as a removable quote card. The user adds a question and sends both as one
23+
bounded prompt with an explicit untrusted-context boundary.
24+
6. The original terminal continues without receiving input, focus, or an interrupt.
25+
26+
## Architecture
27+
28+
| Concern | Existing Orca primitive | Side Quest behavior |
29+
| --- | --- | --- |
30+
| Side-by-side layout | terminal split groups | create an empty right split beside the source group |
31+
| Independent agent | Codex app-server manager | reuse one warm process and create a provider-owned thread per Side Quest |
32+
| Agent choice | detected leaf, launched tab, default-agent settings | prefer the detected source agent, then launch metadata, then default |
33+
| Chat surface | experimental native chat | force the created unified terminal tab to chat mode |
34+
| Context | bounded session transcript cleaner | strip terminal control data and cap context at the existing transcript budget |
35+
| Prompt safety | native chat send path | fence output as untrusted quoted text and append the user's question |
36+
| Pending handoff | bounded renderer cache keyed by terminal tab ID | seed before chat's first render and clear after send/removal |
37+
| Durability | terminal-tab workspace state | persist only the Side Quest/provider thread reference; the provider owns messages |
38+
| Live updates | preload IPC subscription | stream agent deltas, completed items, turn completion, errors, and interrupts |
39+
40+
Codex app-server threads use `sandbox: read-only` and `approvalPolicy: never`. Inherited MCP servers
41+
and apps are disabled for these research conversations, avoiding both unwanted capabilities and the
42+
startup delay that motivated the direct provider transport. Claude launches with
43+
`--permission-mode plan` on the compatibility path. Terminal fallbacks intentionally bypass
44+
configured command overrides because an override can embed unrestricted flags that would defeat
45+
the shared-worktree guarantee.
46+
47+
## Platform viability
48+
49+
| Environment | Status | Notes |
50+
| --- | --- | --- |
51+
| macOS, Linux, Windows local worktrees | Supported | cross-platform process spawning plus the existing split/chat UI |
52+
| WSL worktrees | Supported by provider manager | converts renderer UNC paths to the selected distro's Linux path |
53+
| Ordinary SSH worktrees | Compatibility path | existing terminal launch routing keeps process creation on the SSH target |
54+
| Runtime-owned web/paired/headless worktrees | Blocked on host API | renderer receives no created tab ID, so context cannot be bound to a specific chat safely |
55+
56+
The runtime fix should make host terminal creation awaitable and return the created terminal and
57+
unified-tab identities. The same transaction should accept the target group, initial view mode,
58+
label, and pending Side Quest context. Until then the UI reports this limitation and avoids creating
59+
an orphan local split.
60+
61+
Local provider creation is asynchronous after tab allocation, so the composer remains draftable
62+
while the provider thread starts. The vertical slice rolls back tab-registration and split-creation
63+
failures, surfaces provider startup errors inline, and treats an app-server thread as an empty
64+
conversation until its first user turn materializes it.
65+
66+
## Delivery phases
67+
68+
### Phase 1 — vertical slice
69+
70+
- Selection floating action and terminal context-menu entry.
71+
- No-selection context-menu entry.
72+
- Right split, independent read-only provider thread, native chat mode, and Side Quest label.
73+
- Bounded removable quote card and safe first-question composition.
74+
- Unit coverage for agent choice, launch rollback, context fencing, composer handoff, and UI actions.
75+
76+
### Phase 2 — durable product model (partially delivered)
77+
78+
- Persist a `sideQuest` provider-thread reference rather than relying on terminal labels.
79+
- Reuse one warm Codex app-server and resume durable threads after process/app restart.
80+
- Persist unsent quoted context across app restarts.
81+
- Add Side Quest history, rename, close/archive, and source-conversation metadata.
82+
- Add `@side-quest` mention/search so a main conversation can import a bounded summary or selected
83+
messages without coupling the two live sessions.
84+
85+
### Phase 3 — runtime parity and polish
86+
87+
- Extend the runtime host create-tab API and enable web/paired/headless worktrees.
88+
- Add an awaited agent-readiness result so late local/SSH startup failures can close the tab and
89+
collapse its split automatically.
90+
- Add keyboard access and command-palette launch.
91+
- Add telemetry for launch, first question, context removal, and return-to-main-thread behavior.
92+
- Run interaction QA on macOS, Linux, Windows, local SSH, and high-latency SSH.
93+
94+
## Acceptance criteria
95+
96+
- Launching never writes to or interrupts the source terminal.
97+
- The Side Quest agent runs in the same worktree and execution environment as the source.
98+
- The created surface is native chat, not the TUI, when experimental native chat is enabled.
99+
- Selected output is visibly quoted, removable, bounded, cleaned of terminal escape data, and treated
100+
as untrusted input.
101+
- Slash commands remain valid and do not accidentally consume pending quote context.
102+
- A failed launch collapses the empty split and shows a user-facing error.
103+
- Platform-specific behavior stays behind existing runtime checks.

src/main/ipc/register-core-handlers.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const {
5959
registerWorkspacePortHandlersMock,
6060
registerLocalhostWorktreeLabelHandlersMock,
6161
registerNativeChatHandlersMock,
62+
registerSideQuestHandlersMock,
6263
registerEmulatorFrameStreamHandlersMock,
6364
registerEmulatorVideoStreamHandlersMock
6465
} = vi.hoisted(() => ({
@@ -120,16 +121,22 @@ const {
120121
registerWorkspacePortHandlersMock: vi.fn(),
121122
registerLocalhostWorktreeLabelHandlersMock: vi.fn(),
122123
registerNativeChatHandlersMock: vi.fn(),
124+
registerSideQuestHandlersMock: vi.fn(() => vi.fn()),
123125
registerEmulatorFrameStreamHandlersMock: vi.fn(),
124126
registerEmulatorVideoStreamHandlersMock: vi.fn()
125127
}))
126128

127129
vi.mock('electron', () => ({
128130
app: {
129-
getPath: getPathMock
131+
getPath: getPathMock,
132+
once: vi.fn()
130133
}
131134
}))
132135

136+
vi.mock('./side-quest', () => ({
137+
registerSideQuestHandlers: registerSideQuestHandlersMock
138+
}))
139+
133140
vi.mock('../../shared/runtime-environment-store', () => ({
134141
listEnvironments: listEnvironmentsMock
135142
}))
@@ -416,6 +423,8 @@ describe('registerCoreHandlers', () => {
416423
registerWorkspacePortHandlersMock.mockReset()
417424
registerLocalhostWorktreeLabelHandlersMock.mockReset()
418425
registerNativeChatHandlersMock.mockReset()
426+
registerSideQuestHandlersMock.mockReset()
427+
registerSideQuestHandlersMock.mockReturnValue(vi.fn())
419428
registerEmulatorFrameStreamHandlersMock.mockReset()
420429
registerEmulatorVideoStreamHandlersMock.mockReset()
421430
})
@@ -468,6 +477,7 @@ describe('registerCoreHandlers', () => {
468477
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
469478
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
470479
expect(registerPetHandlersMock).toHaveBeenCalled()
480+
expect(registerSideQuestHandlersMock).toHaveBeenCalled()
471481
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
472482
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
473483
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()

src/main/ipc/register-core-handlers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
2727
import { registerEphemeralVmHandlers } from './ephemeral-vm'
2828
import { registerAiVaultHandlers } from './ai-vault'
2929
import { registerNativeChatHandlers } from './native-chat'
30+
import { registerSideQuestHandlers } from './side-quest'
3031
import { registerNotificationHandlers } from './notifications'
3132
import { registerNotebookHandlers } from './notebook'
3233
import { registerOnboardingHandlers } from './onboarding'
@@ -189,6 +190,10 @@ export function registerCoreHandlers(
189190
scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options)
190191
})
191192
registerNativeChatHandlers()
193+
const disposeSideQuests = registerSideQuestHandlers()
194+
// Why: the warm provider process is app-scoped rather than tab-scoped, so
195+
// closing one Side Quest must not stop it; app shutdown is its owner boundary.
196+
app.once('before-quit', disposeSideQuests)
192197
registerClipboardHandlers(store)
193198
registerUpdaterHandlers(store)
194199
registerSpeechHandlers(store)

src/main/ipc/side-quest.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { ipcMain, type IpcMainEvent, type WebContents } from 'electron'
2+
import type {
3+
SideQuestCreateArgs,
4+
SideQuestCreateResult,
5+
SideQuestInterruptArgs,
6+
SideQuestReadArgs,
7+
SideQuestReadResult,
8+
SideQuestSendArgs,
9+
SideQuestSendResult,
10+
SideQuestStreamEvent,
11+
SideQuestStreamPayload,
12+
SideQuestSubscribeArgs
13+
} from '../../shared/side-quest-runtime-types'
14+
import {
15+
CodexSideQuestManager,
16+
type CodexSideQuestManagerOptions
17+
} from '../side-quest/codex-side-quest-manager'
18+
import {
19+
codexSideQuestItemToMessage,
20+
codexSideQuestThreadMessages,
21+
codexSideQuestTurnError,
22+
isCodexSideQuestEmptyThreadReadError
23+
} from '../side-quest/codex-side-quest-native-chat'
24+
import type { CodexAppServerEvent } from '../side-quest/codex-app-server-protocol'
25+
26+
type SideQuestSubscription = {
27+
sender: WebContents
28+
subscriptionId: string
29+
providerThreadId: string
30+
}
31+
32+
const subscriptions = new Map<number, Map<string, SideQuestSubscription>>()
33+
const senderCleanupRegistered = new Set<number>()
34+
35+
function removeSubscription(senderId: number, subscriptionId: string): void {
36+
const senderSubscriptions = subscriptions.get(senderId)
37+
senderSubscriptions?.delete(subscriptionId)
38+
if (senderSubscriptions?.size === 0) {
39+
subscriptions.delete(senderId)
40+
}
41+
}
42+
43+
function removeSenderSubscriptions(senderId: number): void {
44+
subscriptions.delete(senderId)
45+
senderCleanupRegistered.delete(senderId)
46+
}
47+
48+
function subscribe(event: IpcMainEvent, args: SideQuestSubscribeArgs): void {
49+
if (event.sender.isDestroyed()) {
50+
return
51+
}
52+
if (!senderCleanupRegistered.has(event.sender.id)) {
53+
senderCleanupRegistered.add(event.sender.id)
54+
event.sender.once('destroyed', () => removeSenderSubscriptions(event.sender.id))
55+
}
56+
const senderSubscriptions = subscriptions.get(event.sender.id) ?? new Map()
57+
senderSubscriptions.set(args.subscriptionId, {
58+
sender: event.sender,
59+
subscriptionId: args.subscriptionId,
60+
providerThreadId: args.providerThreadId
61+
})
62+
subscriptions.set(event.sender.id, senderSubscriptions)
63+
}
64+
65+
function publish(event: SideQuestStreamEvent): void {
66+
for (const senderSubscriptions of subscriptions.values()) {
67+
for (const subscription of senderSubscriptions.values()) {
68+
if (
69+
subscription.providerThreadId !== event.providerThreadId ||
70+
subscription.sender.isDestroyed()
71+
) {
72+
continue
73+
}
74+
const payload: SideQuestStreamPayload = {
75+
subscriptionId: subscription.subscriptionId,
76+
event
77+
}
78+
subscription.sender.send('sideQuest:event', payload)
79+
}
80+
}
81+
}
82+
83+
function publishManagerEvent(event: CodexAppServerEvent): void {
84+
if (event.type === 'agent-message-delta') {
85+
publish({
86+
type: event.type,
87+
providerThreadId: event.threadId,
88+
turnId: event.turnId,
89+
itemId: event.itemId,
90+
delta: event.delta
91+
})
92+
return
93+
}
94+
if (event.type === 'item-completed') {
95+
const message = codexSideQuestItemToMessage({
96+
item: event.item,
97+
turnId: event.turnId,
98+
timestamp: event.completedAtMs,
99+
source: 'hook'
100+
})
101+
if (message) {
102+
publish({
103+
type: 'message-completed',
104+
providerThreadId: event.threadId,
105+
turnId: event.turnId,
106+
message
107+
})
108+
}
109+
return
110+
}
111+
if (event.type === 'turn-completed') {
112+
publish({
113+
type: 'turn-completed',
114+
providerThreadId: event.threadId,
115+
turnId: event.turn.id,
116+
status: event.turn.status,
117+
error: codexSideQuestTurnError(event.turn)
118+
})
119+
return
120+
}
121+
if (event.threadId) {
122+
publish({ type: 'error', providerThreadId: event.threadId, message: event.message })
123+
}
124+
}
125+
126+
export function registerSideQuestHandlers(options: CodexSideQuestManagerOptions = {}): () => void {
127+
const manager = new CodexSideQuestManager(options)
128+
const unsubscribeManager = manager.subscribe(publishManagerEvent)
129+
130+
ipcMain.handle(
131+
'sideQuest:create',
132+
async (_event, args: SideQuestCreateArgs): Promise<SideQuestCreateResult> => {
133+
const thread = await manager.startSession({ cwd: args.cwd })
134+
return { providerThreadId: thread.id }
135+
}
136+
)
137+
ipcMain.handle(
138+
'sideQuest:read',
139+
async (_event, args: SideQuestReadArgs): Promise<SideQuestReadResult> => {
140+
try {
141+
return {
142+
messages: codexSideQuestThreadMessages(await manager.readSession(args.providerThreadId))
143+
}
144+
} catch (error) {
145+
// Why: app-server does not materialize a new durable thread until its
146+
// first turn. Before then, an unavailable includeTurns view means the
147+
// conversation is empty—not broken.
148+
if (isCodexSideQuestEmptyThreadReadError(error)) {
149+
return { messages: [] }
150+
}
151+
throw error
152+
}
153+
}
154+
)
155+
ipcMain.handle(
156+
'sideQuest:send',
157+
async (_event, args: SideQuestSendArgs): Promise<SideQuestSendResult> => {
158+
const turn = await manager.startTurn({
159+
threadId: args.providerThreadId,
160+
text: args.text,
161+
clientUserMessageId: args.clientUserMessageId,
162+
effort: 'low'
163+
})
164+
return { turnId: turn.id }
165+
}
166+
)
167+
ipcMain.handle('sideQuest:interrupt', async (_event, args: SideQuestInterruptArgs) => {
168+
await manager.interruptTurn(args.providerThreadId, args.turnId)
169+
})
170+
ipcMain.on('sideQuest:subscribe', (event, args: SideQuestSubscribeArgs) => subscribe(event, args))
171+
ipcMain.on('sideQuest:unsubscribe', (event, args: { subscriptionId: string }) =>
172+
removeSubscription(event.sender.id, args.subscriptionId)
173+
)
174+
175+
return () => {
176+
unsubscribeManager()
177+
manager.dispose()
178+
subscriptions.clear()
179+
senderCleanupRegistered.clear()
180+
}
181+
}

src/main/native-chat/transcript-read-cache.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ export async function readNativeChatTranscriptCached(
9191
): Promise<ReadTranscriptResult> {
9292
const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
9393
if (!filePath) {
94-
return { error: `No transcript found for ${agent} session ${sessionId}` }
94+
return {
95+
error: `No transcript found for ${agent} session ${sessionId}`,
96+
code: 'transcript_not_found'
97+
}
9598
}
9699

97100
const key = cacheKey(agent, filePath)

src/main/native-chat/transcript-reader.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
} from './transcript-line-decoders'
1010
import { decodeTranscriptStream } from './transcript-stream-lines'
1111

12-
export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string }
12+
export type ReadTranscriptResult =
13+
| { messages: NativeChatMessage[] }
14+
| { error: string; code?: 'transcript_not_found' }
1315

1416
export type ReadTranscriptOptions = ResolveSessionFileOptions & {
1517
/** Resolve directly to this file, skipping path discovery (used by tests). */
@@ -30,7 +32,10 @@ export async function readNativeChatTranscript(
3032
): Promise<ReadTranscriptResult> {
3133
const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
3234
if (!filePath) {
33-
return { error: `No transcript found for ${agent} session ${sessionId}` }
35+
return {
36+
error: `No transcript found for ${agent} session ${sessionId}`,
37+
code: 'transcript_not_found'
38+
}
3439
}
3540
try {
3641
if (agent === 'claude') {

0 commit comments

Comments
 (0)