Skip to content
This repository was archived by the owner on Jun 23, 2026. It is now read-only.
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
48 changes: 42 additions & 6 deletions packages/cli/src/local/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,51 @@ export function downloadFile(remotePath: string, localPath: string): void {

// ─── Interactive Session ─────────────────────────────────────────────────────

/** Launch an interactive shell session locally. */
/** Single-quote a string for safe use inside a shell command (Linux `script -c`). */
function shSingleQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}

/** Whether the `script` utility is available to allocate a PTY. */
function hasScriptUtil(): boolean {
const r = tryCatch(() =>
Bun.spawnSync(["sh", "-c", "command -v script >/dev/null 2>&1"], {
stdio: ["ignore", "ignore", "ignore"],
}),
);
return r.ok && r.data.exitCode === 0;
}

/**
* Wrap a launch argv so the agent runs inside its OWN pseudo-terminal via `script`.
*
* OpenTUI-based agents (OpenCode, Kilo Code) only accept keyboard input when they
* own a controlling PTY. Remotely that's provided by `ssh -tt`; locally they were
* launched as a child sharing the CLI's terminal, so input and Ctrl-C were dead on
* macOS. `script` gives them a fresh PTY (verified: input works wrapped, dead
* unwrapped). Returns null when `script` is unavailable so the caller falls back.
*/
function ptyWrapArgv(argv: string[]): string[] | null {
if (!hasScriptUtil()) {
return null;
}
if (process.platform === "darwin") {
// BSD script: `script -q /dev/null cmd args…` (returns the child's exit status).
return ["script", "-q", "/dev/null", ...argv];
}
if (process.platform === "linux") {
// util-linux script: `script -q -e -c "<command>" /dev/null` (-e: child exit code).
return ["script", "-q", "-e", "-c", argv.map(shSingleQuote).join(" "), "/dev/null"];
}
return null;
}

/** Launch an interactive shell session locally (inside a PTY so TUIs get input). */
export async function interactiveSession(cmd: string): Promise<number> {
validateCommand(cmd);
const [shell, flag] = getLocalShell();
return agentseaInteractive([
shell,
flag,
cmd,
]);
const direct = [shell, flag, cmd];
return agentseaInteractive(ptyWrapArgv(direct) ?? direct);
}

// ─── Docker Sandbox ─────────────────────────────────────────────────────────
Expand Down
26 changes: 23 additions & 3 deletions packages/cli/src/shared/agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1351,9 +1351,29 @@ export async function startHermesDashboard(runner: CloudRunner): Promise<void> {
// ─── OpenCode Install Command ────────────────────────────────────────────────

function openCodeInstallCmd(): string {
// Use $VAR not ${VAR} in the release URL: setupAutoUpdate() embeds this in a
// systemd-friendly template that rejects "${" (defense against JS interpolation).
return 'OC_ARCH=$(uname -m); case "$OC_ARCH" in aarch64) OC_ARCH=arm64;; x86_64) OC_ARCH=x64;; esac; OC_OS=$(uname -s | tr A-Z a-z); mkdir -p /tmp/opencode-install "$HOME/.opencode/bin" && curl --proto \'=https\' -fsSL -o /tmp/opencode-install/oc.tar.gz "https://github.com/sst/opencode/releases/latest/download/opencode-$OC_OS-$OC_ARCH.tar.gz" && if tar -tzf /tmp/opencode-install/oc.tar.gz | grep -qE \'(^/|\\.\\.)\'; then echo "Tarball contains unsafe paths" >&2; exit 1; fi && tar xzf /tmp/opencode-install/oc.tar.gz -C /tmp/opencode-install && mv /tmp/opencode-install/opencode "$HOME/.opencode/bin/" && rm -rf /tmp/opencode-install && for _rc in "$HOME/.bashrc" "$HOME/.profile" "$HOME/.bash_profile"; do grep -q ".opencode/bin" "$_rc" 2>/dev/null || echo \'export PATH="$HOME/.opencode/bin:$PATH"\' >> "$_rc"; done; { [ ! -f "$HOME/.zshrc" ] || grep -q ".opencode/bin" "$HOME/.zshrc" 2>/dev/null || echo \'export PATH="$HOME/.opencode/bin:$PATH"\' >> "$HOME/.zshrc"; }; export PATH="$HOME/.opencode/bin:$PATH"';
// sst/opencode ships .zip assets on macOS and .tar.gz on Linux. Pick the right
// one per-OS, extract accordingly, and CHAIN EVERYTHING with && ending in a
// binary check — so a failed download (e.g. the 404 when they renamed assets)
// fails the step instead of silently reporting "installed" (the trailing
// `;`-separated PATH setup previously masked the exit code).
// Use $VAR not ${VAR}: setupAutoUpdate() embeds this in a template that rejects "${".
return [
"OC_ARCH=$(uname -m)",
'case "$OC_ARCH" in aarch64) OC_ARCH=arm64;; x86_64) OC_ARCH=x64;; esac',
"OC_OS=$(uname -s | tr A-Z a-z)",
'if [ "$OC_OS" = darwin ]; then OC_EXT=zip; else OC_EXT=tar.gz; fi',
"rm -rf /tmp/opencode-install",
'mkdir -p /tmp/opencode-install "$HOME/.opencode/bin"',
"curl --proto '=https' -fsSL -o \"/tmp/opencode-install/oc.$OC_EXT\" \"https://github.com/sst/opencode/releases/latest/download/opencode-$OC_OS-$OC_ARCH.$OC_EXT\"",
'if [ "$OC_EXT" = zip ]; then unzip -o -q /tmp/opencode-install/oc.zip -d /tmp/opencode-install; else if tar -tzf /tmp/opencode-install/oc.tar.gz | grep -qE \'(^/|\\.\\.)\'; then echo "unsafe tarball" >&2; exit 1; fi; tar xzf /tmp/opencode-install/oc.tar.gz -C /tmp/opencode-install; fi',
'mv /tmp/opencode-install/opencode "$HOME/.opencode/bin/opencode"',
'chmod +x "$HOME/.opencode/bin/opencode"',
"rm -rf /tmp/opencode-install",
'for _rc in "$HOME/.bashrc" "$HOME/.profile" "$HOME/.bash_profile"; do grep -q .opencode/bin "$_rc" 2>/dev/null || echo \'export PATH="$HOME/.opencode/bin:$PATH"\' >> "$_rc"; done',
'{ [ ! -f "$HOME/.zshrc" ] || grep -q .opencode/bin "$HOME/.zshrc" 2>/dev/null || echo \'export PATH="$HOME/.opencode/bin:$PATH"\' >> "$HOME/.zshrc"; }',
'export PATH="$HOME/.opencode/bin:$PATH"',
'test -x "$HOME/.opencode/bin/opencode"',
].join(" && ");
}

// ─── npm prefix helper ────────────────────────────────────────────────────────
Expand Down
Loading