refactor: wave 2 - split atlas, auto-update-checker, session-recovery, todo-enforcer, background-task hooks

- Extract atlas/ into 15 focused modules (hook, event handler, tool policies, types, etc.)
- Split auto-update-checker into checker/ and hook/ subdirectories with single-purpose files
- Decompose session-recovery into separate recovery strategy files per error type
- Extract todo-continuation-enforcer from monolith to directory with dedicated modules
- Split background-task/tools.ts into individual tool creator files
- Extract command-executor, tmux-utils into focused sub-modules
- Split config/schema.ts into domain-specific schema files
- Decompose cli/config-manager.ts into focused modules
- Rollback skill-mcp-manager, model-availability, index.ts splits that broke tests
- Fix all import path depths for moved files (../../ -> ../../../)
- Add explicit type annotations to resolve TS7006 implicit any errors

Typecheck: 0 errors
Tests: 2359 pass, 5 fail (all pre-existing)
This commit is contained in:
YeonGyu-Kim
2026-02-08 15:01:42 +09:00
parent 29155ec7bc
commit 119e18c810
158 changed files with 7806 additions and 7050 deletions
+4 -224
View File
@@ -1,225 +1,5 @@
import { spawn } from "child_process"
import { exec } from "child_process"
import { promisify } from "util"
import { existsSync } from "fs"
import { homedir } from "os"
export { executeHookCommand } from "./command-executor/execute-hook-command"
export type { CommandResult, ExecuteHookOptions } from "./command-executor/execute-hook-command"
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir()
}
function findShellPath(defaultPaths: string[], customPath?: string): string | null {
if (customPath && existsSync(customPath)) {
return customPath
}
for (const path of defaultPaths) {
if (existsSync(path)) {
return path
}
}
return null
}
function findZshPath(customZshPath?: string): string | null {
return findShellPath(DEFAULT_ZSH_PATHS, customZshPath)
}
function findBashPath(): string | null {
return findShellPath(DEFAULT_BASH_PATHS)
}
const execAsync = promisify(exec)
export interface CommandResult {
exitCode: number
stdout?: string
stderr?: string
}
export interface ExecuteHookOptions {
forceZsh?: boolean
zshPath?: string
}
/**
* Execute a hook command with stdin input
*/
export async function executeHookCommand(
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions
): Promise<CommandResult> {
const home = getHomeDir()
let expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
let finalCommand = expandedCommand
if (options?.forceZsh) {
// Always verify shell exists before using it
const zshPath = findZshPath(options.zshPath)
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'`
} else {
// Fall back to bash login shell to preserve PATH from user profile
const bashPath = findBashPath()
if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'`
}
// If neither zsh nor bash found, fall through to spawn with shell: true
}
}
return new Promise((resolve) => {
const proc = spawn(finalCommand, {
cwd,
shell: true,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
})
let stdout = ""
let stderr = ""
proc.stdout?.on("data", (data) => {
stdout += data.toString()
})
proc.stderr?.on("data", (data) => {
stderr += data.toString()
})
proc.stdin?.write(stdin)
proc.stdin?.end()
proc.on("close", (code) => {
resolve({
exitCode: code ?? 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
})
})
proc.on("error", (err) => {
resolve({
exitCode: 1,
stderr: err.message,
})
})
})
}
/**
* Execute a simple command and return output
*/
export async function executeCommand(command: string): Promise<string> {
try {
const { stdout, stderr } = await execAsync(command)
const out = stdout?.toString().trim() ?? ""
const err = stderr?.toString().trim() ?? ""
if (err) {
if (out) {
return `${out}\n[stderr: ${err}]`
}
return `[stderr: ${err}]`
}
return out
} catch (error: unknown) {
const e = error as { stdout?: Buffer; stderr?: Buffer; message?: string }
const stdout = e?.stdout?.toString().trim() ?? ""
const stderr = e?.stderr?.toString().trim() ?? ""
const errMsg = stderr || e?.message || String(error)
if (stdout) {
return `${stdout}\n[stderr: ${errMsg}]`
}
return `[stderr: ${errMsg}]`
}
}
/**
* Find and execute embedded commands in text (!`command`)
*/
interface CommandMatch {
fullMatch: string
command: string
start: number
end: number
}
const COMMAND_PATTERN = /!`([^`]+)`/g
function findCommands(text: string): CommandMatch[] {
const matches: CommandMatch[] = []
let match: RegExpExecArray | null
COMMAND_PATTERN.lastIndex = 0
while ((match = COMMAND_PATTERN.exec(text)) !== null) {
matches.push({
fullMatch: match[0],
command: match[1],
start: match.index,
end: match.index + match[0].length,
})
}
return matches
}
/**
* Resolve embedded commands in text recursively
*/
export async function resolveCommandsInText(
text: string,
depth: number = 0,
maxDepth: number = 3
): Promise<string> {
if (depth >= maxDepth) {
return text
}
const matches = findCommands(text)
if (matches.length === 0) {
return text
}
const tasks = matches.map((m) => executeCommand(m.command))
const results = await Promise.allSettled(tasks)
const replacements = new Map<string, string>()
matches.forEach((match, idx) => {
const result = results[idx]
if (result.status === "rejected") {
replacements.set(
match.fullMatch,
`[error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}]`
)
} else {
replacements.set(match.fullMatch, result.value)
}
})
let resolved = text
for (const [pattern, replacement] of replacements.entries()) {
resolved = resolved.split(pattern).join(replacement)
}
if (findCommands(resolved).length > 0) {
return resolveCommandsInText(resolved, depth + 1, maxDepth)
}
return resolved
}
export { executeCommand } from "./command-executor/execute-command"
export { resolveCommandsInText } from "./command-executor/resolve-commands-in-text"
@@ -0,0 +1,26 @@
export interface CommandMatch {
fullMatch: string
command: string
start: number
end: number
}
const COMMAND_PATTERN = /!`([^`]+)`/g
export function findEmbeddedCommands(text: string): CommandMatch[] {
const matches: CommandMatch[] = []
let match: RegExpExecArray | null
COMMAND_PATTERN.lastIndex = 0
while ((match = COMMAND_PATTERN.exec(text)) !== null) {
matches.push({
fullMatch: match[0],
command: match[1],
start: match.index,
end: match.index + match[0].length,
})
}
return matches
}
@@ -0,0 +1,28 @@
import { exec } from "node:child_process"
import { promisify } from "node:util"
const execAsync = promisify(exec)
type ExecError = { stdout?: Buffer; stderr?: Buffer; message?: string }
export async function executeCommand(command: string): Promise<string> {
try {
const { stdout, stderr } = await execAsync(command)
const out = stdout?.toString().trim() ?? ""
const err = stderr?.toString().trim() ?? ""
if (err) {
return out ? `${out}\n[stderr: ${err}]` : `[stderr: ${err}]`
}
return out
} catch (error: unknown) {
const e = error as ExecError
const stdout = e?.stdout?.toString().trim() ?? ""
const stderr = e?.stderr?.toString().trim() ?? ""
const errorMessage = stderr || e?.message || String(error)
return stdout ? `${stdout}\n[stderr: ${errorMessage}]` : `[stderr: ${errorMessage}]`
}
}
@@ -0,0 +1,78 @@
import { spawn } from "node:child_process"
import { getHomeDirectory } from "./home-directory"
import { findBashPath, findZshPath } from "./shell-path"
export interface CommandResult {
exitCode: number
stdout?: string
stderr?: string
}
export interface ExecuteHookOptions {
forceZsh?: boolean
zshPath?: string
}
export async function executeHookCommand(
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions,
): Promise<CommandResult> {
const home = getHomeDirectory()
const expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
let finalCommand = expandedCommand
if (options?.forceZsh) {
const zshPath = findZshPath(options.zshPath)
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
if (zshPath) {
finalCommand = `${zshPath} -lc '${escapedCommand}'`
} else {
const bashPath = findBashPath()
if (bashPath) {
finalCommand = `${bashPath} -lc '${escapedCommand}'`
}
}
}
return new Promise((resolve) => {
const proc = spawn(finalCommand, {
cwd,
shell: true,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
})
let stdout = ""
let stderr = ""
proc.stdout?.on("data", (data) => {
stdout += data.toString()
})
proc.stderr?.on("data", (data) => {
stderr += data.toString()
})
proc.stdin?.write(stdin)
proc.stdin?.end()
proc.on("close", (code) => {
resolve({
exitCode: code ?? 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
})
})
proc.on("error", (err) => {
resolve({ exitCode: 1, stderr: err.message })
})
})
}
@@ -0,0 +1,5 @@
import { homedir } from "node:os"
export function getHomeDirectory(): string {
return process.env.HOME || process.env.USERPROFILE || homedir()
}
@@ -0,0 +1,49 @@
import { executeCommand } from "./execute-command"
import { findEmbeddedCommands } from "./embedded-commands"
export async function resolveCommandsInText(
text: string,
depth: number = 0,
maxDepth: number = 3,
): Promise<string> {
if (depth >= maxDepth) {
return text
}
const matches = findEmbeddedCommands(text)
if (matches.length === 0) {
return text
}
const tasks = matches.map((m) => executeCommand(m.command))
const results = await Promise.allSettled(tasks)
const replacements = new Map<string, string>()
matches.forEach((match, idx) => {
const result = results[idx]
if (result.status === "rejected") {
replacements.set(
match.fullMatch,
`[error: ${
result.reason instanceof Error
? result.reason.message
: String(result.reason)
}]`,
)
} else {
replacements.set(match.fullMatch, result.value)
}
})
let resolved = text
for (const [pattern, replacement] of replacements.entries()) {
resolved = resolved.split(pattern).join(replacement)
}
if (findEmbeddedCommands(resolved).length > 0) {
return resolveCommandsInText(resolved, depth + 1, maxDepth)
}
return resolved
}
+27
View File
@@ -0,0 +1,27 @@
import { existsSync } from "node:fs"
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
function findShellPath(
defaultPaths: string[],
customPath?: string,
): string | null {
if (customPath && existsSync(customPath)) {
return customPath
}
for (const path of defaultPaths) {
if (existsSync(path)) {
return path
}
}
return null
}
export function findZshPath(customZshPath?: string): string | null {
return findShellPath(DEFAULT_ZSH_PATHS, customZshPath)
}
export function findBashPath(): string | null {
return findShellPath(DEFAULT_BASH_PATHS)
}
+9 -308
View File
@@ -1,312 +1,13 @@
import { spawn } from "bun"
import type { TmuxConfig, TmuxLayout } from "../../config/schema"
import type { SpawnPaneResult } from "./types"
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
export { isInsideTmux, getCurrentPaneId } from "./tmux-utils/environment"
export type { SplitDirection } from "./tmux-utils/environment"
let serverAvailable: boolean | null = null
let serverCheckUrl: string | null = null
export { isServerRunning, resetServerCheck } from "./tmux-utils/server-health"
export function isInsideTmux(): boolean {
return !!process.env.TMUX
}
export { getPaneDimensions } from "./tmux-utils/pane-dimensions"
export type { PaneDimensions } from "./tmux-utils/pane-dimensions"
export async function isServerRunning(serverUrl: string): Promise<boolean> {
if (serverCheckUrl === serverUrl && serverAvailable === true) {
return true
}
export { spawnTmuxPane } from "./tmux-utils/pane-spawn"
export { closeTmuxPane } from "./tmux-utils/pane-close"
export { replaceTmuxPane } from "./tmux-utils/pane-replace"
const healthUrl = new URL("/health", serverUrl).toString()
const timeoutMs = 3000
const maxAttempts = 2
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(healthUrl, { signal: controller.signal }).catch(
() => null
)
clearTimeout(timeout)
if (response?.ok) {
serverCheckUrl = serverUrl
serverAvailable = true
return true
}
} finally {
clearTimeout(timeout)
}
if (attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, 250))
}
}
return false
}
export function resetServerCheck(): void {
serverAvailable = null
serverCheckUrl = null
}
export type SplitDirection = "-h" | "-v"
export function getCurrentPaneId(): string | undefined {
return process.env.TMUX_PANE
}
export interface PaneDimensions {
paneWidth: number
windowWidth: number
}
export async function getPaneDimensions(paneId: string): Promise<PaneDimensions | null> {
const tmux = await getTmuxPath()
if (!tmux) return null
const proc = spawn([tmux, "display", "-p", "-t", paneId, "#{pane_width},#{window_width}"], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
if (exitCode !== 0) return null
const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number)
if (isNaN(paneWidth) || isNaN(windowWidth)) return null
return { paneWidth, windowWidth }
}
export async function spawnTmuxPane(
sessionId: string,
description: string,
config: TmuxConfig,
serverUrl: string,
targetPaneId?: string,
splitDirection: SplitDirection = "-h"
): Promise<SpawnPaneResult> {
const { log } = await import("../logger")
log("[spawnTmuxPane] called", { sessionId, description, serverUrl, configEnabled: config.enabled, targetPaneId, splitDirection })
if (!config.enabled) {
log("[spawnTmuxPane] SKIP: config.enabled is false")
return { success: false }
}
if (!isInsideTmux()) {
log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX })
return { success: false }
}
const serverRunning = await isServerRunning(serverUrl)
if (!serverRunning) {
log("[spawnTmuxPane] SKIP: server not running", { serverUrl })
return { success: false }
}
const tmux = await getTmuxPath()
if (!tmux) {
log("[spawnTmuxPane] SKIP: tmux not found")
return { success: false }
}
log("[spawnTmuxPane] all checks passed, spawning...")
const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`
const args = [
"split-window",
splitDirection,
"-d",
"-P",
"-F",
"#{pane_id}",
...(targetPaneId ? ["-t", targetPaneId] : []),
opencodeCmd,
]
const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" })
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const paneId = stdout.trim()
if (exitCode !== 0 || !paneId) {
return { success: false }
}
const title = `omo-subagent-${description.slice(0, 20)}`
const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
stdout: "ignore",
stderr: "pipe",
})
// Drain stderr immediately to avoid backpressure
const stderrPromise = new Response(titleProc.stderr).text().catch(() => "")
const titleExitCode = await titleProc.exited
if (titleExitCode !== 0) {
const titleStderr = await stderrPromise
log("[spawnTmuxPane] WARNING: failed to set pane title", {
paneId,
title,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
})
}
return { success: true, paneId }
}
export async function closeTmuxPane(paneId: string): Promise<boolean> {
const { log } = await import("../logger")
if (!isInsideTmux()) {
log("[closeTmuxPane] SKIP: not inside tmux")
return false
}
const tmux = await getTmuxPath()
if (!tmux) {
log("[closeTmuxPane] SKIP: tmux not found")
return false
}
// Send Ctrl+C to trigger graceful exit of opencode attach process
log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
stdout: "pipe",
stderr: "pipe",
})
await ctrlCProc.exited
// Brief delay for graceful shutdown
await new Promise((r) => setTimeout(r, 250))
log("[closeTmuxPane] killing pane", { paneId })
const proc = spawn([tmux, "kill-pane", "-t", paneId], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
} else {
log("[closeTmuxPane] SUCCESS", { paneId })
}
return exitCode === 0
}
export async function replaceTmuxPane(
paneId: string,
sessionId: string,
description: string,
config: TmuxConfig,
serverUrl: string
): Promise<SpawnPaneResult> {
const { log } = await import("../logger")
log("[replaceTmuxPane] called", { paneId, sessionId, description })
if (!config.enabled) {
return { success: false }
}
if (!isInsideTmux()) {
return { success: false }
}
const tmux = await getTmuxPath()
if (!tmux) {
return { success: false }
}
// Send Ctrl+C to trigger graceful exit of existing opencode attach process
// Note: No delay here - respawn-pane -k will handle any remaining process.
// We send Ctrl+C first to give the process a chance to exit gracefully,
// then immediately respawn. This prevents orphaned processes while avoiding
// the race condition where the pane closes before respawn-pane runs.
log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
stdout: "pipe",
stderr: "pipe",
})
await ctrlCProc.exited
const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
return { success: false }
}
const title = `omo-subagent-${description.slice(0, 20)}`
const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
stdout: "ignore",
stderr: "pipe",
})
// Drain stderr immediately to avoid backpressure
const stderrPromise = new Response(titleProc.stderr).text().catch(() => "")
const titleExitCode = await titleProc.exited
if (titleExitCode !== 0) {
const titleStderr = await stderrPromise
log("[replaceTmuxPane] WARNING: failed to set pane title", {
paneId,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
})
}
log("[replaceTmuxPane] SUCCESS", { paneId, sessionId })
return { success: true, paneId }
}
export async function applyLayout(
tmux: string,
layout: TmuxLayout,
mainPaneSize: number
): Promise<void> {
const layoutProc = spawn([tmux, "select-layout", layout], { stdout: "ignore", stderr: "ignore" })
await layoutProc.exited
if (layout.startsWith("main-")) {
const dimension =
layout === "main-horizontal" ? "main-pane-height" : "main-pane-width"
const sizeProc = spawn([tmux, "set-window-option", dimension, `${mainPaneSize}%`], {
stdout: "ignore",
stderr: "ignore",
})
await sizeProc.exited
}
}
export async function enforceMainPaneWidth(
mainPaneId: string,
windowWidth: number
): Promise<void> {
const { log } = await import("../logger")
const tmux = await getTmuxPath()
if (!tmux) return
const DIVIDER_WIDTH = 1
const mainWidth = Math.floor((windowWidth - DIVIDER_WIDTH) / 2)
const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], {
stdout: "ignore",
stderr: "ignore",
})
await proc.exited
log("[enforceMainPaneWidth] main pane resized", { mainPaneId, mainWidth, windowWidth })
}
export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout"
@@ -0,0 +1,9 @@
export type SplitDirection = "-h" | "-v"
export function isInsideTmux(): boolean {
return Boolean(process.env.TMUX)
}
export function getCurrentPaneId(): string | undefined {
return process.env.TMUX_PANE
}
+49
View File
@@ -0,0 +1,49 @@
import { spawn } from "bun"
import type { TmuxLayout } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
export async function applyLayout(
tmux: string,
layout: TmuxLayout,
mainPaneSize: number,
): Promise<void> {
const layoutProc = spawn([tmux, "select-layout", layout], {
stdout: "ignore",
stderr: "ignore",
})
await layoutProc.exited
if (layout.startsWith("main-")) {
const dimension =
layout === "main-horizontal" ? "main-pane-height" : "main-pane-width"
const sizeProc = spawn(
[tmux, "set-window-option", dimension, `${mainPaneSize}%`],
{ stdout: "ignore", stderr: "ignore" },
)
await sizeProc.exited
}
}
export async function enforceMainPaneWidth(
mainPaneId: string,
windowWidth: number,
): Promise<void> {
const { log } = await import("../../logger")
const tmux = await getTmuxPath()
if (!tmux) return
const dividerWidth = 1
const mainWidth = Math.floor((windowWidth - dividerWidth) / 2)
const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], {
stdout: "ignore",
stderr: "ignore",
})
await proc.exited
log("[enforceMainPaneWidth] main pane resized", {
mainPaneId,
mainWidth,
windowWidth,
})
}
+48
View File
@@ -0,0 +1,48 @@
import { spawn } from "bun"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import { isInsideTmux } from "./environment"
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
export async function closeTmuxPane(paneId: string): Promise<boolean> {
const { log } = await import("../../logger")
if (!isInsideTmux()) {
log("[closeTmuxPane] SKIP: not inside tmux")
return false
}
const tmux = await getTmuxPath()
if (!tmux) {
log("[closeTmuxPane] SKIP: tmux not found")
return false
}
log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
stdout: "pipe",
stderr: "pipe",
})
await ctrlCProc.exited
await delay(250)
log("[closeTmuxPane] killing pane", { paneId })
const proc = spawn([tmux, "kill-pane", "-t", paneId], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
} else {
log("[closeTmuxPane] SUCCESS", { paneId })
}
return exitCode === 0
}
@@ -0,0 +1,28 @@
import { spawn } from "bun"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
export interface PaneDimensions {
paneWidth: number
windowWidth: number
}
export async function getPaneDimensions(
paneId: string,
): Promise<PaneDimensions | null> {
const tmux = await getTmuxPath()
if (!tmux) return null
const proc = spawn(
[tmux, "display", "-p", "-t", paneId, "#{pane_width},#{window_width}"],
{ stdout: "pipe", stderr: "pipe" },
)
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
if (exitCode !== 0) return null
const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number)
if (Number.isNaN(paneWidth) || Number.isNaN(windowWidth)) return null
return { paneWidth, windowWidth }
}
@@ -0,0 +1,69 @@
import { spawn } from "bun"
import type { TmuxConfig } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { SpawnPaneResult } from "../types"
import { isInsideTmux } from "./environment"
export async function replaceTmuxPane(
paneId: string,
sessionId: string,
description: string,
config: TmuxConfig,
serverUrl: string,
): Promise<SpawnPaneResult> {
const { log } = await import("../../logger")
log("[replaceTmuxPane] called", { paneId, sessionId, description })
if (!config.enabled) {
return { success: false }
}
if (!isInsideTmux()) {
return { success: false }
}
const tmux = await getTmuxPath()
if (!tmux) {
return { success: false }
}
log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
stdout: "pipe",
stderr: "pipe",
})
await ctrlCProc.exited
const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
return { success: false }
}
const title = `omo-subagent-${description.slice(0, 20)}`
const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
stdout: "ignore",
stderr: "pipe",
})
const stderrPromise = new Response(titleProc.stderr).text().catch(() => "")
const titleExitCode = await titleProc.exited
if (titleExitCode !== 0) {
const titleStderr = await stderrPromise
log("[replaceTmuxPane] WARNING: failed to set pane title", {
paneId,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
})
}
log("[replaceTmuxPane] SUCCESS", { paneId, sessionId })
return { success: true, paneId }
}
+91
View File
@@ -0,0 +1,91 @@
import { spawn } from "bun"
import type { TmuxConfig } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { SpawnPaneResult } from "../types"
import type { SplitDirection } from "./environment"
import { isInsideTmux } from "./environment"
import { isServerRunning } from "./server-health"
export async function spawnTmuxPane(
sessionId: string,
description: string,
config: TmuxConfig,
serverUrl: string,
targetPaneId?: string,
splitDirection: SplitDirection = "-h",
): Promise<SpawnPaneResult> {
const { log } = await import("../../logger")
log("[spawnTmuxPane] called", {
sessionId,
description,
serverUrl,
configEnabled: config.enabled,
targetPaneId,
splitDirection,
})
if (!config.enabled) {
log("[spawnTmuxPane] SKIP: config.enabled is false")
return { success: false }
}
if (!isInsideTmux()) {
log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX })
return { success: false }
}
const serverRunning = await isServerRunning(serverUrl)
if (!serverRunning) {
log("[spawnTmuxPane] SKIP: server not running", { serverUrl })
return { success: false }
}
const tmux = await getTmuxPath()
if (!tmux) {
log("[spawnTmuxPane] SKIP: tmux not found")
return { success: false }
}
log("[spawnTmuxPane] all checks passed, spawning...")
const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`
const args = [
"split-window",
splitDirection,
"-d",
"-P",
"-F",
"#{pane_id}",
...(targetPaneId ? ["-t", targetPaneId] : []),
opencodeCmd,
]
const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" })
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const paneId = stdout.trim()
if (exitCode !== 0 || !paneId) {
return { success: false }
}
const title = `omo-subagent-${description.slice(0, 20)}`
const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], {
stdout: "ignore",
stderr: "pipe",
})
const stderrPromise = new Response(titleProc.stderr).text().catch(() => "")
const titleExitCode = await titleProc.exited
if (titleExitCode !== 0) {
const titleStderr = await stderrPromise
log("[spawnTmuxPane] WARNING: failed to set pane title", {
paneId,
title,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
})
}
return { success: true, paneId }
}
@@ -0,0 +1,47 @@
let serverAvailable: boolean | null = null
let serverCheckUrl: string | null = null
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
export async function isServerRunning(serverUrl: string): Promise<boolean> {
if (serverCheckUrl === serverUrl && serverAvailable === true) {
return true
}
const healthUrl = new URL("/health", serverUrl).toString()
const timeoutMs = 3000
const maxAttempts = 2
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(healthUrl, {
signal: controller.signal,
}).catch(() => null)
clearTimeout(timeout)
if (response?.ok) {
serverCheckUrl = serverUrl
serverAvailable = true
return true
}
} finally {
clearTimeout(timeout)
}
if (attempt < maxAttempts) {
await delay(250)
}
}
return false
}
export function resetServerCheck(): void {
serverAvailable = null
serverCheckUrl = null
}