Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,47 @@ export class SessionManager extends EventEmitter {
return session.awaitReadyForPrompt(opts)
}

/**
* Deliver a prompt to an agent session using the PROVIDER'S OWN
* delivery protocol (#394 phase 2c).
*
* WHY this lives on the manager instead of MCP calling the registry
* directly: the provider implementations need the live AgentSession
* object plus a liveness-aware write, and both are manager-owned
* state. MCP (and any future caller — composer flows, dispatch)
* gets one provider-agnostic entry point; the per-provider
* discipline (Codex readiness-gate + atomic paste+Enter, Claude
* paste → placeholder confirm → Enter) lives in
* providers/<kind>/runtime/promptDelivery.ts.
*
* An unknown/non-agent kind is a LOUD failure — the predecessor
* inline branches let a third provider fall through to a
* protocol-free paste with no readiness gate and no confirmation
* (#394 §4.2).
*/
async deliverPromptToAgent(
sessionId: string,
prompt: string,
): Promise<{ ok: true } | { ok: false; message: string }> {
const entry = this.sessions.get(sessionId)
// `=== 'terminal'` rather than !isAgentProviderKind: TypeScript
// only discriminates the RegistryEntry union on literal kind
// comparisons, and we need `entry.session` narrowed to
// AgentSession for the registry call below.
if (!entry || entry.kind === 'terminal') {
return {
ok: false,
message: `Cannot deliver prompt: ${sessionId} is not a live agent session`,
}
}
return getMainProvider(entry.kind).deliverPrompt({
session: entry.session,
write: data => this.write(sessionId, data),
sessionId,
prompt,
})
}

