diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 5eb0bffb8e8..30a29457c37 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -18,7 +18,7 @@ import { resolveTerminalOrchestrationCliCommand, type OrchestrationCliCommand } from './orchestration/cli-command' -import { getAppEnvironment } from '../../shared/app-environment' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' @@ -264,8 +264,7 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim connectionId: pty.connectionId, isWsl: pty.isWsl, worktreeId: pty.worktreeId, - // Dev builds run the CLI as `orca-dev`; a packaged app must not advertise it. - runtimeCliCommand: getAppEnvironment().isPackaged() ? undefined : 'orca-dev', + runtimeCliCommand: runtimeOrchestrationCliCommand(), projectRuntime: this.store ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) : undefined @@ -305,4 +304,14 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } return null } + + /** What a local, non-WSL terminal is told to run; a structured session is always one. */ + getLocalOrchestrationCliCommand(): OrchestrationCliCommand { + return runtimeOrchestrationCliCommand() ?? 'orca' + } +} + +/** Dev builds run the CLI as `orca-dev`; a packaged app, or a process with no app, must not advertise it. */ +function runtimeOrchestrationCliCommand(): OrchestrationCliCommand | undefined { + return hasAppEnvironment() && !getAppEnvironment().isPackaged() ? 'orca-dev' : undefined } diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 4989245fdf6..620aad8499e 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -221,6 +221,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), + getCliCommand: () => this.getLocalOrchestrationCliCommand(), host: createStructuredMailboxPointerHost() }) diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts index bf9cfaf0f16..809be9a4b88 100644 --- a/src/main/runtime/orchestration/cli-command.ts +++ b/src/main/runtime/orchestration/cli-command.ts @@ -4,28 +4,6 @@ import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' export type OrchestrationCliCommand = 'orca' | 'orca-dev' | 'orca-ide' -/** The CLI invocation text a structured session's shell can run, one form per shell family. */ -export type StructuredSessionCliInvocation = '"$ORCA_CLI_COMMAND"' | '& $env:ORCA_CLI_COMMAND' - -/** - * How text addressed to a structured session invokes this app's CLI, in the shell that session's - * commands actually run in. The env carries the absolute launcher in `ORCA_CLI_COMMAND`; a bare - * `orca` can resolve elsewhere once a profile-loading shell rebuilds PATH ahead of Orca's entry. - * - * - Codex on Windows runs PowerShell (pwsh, else Windows PowerShell) and loads its profile, so the - * env var is read as `$env:…` and invoked with `&`. Codex falls back to cmd only when no - * PowerShell exists at all, which Orca cannot see from here. - * - Claude on Windows runs its commands in Git Bash; macOS and Linux shells are POSIX for both. - */ -export function structuredSessionCliInvocation(session: { - platform: NodeJS.Platform - provider: 'claude' | 'codex' -}): StructuredSessionCliInvocation { - return session.platform === 'win32' && session.provider === 'codex' - ? '& $env:ORCA_CLI_COMMAND' - : '"$ORCA_CLI_COMMAND"' -} - export function resolveTerminalOrchestrationCliCommand(args: { connectionId: string | null isWsl: boolean | null | undefined diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index 6b5a363d322..2dd4f86777b 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -1,6 +1,6 @@ import type { MessageRow } from './types' import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract' -import type { OrchestrationCliCommand, StructuredSessionCliInvocation } from './cli-command' +import type { OrchestrationCliCommand } from './cli-command' const BANNER_WIDTH = 60 const SEPARATOR = '─'.repeat(BANNER_WIDTH) @@ -112,14 +112,11 @@ export function formatMessagesForInjection(messages: MessageRow[]): string { export function formatMessagePointer( count: number, mailboxHandle?: string, - cliCommand: OrchestrationCliCommand | StructuredSessionCliInvocation = 'orca', - /** The unacknowledged batch the reader holds; `check` replays it until acked. */ - ackDeliveryId?: string + cliCommand: OrchestrationCliCommand = 'orca' ): string { const noun = count === 1 ? 'message' : 'messages' const runFlag = mailboxHandle?.startsWith('run:') ? ` --run ${mailboxHandle.slice('run:'.length)}` : '' - const ackFlag = ackDeliveryId ? ` --ack ${ackDeliveryId}` : '' - return `\nYou have ${count} ${ackDeliveryId ? 'new ' : ''}orchestration ${noun}. Run \`${cliCommand} orchestration check${runFlag}${ackFlag}\`.\n` + return `\nYou have ${count} orchestration ${noun}. Run \`${cliCommand} orchestration check${runFlag}\`.\n` } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 048f6d6be30..7f54eb64b03 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -5,6 +5,7 @@ import { type StructuredMailboxPointerHost, type StructuredPointerSettlement } from './structured-mailbox-pointer-delivery' +import { formatMessagePointer } from './formatter' import { structuredSessionGateFacts } from './structured-session-pointer-delivery' import type { StructuredWorkerIdentity } from '../structured-worker-identity' @@ -139,10 +140,10 @@ function harness(options: { getMessageWaiters: () => undefined, resolveStructuredTarget: (mailboxHandle) => mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, + getCliCommand: () => 'orca-dev', host: { readGateFacts: async () => (journal === null ? null : structuredSessionGateFacts(journal)), currentFence: () => 4, - cliInvocation: () => '"$ORCA_CLI_COMMAND"', send, ...('wakeTo' in options ? { wake } : {}) } @@ -297,10 +298,9 @@ describe('structured mailbox pointer delivery', () => { expect(send).not.toHaveBeenCalled() }) - it('points newer mail while the reader holds an unacknowledged batch, naming its ack', async () => { + it('points newer mail while the reader holds an unacknowledged batch, in the PTY pointer text', async () => { // The strand this pins: a chat reads a result, ends its turn without acking, and the gate on - // "an unacknowledged batch exists" silenced every later result. `check` replays that batch - // until acked, so the pointer must name the ack to reach the new mail. + // "an unacknowledged batch exists" silenced every later result. The text stays the PTY lane's. const { delivery, send, markAsDelivered } = harness({ journal: idleJournal(), outstandingOwnDelivery: true, @@ -310,7 +310,7 @@ describe('structured mailbox pointer delivery', () => { await flush() expect(send).toHaveBeenCalledTimes(1) expect(send.mock.calls[0]![0].body.blocks[0]).toMatchObject({ - text: expect.stringMatching(/You have 1 new orchestration message\. .*--ack delivery_held`/) + text: formatMessagePointer(1, 'dispatch:d1', 'orca-dev').trim() }) expect(markAsDelivered).toHaveBeenCalledWith(['m2']) }) @@ -485,10 +485,10 @@ describe('forgetting one settled worker', () => { ? { sessionId, dispatchId: mailboxHandle.slice('dispatch:'.length) } : null }, + getCliCommand: () => 'orca', host: { readGateFacts: async () => structuredSessionGateFacts(journal), currentFence: () => 4, - cliInvocation: () => '"$ORCA_CLI_COMMAND"', send } }) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 7e160203e45..9c7eea1717c 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -15,7 +15,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' -import type { StructuredSessionCliInvocation } from './cli-command' +import type { OrchestrationCliCommand } from './cli-command' import { selectOrchestrationPointerBatch, type OrchestrationMessageWaiter @@ -66,8 +66,6 @@ export type StructuredMailboxPointerHost = { }) => Promise /** Current lease fence; `null` when no record backs the session any more. */ currentFence: (sessionId: string) => number | null - /** How this session's shell invokes this app's CLI; see `structuredSessionCliInvocation`. */ - cliInvocation: (sessionId: string) => StructuredSessionCliInvocation /** * Holds the session for one attempt, resuming its provider child if the host evicted it; the * returned release hands it back to the host's release clock. Null when it cannot be resumed. @@ -85,6 +83,8 @@ type StructuredPointerDeliveryDependencies StructuredPointerTarget | null + /** The CLI name the PTY lane types for a local agent, so both lanes send the same pointer. */ + getCliCommand: () => OrchestrationCliCommand host: StructuredMailboxPointerHost onRetain?: (input: { mailboxHandle: string @@ -165,8 +165,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< } // Eligibility is "not yet pointed" (`delivered_at`), never "has the consumer acked": a chat that // reads a batch and ends its turn without acking must still be pointed at the NEXT result. The - // batch it holds is excluded, and the pointer names the `--ack` that releases it, since `check` - // replays an unacknowledged batch instead of returning newer mail. + // batch it holds is excluded; its own `check` replays that batch and names its ack. const outstanding = db.getOutstandingMailboxDelivery?.(mailboxHandle) const unread = selectOrchestrationPointerBatch({ db, @@ -179,7 +178,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< } this.inFlight.add(mailboxHandle) try { - await this.attempt(db, mailboxHandle, target, unread, reservedTypes, outstanding?.id) + await this.attempt(db, mailboxHandle, target, unread, reservedTypes) } finally { this.inFlight.delete(mailboxHandle) } @@ -190,12 +189,11 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle: string, target: StructuredPointerTarget, unread: readonly { id: string; type: string; sequence: number }[], - reservedTypes: ReadonlySet | undefined, - ackDeliveryId: string | undefined + reservedTypes: ReadonlySet | undefined ): Promise { const release = await this.deps.host.wake?.(target.sessionId) try { - await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes, ackDeliveryId) + await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes) } finally { release?.() } @@ -206,8 +204,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle: string, target: StructuredPointerTarget, unread: readonly { id: string; type: string; sequence: number }[], - reservedTypes: ReadonlySet | undefined, - ackDeliveryId: string | undefined + reservedTypes: ReadonlySet | undefined ): Promise { const sessionId = target.sessionId const session = await this.deps.host.readGateFacts(sessionId) @@ -227,12 +224,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< blocks: [ { type: 'text', - text: formatMessagePointer( - unread.length, - mailboxHandle, - this.deps.host.cliInvocation(sessionId), - ackDeliveryId - ).trim() + text: formatMessagePointer(unread.length, mailboxHandle, this.deps.getCliCommand()).trim() } ] } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 6313aa444de..cdf3d1ad4d4 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -13,7 +13,6 @@ import type { StructuredMailboxPointerHost, StructuredPointerSettlement } from './structured-mailbox-pointer-delivery' -import { structuredSessionCliInvocation } from './cli-command' import { structuredSessionGateFacts, type StructuredSessionGateFacts @@ -92,14 +91,6 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo return readStructuredSessionGateFacts(sessionId) }, - cliInvocation(sessionId) { - const provider = getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.provider - return structuredSessionCliInvocation({ - platform: process.platform, - provider: provider ?? 'claude' - }) - }, - currentFence(sessionId) { return ( getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null diff --git a/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts b/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts deleted file mode 100644 index 79d633f561e..00000000000 --- a/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { structuredSessionCliInvocation } from './cli-command' -import { formatMessagePointer } from './formatter' - -describe('how a structured session invokes this app’s CLI, per shell', () => { - it.each([ - ['darwin', 'claude', '"$ORCA_CLI_COMMAND"'], - ['darwin', 'codex', '"$ORCA_CLI_COMMAND"'], - ['linux', 'claude', '"$ORCA_CLI_COMMAND"'], - ['linux', 'codex', '"$ORCA_CLI_COMMAND"'], - // Claude's command tool runs in Git Bash on Windows: still a POSIX expansion. - ['win32', 'claude', '"$ORCA_CLI_COMMAND"'], - // Codex's defaults to PowerShell on Windows, where "$X" is a PS variable and a quoted string - // followed by arguments does not parse; the env var is `$env:X`, invoked with `&`. - ['win32', 'codex', '& $env:ORCA_CLI_COMMAND'] - ] as const)('%s / %s → %s', (platform, provider, invocation) => { - expect(structuredSessionCliInvocation({ platform, provider })).toBe(invocation) - }) - - it('renders a pointer each shell can run as written', () => { - expect( - formatMessagePointer( - 1, - 'run:run_1', - structuredSessionCliInvocation({ platform: 'win32', provider: 'codex' }) - ).trim() - ).toBe( - 'You have 1 orchestration message. Run `& $env:ORCA_CLI_COMMAND orchestration check --run run_1`.' - ) - expect( - formatMessagePointer( - 1, - 'session:s1', - structuredSessionCliInvocation({ platform: 'darwin', provider: 'codex' }) - ).trim() - ).toBe('You have 1 orchestration message. Run `"$ORCA_CLI_COMMAND" orchestration check`.') - }) -}) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 5d6627163d2..f584993b867 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -20,6 +20,7 @@ import { attachFingerprintFields } from '../native-chat/agent-session-wire/struc import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' +import { formatMessagePointer } from './orchestration/formatter' import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' import type { RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' @@ -347,12 +348,12 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -// The session's own CLI by its env var: a bare `orca` can resolve elsewhere in a login shell. // Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. const WAIT = { timeout: 10_000 } +// The text the PTY lane types into a terminal coordinator, byte for byte. const POINTER = - /You have 1 orchestration message\. Run `\\?"\$ORCA_CLI_COMMAND\\?" orchestration check --run run_/ + /^You have 1 orchestration message\. Run `orca orchestration check --run run_\w+`\.$/ describe('a worker result reaches the structured chat that coordinates it', () => { it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => { @@ -414,8 +415,8 @@ describe('a worker result reaches the structured chat that coordinates it', () = ) await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) - expect(chat.turns[1]!.text).toContain('1 new orchestration message') - expect(chat.turns[1]!.text).toContain(`--ack ${heldDelivery}`) + // The PTY lane's text: `check` itself replays the held batch and names its ack. + expect(chat.turns[1]!.text).toBe(formatMessagePointer(1, `run:${runId}`).trim()) await settleTurn(COORDINATOR, 1) // Exactly once per new message: a retry and the idle edge point nothing further. @@ -644,8 +645,7 @@ describe('any live session is addressable by its id', () => { await vi.waitFor(() => expect(peer.turns).toHaveLength(1), WAIT) // Direct mail is not in a Run, so the pointer names no `--run`. - expect(peer.turns[0]!.text).toContain('orchestration check`.') - expect(peer.turns[0]!.text).toContain('$ORCA_CLI_COMMAND') + expect(peer.turns[0]!.text).toBe(formatMessagePointer(1, `session:${PEER_CHAT}`).trim()) await settleTurn(PEER_CHAT, 0) const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] })