Skip to content
Draft
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
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
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
Loading