Merge pull request #3500 from Disaster-Terminator/fix/tmux-defer-attach-until-focus
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
killTmuxSessionIfExists,
|
killTmuxSessionIfExists,
|
||||||
getIsolatedSessionName,
|
getIsolatedSessionName,
|
||||||
sweepStaleOmoAgentSessions,
|
sweepStaleOmoAgentSessions,
|
||||||
|
activateTmuxPane,
|
||||||
} from "../../shared/tmux"
|
} from "../../shared/tmux"
|
||||||
import { queryWindowState as defaultQueryWindowState } from "./pane-state-querier"
|
import { queryWindowState as defaultQueryWindowState } from "./pane-state-querier"
|
||||||
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
|
import { decideSpawnActions, decideCloseAction, type SessionMapping } from "./decision-engine"
|
||||||
@@ -76,6 +77,13 @@ const FAILED_READINESS_SWEEP_INTERVAL_MS = 60 * 1000
|
|||||||
const MAX_DEFERRED_QUEUE_SIZE = 20
|
const MAX_DEFERRED_QUEUE_SIZE = 20
|
||||||
const MAX_CLOSE_RETRY_COUNT = 3
|
const MAX_CLOSE_RETRY_COUNT = 3
|
||||||
const MAX_ISOLATED_CONTAINER_NULL_STATE_COUNT = 2
|
const MAX_ISOLATED_CONTAINER_NULL_STATE_COUNT = 2
|
||||||
|
let nextIsolatedSessionManagerId = 1
|
||||||
|
|
||||||
|
function createIsolatedSessionManagerId(): string {
|
||||||
|
const managerId = String(nextIsolatedSessionManagerId)
|
||||||
|
nextIsolatedSessionManagerId += 1
|
||||||
|
return managerId
|
||||||
|
}
|
||||||
|
|
||||||
export class TmuxSessionManager {
|
export class TmuxSessionManager {
|
||||||
private client: OpencodeClient
|
private client: OpencodeClient
|
||||||
@@ -101,6 +109,7 @@ export class TmuxSessionManager {
|
|||||||
private isolatedContainerNullStateCount = 0
|
private isolatedContainerNullStateCount = 0
|
||||||
private staleSweepCompleted = false
|
private staleSweepCompleted = false
|
||||||
private staleSweepInProgress = false
|
private staleSweepInProgress = false
|
||||||
|
private isolatedSessionManagerId = createIsolatedSessionManagerId()
|
||||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: Partial<TmuxUtilDeps> = {}) {
|
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: Partial<TmuxUtilDeps> = {}) {
|
||||||
this.client = ctx.client
|
this.client = ctx.client
|
||||||
this.tmuxConfig = tmuxConfig
|
this.tmuxConfig = tmuxConfig
|
||||||
@@ -133,7 +142,10 @@ export class TmuxSessionManager {
|
|||||||
this.client,
|
this.client,
|
||||||
this.sessions,
|
this.sessions,
|
||||||
this.closeSessionFromPolling.bind(this),
|
this.closeSessionFromPolling.bind(this),
|
||||||
this.retryPendingCloses.bind(this)
|
this.retryPendingCloses.bind(this),
|
||||||
|
this.queryWindowStateSafely.bind(this),
|
||||||
|
this.activateTrackedSessionPane.bind(this),
|
||||||
|
this.canAutoActivatePane.bind(this),
|
||||||
)
|
)
|
||||||
this.deps.log("[tmux-session-manager] initialized", {
|
this.deps.log("[tmux-session-manager] initialized", {
|
||||||
configEnabled: this.tmuxConfig.enabled,
|
configEnabled: this.tmuxConfig.enabled,
|
||||||
@@ -193,7 +205,16 @@ export class TmuxSessionManager {
|
|||||||
this.deps.log("[tmux-session-manager] creating isolated tmux container", { isolation, sessionId, title })
|
this.deps.log("[tmux-session-manager] creating isolated tmux container", { isolation, sessionId, title })
|
||||||
|
|
||||||
const result = isolation === "session"
|
const result = isolation === "session"
|
||||||
? await spawnTmuxSession(sessionId, title, this.tmuxConfig, this.serverUrl, this.projectDirectory, this.sourcePaneId)
|
? await spawnTmuxSession(
|
||||||
|
sessionId,
|
||||||
|
title,
|
||||||
|
this.tmuxConfig,
|
||||||
|
this.serverUrl,
|
||||||
|
this.projectDirectory,
|
||||||
|
this.sourcePaneId,
|
||||||
|
undefined,
|
||||||
|
this.isolatedSessionManagerId,
|
||||||
|
)
|
||||||
: await spawnTmuxWindow(sessionId, title, this.tmuxConfig, this.serverUrl, this.projectDirectory)
|
: await spawnTmuxWindow(sessionId, title, this.tmuxConfig, this.serverUrl, this.projectDirectory)
|
||||||
|
|
||||||
if (result.success && result.paneId) {
|
if (result.success && result.paneId) {
|
||||||
@@ -337,6 +358,10 @@ export class TmuxSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async activateTrackedSessionPane(tracked: TrackedSession): Promise<boolean> {
|
||||||
|
return activateTmuxPane(tracked.paneId, tracked.sessionId, this.serverUrl, this.projectDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
private windowStateContainsPane(state: WindowState, paneId: string): boolean {
|
private windowStateContainsPane(state: WindowState, paneId: string): boolean {
|
||||||
return state.mainPane?.paneId === paneId
|
return state.mainPane?.paneId === paneId
|
||||||
|| state.agentPanes.some((pane) => pane.paneId === paneId)
|
|| state.agentPanes.some((pane) => pane.paneId === paneId)
|
||||||
@@ -378,6 +403,11 @@ export class TmuxSessionManager {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private canAutoActivatePane(state: WindowState): boolean {
|
||||||
|
if (!this.isIsolated()) return true
|
||||||
|
return state.windowActive === true && state.sessionAttached === true
|
||||||
|
}
|
||||||
|
|
||||||
private async closeTrackedSessionPane(args: {
|
private async closeTrackedSessionPane(args: {
|
||||||
tracked: TrackedSession
|
tracked: TrackedSession
|
||||||
state: WindowState
|
state: WindowState
|
||||||
@@ -1309,7 +1339,7 @@ export class TmuxSessionManager {
|
|||||||
this.isolatedWindowPaneId = undefined
|
this.isolatedWindowPaneId = undefined
|
||||||
|
|
||||||
if (this.tmuxConfig.isolation === "session") {
|
if (this.tmuxConfig.isolation === "session") {
|
||||||
const isolatedSessionName = getIsolatedSessionName()
|
const isolatedSessionName = getIsolatedSessionName(process.pid, this.isolatedSessionManagerId)
|
||||||
try {
|
try {
|
||||||
const killed = await killTmuxSessionIfExists(isolatedSessionName)
|
const killed = await killTmuxSessionIfExists(isolatedSessionName)
|
||||||
this.deps.log("[tmux-session-manager] isolated session teardown", {
|
this.deps.log("[tmux-session-manager] isolated session teardown", {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { parsePaneStateOutput } from "./pane-state-parser"
|
|||||||
describe("parsePaneStateOutput", () => {
|
describe("parsePaneStateOutput", () => {
|
||||||
it("rejects malformed integer fields", () => {
|
it("rejects malformed integer fields", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\n"
|
const stdout = "%0\t120oops\t40\t0\t0\t1\t120\t40\t1\t1\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
@@ -17,7 +17,7 @@ describe("parsePaneStateOutput", () => {
|
|||||||
|
|
||||||
it("rejects negative integer fields", () => {
|
it("rejects negative integer fields", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\n"
|
const stdout = "%0\t-1\t40\t0\t0\t1\t120\t40\t1\t1\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
@@ -28,7 +28,7 @@ describe("parsePaneStateOutput", () => {
|
|||||||
|
|
||||||
it("rejects empty integer fields", () => {
|
it("rejects empty integer fields", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t\t40\t0\t0\t1\t120\t40\n"
|
const stdout = "%0\t\t40\t0\t0\t1\t120\t40\t1\t1\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
@@ -39,7 +39,7 @@ describe("parsePaneStateOutput", () => {
|
|||||||
|
|
||||||
it("rejects non-binary active flags", () => {
|
it("rejects non-binary active flags", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\n"
|
const stdout = "%0\t120\t40\t0\t0\tx\t120\t40\t1\t1\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
@@ -50,7 +50,7 @@ describe("parsePaneStateOutput", () => {
|
|||||||
|
|
||||||
it("rejects numeric active flags other than zero or one", () => {
|
it("rejects numeric active flags other than zero or one", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\n"
|
const stdout = "%0\t120\t40\t0\t0\t2\t120\t40\t1\t1\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
@@ -61,7 +61,18 @@ describe("parsePaneStateOutput", () => {
|
|||||||
|
|
||||||
it("rejects empty active flags", () => {
|
it("rejects empty active flags", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t120\t40\t0\t0\t\t120\t40\n"
|
const stdout = "%0\t120\t40\t0\t0\t\t120\t40\t1\t1\n"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = parsePaneStateOutput(stdout)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects malformed session attached field", () => {
|
||||||
|
// given
|
||||||
|
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\tnope\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { TmuxPaneInfo } from "./types"
|
import type { TmuxPaneInfo } from "./types"
|
||||||
|
|
||||||
const MANDATORY_PANE_FIELD_COUNT = 8
|
const MANDATORY_PANE_FIELD_COUNT = 10
|
||||||
|
|
||||||
type ParsedPaneState = {
|
type ParsedPaneState = {
|
||||||
windowWidth: number
|
windowWidth: number
|
||||||
windowHeight: number
|
windowHeight: number
|
||||||
|
windowActive: boolean
|
||||||
|
sessionAttached: boolean
|
||||||
panes: TmuxPaneInfo[]
|
panes: TmuxPaneInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ type ParsedPaneLine = {
|
|||||||
pane: TmuxPaneInfo
|
pane: TmuxPaneInfo
|
||||||
windowWidth: number
|
windowWidth: number
|
||||||
windowHeight: number
|
windowHeight: number
|
||||||
|
windowActive: boolean
|
||||||
|
sessionAttached: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type MandatoryPaneFields = [
|
type MandatoryPaneFields = [
|
||||||
@@ -23,6 +27,8 @@ type MandatoryPaneFields = [
|
|||||||
activeString: string,
|
activeString: string,
|
||||||
windowWidthString: string,
|
windowWidthString: string,
|
||||||
windowHeightString: string,
|
windowHeightString: string,
|
||||||
|
windowActiveString: string,
|
||||||
|
sessionAttachedString: string,
|
||||||
]
|
]
|
||||||
|
|
||||||
export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
|
export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
|
||||||
@@ -45,6 +51,8 @@ export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
|
|||||||
return {
|
return {
|
||||||
windowWidth: latestPaneLine.windowWidth,
|
windowWidth: latestPaneLine.windowWidth,
|
||||||
windowHeight: latestPaneLine.windowHeight,
|
windowHeight: latestPaneLine.windowHeight,
|
||||||
|
windowActive: latestPaneLine.windowActive,
|
||||||
|
sessionAttached: latestPaneLine.sessionAttached,
|
||||||
panes: parsedPaneLines.map(({ pane }) => pane),
|
panes: parsedPaneLines.map(({ pane }) => pane),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +62,7 @@ function parsePaneLine(line: string): ParsedPaneLine | null {
|
|||||||
const mandatoryFields = getMandatoryPaneFields(fields)
|
const mandatoryFields = getMandatoryPaneFields(fields)
|
||||||
if (!mandatoryFields) return null
|
if (!mandatoryFields) return null
|
||||||
|
|
||||||
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields
|
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString, windowActiveString, sessionAttachedString] = mandatoryFields
|
||||||
|
|
||||||
const width = parseInteger(widthString)
|
const width = parseInteger(widthString)
|
||||||
const height = parseInteger(heightString)
|
const height = parseInteger(heightString)
|
||||||
@@ -63,6 +71,8 @@ function parsePaneLine(line: string): ParsedPaneLine | null {
|
|||||||
const isActive = parseActiveValue(activeString)
|
const isActive = parseActiveValue(activeString)
|
||||||
const windowWidth = parseInteger(windowWidthString)
|
const windowWidth = parseInteger(windowWidthString)
|
||||||
const windowHeight = parseInteger(windowHeightString)
|
const windowHeight = parseInteger(windowHeightString)
|
||||||
|
const windowActive = parseActiveValue(windowActiveString)
|
||||||
|
const sessionAttached = parseAttachedValue(sessionAttachedString)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
width === null ||
|
width === null ||
|
||||||
@@ -71,7 +81,9 @@ function parsePaneLine(line: string): ParsedPaneLine | null {
|
|||||||
top === null ||
|
top === null ||
|
||||||
isActive === null ||
|
isActive === null ||
|
||||||
windowWidth === null ||
|
windowWidth === null ||
|
||||||
windowHeight === null
|
windowHeight === null ||
|
||||||
|
windowActive === null ||
|
||||||
|
sessionAttached === null
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -88,13 +100,15 @@ function parsePaneLine(line: string): ParsedPaneLine | null {
|
|||||||
},
|
},
|
||||||
windowWidth,
|
windowWidth,
|
||||||
windowHeight,
|
windowHeight,
|
||||||
|
windowActive,
|
||||||
|
sessionAttached,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
|
function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
|
||||||
if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null
|
if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null
|
||||||
|
|
||||||
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields
|
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString, windowActiveString, sessionAttachedString] = fields
|
||||||
|
|
||||||
if (
|
if (
|
||||||
paneId === undefined ||
|
paneId === undefined ||
|
||||||
@@ -104,7 +118,9 @@ function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
|
|||||||
topString === undefined ||
|
topString === undefined ||
|
||||||
activeString === undefined ||
|
activeString === undefined ||
|
||||||
windowWidthString === undefined ||
|
windowWidthString === undefined ||
|
||||||
windowHeightString === undefined
|
windowHeightString === undefined ||
|
||||||
|
windowActiveString === undefined ||
|
||||||
|
sessionAttachedString === undefined
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -118,6 +134,8 @@ function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
|
|||||||
activeString,
|
activeString,
|
||||||
windowWidthString,
|
windowWidthString,
|
||||||
windowHeightString,
|
windowHeightString,
|
||||||
|
windowActiveString,
|
||||||
|
sessionAttachedString,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,3 +151,8 @@ function parseActiveValue(value: string): boolean | null {
|
|||||||
if (value === "0") return false
|
if (value === "0") return false
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAttachedValue(value: string): boolean | null {
|
||||||
|
if (!/^\d+$/.test(value)) return null
|
||||||
|
return Number.parseInt(value, 10) > 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ describe("queryWindowState runner integration", () => {
|
|||||||
|
|
||||||
runTmuxCommandMock.mockResolvedValue({
|
runTmuxCommandMock.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
output: "%0\t120\t40\t0\t0\t1\t120\t40\t\n%1\t60\t40\t60\t0\t0\t120\t40\tagent",
|
output: "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\t\n%1\t60\t40\t60\t0\t0\t120\t40\t1\t1\tagent",
|
||||||
stdout: "%0\t120\t40\t0\t0\t1\t120\t40\t\n%1\t60\t40\t60\t0\t0\t120\t40\tagent",
|
stdout: "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\t\n%1\t60\t40\t60\t0\t0\t120\t40\t1\t1\tagent",
|
||||||
stderr: "",
|
stderr: "",
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
})
|
})
|
||||||
@@ -52,7 +52,7 @@ describe("queryWindowState runner integration", () => {
|
|||||||
"-t",
|
"-t",
|
||||||
"%0",
|
"%0",
|
||||||
"-F",
|
"-F",
|
||||||
"#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}",
|
"#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{window_active}\t#{session_attached}\t#{pane_title}",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { parsePaneStateOutput } from "./pane-state-parser"
|
|||||||
describe("parsePaneStateOutput", () => {
|
describe("parsePaneStateOutput", () => {
|
||||||
it("accepts a single pane when tmux omits the empty trailing title field", () => {
|
it("accepts a single pane when tmux omits the empty trailing title field", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n"
|
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
@@ -16,6 +16,8 @@ describe("parsePaneStateOutput", () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
windowWidth: 120,
|
windowWidth: 120,
|
||||||
windowHeight: 40,
|
windowHeight: 40,
|
||||||
|
windowActive: true,
|
||||||
|
sessionAttached: true,
|
||||||
panes: [
|
panes: [
|
||||||
{
|
{
|
||||||
paneId: "%0",
|
paneId: "%0",
|
||||||
@@ -32,7 +34,7 @@ describe("parsePaneStateOutput", () => {
|
|||||||
|
|
||||||
it("handles CRLF line endings without dropping panes", () => {
|
it("handles CRLF line endings without dropping panes", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n"
|
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t1\t1\r\n%1\t60\t40\t60\t0\t0\t120\t40\t1\t1\tagent\r\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
@@ -63,13 +65,15 @@ describe("parsePaneStateOutput", () => {
|
|||||||
|
|
||||||
it("preserves tabs inside pane titles", () => {
|
it("preserves tabs inside pane titles", () => {
|
||||||
// given
|
// given
|
||||||
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n"
|
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\t0\t0\ttitle\twith\ttabs\n"
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = parsePaneStateOutput(stdout)
|
const result = parsePaneStateOutput(stdout)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result).not.toBe(null)
|
expect(result).not.toBe(null)
|
||||||
|
expect(result?.windowActive).toBe(false)
|
||||||
|
expect(result?.sessionAttached).toBe(false)
|
||||||
expect(result?.panes[0]?.title).toBe("title\twith\ttabs")
|
expect(result?.panes[0]?.title).toBe("title\twith\ttabs")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query
|
|||||||
"-t",
|
"-t",
|
||||||
sourcePaneId,
|
sourcePaneId,
|
||||||
"-F",
|
"-F",
|
||||||
"#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{pane_title}",
|
"#{pane_id}\t#{pane_width}\t#{pane_height}\t#{pane_left}\t#{pane_top}\t#{pane_active}\t#{window_width}\t#{window_height}\t#{window_active}\t#{session_attached}\t#{pane_title}",
|
||||||
])
|
])
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
@@ -38,6 +38,8 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query
|
|||||||
const { panes } = parsedPaneState
|
const { panes } = parsedPaneState
|
||||||
const windowWidth = parsedPaneState.windowWidth
|
const windowWidth = parsedPaneState.windowWidth
|
||||||
const windowHeight = parsedPaneState.windowHeight
|
const windowHeight = parsedPaneState.windowHeight
|
||||||
|
const windowActive = parsedPaneState.windowActive
|
||||||
|
const sessionAttached = parsedPaneState.sessionAttached
|
||||||
|
|
||||||
panes.sort((a, b) => a.left - b.left || a.top - b.top)
|
panes.sort((a, b) => a.left - b.left || a.top - b.top)
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ export async function queryWindowStateWithDeps(sourcePaneId: string, deps: Query
|
|||||||
agentPaneCount: agentPanes.length,
|
agentPaneCount: agentPanes.length,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { windowWidth, windowHeight, mainPane, agentPanes }
|
return { windowWidth, windowHeight, windowActive, sessionAttached, mainPane, agentPanes }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { TmuxPollingManager } from "./polling-manager"
|
import { TmuxPollingManager } from "./polling-manager"
|
||||||
import type { TrackedSession } from "./types"
|
import type { TrackedSession, WindowState } from "./types"
|
||||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||||
|
|
||||||
describe("TmuxPollingManager overlap", () => {
|
describe("TmuxPollingManager overlap", () => {
|
||||||
@@ -11,6 +11,7 @@ describe("TmuxPollingManager overlap", () => {
|
|||||||
sessionId: "ses-1",
|
sessionId: "ses-1",
|
||||||
paneId: "%1",
|
paneId: "%1",
|
||||||
description: "test",
|
description: "test",
|
||||||
|
attachActivated: true,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
closePending: false,
|
closePending: false,
|
||||||
@@ -64,6 +65,7 @@ describe("TmuxPollingManager overlap", () => {
|
|||||||
sessionId: "ses-1",
|
sessionId: "ses-1",
|
||||||
paneId: "%1",
|
paneId: "%1",
|
||||||
description: "test",
|
description: "test",
|
||||||
|
attachActivated: true,
|
||||||
createdAt: new Date(Date.now() - 15_000),
|
createdAt: new Date(Date.now() - 15_000),
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
closePending: false,
|
closePending: false,
|
||||||
@@ -117,6 +119,7 @@ describe("TmuxPollingManager overlap", () => {
|
|||||||
sessionId: "ses-1",
|
sessionId: "ses-1",
|
||||||
paneId: "%1",
|
paneId: "%1",
|
||||||
description: "test",
|
description: "test",
|
||||||
|
attachActivated: true,
|
||||||
createdAt: new Date(now - 1_000),
|
createdAt: new Date(now - 1_000),
|
||||||
lastSeenAt: new Date(now - 7_000),
|
lastSeenAt: new Date(now - 7_000),
|
||||||
closePending: false,
|
closePending: false,
|
||||||
@@ -156,6 +159,7 @@ describe("TmuxPollingManager overlap", () => {
|
|||||||
sessionId: "ses-1",
|
sessionId: "ses-1",
|
||||||
paneId: "%1",
|
paneId: "%1",
|
||||||
description: "test",
|
description: "test",
|
||||||
|
attachActivated: true,
|
||||||
createdAt: new Date(now - 11 * 60 * 1000),
|
createdAt: new Date(now - 11 * 60 * 1000),
|
||||||
lastSeenAt: new Date(now),
|
lastSeenAt: new Date(now),
|
||||||
closePending: false,
|
closePending: false,
|
||||||
@@ -194,6 +198,7 @@ describe("TmuxPollingManager overlap", () => {
|
|||||||
sessionId: "ses-1",
|
sessionId: "ses-1",
|
||||||
paneId: "%1",
|
paneId: "%1",
|
||||||
description: "test",
|
description: "test",
|
||||||
|
attachActivated: true,
|
||||||
createdAt: new Date(Date.now() - 15_000),
|
createdAt: new Date(Date.now() - 15_000),
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
closePending: false,
|
closePending: false,
|
||||||
@@ -237,4 +242,222 @@ describe("TmuxPollingManager overlap", () => {
|
|||||||
// then
|
// then
|
||||||
expect(closedSessionIds).toEqual([])
|
expect(closedSessionIds).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("activates focused panes once before polling statuses", async () => {
|
||||||
|
//#given
|
||||||
|
const sessions = new Map<string, TrackedSession>()
|
||||||
|
const tracked: TrackedSession = {
|
||||||
|
sessionId: "ses-1",
|
||||||
|
paneId: "%1",
|
||||||
|
description: "test",
|
||||||
|
attachActivated: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
closePending: false,
|
||||||
|
closeRetryCount: 0,
|
||||||
|
activityVersion: 0,
|
||||||
|
}
|
||||||
|
sessions.set("ses-1", tracked)
|
||||||
|
|
||||||
|
const activatedSessionIds: string[] = []
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { "ses-1": { type: "running" } } }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const windowState: WindowState = {
|
||||||
|
windowWidth: 160,
|
||||||
|
windowHeight: 48,
|
||||||
|
windowActive: true,
|
||||||
|
sessionAttached: true,
|
||||||
|
mainPane: null,
|
||||||
|
agentPanes: [
|
||||||
|
{ paneId: "%1", width: 80, height: 24, left: 0, top: 0, title: "agent", isActive: true },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const manager = new TmuxPollingManager(
|
||||||
|
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||||
|
sessions,
|
||||||
|
async () => {},
|
||||||
|
undefined,
|
||||||
|
async () => windowState,
|
||||||
|
async (session) => {
|
||||||
|
activatedSessionIds.push(session.sessionId)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const pollSessions = unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager).pollSessions
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(activatedSessionIds).toEqual(["ses-1"])
|
||||||
|
expect(tracked.attachActivated).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not close non-activated panes before they report any session status", async () => {
|
||||||
|
//#given
|
||||||
|
const sessions = new Map<string, TrackedSession>()
|
||||||
|
sessions.set("ses-1", {
|
||||||
|
sessionId: "ses-1",
|
||||||
|
paneId: "%1",
|
||||||
|
description: "test",
|
||||||
|
attachActivated: false,
|
||||||
|
createdAt: new Date(Date.now() - 15_000),
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
closePending: false,
|
||||||
|
closeRetryCount: 0,
|
||||||
|
activityVersion: 0,
|
||||||
|
stableIdlePolls: 3,
|
||||||
|
observedIdleActivityVersion: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const closedSessionIds: string[] = []
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: {} }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new TmuxPollingManager(
|
||||||
|
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||||
|
sessions,
|
||||||
|
async (sessionId) => {
|
||||||
|
closedSessionIds.push(sessionId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const pollSessions = unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager).pollSessions
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(closedSessionIds).toEqual([])
|
||||||
|
expect(sessions.has("ses-1")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not close immediately when first status is delayed after focused activation", async () => {
|
||||||
|
//#given
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
let now = 0
|
||||||
|
Date.now = () => now
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = new Map<string, TrackedSession>()
|
||||||
|
const tracked: TrackedSession = {
|
||||||
|
sessionId: "ses-1",
|
||||||
|
paneId: "%1",
|
||||||
|
description: "test",
|
||||||
|
attachActivated: false,
|
||||||
|
createdAt: new Date(0),
|
||||||
|
lastSeenAt: new Date(0),
|
||||||
|
closePending: false,
|
||||||
|
closeRetryCount: 0,
|
||||||
|
}
|
||||||
|
sessions.set("ses-1", tracked)
|
||||||
|
|
||||||
|
let activationCount = 0
|
||||||
|
let statusCalls = 0
|
||||||
|
const closedSessionIds: string[] = []
|
||||||
|
const getWindowState = async (): Promise<WindowState> => ({
|
||||||
|
windowWidth: 220,
|
||||||
|
windowHeight: 44,
|
||||||
|
mainPane: { paneId: "%0", width: 110, height: 44, left: 0, top: 0, title: "main", isActive: false },
|
||||||
|
agentPanes: [{ paneId: "%1", width: 110, height: 44, left: 110, top: 0, title: "agent", isActive: true }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => {
|
||||||
|
statusCalls += 1
|
||||||
|
now += 3_000
|
||||||
|
if (statusCalls <= 3) {
|
||||||
|
return { data: {} }
|
||||||
|
}
|
||||||
|
return { data: { "ses-1": { type: "running" } } }
|
||||||
|
},
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new TmuxPollingManager(
|
||||||
|
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||||
|
sessions,
|
||||||
|
async (sessionId) => {
|
||||||
|
closedSessionIds.push(sessionId)
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
getWindowState,
|
||||||
|
async () => {
|
||||||
|
activationCount += 1
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const pollSessions = unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager).pollSessions
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(activationCount).toBe(1)
|
||||||
|
expect(tracked.attachActivated).toBe(true)
|
||||||
|
expect(closedSessionIds).toEqual([])
|
||||||
|
expect(sessions.has("ses-1")).toBe(true)
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can still close non-activated sessions once status is idle and stable", async () => {
|
||||||
|
//#given
|
||||||
|
const sessions = new Map<string, TrackedSession>()
|
||||||
|
sessions.set("ses-1", {
|
||||||
|
sessionId: "ses-1",
|
||||||
|
paneId: "%1",
|
||||||
|
description: "test",
|
||||||
|
attachActivated: false,
|
||||||
|
createdAt: new Date(Date.now() - 15_000),
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
closePending: false,
|
||||||
|
closeRetryCount: 0,
|
||||||
|
activityVersion: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const closedSessionIds: string[] = []
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { "ses-1": { type: "idle" } } }),
|
||||||
|
messages: async () => ({ data: [] }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new TmuxPollingManager(
|
||||||
|
unsafeTestValue<import("../../tools/delegate-task/types").OpencodeClient>(client),
|
||||||
|
sessions,
|
||||||
|
async (sessionId) => {
|
||||||
|
closedSessionIds.push(sessionId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.handleEvent({
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: { sessionID: "ses-1", field: "text", delta: "done" },
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const pollSessions = unsafeTestValue<{ pollSessions: () => Promise<void> }>(manager).pollSessions
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
await pollSessions.call(manager)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(closedSessionIds).toEqual(["ses-1"])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { OpencodeClient } from "../../tools/delegate-task/types"
|
|||||||
import {
|
import {
|
||||||
POLL_INTERVAL_BACKGROUND_MS,
|
POLL_INTERVAL_BACKGROUND_MS,
|
||||||
SESSION_MISSING_GRACE_MS,
|
SESSION_MISSING_GRACE_MS,
|
||||||
|
SESSION_READY_TIMEOUT_MS,
|
||||||
SESSION_TIMEOUT_MS,
|
SESSION_TIMEOUT_MS,
|
||||||
} from "../../shared/tmux"
|
} from "../../shared/tmux"
|
||||||
import type { TrackedSession } from "./types"
|
import type { TrackedSession, WindowState } from "./types"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { normalizeSDKResponse } from "../../shared"
|
||||||
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
import { resolveMessageEventSessionID } from "../../shared/event-session-id"
|
||||||
@@ -20,7 +21,10 @@ export class TmuxPollingManager {
|
|||||||
private client: OpencodeClient,
|
private client: OpencodeClient,
|
||||||
private sessions: Map<string, TrackedSession>,
|
private sessions: Map<string, TrackedSession>,
|
||||||
private closeSessionById: (sessionId: string) => Promise<void>,
|
private closeSessionById: (sessionId: string) => Promise<void>,
|
||||||
private retryPendingCloses?: () => Promise<void>
|
private retryPendingCloses?: () => Promise<void>,
|
||||||
|
private getWindowState?: () => Promise<WindowState | null>,
|
||||||
|
private activateSessionPane?: (tracked: TrackedSession) => Promise<boolean>,
|
||||||
|
private canActivatePane: (state: WindowState) => boolean = (state) => state.windowActive !== false && state.sessionAttached !== false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
handleEvent(event: { type: string; properties?: Record<string, unknown> }): void {
|
handleEvent(event: { type: string; properties?: Record<string, unknown> }): void {
|
||||||
@@ -60,6 +64,8 @@ export class TmuxPollingManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.activateFocusedPanes()
|
||||||
|
|
||||||
const statusResult = await this.client.session.status({ path: undefined })
|
const statusResult = await this.client.session.status({ path: undefined })
|
||||||
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
||||||
|
|
||||||
@@ -73,6 +79,29 @@ export class TmuxPollingManager {
|
|||||||
|
|
||||||
for (const [sessionId, tracked] of this.sessions.entries()) {
|
for (const [sessionId, tracked] of this.sessions.entries()) {
|
||||||
const status = allStatuses[sessionId]
|
const status = allStatuses[sessionId]
|
||||||
|
const elapsedMs = now - tracked.createdAt.getTime()
|
||||||
|
if (!tracked.attachActivated && !status) {
|
||||||
|
log("[tmux-session-manager] placeholder pane has not been activated yet; skipping close checks", {
|
||||||
|
sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
elapsedMs,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachElapsedMs = tracked.attachActivatedAt
|
||||||
|
? now - tracked.attachActivatedAt.getTime()
|
||||||
|
: undefined
|
||||||
|
if (tracked.attachActivated && !status && attachElapsedMs !== undefined && attachElapsedMs < SESSION_READY_TIMEOUT_MS) {
|
||||||
|
log("[tmux-session-manager] waiting for first post-activation session status", {
|
||||||
|
sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
attachElapsedMs,
|
||||||
|
graceMs: SESSION_READY_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const isIdle = status?.type === "idle"
|
const isIdle = status?.type === "idle"
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
@@ -81,8 +110,7 @@ export class TmuxPollingManager {
|
|||||||
|
|
||||||
const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0
|
const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0
|
||||||
const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS
|
const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS
|
||||||
const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS
|
const isTimedOut = elapsedMs > SESSION_TIMEOUT_MS
|
||||||
const elapsedMs = now - tracked.createdAt.getTime()
|
|
||||||
|
|
||||||
let shouldCloseViaStability = false
|
let shouldCloseViaStability = false
|
||||||
|
|
||||||
@@ -185,4 +213,42 @@ export class TmuxPollingManager {
|
|||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async activateFocusedPanes(): Promise<void> {
|
||||||
|
if (!this.getWindowState || !this.activateSessionPane || this.sessions.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await this.getWindowState().catch(() => null)
|
||||||
|
if (!state) return
|
||||||
|
if (this.canActivatePane && !this.canActivatePane(state)) {
|
||||||
|
log("[tmux-session-manager] activation gate blocked auto-attach", {
|
||||||
|
windowActive: state.windowActive,
|
||||||
|
sessionAttached: state.sessionAttached,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const panes = [state.mainPane, ...state.agentPanes].filter((pane): pane is NonNullable<typeof pane> => Boolean(pane))
|
||||||
|
const activePaneIds = new Set(panes.filter((pane) => pane.isActive).map((pane) => pane.paneId))
|
||||||
|
if (activePaneIds.size === 0) return
|
||||||
|
|
||||||
|
for (const tracked of this.sessions.values()) {
|
||||||
|
if (tracked.attachActivated) continue
|
||||||
|
if (!activePaneIds.has(tracked.paneId)) continue
|
||||||
|
|
||||||
|
const activated = await this.activateSessionPane(tracked)
|
||||||
|
if (activated) {
|
||||||
|
tracked.attachActivated = true
|
||||||
|
tracked.attachActivatedAt = new Date()
|
||||||
|
tracked.lastSeenAt = new Date()
|
||||||
|
tracked.stableIdlePolls = 0
|
||||||
|
tracked.observedIdleActivityVersion = tracked.activityVersion
|
||||||
|
log("[tmux-session-manager] activated focused pane", {
|
||||||
|
sessionId: tracked.sessionId,
|
||||||
|
paneId: tracked.paneId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export function createTrackedSession(params: {
|
|||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
paneId: params.paneId,
|
paneId: params.paneId,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
|
attachActivated: false,
|
||||||
|
attachActivatedAt: undefined,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastSeenAt: now,
|
lastSeenAt: now,
|
||||||
closePending: false,
|
closePending: false,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export interface TrackedSession {
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
paneId: string
|
paneId: string
|
||||||
description: string
|
description: string
|
||||||
|
attachActivated: boolean
|
||||||
|
attachActivatedAt?: Date
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
lastSeenAt: Date
|
lastSeenAt: Date
|
||||||
closePending: boolean
|
closePending: boolean
|
||||||
@@ -29,6 +31,8 @@ export interface TmuxPaneInfo {
|
|||||||
export interface WindowState {
|
export interface WindowState {
|
||||||
windowWidth: number
|
windowWidth: number
|
||||||
windowHeight: number
|
windowHeight: number
|
||||||
|
windowActive?: boolean
|
||||||
|
sessionAttached?: boolean
|
||||||
mainPane: TmuxPaneInfo | null
|
mainPane: TmuxPaneInfo | null
|
||||||
agentPanes: TmuxPaneInfo[]
|
agentPanes: TmuxPaneInfo[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ export type { PaneDimensions } from "./tmux-utils/pane-dimensions"
|
|||||||
export { spawnTmuxPane } from "./tmux-utils/pane-spawn"
|
export { spawnTmuxPane } from "./tmux-utils/pane-spawn"
|
||||||
export { closeTmuxPane } from "./tmux-utils/pane-close"
|
export { closeTmuxPane } from "./tmux-utils/pane-close"
|
||||||
export { replaceTmuxPane } from "./tmux-utils/pane-replace"
|
export { replaceTmuxPane } from "./tmux-utils/pane-replace"
|
||||||
|
export { activateTmuxPane } from "./tmux-utils/pane-activate"
|
||||||
export { spawnTmuxWindow } from "./tmux-utils/window-spawn"
|
export { spawnTmuxWindow } from "./tmux-utils/window-spawn"
|
||||||
export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn"
|
export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn"
|
||||||
export { killTmuxSessionIfExists } from "./tmux-utils/session-kill"
|
export { killTmuxSessionIfExists } from "./tmux-utils/session-kill"
|
||||||
export { sweepStaleOmoAgentSessions, sweepTmuxSessionsWith } from "./tmux-utils/stale-session-sweep"
|
export { sweepStaleOmoAgentSessions, sweepTmuxSessionsWith } from "./tmux-utils/stale-session-sweep"
|
||||||
|
export { buildTmuxAttachCommand, buildTmuxPlaceholderCommand } from "./tmux-utils/pane-command"
|
||||||
|
|
||||||
export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout"
|
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,58 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import { buildTmuxAttachCommand, buildTmuxPlaceholderCommand } from "./pane-command"
|
||||||
|
|
||||||
|
describe("buildTmuxAttachCommand", () => {
|
||||||
|
it("uses /bin/sh instead of inheriting SHELL", () => {
|
||||||
|
const originalShell = process.env.SHELL
|
||||||
|
process.env.SHELL = "/bin/tcsh"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cmd = buildTmuxAttachCommand("http://localhost:3000", "ses_abc123")
|
||||||
|
expect(cmd.startsWith('/bin/sh -c "')).toBe(true)
|
||||||
|
expect(cmd).not.toContain("/bin/tcsh -c")
|
||||||
|
} finally {
|
||||||
|
process.env.SHELL = originalShell
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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("uses /bin/sh instead of inheriting SHELL", () => {
|
||||||
|
const originalShell = process.env.SHELL
|
||||||
|
process.env.SHELL = "/bin/csh"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cmd = buildTmuxPlaceholderCommand("My Task")
|
||||||
|
expect(cmd.startsWith('/bin/sh -c "')).toBe(true)
|
||||||
|
expect(cmd).not.toContain("/bin/csh -c")
|
||||||
|
} finally {
|
||||||
|
process.env.SHELL = originalShell
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = process.cwd()): 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"`
|
||||||
|
}
|
||||||
@@ -112,21 +112,23 @@ describe("replaceTmuxPane runner integration", () => {
|
|||||||
expect(sendKeysCall[1]).toEqual(["send-keys", "-t", "%42", "C-c"])
|
expect(sendKeysCall[1]).toEqual(["send-keys", "-t", "%42", "C-c"])
|
||||||
expect(respawnCall[1].slice(0, 4)).toEqual(["respawn-pane", "-k", "-t", "%42"])
|
expect(respawnCall[1].slice(0, 4)).toEqual(["respawn-pane", "-k", "-t", "%42"])
|
||||||
expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
||||||
expect(getRespawnCommand()).toContain(` --dir '${directory}'`)
|
expect(getRespawnCommand()).toContain("Focus this pane to attach.")
|
||||||
|
expect(getRespawnCommand()).toContain("tail -f /dev/null")
|
||||||
|
expect(getRespawnCommand()).not.toContain("opencode attach")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given directory with spaces #when replaceTmuxPane called #then wraps --dir value in single quotes", async () => {
|
it("#given description with spaces #when replaceTmuxPane called #then includes it in the placeholder", async () => {
|
||||||
// given
|
// given
|
||||||
const replaceTmuxPane = await loadReplaceTmuxPane()
|
const replaceTmuxPane = await loadReplaceTmuxPane()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", createDeps())
|
await replaceTmuxPane("%42", "session-1", "worker with spaces", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", createDeps())
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(getRespawnCommand()).toContain("--dir '/path with spaces/here'")
|
expect(getRespawnCommand()).toContain("OMO subagent pane ready: worker with spaces")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given empty directory #when replaceTmuxPane called #then falls back to process cwd", async () => {
|
it("#given empty directory #when replaceTmuxPane called #then keeps the placeholder detached from attach", async () => {
|
||||||
// given
|
// given
|
||||||
const replaceTmuxPane = await loadReplaceTmuxPane()
|
const replaceTmuxPane = await loadReplaceTmuxPane()
|
||||||
|
|
||||||
@@ -134,17 +136,18 @@ describe("replaceTmuxPane runner integration", () => {
|
|||||||
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", createDeps())
|
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", createDeps())
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(getRespawnCommand()).toContain(`--dir '${process.cwd()}'`)
|
expect(getRespawnCommand()).not.toContain("--dir")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given directory with single quotes #when replaceTmuxPane called #then escapes the value with POSIX-safe single quoting", async () => {
|
it("#given description with shell metacharacters #when replaceTmuxPane called #then escapes the placeholder", async () => {
|
||||||
// given
|
// given
|
||||||
const replaceTmuxPane = await loadReplaceTmuxPane()
|
const replaceTmuxPane = await loadReplaceTmuxPane()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", createDeps())
|
await replaceTmuxPane("%42", "session-1", 'worker "$(whoami)"', enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", createDeps())
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(getRespawnCommand()).toContain("--dir '/path/with'\\''quote'")
|
expect(getRespawnCommand()).toContain('\\"')
|
||||||
|
expect(getRespawnCommand()).toContain("\\$")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
|||||||
import type { SpawnPaneResult } from "../types"
|
import type { SpawnPaneResult } from "../types"
|
||||||
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
||||||
import { isInsideTmux } from "./environment"
|
import { isInsideTmux } from "./environment"
|
||||||
import { shellSingleQuote } from "../../shell-env"
|
import { buildTmuxPlaceholderCommand } from "./pane-command"
|
||||||
|
|
||||||
type ReplaceTmuxPaneDeps = {
|
type ReplaceTmuxPaneDeps = {
|
||||||
log: (message: string, data?: unknown) => void
|
log: (message: string, data?: unknown) => void
|
||||||
@@ -32,8 +32,8 @@ export async function replaceTmuxPane(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
description: string,
|
description: string,
|
||||||
config: TmuxConfig,
|
config: TmuxConfig,
|
||||||
serverUrl: string,
|
_serverUrl: string,
|
||||||
directory: string,
|
_directory: string,
|
||||||
depsInput?: Partial<ReplaceTmuxPaneDeps>,
|
depsInput?: Partial<ReplaceTmuxPaneDeps>,
|
||||||
): Promise<SpawnPaneResult> {
|
): Promise<SpawnPaneResult> {
|
||||||
const deps = await resolveReplaceTmuxPaneDeps(depsInput)
|
const deps = await resolveReplaceTmuxPaneDeps(depsInput)
|
||||||
@@ -56,10 +56,9 @@ export async function replaceTmuxPane(
|
|||||||
log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
|
log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
|
||||||
await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"])
|
await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"])
|
||||||
|
|
||||||
const effectiveDirectory = directory || process.cwd()
|
const placeholderCmd = buildTmuxPlaceholderCommand(description)
|
||||||
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
|
|
||||||
|
|
||||||
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) {
|
if (result.exitCode !== 0) {
|
||||||
log("[replaceTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: result.stderr.trim() })
|
log("[replaceTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: result.stderr.trim() })
|
||||||
|
|||||||
@@ -115,21 +115,23 @@ describe("spawnTmuxPane runner integration", () => {
|
|||||||
expect(result).toEqual({ success: true, paneId: "%42" })
|
expect(result).toEqual({ success: true, paneId: "%42" })
|
||||||
expect(firstCall[1].slice(0, 8)).toEqual(["split-window", "-h", "-d", "-P", "-F", "#{pane_id}", "-t", "%0"])
|
expect(firstCall[1].slice(0, 8)).toEqual(["split-window", "-h", "-d", "-P", "-F", "#{pane_id}", "-t", "%0"])
|
||||||
expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
||||||
expect(getSplitWindowCommand()).toContain(` --dir '${directory}'`)
|
expect(getSplitWindowCommand()).toContain("Focus this pane to attach.")
|
||||||
|
expect(getSplitWindowCommand()).toContain("tail -f /dev/null")
|
||||||
|
expect(getSplitWindowCommand()).not.toContain("opencode attach")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given directory with spaces #when spawnTmuxPane called #then wraps --dir value in single quotes", async () => {
|
it("#given description with spaces #when spawnTmuxPane called #then includes it in the placeholder", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnTmuxPane = await loadSpawnTmuxPane()
|
const spawnTmuxPane = await loadSpawnTmuxPane()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", "-h", createDeps())
|
await spawnTmuxPane("session-1", "worker with spaces", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", "-h", createDeps())
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(getSplitWindowCommand()).toContain("--dir '/path with spaces/here'")
|
expect(getSplitWindowCommand()).toContain("OMO subagent pane ready: worker with spaces")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given empty directory #when spawnTmuxPane called #then falls back to process cwd", async () => {
|
it("#given empty directory #when spawnTmuxPane called #then keeps the placeholder detached from attach", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnTmuxPane = await loadSpawnTmuxPane()
|
const spawnTmuxPane = await loadSpawnTmuxPane()
|
||||||
|
|
||||||
@@ -137,17 +139,18 @@ describe("spawnTmuxPane runner integration", () => {
|
|||||||
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", "-h", createDeps())
|
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", "-h", createDeps())
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(getSplitWindowCommand()).toContain(`--dir '${process.cwd()}'`)
|
expect(getSplitWindowCommand()).not.toContain("--dir")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given directory with single quotes #when spawnTmuxPane called #then escapes the value with POSIX-safe single quoting", async () => {
|
it("#given description with shell metacharacters #when spawnTmuxPane called #then escapes the placeholder", async () => {
|
||||||
// given
|
// given
|
||||||
const spawnTmuxPane = await loadSpawnTmuxPane()
|
const spawnTmuxPane = await loadSpawnTmuxPane()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", "-h", createDeps())
|
await spawnTmuxPane("session-1", 'worker "$(whoami)"', enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", "-h", createDeps())
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(getSplitWindowCommand()).toContain("--dir '/path/with'\\''quote'")
|
expect(getSplitWindowCommand()).toContain('\\"')
|
||||||
|
expect(getSplitWindowCommand()).toContain("\\$")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
|||||||
import type { SplitDirection } from "./environment"
|
import type { SplitDirection } from "./environment"
|
||||||
import { isInsideTmux } from "./environment"
|
import { isInsideTmux } from "./environment"
|
||||||
import { isServerRunning } from "./server-health"
|
import { isServerRunning } from "./server-health"
|
||||||
import { shellSingleQuote } from "../../shell-env"
|
import { buildTmuxPlaceholderCommand } from "./pane-command"
|
||||||
|
|
||||||
type SpawnTmuxPaneDeps = {
|
type SpawnTmuxPaneDeps = {
|
||||||
log: (message: string, data?: unknown) => void
|
log: (message: string, data?: unknown) => void
|
||||||
@@ -36,7 +36,7 @@ export async function spawnTmuxPane(
|
|||||||
description: string,
|
description: string,
|
||||||
config: TmuxConfig,
|
config: TmuxConfig,
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
directory: string,
|
_directory: string,
|
||||||
targetPaneId?: string,
|
targetPaneId?: string,
|
||||||
splitDirection: SplitDirection = "-h",
|
splitDirection: SplitDirection = "-h",
|
||||||
depsInput?: Partial<SpawnTmuxPaneDeps>,
|
depsInput?: Partial<SpawnTmuxPaneDeps>,
|
||||||
@@ -76,8 +76,7 @@ export async function spawnTmuxPane(
|
|||||||
|
|
||||||
log("[spawnTmuxPane] all checks passed, spawning...")
|
log("[spawnTmuxPane] all checks passed, spawning...")
|
||||||
|
|
||||||
const effectiveDirectory = directory || process.cwd()
|
const placeholderCmd = buildTmuxPlaceholderCommand(description)
|
||||||
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
|
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"split-window",
|
"split-window",
|
||||||
@@ -87,7 +86,7 @@ export async function spawnTmuxPane(
|
|||||||
"-F",
|
"-F",
|
||||||
"#{pane_id}",
|
"#{pane_id}",
|
||||||
...(targetPaneId ? ["-t", targetPaneId] : []),
|
...(targetPaneId ? ["-t", targetPaneId] : []),
|
||||||
opencodeCmd,
|
placeholderCmd,
|
||||||
]
|
]
|
||||||
|
|
||||||
const result = await runTmuxCommand(tmux, args)
|
const result = await runTmuxCommand(tmux, args)
|
||||||
|
|||||||
@@ -102,21 +102,23 @@ describe("spawnTmuxSession runner integration", () => {
|
|||||||
expect(newSessionCall[1].slice(0, 4)).toEqual(["new-session", "-d", "-s", newSessionCall[1][3]])
|
expect(newSessionCall[1].slice(0, 4)).toEqual(["new-session", "-d", "-s", newSessionCall[1][3]])
|
||||||
expect(String(newSessionCall[1][3]).startsWith("omo-agents-")).toBe(true)
|
expect(String(newSessionCall[1][3]).startsWith("omo-agents-")).toBe(true)
|
||||||
expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
||||||
expect(harness.getSpawnCommand()).toContain(` --dir '${directory}'`)
|
expect(harness.getSpawnCommand()).toContain("Focus this pane to attach.")
|
||||||
|
expect(harness.getSpawnCommand()).toContain("tail -f /dev/null")
|
||||||
|
expect(harness.getSpawnCommand()).not.toContain("opencode attach")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given directory with spaces #when spawnTmuxSession called #then wraps --dir value in single quotes", async () => {
|
it("#given description with spaces #when spawnTmuxSession called #then includes it in the placeholder", async () => {
|
||||||
// given
|
// given
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", harness.deps)
|
await spawnTmuxSession("session-1", "worker with spaces", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", harness.deps)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(harness.getSpawnCommand()).toContain("--dir '/path with spaces/here'")
|
expect(harness.getSpawnCommand()).toContain("OMO subagent pane ready: worker with spaces")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given empty directory #when spawnTmuxSession called #then falls back to process cwd", async () => {
|
it("#given empty directory #when spawnTmuxSession called #then keeps the placeholder detached from attach", async () => {
|
||||||
// given
|
// given
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
|
|
||||||
@@ -124,17 +126,18 @@ describe("spawnTmuxSession runner integration", () => {
|
|||||||
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", harness.deps)
|
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", harness.deps)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(harness.getSpawnCommand()).toContain(`--dir '${process.cwd()}'`)
|
expect(harness.getSpawnCommand()).not.toContain("--dir")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given directory with single quotes #when spawnTmuxSession called #then escapes the value with POSIX-safe single quoting", async () => {
|
it("#given description with shell metacharacters #when spawnTmuxSession called #then escapes the placeholder", async () => {
|
||||||
// given
|
// given
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", harness.deps)
|
await spawnTmuxSession("session-1", 'worker "$(whoami)"', enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", harness.deps)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(harness.getSpawnCommand()).toContain("--dir '/path/with'\\''quote'")
|
expect(harness.getSpawnCommand()).toContain('\\"')
|
||||||
|
expect(harness.getSpawnCommand()).toContain("\\$")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { SpawnPaneResult } from "../types"
|
|||||||
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
||||||
import { isInsideTmux } from "./environment"
|
import { isInsideTmux } from "./environment"
|
||||||
import { isServerRunning } from "./server-health"
|
import { isServerRunning } from "./server-health"
|
||||||
import { shellSingleQuote } from "../../shell-env"
|
import { buildTmuxPlaceholderCommand } from "./pane-command"
|
||||||
|
|
||||||
const ISOLATED_SESSION_NAME_PREFIX = "omo-agents"
|
const ISOLATED_SESSION_NAME_PREFIX = "omo-agents"
|
||||||
|
|
||||||
@@ -32,8 +32,10 @@ async function resolveSpawnTmuxSessionDeps(deps?: Partial<SpawnTmuxSessionDeps>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIsolatedSessionName(pid: number = process.pid): string {
|
export function getIsolatedSessionName(pid: number = process.pid, managerId?: string): string {
|
||||||
return `${ISOLATED_SESSION_NAME_PREFIX}-${pid}`
|
return managerId
|
||||||
|
? `${ISOLATED_SESSION_NAME_PREFIX}-${pid}-${managerId}`
|
||||||
|
: `${ISOLATED_SESSION_NAME_PREFIX}-${pid}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWindowDimensions(
|
async function getWindowDimensions(
|
||||||
@@ -61,9 +63,10 @@ export async function spawnTmuxSession(
|
|||||||
description: string,
|
description: string,
|
||||||
config: TmuxConfig,
|
config: TmuxConfig,
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
directory: string,
|
_directory: string,
|
||||||
sourcePaneId?: string,
|
sourcePaneId?: string,
|
||||||
depsInput?: Partial<SpawnTmuxSessionDeps>,
|
depsInput?: Partial<SpawnTmuxSessionDeps>,
|
||||||
|
managerId?: string,
|
||||||
): Promise<SpawnPaneResult> {
|
): Promise<SpawnPaneResult> {
|
||||||
const deps = await resolveSpawnTmuxSessionDeps(depsInput)
|
const deps = await resolveSpawnTmuxSessionDeps(depsInput)
|
||||||
const { log, runTmuxCommand } = deps
|
const { log, runTmuxCommand } = deps
|
||||||
@@ -98,8 +101,7 @@ export async function spawnTmuxSession(
|
|||||||
|
|
||||||
log("[spawnTmuxSession] all checks passed, creating isolated session...")
|
log("[spawnTmuxSession] all checks passed, creating isolated session...")
|
||||||
|
|
||||||
const effectiveDirectory = directory || process.cwd()
|
const placeholderCmd = buildTmuxPlaceholderCommand(description)
|
||||||
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
|
|
||||||
|
|
||||||
const sizeArgs: string[] = []
|
const sizeArgs: string[] = []
|
||||||
if (sourcePaneId) {
|
if (sourcePaneId) {
|
||||||
@@ -109,7 +111,7 @@ export async function spawnTmuxSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isolatedSessionName = getIsolatedSessionName()
|
const isolatedSessionName = getIsolatedSessionName(process.pid, managerId)
|
||||||
const sessionAlreadyExists = await sessionExists(tmux, isolatedSessionName, runTmuxCommand)
|
const sessionAlreadyExists = await sessionExists(tmux, isolatedSessionName, runTmuxCommand)
|
||||||
|
|
||||||
const args = sessionAlreadyExists
|
const args = sessionAlreadyExists
|
||||||
@@ -118,7 +120,7 @@ export async function spawnTmuxSession(
|
|||||||
"-t", isolatedSessionName,
|
"-t", isolatedSessionName,
|
||||||
"-P",
|
"-P",
|
||||||
"-F", "#{pane_id}",
|
"-F", "#{pane_id}",
|
||||||
opencodeCmd,
|
placeholderCmd,
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
"new-session",
|
"new-session",
|
||||||
@@ -127,7 +129,7 @@ export async function spawnTmuxSession(
|
|||||||
...sizeArgs,
|
...sizeArgs,
|
||||||
"-P",
|
"-P",
|
||||||
"-F", "#{pane_id}",
|
"-F", "#{pane_id}",
|
||||||
opencodeCmd,
|
placeholderCmd,
|
||||||
]
|
]
|
||||||
|
|
||||||
log("[spawnTmuxSession] spawning", {
|
log("[spawnTmuxSession] spawning", {
|
||||||
|
|||||||
@@ -99,6 +99,19 @@ describe("sweepStaleOmoAgentSessionsWith", () => {
|
|||||||
expect(fixture.killed).toEqual(["omo-agents-99991", "omo-agents-99992"])
|
expect(fixture.killed).toEqual(["omo-agents-99991", "omo-agents-99992"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("#given suffixed sessions with dead PIDs #when sweep called #then they are also killed", async () => {
|
||||||
|
// given
|
||||||
|
fixture.setCandidates(["omo-agents-99991-1", "omo-agents-99992-abc123"])
|
||||||
|
fixture.setAlive(() => false)
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await sweepStaleOmoAgentSessionsWith(fixture.deps)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(2)
|
||||||
|
expect(fixture.killed).toEqual(["omo-agents-99991-1", "omo-agents-99992-abc123"])
|
||||||
|
})
|
||||||
|
|
||||||
it("#given session matches current PID #when sweep called #then it is NOT killed", async () => {
|
it("#given session matches current PID #when sweep called #then it is NOT killed", async () => {
|
||||||
// given
|
// given
|
||||||
fixture.setCandidates([`omo-agents-${fixture.deps.currentPid}`, "omo-agents-99999"])
|
fixture.setCandidates([`omo-agents-${fixture.deps.currentPid}`, "omo-agents-99999"])
|
||||||
@@ -139,9 +152,9 @@ describe("sweepStaleOmoAgentSessionsWith", () => {
|
|||||||
expect(fixture.killSessionMock).toHaveBeenCalledTimes(1)
|
expect(fixture.killSessionMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given non-matching sessions mixed in #when sweep called #then only omo-agents-<pid> sessions are considered", async () => {
|
it("#given non-matching sessions mixed in #when sweep called #then only supported omo-agents session names are considered", async () => {
|
||||||
// given
|
// given
|
||||||
fixture.setCandidates(["main", "omo-agents-99999", "other-session", "omo-agents-abc"])
|
fixture.setCandidates(["main", "omo-agents-99999", "omo-agents-99999-1-2", "other-session", "omo-agents-abc"])
|
||||||
fixture.setAlive(() => false)
|
fixture.setAlive(() => false)
|
||||||
|
|
||||||
// when
|
// when
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const STALE_SESSION_PATTERN = /^omo-agents-(\d+)$/
|
const STALE_SESSION_PATTERN = /^omo-agents-(\d+)(?:-([A-Za-z0-9]+))?$/
|
||||||
|
|
||||||
function getErrorMessage(error: unknown): string {
|
function getErrorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|||||||
@@ -90,21 +90,23 @@ describe("spawnTmuxWindow runner integration", () => {
|
|||||||
expect(result).toEqual({ success: true, paneId: "%42" })
|
expect(result).toEqual({ success: true, paneId: "%42" })
|
||||||
expect(firstCall[1].slice(0, 7)).toEqual(["new-window", "-d", "-n", "omo-agents", "-P", "-F", "#{pane_id}"])
|
expect(firstCall[1].slice(0, 7)).toEqual(["new-window", "-d", "-n", "omo-agents", "-P", "-F", "#{pane_id}"])
|
||||||
expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
||||||
expect(harness.getNewWindowCommand()).toContain(` --dir '${directory}'`)
|
expect(harness.getNewWindowCommand()).toContain("Focus this pane to attach.")
|
||||||
|
expect(harness.getNewWindowCommand()).toContain("tail -f /dev/null")
|
||||||
|
expect(harness.getNewWindowCommand()).not.toContain("opencode attach")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given directory with spaces #when spawnTmuxWindow called #then wraps --dir value in single quotes", async () => {
|
it("#given description with spaces #when spawnTmuxWindow called #then includes it in the placeholder", async () => {
|
||||||
// given
|
// given
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", harness.deps)
|
await spawnTmuxWindow("session-1", "worker with spaces", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", harness.deps)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(harness.getNewWindowCommand()).toContain("--dir '/path with spaces/here'")
|
expect(harness.getNewWindowCommand()).toContain("OMO subagent pane ready: worker with spaces")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given empty directory #when spawnTmuxWindow called #then falls back to process cwd", async () => {
|
it("#given empty directory #when spawnTmuxWindow called #then keeps the placeholder detached from attach", async () => {
|
||||||
// given
|
// given
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
|
|
||||||
@@ -112,17 +114,18 @@ describe("spawnTmuxWindow runner integration", () => {
|
|||||||
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", harness.deps)
|
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", harness.deps)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(harness.getNewWindowCommand()).toContain(`--dir '${process.cwd()}'`)
|
expect(harness.getNewWindowCommand()).not.toContain("--dir")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given directory with single quotes #when spawnTmuxWindow called #then escapes the value with POSIX-safe single quoting", async () => {
|
it("#given description with shell metacharacters #when spawnTmuxWindow called #then escapes the placeholder", async () => {
|
||||||
// given
|
// given
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
|
|
||||||
// when
|
// when
|
||||||
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", harness.deps)
|
await spawnTmuxWindow("session-1", 'worker "$(whoami)"', enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", harness.deps)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(harness.getNewWindowCommand()).toContain("--dir '/path/with'\\''quote'")
|
expect(harness.getNewWindowCommand()).toContain('\\"')
|
||||||
|
expect(harness.getNewWindowCommand()).toContain("\\$")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
|||||||
import type { SpawnPaneResult } from "../types"
|
import type { SpawnPaneResult } from "../types"
|
||||||
import { isInsideTmux } from "./environment"
|
import { isInsideTmux } from "./environment"
|
||||||
import { isServerRunning } from "./server-health"
|
import { isServerRunning } from "./server-health"
|
||||||
import { shellSingleQuote } from "../../shell-env"
|
|
||||||
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
|
||||||
|
import { buildTmuxPlaceholderCommand } from "./pane-command"
|
||||||
|
|
||||||
const ISOLATED_WINDOW_NAME = "omo-agents"
|
const ISOLATED_WINDOW_NAME = "omo-agents"
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export async function spawnTmuxWindow(
|
|||||||
description: string,
|
description: string,
|
||||||
config: TmuxConfig,
|
config: TmuxConfig,
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
directory: string,
|
_directory: string,
|
||||||
depsInput?: Partial<SpawnTmuxWindowDeps>,
|
depsInput?: Partial<SpawnTmuxWindowDeps>,
|
||||||
): Promise<SpawnPaneResult> {
|
): Promise<SpawnPaneResult> {
|
||||||
const deps = await resolveSpawnTmuxWindowDeps(depsInput)
|
const deps = await resolveSpawnTmuxWindowDeps(depsInput)
|
||||||
@@ -73,8 +73,7 @@ export async function spawnTmuxWindow(
|
|||||||
|
|
||||||
log("[spawnTmuxWindow] all checks passed, creating isolated window...")
|
log("[spawnTmuxWindow] all checks passed, creating isolated window...")
|
||||||
|
|
||||||
const effectiveDirectory = directory || process.cwd()
|
const placeholderCmd = buildTmuxPlaceholderCommand(description)
|
||||||
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
|
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"new-window",
|
"new-window",
|
||||||
@@ -82,7 +81,7 @@ export async function spawnTmuxWindow(
|
|||||||
"-n", ISOLATED_WINDOW_NAME,
|
"-n", ISOLATED_WINDOW_NAME,
|
||||||
"-P",
|
"-P",
|
||||||
"-F", "#{pane_id}",
|
"-F", "#{pane_id}",
|
||||||
opencodeCmd,
|
placeholderCmd,
|
||||||
]
|
]
|
||||||
|
|
||||||
const result = await runTmuxCommand(tmux, args)
|
const result = await runTmuxCommand(tmux, args)
|
||||||
|
|||||||
Reference in New Issue
Block a user