Skip to content
Draft
256 changes: 256 additions & 0 deletions docs/known-issues/2026-07-16-codex-running-reported-idle.md

Large diffs are not rendered by default.

31 changes: 20 additions & 11 deletions src/adapters/backend/herdr-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export class HerdrBackend implements SessionBackend {
private lastText = '';
private exited = false;
private started = false;
private actuallyReattached = false;
private cols = 200;
private rows = 50;
private agentProbeFailures = 0;
Expand Down Expand Up @@ -467,7 +468,7 @@ export class HerdrBackend implements SessionBackend {
}

get isReattach(): boolean {
return this.opts.isReattach ?? false;
return this.actuallyReattached;
}

spawn(bin: string, args: string[], opts: SpawnOpts): void {
Expand Down Expand Up @@ -497,6 +498,7 @@ export class HerdrBackend implements SessionBackend {

const external = this.opts.externalTarget;
if (external) {
this.actuallyReattached = false;
this.paneId = external.paneId ?? external.target;
} else {
// Reuse an existing `botmux` agent ONLY when we're genuinely re-attaching
Expand All @@ -507,8 +509,9 @@ export class HerdrBackend implements SessionBackend {
// row from persisted metadata, and reuse would skip `agent start` so the
// new command never ran. killSession() now deletes that metadata, but we
// also gate reuse on isReattach so a stale row can never be adopted.
const existing = this.isReattach ? this.getAgent() : undefined;
const existing = this.opts.isReattach ? this.getAgent() : undefined;
if (existing) {
this.actuallyReattached = true;
this.paneId = existing.pane_id;
} else if (herdrUsesPaneAgentStart()) {
this.paneId = this.startPaneAgent(bin, args, opts);
Expand Down Expand Up @@ -540,25 +543,31 @@ export class HerdrBackend implements SessionBackend {
// - Re-attach / external adopt: snapshot the current screen so we only
// stream new deltas. Worker.ts explicitly seeds the initial screen
// via captureCurrentScreen() in those paths.
this.lastText = (this.isReattach || this.opts.externalTarget) ? this.readRecentAnsi() : '';
this.lastText = (this.actuallyReattached || this.opts.externalTarget) ? this.readRecentAnsi() : '';
this.startPolling();
this.startStatusWatcher();
}

write(data: string): void {
if (this.exited) return;
write(data: string): boolean {
if (this.exited) return false;
const target = this.paneId ?? this.agentName;
runHerdr(herdrSessionArgs(this.sessionName, ['pane', 'send-text', target, data]), { timeout: 5000 });
return runHerdr(
herdrSessionArgs(this.sessionName, ['pane', 'send-text', target, data]),
{ timeout: 5000 },
);
}

sendText(text: string): void {
this.write(text);
sendText(text: string): boolean {
return this.write(text);
}

sendSpecialKeys(...keys: string[]): void {
if (this.exited) return;
sendSpecialKeys(...keys: string[]): boolean {
if (this.exited) return false;
const target = this.paneId ?? this.agentName;
runHerdr(herdrSessionArgs(this.sessionName, ['pane', 'send-keys', target, ...keys]), { timeout: 5000 });
return runHerdr(
herdrSessionArgs(this.sessionName, ['pane', 'send-keys', target, ...keys]),
{ timeout: 5000 },
);
}

pasteText(text: string): void {
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/backend/pty-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ export class PtyBackend implements SessionBackend {
logger.debug(`[pty] spawned pid=${this.process.pid}`);
}

write(data: string): void {
this.process?.write(data);
write(data: string): boolean {
if (!this.process) return false;
this.process.write(data);
return true;
}

resize(cols: number, rows: number): void {
Expand Down
20 changes: 19 additions & 1 deletion src/adapters/backend/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ export function buildRelayHostEnv(
const env: NodeJS.ProcessEnv = { ...baseEnv };
delete env.BOTMUX_SEND_RELAY;
delete env.BOTMUX_CARD_PREPARED_CONTENT_FILE;
delete env.BOTMUX_HOST_RELAY_REQUIRES_CODEX_APP_LEDGER;
if (preparedContentFile) {
env.BOTMUX_CARD_LOCAL_LINK_MODE = 'disabled';
env.BOTMUX_CARD_PREPARED_CONTENT_FILE = preparedContentFile;
Expand All @@ -904,7 +905,19 @@ export function startOutboxWatcher(
* absent the relay still runs, but carries NO durable origin — a missing
* hook must never let the sandbox promote its own origin fields. */
authorize?: (claim: { capability?: string }) =>
| { ok: true; origin: { turnId?: string; dispatchAttempt?: number } }
| {
ok: true;
origin: {
turnId?: string;
dispatchAttempt?: number;
/** The worker matched an unsettled Codex App ledger entry. The
* host child must still find that exact entry before any provider
* side effect; terminal settlement/revocation between authorize
* and re-exec therefore fails closed instead of degrading to an
* ordinary mutable-session send. */
requiresCodexAppLedger?: boolean;
};
}
| { ok: false; error: string };
cliPath?: string;
} = {},
Expand Down Expand Up @@ -1028,6 +1041,11 @@ export function startOutboxWatcher(
if (trustedOrigin?.dispatchAttempt !== undefined) {
requestEnv.BOTMUX_DISPATCH_ATTEMPT = String(trustedOrigin.dispatchAttempt);
}
if (trustedOrigin?.requiresCodexAppLedger) {
requestEnv.BOTMUX_HOST_RELAY_REQUIRES_CODEX_APP_LEDGER = '1';
} else {
delete requestEnv.BOTMUX_HOST_RELAY_REQUIRES_CODEX_APP_LEDGER;
}
const child = spawn(process.execPath, [cli, 'send', ...hostArgs], { env: requestEnv });
let out = '', err = '';
child.stdout.on('data', d => { out += d; });
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/backend/tmux-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,10 @@ export class TmuxBackend implements SessionBackend {
* file's cwd field so a recycled PID can't mislead the resolver. */
cliCwd?: string;

write(data: string): void {
this.process?.write(data);
write(data: string): boolean {
if (!this.process) return false;
this.process.write(data);
return true;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/backend/tmux-pipe-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,9 @@ export class TmuxPipeBackend implements SessionBackend {
}
}

write(data: string): void {
write(data: string): boolean {
// No PTY to write to — interpret as a literal send-keys.
this.sendText(data);
return this.sendText(data);
}

sendText(text: string): boolean {
Expand Down
16 changes: 10 additions & 6 deletions src/adapters/backend/zellij-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,20 +201,24 @@ export class ZellijBackend implements SessionBackend {
// are forwarded verbatim to the focused pane — so every input path collapses
// to pty.write(), exactly like TmuxBackend.write().

write(data: string): void {
this.process?.write(data);
write(data: string): boolean {
if (!this.process) return false;
this.process.write(data);
return true;
}

/** Literal text, no Enter. */
sendText(text: string): void {
this.process?.write(text);
sendText(text: string): boolean {
return this.write(text);
}

/** Special keys by tmux-style name (Enter, Escape, C-c, M-Enter, …). */
sendSpecialKeys(...keys: string[]): void {
sendSpecialKeys(...keys: string[]): boolean {
if (!this.process) return false;
for (const key of keys) {
this.process?.write(tmuxKeyToBytes(key));
this.process.write(tmuxKeyToBytes(key));
}
return true;
}

/** Bracketed paste: wrap with \e[200~ … \e[201~ so TUIs (CoCo/Ink/Codex)
Expand Down
24 changes: 14 additions & 10 deletions src/adapters/backend/zellij-observe-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,19 +182,22 @@ export class ZellijObserveBackend implements ObserveBackend {

// ── Input: targeted `action` calls (focus-neutral, non-invasive) ──

write(data: string): void {
this.writeBytes(data);
write(data: string): boolean {
return this.writeBytes(data);
}

/** Literal text via write-chars (preserves UTF-8). */
sendText(text: string): void {
if (!text) return;
this.action(['write-chars', '--pane-id', this.paneId, '--', text]);
sendText(text: string): boolean {
if (!text) return true;
return this.action(['write-chars', '--pane-id', this.paneId, '--', text]) !== null;
}

/** Special keys by tmux-style name → raw bytes → `action write`. */
sendSpecialKeys(...keys: string[]): void {
for (const key of keys) this.writeBytes(tmuxKeyToBytes(key));
sendSpecialKeys(...keys: string[]): boolean {
for (const key of keys) {
if (!this.writeBytes(tmuxKeyToBytes(key))) return false;
}
return true;
}

/** Bracketed paste — wrap so TUIs detect the boundary (mirrors paste-buffer -p). */
Expand All @@ -207,14 +210,15 @@ export class ZellijObserveBackend implements ObserveBackend {
/** Write arbitrary bytes via `action write <decimal>…` (handles control/escape).
* Chunked so a large web-terminal paste can't blow the argv limit; zellij
* serialises the writes in arrival order. */
private writeBytes(data: string): void {
if (!data) return;
private writeBytes(data: string): boolean {
if (!data) return true;
const buf = Buffer.from(data, 'utf-8');
const CHUNK = 512;
for (let i = 0; i < buf.length; i += CHUNK) {
const bytes = Array.from(buf.subarray(i, i + CHUNK), b => String(b));
this.action(['write', '--pane-id', this.paneId, ...bytes]);
if (this.action(['write', '--pane-id', this.paneId, ...bytes]) === null) return false;
}
return true;
}

/** Resize is a NO-OP in observe mode — the pane size is the user's, and
Expand Down
6 changes: 3 additions & 3 deletions src/adapters/cli/coco.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export function createCocoAdapter(pathOverride?: string): CliAdapter {
// silently mask a real submit failure on a new install.
if (!existsSync(HISTORY_PATH) && baseByte === 0) {
if (await waitForHistoryAppend(HISTORY_PATH, baseByte, prefix, 1200)) {
return undefined;
return { submitted: true };
}
if (!existsSync(HISTORY_PATH)) {
return undefined;
Expand All @@ -242,12 +242,12 @@ export function createCocoAdapter(pathOverride?: string): CliAdapter {

for (let attempt = 0; attempt < 3; attempt++) {
if (await waitForHistoryAppend(HISTORY_PATH, baseByte, prefix, 800)) {
return undefined;
return { submitted: true };
}
if (!trySendEnter()) return { submitted: false };
}
if (await waitForHistoryAppend(HISTORY_PATH, baseByte, prefix, 800)) {
return undefined;
return { submitted: true };
}
// In-band budget exhausted. Hand the worker a recheck closure: a slow
// CoCo (cold start, large initial prompt, heavy hooks) may still
Expand Down
8 changes: 8 additions & 0 deletions src/adapters/cli/codex-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import type { CliAdapter, PtyHandle } from './types.js';
import { writeRunnerInput } from './runner-input.js';

function runnerPath(): string {
// Source-level worker integration tests execute through tsx and need the
// matching source runner rather than a possibly absent/stale ignored dist
// tree. Keep the override strictly test-scoped so production launch
// resolution remains canonical and cannot be redirected by ambient env.
const testOverride = process.env.NODE_ENV === 'test'
? process.env.BOTMUX_TEST_CODEX_APP_RUNNER_PATH
: undefined;
if (testOverride) return resolve(testOverride);
const here = dirname(fileURLToPath(import.meta.url));
const compiledSibling = resolve(here, '..', '..', 'codex-app-runner.js');
if (existsSync(compiledSibling)) return compiledSibling;
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/cli/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,15 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
if (match.found) {
return match.cliSessionId
? { submitted: true, cliSessionId: match.cliSessionId }
: undefined;
: { submitted: true };
}
if (!trySendEnter()) return { submitted: false };
}
const match = await waitForHistoryAppend(historyPath, baseByte, content, 800);
if (match.found) {
return match.cliSessionId
? { submitted: true, cliSessionId: match.cliSessionId }
: undefined;
: { submitted: true };
}
// In-band budget exhausted. Hand the worker a recheck closure: a
// slow-startup Codex (or one whose first turn is delayed by a heavy
Expand Down
Loading