From 1e61a4b9f1d183ba637042f55a24f4f0c3729899 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 10:11:17 +0100 Subject: [PATCH 1/3] feat(guest): shared turn-based guest dispatch helpers New node-safe module shared by the renderer and the Mac bridge: the steering preamble, parent-transcript peer-context builder, the full guest prompt composer, and the guest->parent reply mirror message builder (deduped by guestRunId). Provider-label is injected so the module stays free of any renderer/main label-resolution split. Foundation for making guest participation turn-based AND reaching iOS-origin turns (which run through the bridge, not the renderer). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/GuestParticipantRun.test.ts | 167 +++++++++++++++++++++++++++ src/main/GuestParticipantRun.ts | 159 +++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/main/GuestParticipantRun.test.ts create mode 100644 src/main/GuestParticipantRun.ts diff --git a/src/main/GuestParticipantRun.test.ts b/src/main/GuestParticipantRun.test.ts new file mode 100644 index 00000000..254b5364 --- /dev/null +++ b/src/main/GuestParticipantRun.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest' +import type { ChatMessage, ChatRecord, ProviderId } from './store/types' +import { + GUEST_PARTICIPANT_STEERING_PREAMBLE, + truncateGuestContextText, + formatGuestParentContextMessage, + buildGuestParentTranscriptContext, + buildGuestParticipantPrompt, + buildGuestParticipantReplyMessage +} from './GuestParticipantRun' + +const label = (p: ProviderId): string => ({ codex: 'Codex', claude: 'Claude' })[p as string] || p + +function msg(partial: Partial): ChatMessage { + return { + id: partial.id || 'm', + role: partial.role || 'user', + content: partial.content ?? '', + timestamp: partial.timestamp || '2026-01-01T00:00:00.000Z', + ...partial + } as ChatMessage +} + +function chat(messages: ChatMessage[], provider: ProviderId = 'claude'): ChatRecord { + return { appChatId: 'parent-1', provider, messages } as ChatRecord +} + +describe('truncateGuestContextText', () => { + it('returns the value unchanged when within the limit', () => { + expect(truncateGuestContextText('hello', 10)).toBe('hello') + }) + it('truncates with an ellipsis when over the limit', () => { + const out = truncateGuestContextText('abcdefghij', 5) + expect(out).toBe('abcd…') + expect(out.length).toBe(5) + }) +}) + +describe('formatGuestParentContextMessage', () => { + it('labels user and assistant turns, skipping empties', () => { + expect(formatGuestParentContextMessage(msg({ role: 'user', content: 'hi' }), 'claude', label)).toBe( + 'User: hi' + ) + expect( + formatGuestParentContextMessage(msg({ role: 'assistant', content: 'yo' }), 'claude', label) + ).toBe('Claude parent agent: yo') + expect(formatGuestParentContextMessage(msg({ role: 'user', content: ' ' }), 'claude', label)).toBeNull() + }) + it('skips prior guest replies so the guest never re-reads its own output', () => { + const guestReply = msg({ + role: 'system', + content: 'earlier guest reply', + metadata: { kind: 'guestParticipantReply' } + }) + expect(formatGuestParentContextMessage(guestReply, 'claude', label)).toBeNull() + }) + it('surfaces returned sub-thread context', () => { + const sub = msg({ role: 'system', content: 'sub result', metadata: { kind: 'subThreadReturn' } }) + expect(formatGuestParentContextMessage(sub, 'claude', label)).toBe( + 'Returned sub-thread context: sub result' + ) + }) +}) + +describe('buildGuestParentTranscriptContext', () => { + it('is empty when there is nothing quotable', () => { + expect(buildGuestParentTranscriptContext(chat([]), label)).toBe('') + expect(buildGuestParentTranscriptContext(chat([msg({ content: '' })]), label)).toBe('') + }) + it('includes the heading + the host reply so the guest sees the parent turn', () => { + const out = buildGuestParentTranscriptContext( + chat([msg({ role: 'user', content: 'do X' }), msg({ role: 'assistant', content: 'host did X' })]), + label + ) + expect(out).toContain('Parent transcript context') + expect(out).toContain('User: do X') + expect(out).toContain('Claude parent agent: host did X') + }) + it('keeps only the last 20 turns', () => { + const many = Array.from({ length: 30 }, (_, i) => msg({ role: 'user', content: `turn ${i}` })) + const out = buildGuestParentTranscriptContext(chat(many), label) + expect(out).not.toContain('turn 9') + expect(out).toContain('turn 29') + }) +}) + +describe('buildGuestParticipantPrompt', () => { + it('joins preamble + context + request', () => { + const out = buildGuestParticipantPrompt({ + parentChat: chat([msg({ role: 'assistant', content: 'host reply' })]), + userText: 'please help', + providerLabel: label + }) + expect(out.startsWith(GUEST_PARTICIPANT_STEERING_PREAMBLE)).toBe(true) + expect(out).toContain('Claude parent agent: host reply') + expect(out).toContain('Current user request:\nplease help') + }) + it('omits the empty context block on a fresh chat', () => { + const out = buildGuestParticipantPrompt({ + parentChat: chat([]), + userText: 'first message', + providerLabel: label + }) + expect(out).toBe(`${GUEST_PARTICIPANT_STEERING_PREAMBLE}\n\nCurrent user request:\nfirst message`) + }) +}) + +describe('buildGuestParticipantReplyMessage', () => { + it('builds the parent mirror message with guest metadata', () => { + const message = buildGuestParticipantReplyMessage({ + parentChat: chat([]), + guestChatId: 'guest-1', + runId: 'run-9', + provider: 'codex', + model: 'gpt-x', + role: 'Guest', + content: ' guest opinion ' + }) + expect(message).not.toBeNull() + expect(message!.id).toBe('guest-return-run-9') + expect(message!.role).toBe('system') + expect(message!.content).toBe('guest opinion') + expect(message!.metadata).toMatchObject({ + kind: 'guestParticipantReply', + guestChatId: 'guest-1', + guestProvider: 'codex', + guestModel: 'gpt-x', + guestRole: 'Guest', + guestRunId: 'run-9', + parentChatId: 'parent-1' + }) + }) + it('returns null on empty content', () => { + expect( + buildGuestParticipantReplyMessage({ + parentChat: chat([]), + guestChatId: 'g', + runId: 'r', + provider: 'codex', + model: 'm', + role: 'Guest', + content: ' ' + }) + ).toBeNull() + }) + it('dedupes by guestRunId — never mirrors the same run twice', () => { + const existing = chat([ + msg({ + id: 'guest-return-run-9', + role: 'system', + content: 'already here', + metadata: { kind: 'guestParticipantReply', guestRunId: 'run-9' } + }) + ]) + expect( + buildGuestParticipantReplyMessage({ + parentChat: existing, + guestChatId: 'g', + runId: 'run-9', + provider: 'codex', + model: 'm', + role: 'Guest', + content: 'second attempt' + }) + ).toBeNull() + }) +}) diff --git a/src/main/GuestParticipantRun.ts b/src/main/GuestParticipantRun.ts new file mode 100644 index 00000000..30cffc68 --- /dev/null +++ b/src/main/GuestParticipantRun.ts @@ -0,0 +1,159 @@ +/** + * Guest-participant turn dispatch — the SINGLE SOURCE shared by the + * renderer (desktop sends) and the main-process bridge (iOS-origin sends). + * + * A solo chat may hold ONE `guestParticipant` (host + 1 guest). On a normal + * send the host (parent agent) responds first; the guest then responds in + * its child chat with the host's reply already in context (turn-based), and + * its final reply is mirrored back into the parent transcript as a + * `role:'system'` message with `metadata.kind:'guestParticipantReply'`. + * + * Before this module the trigger + mirror lived only in the renderer + * (`App.tsx`), so iOS-origin turns — which run through the bridge's + * `composerPromptFn`, never the renderer — never fired the guest. These + * pure helpers let both paths build the guest prompt + the mirrored reply + * identically. The provider-label function is injected so the module stays + * free of any renderer/main label-resolution split. + */ + +import type { ChatMessage, ChatRecord, ProviderId } from './store/types' + +/** Injected provider → display label (renderer `getProviderLabel`, main + * `providerLabel`). Cosmetic — it only labels the parent agent inside the + * guest's context block. */ +export type ProviderLabelFn = (provider: ProviderId) => string + +export const GUEST_PARTICIPANT_STEERING_PREAMBLE = + 'You are a guest participant attached to a standard TaskWraith chat. The main parent agent has priority. Respond to the user request in parallel as a second opinion or disjoint helper. Write or edit files only when useful and keep any changes disjoint from the main agent. If your intended edits overlap or conflict with the main agent, stop and explain the conflict instead of fighting the main agent.' + +const GUEST_PARENT_CONTEXT_TURN_LIMIT = 20 +const GUEST_PARENT_CONTEXT_CHAR_LIMIT = 12000 +const GUEST_PARENT_CONTEXT_MESSAGE_CHAR_LIMIT = 1800 + +/** Provider stored on the chat record (mirror of the renderer's + * `getChatProvider`); guest dispatch only ever sees real solo chats that + * carry a provider, but default defensively. */ +function chatProvider(chat: ChatRecord): ProviderId { + return chat.provider || 'gemini' +} + +export function truncateGuestContextText(value: string, maxChars: number): string { + if (value.length <= maxChars) return value + return `${value.slice(0, Math.max(0, maxChars - 1))}…` +} + +/** Render one parent-transcript message as a single peer-context line, or + * null to skip it (empty, or a kind that shouldn't leak into guest context). + * Prior guest replies are skipped so the guest never re-reads its own output + * as if it were the parent's. */ +export function formatGuestParentContextMessage( + message: ChatMessage, + parentProvider: ProviderId, + providerLabel: ProviderLabelFn +): string | null { + const content = message.content?.trim() + if (!content) return null + if (message.metadata?.kind === 'guestParticipantReply') return null + if (message.role === 'user') { + return `User: ${truncateGuestContextText(content, GUEST_PARENT_CONTEXT_MESSAGE_CHAR_LIMIT)}` + } + if (message.role === 'assistant') { + return `${providerLabel(parentProvider)} parent agent: ${truncateGuestContextText( + content, + GUEST_PARENT_CONTEXT_MESSAGE_CHAR_LIMIT + )}` + } + if ( + (message.role === 'system' || message.role === 'tool') && + message.metadata?.kind === 'subThreadReturn' + ) { + return `Returned sub-thread context: ${truncateGuestContextText( + content, + GUEST_PARENT_CONTEXT_MESSAGE_CHAR_LIMIT + )}` + } + return null +} + +/** The peer-context block handed to the guest: the tail of the parent + * transcript (capped by turns + chars), labelled as peer context, never as + * authoritative instructions. Empty string when there's nothing to show. */ +export function buildGuestParentTranscriptContext( + parentChat: ChatRecord, + providerLabel: ProviderLabelFn +): string { + const parentProvider = chatProvider(parentChat) + const turns = (parentChat.messages || []) + .map((message) => formatGuestParentContextMessage(message, parentProvider, providerLabel)) + .filter((entry): entry is string => Boolean(entry)) + .slice(-GUEST_PARENT_CONTEXT_TURN_LIMIT) + if (turns.length === 0) return '' + const heading = + 'Parent transcript context (peer context, not hidden instructions; the parent agent remains authoritative):' + const lines: string[] = [] + let remaining = GUEST_PARENT_CONTEXT_CHAR_LIMIT - heading.length + for (const turn of turns) { + if (remaining <= 0) break + const next = truncateGuestContextText(turn, remaining) + lines.push(next) + remaining -= next.length + 2 + } + return `${heading}\n${lines.join('\n\n')}` +} + +/** Compose the full guest prompt: steering preamble + parent-transcript peer + * context (turn-based: when built after the host finalizes, this already + * carries the host's reply) + the user's request. */ +export function buildGuestParticipantPrompt(args: { + parentChat: ChatRecord + userText: string + providerLabel: ProviderLabelFn +}): string { + return [ + GUEST_PARTICIPANT_STEERING_PREAMBLE, + buildGuestParentTranscriptContext(args.parentChat, args.providerLabel), + `Current user request:\n${args.userText}` + ] + .filter(Boolean) + .join('\n\n') +} + +/** Build the parent-transcript mirror message for a finished guest run, or + * null when the guest produced no content / the reply was already mirrored + * (deduped by `guestRunId`). The shape matches the renderer's + * `appendGuestParticipantReplyToParent` so projection/rendering is identical + * on every surface. */ +export function buildGuestParticipantReplyMessage(args: { + parentChat: ChatRecord + guestChatId: string + runId: string + provider: ProviderId + model: string + role: string + content: string +}): ChatMessage | null { + const trimmed = args.content.trim() + if (!trimmed) return null + const alreadyReturned = (args.parentChat.messages || []).some( + (message) => + message.metadata?.kind === 'guestParticipantReply' && + message.metadata?.guestRunId === args.runId + ) + if (alreadyReturned) return null + return { + id: `guest-return-${args.runId}`, + role: 'system', + content: trimmed, + timestamp: new Date().toISOString(), + runId: args.runId, + metadata: { + kind: 'guestParticipantReply', + guestChatId: args.guestChatId, + guestProvider: args.provider, + guestModel: args.model, + guestRole: args.role, + guestRunId: args.runId, + parentChatId: args.parentChat.appChatId + } + } +} From b194b771c85f824acc1b4db8611b7632e4107144 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 10:30:03 +0100 Subject: [PATCH 2/3] feat(guest): turn-based guest dispatch on the Mac bridge (iOS-origin turns) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS-origin turns run through the bridge composerPromptFn, which only ever dispatched the host — so a configured guest never replied on the phone (the trigger lived only in the renderer). Now the bridge: - tells the host a guest is attached (passes guestParticipant to composeRunPrompt) so it can anticipate/avoid conflicts; - routes @-tags like ensemble (@guest -> guest only, @parent/@host -> host only, no tag -> host then guest); - after the host run finalizes (reply persisted), dispatches the guest as a normal leaf run on its child chat so it answers WITH the host's reply in context (turn-based); - mirrors the guest's reply into the parent transcript on the guest run's finalize (guest-return-${runId}, deduped by guestRunId). Driven via a module-ref runner assigned in the bridge closure so the module-scope finalizeBridgeRunTranscript can reach the closure dispatch helpers. iOS needs no changes — it already renders guestParticipantReply. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/index.ts | 364 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index ef4c811b..ed791100 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -367,6 +367,7 @@ import { ChatRecord, ChatMessage, ChatRun, + GuestParticipantConfig, ChatScope, ToolActivity, WorkspaceSnapshot, @@ -416,6 +417,11 @@ import { WorkflowDefinition } from './store/types' import type { AgentRunPayload, AgentRunRoute } from './run/AgentRunTypes' +import { + buildGuestParticipantPrompt, + buildGuestParticipantReplyMessage +} from './GuestParticipantRun' +import { extractGuestParticipantAddressTarget } from '../renderer/src/lib/ComposerMentionTrigger' import { DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, @@ -766,6 +772,29 @@ const remoteQuestionRegistry = new RemoteQuestionRegistry({ defaultTtlMs: AGENT_QUESTION_TIMEOUT_MS }) let bridgeBroadcasterRef: BridgeBroadcaster | null = null +/** Guest-participant turn dispatch + reply mirror. Assigned inside the bridge + * setup closure (where dispatchAgentRun + the broadcast helpers live) so the + * module-scope finalizeBridgeRunTranscript can drive turn-based guest runs: + * host finishes -> dispatch guest (sees host reply) -> mirror guest reply. */ +let bridgeGuestParticipantRunner: { + dispatchGuestTurn: (args: { + parentChatId: string + guestConfig: GuestParticipantConfig + userText: string + workspaceId: string + approvalMode: string + }) => void + mirrorGuestReply: (args: { + runId: string + parentChatId: string + guestChatId: string + workspaceId: string + provider: ProviderId + model: string + role: string + content: string + }) => void +} | null = null // Remote-session power assertion: while >=1 iOS device is connected over the // bridge, hold an Electron powerSaveBlocker so the Mac stays awake to serve // remote approvals/questions (the agent runs on-Mac; a sleeping Mac can't be @@ -1387,6 +1416,26 @@ type BridgeRunTranscriptState = { extraWorkspacePaths?: string[] runDiff?: ChatRun['runDiff'] runDiffByPath?: ChatRun['runDiffByPath'] + /** Set on a HOST bridge run when the chat has a guest participant and the + * turn fanned out (no @-tag, or not @parent): finalize dispatches the guest + * run AFTER the host finishes so the guest sees the host's reply (turn-based). */ + guestFanout?: { + guestConfig: GuestParticipantConfig + userText: string + parentChatId: string + workspaceId: string + approvalMode: string + } + /** Set on a GUEST bridge run: finalize mirrors the guest's reply into the + * parent transcript (deduped by guestRunId), matching the renderer. */ + guestReturn?: { + parentChatId: string + guestChatId: string + workspaceId: string + provider: ProviderId + model: string + role: string + } } const bridgeRunTranscripts = new Map() @@ -3503,6 +3552,32 @@ function finalizeBridgeRunTranscript( console.warn(`[bridge-run] run diff failed for ${runId}:`, err) } flushBridgeRunTranscript(runId, true) + // Turn-based guest participation. After the HOST reply is flushed + // (persisted), dispatch the guest so it answers WITH the host's reply in + // context; after a GUEST run finishes, mirror its reply into the parent. + if (state.guestFanout && resolvedStatus === 'success') { + const f = state.guestFanout + bridgeGuestParticipantRunner?.dispatchGuestTurn({ + parentChatId: f.parentChatId, + guestConfig: f.guestConfig, + userText: f.userText, + workspaceId: f.workspaceId, + approvalMode: f.approvalMode + }) + } + if (state.guestReturn) { + const g = state.guestReturn + bridgeGuestParticipantRunner?.mirrorGuestReply({ + runId, + parentChatId: g.parentChatId, + guestChatId: g.guestChatId, + workspaceId: g.workspaceId, + provider: g.provider, + model: g.model, + role: g.role, + content: state.content + }) + } })() } @@ -16943,6 +17018,34 @@ if (isGeminiMcpBridgeProcess) { .find((run) => run.provider === 'gemini' && run.geminiAuthProfileId !== undefined) ?.geminiAuthProfileId : undefined + // Guest participant turn routing (parity with the renderer send + // path): default = host now, guest AFTER the host finishes + // (turn-based, marked below); @parent/@host = host only; @guest = + // guest only. The guest runs as a normal leaf turn on its child chat. + const guestParticipantConfig = + chat.chatKind !== 'ensemble' ? (chat.guestParticipant ?? null) : null + const guestAddressTarget = guestParticipantConfig + ? extractGuestParticipantAddressTarget(action.text, { + parentProvider: provider, + guestProvider: guestParticipantConfig.provider + }) + : null + if (guestParticipantConfig && guestAddressTarget === 'guest') { + // @guest — only the guest runs. The user message is already on the + // parent (prepareIosComposerPromptChat); the guest reply mirrors + // back via finalize. No host run, so no host appRunId to ack. + bridgeGuestParticipantRunner?.dispatchGuestTurn({ + parentChatId: chat.appChatId, + guestConfig: guestParticipantConfig, + userText: action.text, + workspaceId: action.workspaceId, + approvalMode: effectiveApprovalMode || 'default' + }) + const refreshed = AppStore.getChat(chat.appChatId) + if (refreshed) pushRemoteThreadSnapshot(refreshed, action.workspaceId) + bridgeBroadcasterRef?.broadcastRemoteProjectionSnapshot() + return { dispatched: true, appRunId: null } + } const route = routeWithRunId(provider, { appChatId: chat.appChatId, appRunId: undefined @@ -17008,6 +17111,20 @@ if (isGeminiMcpBridgeProcess) { promptMessageId, workspacePath: workspaceRecord?.path ?? globalRunCwd() }) + if (guestParticipantConfig && guestAddressTarget !== 'parent') { + // Default fan-out (no tag): dispatch the guest once THIS host run + // finalizes, so it answers with the host's reply already in context. + const hostState = bridgeRunTranscripts.get(runId) + if (hostState) { + hostState.guestFanout = { + guestConfig: guestParticipantConfig, + userText: action.text, + parentChatId: chat.appChatId, + workspaceId: action.workspaceId, + approvalMode: effectiveApprovalMode || 'default' + } + } + } if (extraWorkspacePaths.length > 0) { const transcript = bridgeRunTranscripts.get(runId) if (transcript) transcript.extraWorkspacePaths = extraWorkspacePaths @@ -17066,6 +17183,10 @@ if (isGeminiMcpBridgeProcess) { finalPrompt: action.text, messages: priorMessages, chatContextTurns: AppStore.getSettings().chatContextTurns, + // Host-awareness: tell the parent agent a guest is attached + replay + // prior guest replies, so it can anticipate/avoid conflicts (parity + // with the desktop send path, which already passes this). + guestParticipant: guestParticipantConfig ?? undefined, ...(resumeSessionId ? { resumeSessionId } : {}), nextModel: action.model, codexHandoffsApplied: [], @@ -21594,6 +21715,249 @@ if (isGeminiMcpBridgeProcess) { return dispatchRunWithProviderPause(payload, event) } + // Guest-participant turn dispatch + reply mirror for bridge (iOS-origin) + // turns. The renderer's dispatchGuestParticipantRun/appendGuestParticipant + // ReplyToParent only ran in the renderer, so phone turns never fired the + // guest. This mirrors that on the bridge: dispatched AFTER the host (so the + // guest sees the host's reply — turn-based), running as a normal leaf run + // on the guest's child chat (no re-entry of the host fan-out logic, since + // the child chat has no guest participant of its own). + bridgeGuestParticipantRunner = { + dispatchGuestTurn: ({ parentChatId, guestConfig, userText, workspaceId, approvalMode }) => { + void (async () => { + try { + const isGlobalScope = workspaceId === GLOBAL_REMOTE_SCOPE + const workspaceRecord = isGlobalScope + ? null + : (AppStore.getWorkspaces().find((w) => w.id === workspaceId) ?? null) + if (!isGlobalScope && !workspaceRecord) return + const parent = AppStore.getChat(parentChatId) + let guestChat = AppStore.getChat(guestConfig.childChatId) + if ( + !parent || + !guestChat || + guestChat.archived || + guestChat.parentChatId !== parentChatId || + guestChat.parentChatRelation !== 'sideChat' || + guestChat.sideChatContext?.mode !== 'guestParticipant' + ) { + return + } + const provider = assertProviderId(guestConfig.provider) + const guestModel = + guestConfig.selectedModelType === 'custom' + ? guestConfig.customModel || undefined + : guestConfig.selectedModelType && guestConfig.selectedModelType !== 'default' + ? guestConfig.selectedModelType + : undefined + // Built AFTER the host finalized, so the parent transcript already + // carries the host's reply — the guest replies to it (turn-based). + const guestPrompt = buildGuestParticipantPrompt({ + parentChat: parent, + userText, + providerLabel + }) + const now = Date.now() + const userMessage: ChatMessage = { + id: `ios-guest-user-${randomUUID()}`, + role: 'user', + content: userText.trim(), + timestamp: new Date(now).toISOString() + } + guestChat = { + ...guestChat, + provider, + messages: [...(guestChat.messages || []), userMessage], + updatedAt: now + } + AppStore.saveChat(guestChat) + const route = routeWithRunId(provider, { + appChatId: guestChat.appChatId, + appRunId: undefined + } as AgentRunRoute) + const runId = route.appRunId! + const lastProviderRun = [...(guestChat.runs ?? [])] + .reverse() + .find((entry) => entry.runId !== runId && entry.provider === provider) + const inheritedProfileId = [...(guestChat.runs ?? [])] + .reverse() + .find((run) => run.provider === provider && run.runtimeProfileId)?.runtimeProfileId + const defaultProfileId = + inheritedProfileId || isGlobalScope + ? undefined + : AppStore.getRuntimeProfiles(provider).find( + (profile) => profile.builtin && profile.scope === 'workspace' + )?.id + const resolvedProfileId = inheritedProfileId ?? defaultProfileId + const run: ChatRun = { + runId, + provider, + startedAt: new Date().toISOString(), + promptMessageId: userMessage.id, + requestedModel: guestModel, + approvalMode, + ...(resolvedProfileId ? { runtimeProfileId: resolvedProfileId } : {}), + status: 'running', + rawEventsFile: `run-events/${runId}.jsonl` + } + guestChat = { + ...guestChat, + runs: [...(guestChat.runs || []).filter((entry) => entry.runId !== runId), run], + updatedAt: Date.now() + } + AppStore.saveChat(guestChat) + registerBridgeRunTranscript({ + runId, + chatId: guestChat.appChatId, + provider, + promptMessageId: userMessage.id, + workspacePath: workspaceRecord?.path ?? globalRunCwd() + }) + const guestState = bridgeRunTranscripts.get(runId) + if (guestState) { + guestState.guestReturn = { + parentChatId, + guestChatId: guestChat.appChatId, + workspaceId, + provider, + model: guestModel || '', + role: 'Guest' + } + } + if (workspaceRecord) { + void captureWorkspaceSnapshot(workspaceRecord.path) + .then((snapshot) => { + const t = bridgeRunTranscripts.get(runId) + if (t) t.preSnapshot = snapshot + }) + .catch(() => {}) + } + broadcastChatUpdated(guestChat) + broadcastThreadUpdate(guestChat.appChatId) + pushRemoteThreadSnapshot(guestChat, workspaceId) + bridgeBroadcasterRef?.broadcastRemoteProjectionSnapshot() + const linkedSessionForProvider = + provider === 'gemini' + ? guestChat.linkedGeminiSessionId + : guestChat.linkedProviderSessionId + const resumeSessionId = + (lastProviderRun + ? linkedSessionForProvider || lastProviderRun.providerThreadId + : undefined) || undefined + const priorMessages = guestChat.messages.filter((m) => m.id !== userMessage.id) + const composed = composeRunPrompt({ + provider, + finalPrompt: guestPrompt, + messages: priorMessages, + chatContextTurns: AppStore.getSettings().chatContextTurns, + ...(resumeSessionId ? { resumeSessionId } : {}), + nextModel: guestModel, + codexHandoffsApplied: [], + isGlobalRun: isGlobalScope, + approvalMode: approvalMode || 'default', + providerLabel: providerLabel(provider) + }) + const guestEffectivePermissions = + approvalMode === 'plan' + ? resolveEffectiveRunPermissions({ + provider, + workspacePath: isGlobalScope ? undefined : workspaceRecord?.path, + settings: AppStore.getSettings(), + presetId: 'read_only' + }) + : undefined + const payload: AgentRunPayload = { + provider, + scope: isGlobalScope ? 'global' : 'workspace', + ...(workspaceRecord ? { workspace: workspaceRecord.path } : {}), + prompt: composed.contextualPrompt, + ...(resumeSessionId ? { providerSessionId: resumeSessionId } : {}), + appChatId: guestChat.appChatId, + appRunId: runId, + approvalMode, + ...(guestEffectivePermissions + ? { effectivePermissions: guestEffectivePermissions } + : {}), + model: guestModel, + ...(provider === 'codex' && guestConfig.codexReasoningEffort + ? { reasoningEffort: guestConfig.codexReasoningEffort } + : {}), + ...(provider === 'claude' && guestConfig.claudeReasoningEffort + ? { claudeReasoningEffort: guestConfig.claudeReasoningEffort } + : {}), + ...(resolvedProfileId ? { runtimeProfileId: resolvedProfileId } : {}) + } + payload.effectivePermissionsSignature = signRunPosture( + payload.approvalMode, + payload.effectivePermissions, + runPostureContextFromPayload(payload) + ) + const liveSender = mainWindow?.webContents + const sender = + liveSender && !liveSender.isDestroyed() ? liveSender : createHeadlessRunSender() + const fakeEvent = { sender } as unknown as Electron.IpcMainInvokeEvent + void dispatchAgentRun(payload, fakeEvent) + .then((result) => { + if (!result.dispatched) { + finalizeBridgeRunTranscript( + runId, + 'failed', + 'Guest participant run did not dispatch — check the guest provider profile on your Mac.' + ) + } + const refreshed = AppStore.getChat(guestChat.appChatId) + if (refreshed) pushRemoteThreadSnapshot(refreshed, workspaceId) + bridgeBroadcasterRef?.broadcastRemoteProjectionSnapshot() + }) + .catch((err) => { + console.error('[remote-bridge] guest participant dispatch failed:', err) + }) + console.log( + `[bridge-run] guest participant turn dispatched run=${runId} parent=${parentChatId} provider=${provider}` + ) + } catch (err) { + console.error('[remote-bridge] guest participant dispatch error:', err) + } + })() + }, + mirrorGuestReply: ({ + runId, + parentChatId, + guestChatId, + workspaceId, + provider, + model, + role, + content + }) => { + const parent = AppStore.getChat(parentChatId) + if (!parent) return + const message = buildGuestParticipantReplyMessage({ + parentChat: parent, + guestChatId, + runId, + provider, + model, + role, + content + }) + if (!message) return + const updated: ChatRecord = { + ...parent, + messages: [...(parent.messages || []), message], + updatedAt: Date.now() + } + AppStore.saveChat(updated) + broadcastChatUpdated(updated) + broadcastThreadUpdate(updated.appChatId) + if (workspaceId) pushRemoteThreadSnapshot(updated, workspaceId) + bridgeBroadcasterRef?.broadcastRemoteProjectionSnapshot() + console.log( + `[bridge-run] mirrored guest reply run=${runId} -> parent=${parentChatId} (${content.length} chars)` + ) + } + } + ipcMain.handle('run-agent', async (event, payload: AgentRunPayload) => { await dispatchAgentRun(payload, event) }) From fefda19f0cf7ae917a353e4ba6364bb509c3e105 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Thu, 18 Jun 2026 10:35:50 +0100 Subject: [PATCH 3/3] feat(guest): make desktop guest participation turn-based (parity) The renderer fanned the guest out in parallel with the host, so the guest never saw the host's reply to the same turn. Now a fan-out send defers the guest: it's dispatched from the run-completion handler once the host run on that chat finishes, with the host's reply already in the parent transcript (passed via a fresh chatRecord). @guest stays immediate (guest only); @parent/@host stays host only. This matches the new bridge behavior so desktop and iOS guest chats now behave identically (turn-based). Also drop the host-success gate on the bridge guest dispatch so the guest still answers when the host run fails (fallback / second opinion), matching the renderer completion hook which fires regardless of exit code. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/index.ts | 2 +- src/renderer/src/App.tsx | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ed791100..7b316b92 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3555,7 +3555,7 @@ function finalizeBridgeRunTranscript( // Turn-based guest participation. After the HOST reply is flushed // (persisted), dispatch the guest so it answers WITH the host's reply in // context; after a GUEST run finishes, mirror its reply into the parent. - if (state.guestFanout && resolvedStatus === 'success') { + if (state.guestFanout) { const f = state.guestFanout bridgeGuestParticipantRunner?.dispatchGuestTurn({ parentChatId: f.parentChatId, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bfe37f82..fb879370 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2360,6 +2360,15 @@ function App(): React.JSX.Element { const currentWorkspaceIdRef = useRef(null) const currentChatIdRef = useRef(null) const chatByIdRef = useRef>(new Map()) + // Turn-based guest participation: a guest send is deferred here (keyed by + // parent chatId) and dispatched only once the host run on that chat + // completes, so the guest answers WITH the host's reply in context. The ref + // to dispatchGuestParticipantRun (defined far below) lets the run-completion + // handler fire it without a forward reference. + const pendingGuestDispatchRef = useRef>(new Map()) + const dispatchGuestParticipantRunRef = useRef< + ((request: QueuedRunRequest) => Promise) | null + >(null) const clearedChatIdsRef = useRef>(new Set()) const rawLogsByChatIdRef = useRef>(new Map()) const activeRunChatSnapshotRef = useRef(null) @@ -7731,6 +7740,20 @@ function App(): React.JSX.Element { } }) handlers.triggerFxBurst('run-complete') + // Turn-based guest participation (parity with the bridge): the host run + // just finished — fire the deferred guest now, with the host's reply + // already in the parent transcript so the guest can respond to it. + if (completedRunChatId && pendingGuestDispatchRef.current.has(completedRunChatId)) { + const deferredGuestRequest = pendingGuestDispatchRef.current.get(completedRunChatId) + pendingGuestDispatchRef.current.delete(completedRunChatId) + if (deferredGuestRequest) { + const freshParent = chatByIdRef.current.get(completedRunChatId) + void dispatchGuestParticipantRunRef.current?.({ + ...deferredGuestRequest, + chatRecord: freshParent ?? deferredGuestRequest.chatRecord + }) + } + } if (context.warnings.length > 0) { handlers.triggerFxBurst('run-summary') } @@ -10320,6 +10343,7 @@ function App(): React.JSX.Element { } void executeRunRef.current(guestRequest) } + dispatchGuestParticipantRunRef.current = dispatchGuestParticipantRun const appendGuestAddressedUserMessage = (request: QueuedRunRequest): void => { const parentChat = request.chatRecord || currentChat @@ -10494,9 +10518,10 @@ function App(): React.JSX.Element { if (isChatBusy(request.chatRecord?.appChatId || currentChat?.appChatId)) { queueRunRequest(request) - if (shouldDispatchGuest) { - if (parentChat?.appChatId) markGuestDispatchPending(parentChat.appChatId) - void dispatchGuestParticipantRun(request) + if (shouldDispatchGuest && parentChat?.appChatId) { + markGuestDispatchPending(parentChat.appChatId) + // Turn-based: defer the guest until this queued host run completes. + pendingGuestDispatchRef.current.set(parentChat.appChatId, request) } clearComposerAttachmentsForSubmittedRequest(request) if (!request.existingPrompt) { @@ -10509,9 +10534,11 @@ function App(): React.JSX.Element { } void executeRun(request) - if (shouldDispatchGuest) { - if (parentChat?.appChatId) markGuestDispatchPending(parentChat.appChatId) - void dispatchGuestParticipantRun(request) + if (shouldDispatchGuest && parentChat?.appChatId) { + markGuestDispatchPending(parentChat.appChatId) + // Turn-based: defer the guest until the host run completes (see the + // run-completion handler), so it answers with the host's reply in context. + pendingGuestDispatchRef.current.set(parentChat.appChatId, request) } }