fix(tmux): defer subagent attach until pane focus
This commit is contained in:
committed by
YeonGyu-Kim
parent
6e5a127f88
commit
688bb551b2
@@ -9,9 +9,11 @@ export type { PaneDimensions } from "./tmux-utils/pane-dimensions"
|
||||
export { spawnTmuxPane } from "./tmux-utils/pane-spawn"
|
||||
export { closeTmuxPane } from "./tmux-utils/pane-close"
|
||||
export { replaceTmuxPane } from "./tmux-utils/pane-replace"
|
||||
export { activateTmuxPane } from "./tmux-utils/pane-activate"
|
||||
export { spawnTmuxWindow } from "./tmux-utils/window-spawn"
|
||||
export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn"
|
||||
export { killTmuxSessionIfExists } from "./tmux-utils/session-kill"
|
||||
export { sweepStaleOmoAgentSessions, sweepTmuxSessionsWith } from "./tmux-utils/stale-session-sweep"
|
||||
export { buildTmuxAttachCommand, buildTmuxPlaceholderCommand } from "./tmux-utils/pane-command"
|
||||
|
||||
export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
import { log } from "../../logger"
|
||||
import { runTmuxCommand } from "../runner"
|
||||
import { isInsideTmux } from "./environment"
|
||||
import { buildTmuxAttachCommand } from "./pane-command"
|
||||
|
||||
export async function activateTmuxPane(
|
||||
paneId: string,
|
||||
sessionId: string,
|
||||
serverUrl: string,
|
||||
directory: string,
|
||||
): Promise<boolean> {
|
||||
if (!isInsideTmux()) {
|
||||
log("[activateTmuxPane] SKIP: not inside tmux", { paneId, sessionId })
|
||||
return false
|
||||
}
|
||||
|
||||
const tmux = await getTmuxPath()
|
||||
if (!tmux) {
|
||||
log("[activateTmuxPane] SKIP: tmux not found", { paneId, sessionId })
|
||||
return false
|
||||
}
|
||||
|
||||
const opencodeCmd = buildTmuxAttachCommand(serverUrl, sessionId, directory)
|
||||
const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, opencodeCmd])
|
||||
if (result.exitCode !== 0) {
|
||||
log("[activateTmuxPane] FAILED", { paneId, sessionId, exitCode: result.exitCode, stderr: result.stderr.trim() })
|
||||
return false
|
||||
}
|
||||
|
||||
log("[activateTmuxPane] SUCCESS", { paneId, sessionId })
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { buildTmuxAttachCommand, buildTmuxPlaceholderCommand } from "./pane-command"
|
||||
|
||||
describe("buildTmuxAttachCommand", () => {
|
||||
it("escapes serverUrl shell metacharacters", () => {
|
||||
const cmd = buildTmuxAttachCommand("http://localhost:3000$(whoami);rm -rf /", "ses_abc123")
|
||||
expect(cmd).toContain("\\$")
|
||||
expect(cmd).toContain("\\;")
|
||||
expect(cmd).not.toMatch(/[^\\];\s*rm/)
|
||||
})
|
||||
|
||||
it("escapes session id shell metacharacters", () => {
|
||||
const cmd = buildTmuxAttachCommand("http://localhost:3000", 'ses_abc"$(whoami)"')
|
||||
expect(cmd).toContain('\\"')
|
||||
expect(cmd).toContain("\\$")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildTmuxPlaceholderCommand", () => {
|
||||
it("produces inert placeholder command instead of immediate attach", () => {
|
||||
const cmd = buildTmuxPlaceholderCommand("My Task")
|
||||
expect(cmd).toContain("Focus this pane to attach.")
|
||||
expect(cmd).toContain("tail -f /dev/null")
|
||||
expect(cmd).not.toContain("opencode attach")
|
||||
})
|
||||
|
||||
it("keeps single quotes and percent signs inside safe printf arguments", () => {
|
||||
const cmd = buildTmuxPlaceholderCommand("Fix Bob's 100% broken pane")
|
||||
expect(cmd).toContain(`printf '%s\\n%s\\n'`)
|
||||
expect(cmd).toContain(`"OMO subagent pane ready: Fix Bob's 100% broken pane"`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
|
||||
const TMUX_COMMAND_SHELL = "/bin/sh"
|
||||
|
||||
export function buildTmuxAttachCommand(serverUrl: string, sessionId: string, directory: string): string {
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const escapedSessionId = shellEscapeForDoubleQuotedCommand(sessionId)
|
||||
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory || process.cwd())
|
||||
return `${TMUX_COMMAND_SHELL} -c "opencode attach ${escapedUrl} --session ${escapedSessionId} --dir ${escapedDirectory}"`
|
||||
}
|
||||
|
||||
export function buildTmuxPlaceholderCommand(description: string): string {
|
||||
const escapedDescription = shellEscapeForDoubleQuotedCommand(description)
|
||||
return `${TMUX_COMMAND_SHELL} -c "printf '%s\\n%s\\n' \"OMO subagent pane ready: ${escapedDescription}\" \"Focus this pane to attach.\"; exec tail -f /dev/null"`
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
import type { SpawnPaneResult } from "../types"
|
||||
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
||||
import { isInsideTmux } from "./environment"
|
||||
import { shellSingleQuote } from "../../shell-env"
|
||||
import { buildTmuxPlaceholderCommand } from "./pane-command"
|
||||
|
||||
type ReplaceTmuxPaneDeps = {
|
||||
log: (message: string, data?: unknown) => void
|
||||
@@ -32,8 +32,8 @@ export async function replaceTmuxPane(
|
||||
sessionId: string,
|
||||
description: string,
|
||||
config: TmuxConfig,
|
||||
serverUrl: string,
|
||||
directory: string,
|
||||
_serverUrl: string,
|
||||
_directory: string,
|
||||
depsInput?: Partial<ReplaceTmuxPaneDeps>,
|
||||
): Promise<SpawnPaneResult> {
|
||||
const deps = await resolveReplaceTmuxPaneDeps(depsInput)
|
||||
@@ -56,10 +56,9 @@ export async function replaceTmuxPane(
|
||||
log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
|
||||
await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"])
|
||||
|
||||
const effectiveDirectory = directory || process.cwd()
|
||||
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
|
||||
const placeholderCmd = buildTmuxPlaceholderCommand(description)
|
||||
|
||||
const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, opencodeCmd])
|
||||
const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, placeholderCmd])
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
log("[replaceTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: result.stderr.trim() })
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
||||
import type { SplitDirection } from "./environment"
|
||||
import { isInsideTmux } from "./environment"
|
||||
import { isServerRunning } from "./server-health"
|
||||
import { shellSingleQuote } from "../../shell-env"
|
||||
import { buildTmuxPlaceholderCommand } from "./pane-command"
|
||||
|
||||
type SpawnTmuxPaneDeps = {
|
||||
log: (message: string, data?: unknown) => void
|
||||
@@ -36,7 +36,7 @@ export async function spawnTmuxPane(
|
||||
description: string,
|
||||
config: TmuxConfig,
|
||||
serverUrl: string,
|
||||
directory: string,
|
||||
_directory: string,
|
||||
targetPaneId?: string,
|
||||
splitDirection: SplitDirection = "-h",
|
||||
depsInput?: Partial<SpawnTmuxPaneDeps>,
|
||||
@@ -76,8 +76,7 @@ export async function spawnTmuxPane(
|
||||
|
||||
log("[spawnTmuxPane] all checks passed, spawning...")
|
||||
|
||||
const effectiveDirectory = directory || process.cwd()
|
||||
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
|
||||
const placeholderCmd = buildTmuxPlaceholderCommand(description)
|
||||
|
||||
const args = [
|
||||
"split-window",
|
||||
@@ -87,7 +86,7 @@ export async function spawnTmuxPane(
|
||||
"-F",
|
||||
"#{pane_id}",
|
||||
...(targetPaneId ? ["-t", targetPaneId] : []),
|
||||
opencodeCmd,
|
||||
placeholderCmd,
|
||||
]
|
||||
|
||||
const result = await runTmuxCommand(tmux, args)
|
||||
|
||||
Reference in New Issue
Block a user