/** Resize a session's terminal + PTY. No-op if session doesn't exist. */
resize(sessionId: string, cols: number, rows: number): void {
const entry = this.sessions.get(sessionId)
Expand Down
93 changes: 9 additions & 84 deletions src/mcp/runtime/createBuiltInMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ function registerOrchestrationTools(
const prompt = buildOrchestrationBootstrapPrompt({
task: args.prompt,
})
const delivery = await submitPrompt(manager, agent.sessionId, agent.kind, prompt)
const delivery = await manager.deliverPromptToAgent(agent.sessionId, prompt)
if (!delivery.ok) {
let cleanupAttempted = false
let agentClosed = false
Expand Down Expand Up @@ -626,7 +626,7 @@ function registerOrchestrationTools(
task: args.prompt.trim(),
})
: args.prompt.trim()
const delivery = await submitPrompt(manager, args.sessionId, kind, prompt)
const delivery = await manager.deliverPromptToAgent(args.sessionId, prompt)
if (!delivery.ok) {
dependencies.appRunJournal?.recordIncident({
kind: 'orchestration.prompt_delivery_failed',
Expand Down Expand Up @@ -882,88 +882,13 @@ function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

async function submitPrompt(
manager: NonNullable<BuiltInMcpDependencies['sessionManager']>,
sessionId: string,
kind: AgentProviderKind,
prompt: string,
): Promise<{ ok: true } | { ok: false; message: string }> {
// WHY the MCP tool writes bracketed paste instead of plain text:
//
// Orchestration prompts are often long, markdown-heavy bootstrap prompts.
// Sending them as keystrokes would let provider TUIs interpret newlines or
// escape sequences as interactive input. Bracketed paste is the same
// terminal-level contract the composer uses for large prompts.
//
// WHY Codex waits BEFORE the paste while Claude waits AFTER:
//
// Claude's known race is paste-commit ordering: the composer is present, but
// Enter can arrive before the paste accumulator has replaced the payload
// with `[Pasted text #N]`. Codex's issue #211 race is earlier: `spawn()`
// has resolved and the PTY exists, but the TUI may still be on startup/trust
// chrome. Bytes written in that window disappear and no rollout file is
// created. The parent agent must not see `promptSubmitted: true` for that
// case, so this helper treats provider readiness and write success as the
// delivery boundary.
if (kind === 'codex') {
const ready = await manager.awaitCodexReadyForPrompt(sessionId, {
timeoutMs: 15_000,
pollIntervalMs: 50,
})
if (ready.kind !== 'ready') {
return {
ok: false,
message: `Codex session ${sessionId} was not ready for prompt delivery (${ready.kind})`,
}
}
// WHY Codex uses one PTY write for paste + submit:
// the normal renderer composer treats Codex as the provider whose
// bracketed paste can safely include the trailing Enter in the same
// terminal write. The old orchestration path split these into two writes,
// which made delivery accounting lie in exactly the failure mode inherited
// orchestration cannot tolerate: CodexHeadless records submitted prompts
// when it sees the bracketed paste bytes, before the separate Enter write
// proves the prompt was actually submitted. A child resumed from the
// parent's transcript then keeps reading stale inherited context as if it
// is still the parent. Keeping Codex atomic makes "write returned true"
// match the one operation the TUI needs to see.
if (!manager.write(sessionId, `\x1b[200~${prompt}\x1b[201~\r`)) {
return {
ok: false,
message: `Could not submit orchestration prompt to Codex session ${sessionId}`,
}
}
return { ok: true }
}

if (!manager.write(sessionId, `\x1b[200~${prompt}\x1b[201~`)) {
return {
ok: false,
message: `Could not write orchestration prompt to session ${sessionId}`,
}
}

if (kind === 'claude') {
const placeholder = await manager.awaitClaudePastePlaceholder(sessionId, {
timeoutMs: 2000,
pollIntervalMs: 50,
})
if (placeholder.kind !== 'appeared') {
return {
ok: false,
message: `Claude session ${sessionId} did not confirm pasted prompt before submit (${placeholder.kind})`,
}
}
}

if (!manager.write(sessionId, '\r')) {
return {
ok: false,
message: `Could not submit orchestration prompt to session ${sessionId}`,
}
}
return { ok: true }
}
// Prompt delivery moved to the provider registry (#394 phase 2c):
// manager.deliverPromptToAgent → getMainProvider(kind).deliverPrompt →
// providers/<kind>/runtime/promptDelivery.ts. The per-provider WHY
// blocks (Codex readiness-before-paste + atomic paste+Enter; Claude
// paste → placeholder confirm → separate Enter) moved with the code.
// The inline `if codex … if claude …` that lived here let a third
// provider fall through to a protocol-free paste (#394 §4.2).

function toolText(value: unknown): {
content: Array<{ type: 'text'; text: string }>
Expand Down
61 changes: 61 additions & 0 deletions src/providers/claude/runtime/promptDelivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Claude prompt-delivery protocol (#394 phase 2c). Extracted from the
// inline `if (kind === 'claude')` branch of MCP's submitPrompt so the
// protocol lives with the provider that owns it.
//
// WHY bracketed paste instead of plain keystrokes: orchestration
// prompts are long, markdown-heavy bootstrap prompts. Raw keystrokes
// would let the TUI interpret newlines/escapes as interactive input.
// Bracketed paste is the same terminal-level contract the composer
// uses for large prompts.
//
// WHY Claude waits AFTER the paste (unlike Codex, which gates BEFORE):
// Claude's known race is paste-commit ordering — the composer is
// present, but Enter can arrive before the paste accumulator has
// replaced the payload with `[Pasted text #N]`. Waiting for the
// placeholder proves the paste committed; only then is Enter safe.
// The delivery boundary the caller relies on: `ok: true` means the
// prompt was pasted, confirmed, and submitted.

import type {
PromptDeliveryIo,
PromptDeliveryResult,
} from '@shared/types/providerConfig.js'

export async function deliverClaudePrompt(
io: PromptDeliveryIo,
): Promise<PromptDeliveryResult> {
if (!io.write(`\x1b[200~${io.prompt}\x1b[201~`)) {
return {
ok: false,
message: `Could not write orchestration prompt to session ${io.sessionId}`,
}
}

// awaitPastePlaceholder is a typed optional on AgentSession — a
// Claude runtime build that loses it degrades to a visible delivery
// failure here, not a TypeError.
if (typeof io.session.awaitPastePlaceholder !== 'function') {
return {
ok: false,
message: `Claude session ${io.sessionId} has no paste-placeholder probe (headless unavailable?)`,
}
}
const placeholder = await io.session.awaitPastePlaceholder({
timeoutMs: 2000,
pollIntervalMs: 50,
})
if (placeholder.kind !== 'appeared') {
return {
ok: false,
message: `Claude session ${io.sessionId} did not confirm pasted prompt before submit (${placeholder.kind})`,
}
}

if (!io.write('\r')) {
return {
ok: false,
message: `Could not submit orchestration prompt to session ${io.sessionId}`,
}
}
return { ok: true }
}
53 changes: 53 additions & 0 deletions src/providers/codex/runtime/promptDelivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Codex prompt-delivery protocol (#394 phase 2c). Extracted from the
// inline `if (kind === 'codex')` branch of MCP's submitPrompt so the
// protocol lives with the provider that owns it.
//
// WHY Codex gates on readiness BEFORE the paste (unlike Claude, which
// confirms AFTER): Codex's issue #211 race is earlier in the
// lifecycle — `spawn()` has resolved and the PTY exists, but the TUI
// may still be on startup/trust chrome. Bytes written in that window
// disappear and no rollout file is created. The parent agent must not
// see `promptSubmitted: true` for that case.
//
// WHY one atomic PTY write for paste + Enter: CodexHeadless records
// submitted prompts when it sees the bracketed-paste bytes, BEFORE a
// separate Enter write would prove actual submission. The old
// orchestration path split them into two writes, which made delivery
// accounting lie in exactly the failure mode inherited orchestration
// cannot tolerate: a child resumed from the parent's transcript kept
// reading stale inherited context as if it were still the parent.
// Keeping paste+Enter atomic makes "write returned true" match the one
// operation the TUI needs to see.

import type {
PromptDeliveryIo,
PromptDeliveryResult,
} from '@shared/types/providerConfig.js'

export async function deliverCodexPrompt(
io: PromptDeliveryIo,
): Promise<PromptDeliveryResult> {
if (typeof io.session.awaitReadyForPrompt !== 'function') {
return {
ok: false,
message: `Codex session ${io.sessionId} has no readiness probe (headless unavailable?)`,
}
}
const ready = await io.session.awaitReadyForPrompt({
timeoutMs: 15_000,
pollIntervalMs: 50,
})
if (ready.kind !== 'ready') {
return {
ok: false,
message: `Codex session ${io.sessionId} was not ready for prompt delivery (${ready.kind})`,
}
}
if (!io.write(`\x1b[200~${io.prompt}\x1b[201~\r`)) {
return {
ok: false,
message: `Could not submit orchestration prompt to Codex session ${io.sessionId}`,
}
}
return { ok: true }
}
4 changes: 4 additions & 0 deletions src/providers/registry.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import type { MainProviderConfig } from '@shared/types/providerConfig'
import { type AgentProviderKind, isAgentProviderKind } from '@shared/types/providerKind'
import { ClaudeSession } from '@providers/claude/runtime/claudeSession'
import { listAllClaudeSessions } from '@providers/claude/runtime/sessionList'
import { deliverClaudePrompt } from '@providers/claude/runtime/promptDelivery'
import { listSessionsForCwd, getProjectDirForCwd } from 'claude-code-headless'
import { CodexSession } from '@providers/codex/runtime/codexSession'
import { deliverCodexPrompt } from '@providers/codex/runtime/promptDelivery'
import { listCodexSessions, getCodexSessionsDir } from 'codex-headless'

const CODEX_ROLLOUT_RE =
Expand Down Expand Up @@ -91,6 +93,7 @@ const claudeMain: MainProviderConfig = {
getProjectDir: getProjectDirForCwd,
resolveTranscriptPath: async (cwd, providerSessionId) =>
join(await getProjectDirForCwd(cwd), `${providerSessionId}.jsonl`),
deliverPrompt: deliverClaudePrompt,
}

const codexMain: MainProviderConfig = {
Expand All @@ -111,6 +114,7 @@ const codexMain: MainProviderConfig = {
getProjectDir: async () => getCodexSessionsDir(),
resolveTranscriptPath: async (_cwd, providerSessionId) =>
findCodexRolloutPathByThreadId(await getCodexSessionsDir(), providerSessionId),
deliverPrompt: deliverCodexPrompt,
}

// Typed as Record<AgentProviderKind, …> (not Record<string, …>) so that
Expand Down
36 changes: 36 additions & 0 deletions src/shared/types/providerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,44 @@ export type MainProviderConfig = {
* `getProjectDir`.
*/
resolveTranscriptPath: (cwd: string, providerSessionId: string) => Promise<string | null>
/**
* Provider-owned prompt delivery protocol (#394 phase 2c).
*
* WHY this is a capability and not inline branches: prompt delivery
* disciplines are OPPOSITE between the two shipped providers —
* Codex gates on TUI readiness BEFORE pasting and sends paste+Enter
* as one atomic PTY write (its headless accounts the prompt as
* submitted on the paste bytes); Claude pastes WITHOUT Enter, waits
* for the `[Pasted text #N]` placeholder to prove the paste
* committed, then sends Enter separately. The old inline
* `if codex … if claude …` in MCP's submitPrompt meant a THIRD
* provider fell through to a protocol-free paste+Enter with no
* readiness gate and no confirmation (#394 §4.2) — it "worked"
* exactly until it didn't, silently.
*
* The io bag deliberately passes the AgentSession plus a bound
* write-with-liveness function rather than the SessionManager:
* providers must not depend on the manager (dependency arrow), and
* the typed optionals they need (awaitReadyForPrompt /
* awaitPastePlaceholder) live on AgentSession since phase 2a.
*/
deliverPrompt: (io: PromptDeliveryIo) => Promise<PromptDeliveryResult>
}

export type PromptDeliveryIo = {
session: AgentSession
/** Write raw bytes to the session PTY. Returns false when the
* session is gone — callers already treat that as delivery
* failure, so providers just propagate it. */
write: (data: string) => boolean
sessionId: string
prompt: string
}

export type PromptDeliveryResult =
| { ok: true }
| { ok: false; message: string }

// The combined `ProviderConfig = RendererProviderConfig & MainProviderConfig`
// type used to live here. It was only ever used by
// `providers/<kind>/config.ts` files that implemented the full
Expand Down