merge(dev): resolve background-agent delegated fallback conflicts

Reconcile the latest dev branch changes with the delegated child-session fallback work. Preserve the upstream background-agent updates while keeping the delegated bootstrap cleanup and compatibility wiring fixes intact, then re-verify the affected regression suites and typecheck.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
tw-yshuang
2026-05-11 03:02:14 +08:00
668 changed files with 44608 additions and 6160 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# src/shared/ — 100+ Utility Files
**Generated:** 2026-04-18
**Generated:** 2026-05-08
## OVERVIEW
+4
View File
@@ -214,6 +214,10 @@ describe("stripAgentListSortPrefix", () => {
it("strips legacy zero-width sort prefixes baked into v3.14.0v3.16.0 sessions", () => {
expect(stripAgentListSortPrefix("\u200B\u200BHephaestus - Deep Agent")).toBe("Hephaestus - Deep Agent")
})
it("strips leading and trailing wrapper characters after sort prefix removal", () => {
expect(stripAgentListSortPrefix("\\Hephaestus - Deep Agent\\")).toBe("Hephaestus - Deep Agent")
})
})
describe("normalizeAgentForPrompt", () => {
+3 -1
View File
@@ -27,13 +27,15 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
}
const INVISIBLE_AGENT_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g
const VISIBLE_AGENT_LIST_SORT_PREFIX_REGEX = /^\d+\|/
const AGENT_WRAPPER_CHARS_REGEX = /^[\\/"']+|[\\/"']+$/g
export function stripInvisibleAgentCharacters(agentName: string): string {
return agentName.replace(INVISIBLE_AGENT_CHARACTERS_REGEX, "")
}
export function stripAgentListSortPrefix(agentName: string): string {
return stripInvisibleAgentCharacters(agentName)
return stripInvisibleAgentCharacters(agentName).replace(VISIBLE_AGENT_LIST_SORT_PREFIX_REGEX, "").replace(AGENT_WRAPPER_CHARS_REGEX, "")
}
/**
+61
View File
@@ -0,0 +1,61 @@
import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentListDisplayName } from "./agent-display-names"
export const DEFAULT_AGENT_ORDER = [
"sisyphus",
"hephaestus",
"prometheus",
"atlas",
] as const
export type AgentOrderValidation = {
order: string[]
invalid: string[]
duplicates: string[]
}
const KNOWN_AGENT_KEYS = new Set(Object.keys(AGENT_DISPLAY_NAMES))
function appendUnique(target: string[], value: string): void {
if (!target.includes(value)) {
target.push(value)
}
}
export function validateAgentOrder(agentOrder: readonly string[] | undefined): AgentOrderValidation {
const order: string[] = []
const invalid: string[] = []
const duplicates: string[] = []
const seen = new Set<string>()
for (const rawName of agentOrder ?? []) {
const trimmed = rawName.trim()
if (trimmed.length === 0) {
invalid.push(rawName)
continue
}
const configKey = getAgentConfigKey(trimmed)
if (!KNOWN_AGENT_KEYS.has(configKey)) {
invalid.push(rawName)
continue
}
if (seen.has(configKey)) {
duplicates.push(rawName)
continue
}
seen.add(configKey)
order.push(configKey)
}
for (const configKey of DEFAULT_AGENT_ORDER) {
appendUnique(order, configKey)
}
return { order, invalid, duplicates }
}
export function resolveAgentOrderDisplayNames(agentOrder: readonly string[] | undefined): string[] {
return validateAgentOrder(agentOrder).order.map((configKey) => getAgentListDisplayName(configKey))
}
+122
View File
@@ -0,0 +1,122 @@
/// <reference types="bun-types" />
import { beforeAll, describe, expect, test } from "bun:test"
import {
AGENT_DISPLAY_NAMES,
getAgentListDisplayName,
normalizeAgentForPromptKey,
} from "./agent-display-names"
import { installAgentSortShim } from "./agent-sort-shim"
type AgentListItem = {
name: string
default_agent?: boolean
}
function compareOpenCodeAgentListItems(left: AgentListItem, right: AgentListItem): number {
const leftDefault = left.default_agent ? 1 : 0
const rightDefault = right.default_agent ? 1 : 0
if (leftDefault !== rightDefault) return rightDefault - leftDefault
if (left.name < right.name) return -1
if (left.name > right.name) return 1
return 0
}
function simulateOpencodeSort(agentNames: string[], defaultName: string): string[] {
const agents = agentNames.map((name): AgentListItem => ({
name,
default_agent: name === defaultName,
}))
return [...agents].sort(compareOpenCodeAgentListItems).map((agent) => agent.name)
}
describe("OpenCode Agent.list() sort with runtime display names", () => {
beforeAll(() => {
installAgentSortShim()
})
describe("#given the four core agents and a mix of non-core agents", () => {
test("#when sorted using OpenCode-style ordering #then core agents come first in canonical order", () => {
const sisyphus = getAgentListDisplayName("sisyphus")
const hephaestus = getAgentListDisplayName("hephaestus")
const prometheus = getAgentListDisplayName("prometheus")
const atlas = getAgentListDisplayName("atlas")
const allAgents = [
sisyphus,
hephaestus,
prometheus,
atlas,
"athena",
"explore",
"metis",
"oracle",
]
const sorted = simulateOpencodeSort(allAgents, sisyphus)
const orderedConfigKeys = sorted.map((name) => normalizeAgentForPromptKey(name))
expect(orderedConfigKeys).toEqual([
"sisyphus",
"hephaestus",
"prometheus",
"atlas",
"athena",
"explore",
"metis",
"oracle",
])
})
test("#when default_agent is unset #then canonical core order still holds via the sort shim", () => {
const sisyphus = getAgentListDisplayName("sisyphus")
const hephaestus = getAgentListDisplayName("hephaestus")
const prometheus = getAgentListDisplayName("prometheus")
const atlas = getAgentListDisplayName("atlas")
const allAgents = [hephaestus, prometheus, atlas, sisyphus, "athena", "oracle"]
const sorted = simulateOpencodeSort(allAgents, "no-such-default-agent")
const orderedConfigKeys = sorted.map((name) => normalizeAgentForPromptKey(name))
expect(orderedConfigKeys.slice(0, 4)).toEqual([
"sisyphus",
"hephaestus",
"prometheus",
"atlas",
])
})
})
describe("#given runtime names containing only core agents", () => {
test("#when sorted #then sisyphus, hephaestus, prometheus, atlas in that order", () => {
const sisyphus = getAgentListDisplayName("sisyphus")
const hephaestus = getAgentListDisplayName("hephaestus")
const prometheus = getAgentListDisplayName("prometheus")
const atlas = getAgentListDisplayName("atlas")
const sorted = simulateOpencodeSort([atlas, prometheus, hephaestus, sisyphus], sisyphus)
const orderedConfigKeys = sorted.map((name) => normalizeAgentForPromptKey(name))
expect(orderedConfigKeys).toEqual([
"sisyphus",
"hephaestus",
"prometheus",
"atlas",
])
})
})
describe("#given runtime names are rendered", () => {
test("#then they do not include invisible sort-prefix characters", () => {
const runtimeNames = Object.keys(AGENT_DISPLAY_NAMES).map(getAgentListDisplayName)
const invisibleCharsRegex = /[\u200B\u200C\u200D\uFEFF]/
for (const name of runtimeNames) {
expect(invisibleCharsRegex.test(name)).toBe(false)
}
})
})
})
+61 -2
View File
@@ -1,18 +1,35 @@
/// <reference types="bun-types" />
import { beforeAll, describe, expect, test } from "bun:test"
import { afterEach, beforeAll, describe, expect, test } from "bun:test"
import { installAgentSortShim } from "./agent-sort-shim"
import { installAgentSortShim, setAgentSortOrder } from "./agent-sort-shim"
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
type AgentListItem = {
name: string
default_agent?: boolean
}
declare global {
interface Array<T> {
toSorted(compareFn?: (a: T, b: T) => number): T[]
}
}
describe("agent-sort-shim", () => {
beforeAll(() => {
installAgentSortShim()
})
afterEach(() => {
setAgentSortOrder(undefined)
})
describe("#given an array of all 4 core agent objects in random order", () => {
describe("#when toSorted with alphabetical compareFn", () => {
test("#then returns canonical sisyphus->hephaestus->prometheus->atlas order", () => {
// given
setAgentSortOrder(undefined)
const sisyphus = { name: "Sisyphus - Ultraworker" }
const hephaestus = { name: "Hephaestus - Deep Agent" }
const prometheus = { name: "Prometheus - Plan Builder" }
@@ -25,6 +42,22 @@ describe("agent-sort-shim", () => {
// then
expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas])
})
test("#then follows configured core agent order", () => {
// given
setAgentSortOrder(["hephaestus", "sisyphus", "prometheus", "atlas"])
const sisyphus = { name: "Sisyphus - Ultraworker" }
const hephaestus = { name: "Hephaestus - Deep Agent" }
const prometheus = { name: "Prometheus - Plan Builder" }
const atlas = { name: "Atlas - Plan Executor" }
const input = [atlas, prometheus, hephaestus, sisyphus]
// when
const result = input.toSorted((a, b) => a.name.localeCompare(b.name))
// then
expect(result).toEqual([hephaestus, sisyphus, prometheus, atlas])
})
})
})
@@ -49,6 +82,32 @@ describe("agent-sort-shim", () => {
})
})
describe("#given OpenCode Agent.list style sort with default agent priority", () => {
describe("#when toSorted compares default_agent first and then name", () => {
test("#then core agents stay in canonical order before non-core agents", () => {
// given
const sisyphus = { name: AGENT_DISPLAY_NAMES.sisyphus, default_agent: true }
const hephaestus = { name: AGENT_DISPLAY_NAMES.hephaestus }
const prometheus = { name: AGENT_DISPLAY_NAMES.prometheus }
const atlas = { name: AGENT_DISPLAY_NAMES.atlas }
const oracle = { name: AGENT_DISPLAY_NAMES.oracle }
const explore = { name: AGENT_DISPLAY_NAMES.explore }
const input: AgentListItem[] = [oracle, atlas, explore, prometheus, hephaestus, sisyphus]
// when
const result = input.toSorted((left, right) => {
const leftDefault = left.default_agent ? 1 : 0
const rightDefault = right.default_agent ? 1 : 0
if (leftDefault !== rightDefault) return rightDefault - leftDefault
return left.name.localeCompare(right.name)
})
// then
expect(result).toEqual([sisyphus, hephaestus, prometheus, atlas, explore, oracle])
})
})
})
describe("#given an array with only one core agent and several non-core agent-like objects", () => {
describe("#when toSorted with case-sensitive string-comparison compareFn", () => {
test("#then activation predicate fails and result is ASCII-sensitive order with capital S before lowercase letters", () => {
+27 -17
View File
@@ -3,10 +3,9 @@
*
* OpenCode 1.4.x ignores the agent `order` field (sst/opencode#19127) and
* sorts the agent list by `agent.name` via Remeda `sortBy(x => x.name, "asc")`
* at packages/opencode/src/agent/agent.ts. Without intervention, the four
* core agents collapse into Atlas -> Hephaestus -> Prometheus -> Sisyphus,
* which inverts the canonical sisyphus -> hephaestus -> prometheus -> atlas
* order this project ships.
* at packages/opencode/src/agent/agent.ts. Without intervention, core agents
* collapse into name order, which can invert the default sisyphus -> hephaestus
* -> prometheus -> atlas order or a user's configured `agent_order`.
*
* Earlier attempts to bias the sort key with invisible characters (ZWSP,
* U+2060 WORD JOINER, U+00AD SOFT HYPHEN, ANSI escape) caused visible-gap
@@ -17,22 +16,21 @@
* 1. `isAgentArray` rejects any array element that is null, non-object, or
* lacks a string `name`, eliminating the throw-on-mixed-array failure
* mode that closed the original PR.
* 2. The activation predicate requires >= 2 elements whose `.name` is one
* of the four canonical core display names, so unrelated `.sort()` and
* `.toSorted()` calls (string arrays, number arrays, generic objects)
* execute native behavior unchanged.
* 2. The activation predicate requires >= 2 elements whose `.name` is ranked
* by the active agent order, so unrelated `.sort()` and `.toSorted()` calls
* (string arrays, number arrays, generic objects) execute native behavior
* unchanged.
*
* Remove this shim once OpenCode honors the agent `order` field
* (sst/opencode#19127).
*/
import { CANONICAL_CORE_AGENT_ORDER } from "../plugin-handlers/agent-priority-order"
import { AGENT_DISPLAY_NAMES } from "./agent-display-names"
import { DEFAULT_AGENT_ORDER, resolveAgentOrderDisplayNames } from "./agent-ordering"
import { getAgentListDisplayName } from "./agent-display-names"
const AGENT_RANK: ReadonlyMap<string, number> = new Map(
CANONICAL_CORE_AGENT_ORDER.map(
(configKey, index): [string, number] => [AGENT_DISPLAY_NAMES[configKey], index + 1],
),
let agentRank: ReadonlyMap<string, number> = createAgentRank(undefined)
const AGENT_ARRAY_SENTINELS = new Set(
DEFAULT_AGENT_ORDER.map((configKey) => getAgentListDisplayName(configKey)),
)
const UNRANKED = Number.MAX_SAFE_INTEGER
@@ -51,7 +49,7 @@ function isAgentArray(arr: ReadonlyArray<unknown>): boolean {
if (element === null || typeof element !== "object") return false
const name = (element as { name?: unknown }).name
if (typeof name !== "string") return false
if (AGENT_RANK.has(name)) rankedCount++
if (AGENT_ARRAY_SENTINELS.has(name)) rankedCount++
}
return rankedCount >= 2
@@ -62,8 +60,8 @@ function agentComparator(
b: unknown,
fallback: ((a: unknown, b: unknown) => number) | undefined,
): number {
const aRank = AGENT_RANK.get(extractAgentName(a)) ?? UNRANKED
const bRank = AGENT_RANK.get(extractAgentName(b)) ?? UNRANKED
const aRank = agentRank.get(extractAgentName(a)) ?? UNRANKED
const bRank = agentRank.get(extractAgentName(b)) ?? UNRANKED
if (aRank !== bRank) return aRank - bRank
if (fallback) return fallback(a, b)
@@ -72,6 +70,18 @@ function agentComparator(
let installed = false
function createAgentRank(agentOrder: readonly string[] | undefined): ReadonlyMap<string, number> {
return new Map(
resolveAgentOrderDisplayNames(agentOrder).map(
(displayName, index): [string, number] => [displayName, index + 1],
),
)
}
export function setAgentSortOrder(agentOrder: readonly string[] | undefined): void {
agentRank = createAgentRank(agentOrder)
}
export function installAgentSortShim(): void {
if (installed) return
+26 -4
View File
@@ -6,6 +6,21 @@ import { stripInvisibleAgentCharacters } from "./agent-display-names"
* true = tool allowed, false = tool denied.
*/
const TEAM_TOOL_DENYLIST: Record<string, boolean> = {
team_create: false,
team_delete: false,
team_shutdown_request: false,
team_approve_shutdown: false,
team_reject_shutdown: false,
team_send_message: false,
team_task_create: false,
team_task_list: false,
team_task_update: false,
team_task_get: false,
team_status: false,
team_list: false,
}
const EXPLORATION_AGENT_DENYLIST: Record<string, boolean> = {
write: false,
edit: false,
@@ -44,13 +59,20 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
},
}
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
// Custom/unknown agents get no restrictions (empty object), matching Claude Code's
// trust model where project-registered agents retain full tool access including bash.
type AgentToolRestrictionsOptions = {
includeTeamToolDenylist?: boolean
}
export function getAgentToolRestrictions(agentName: string, options: AgentToolRestrictionsOptions = {}): Record<string, boolean> {
const stripped = stripInvisibleAgentCharacters(agentName)
return AGENT_RESTRICTIONS[stripped]
const agentRestrictions = AGENT_RESTRICTIONS[stripped]
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === stripped.toLowerCase())?.[1]
?? {}
return {
...(options.includeTeamToolDenylist === false ? {} : TEAM_TOOL_DENYLIST),
...agentRestrictions,
}
}
export function hasAgentToolRestrictions(agentName: string): boolean {
+1 -1
View File
@@ -36,7 +36,7 @@ describe("resolveAgentVariant", () => {
sisyphus: { category: "ultrabrain" },
},
categories: {
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
ultrabrain: { model: "openai/gpt-5.5", variant: "xhigh" },
},
} as OhMyOpenCodeConfig
+1 -1
View File
@@ -1,6 +1,6 @@
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import * as path from "node:path";
import { spawn } from "bun";
import { spawn } from "./bun-spawn-shim";
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
import { extractZip } from "./zip-extractor";
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test"
import { spawn, spawnSync } from "./bun-spawn-shim"
describe("bun-spawn-shim", () => {
test("#given array command #when spawn exits successfully #then exited resolves to zero", async () => {
const proc = spawn(["bun", "--version"], { stdout: "pipe", stderr: "pipe" })
const exitCode = await proc.exited
expect(exitCode).toBe(0)
expect(proc.exitCode).toBe(0)
})
test("#given piped stdout #when spawn writes output #then stdout is readable", async () => {
const proc = spawn(["bun", "--print", "'shim-ok'"], { stdout: "pipe", stderr: "pipe" })
const [exitCode, stdout] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
])
expect(exitCode).toBe(0)
expect(stdout.trim()).toBe("shim-ok")
})
test("#given detached object command #when spawn starts #then process exposes daemon controls", async () => {
const proc = spawn({
cmd: ["bun", "--print", "'detached-ok'"],
stdout: "pipe",
stderr: "pipe",
detached: true,
})
proc.unref()
const exitCode = await proc.exited
expect(exitCode).toBe(0)
expect(typeof proc.ref).toBe("function")
expect(typeof proc.unref).toBe("function")
expect(proc.pid).toBeGreaterThan(0)
})
test("#given stdio tuple #when spawn runs #then ignored streams are still safe to read", async () => {
const proc = spawn({
cmd: ["bun", "--print", "'ignored'"],
stdio: ["ignore", "ignore", "ignore"],
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
expect(exitCode).toBe(0)
expect(stdout).toBe("")
})
test("#given spawnSync command #when it writes output #then stdout and exit code match", () => {
const result = spawnSync(["bun", "--print", "'sync-ok'"], { stdout: "pipe", stderr: "pipe" })
expect(result.exitCode).toBe(0)
expect(result.success).toBe(true)
expect(result.stdout).toBeDefined()
expect(Buffer.from(result.stdout!).toString().trim()).toBe("sync-ok")
})
test("#given spawnSync command #when it completes #then result.pid is a positive number", () => {
const result = spawnSync(["bun", "--version"], { stdout: "pipe", stderr: "pipe" })
expect(result.pid).toBeGreaterThan(0)
})
test("#given default stdio #when child reads stdin #then it does not hang waiting for input", async () => {
const proc = spawn(["cat"], { stdout: "pipe", stderr: "pipe" })
const exitCode = await proc.exited
expect(exitCode).toBe(0)
})
test("#given missing executable #when spawn invoked #then the error is surfaced to the caller", async () => {
let observedError: unknown
try {
const proc = spawn(["__omo-shim-missing-binary__"], { stdout: "pipe", stderr: "pipe" })
await proc.exited
} catch (error) {
observedError = error
}
expect(observedError).toBeDefined()
})
})
+168
View File
@@ -0,0 +1,168 @@
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process"
import { Readable, Writable } from "node:stream"
type AnyRecord = Record<string, unknown>
type StdioMode = "pipe" | "inherit" | "ignore"
type StdioTuple = [StdioMode, StdioMode, StdioMode]
export interface SpawnOptions {
cmd?: string[]
cwd?: string
env?: NodeJS.ProcessEnv
stdin?: StdioMode
stdout?: StdioMode
stderr?: StdioMode
stdio?: StdioTuple
detached?: boolean
signal?: AbortSignal
}
export interface SpawnedProcess {
readonly exitCode: number | null
readonly exited: Promise<number>
readonly stdout: ReadableStream<Uint8Array>
readonly stderr: ReadableStream<Uint8Array>
readonly stdin: NodeJS.WritableStream
readonly pid: number | undefined
kill(signal?: NodeJS.Signals): void
ref(): void
unref(): void
}
export interface SpawnSyncResult {
readonly exitCode: number
readonly stdout: Buffer | undefined
readonly stderr: Buffer | undefined
readonly success: boolean
readonly pid: number
}
type BunSpawnRuntime = {
spawn(command: string[], options?: SpawnOptions): SpawnedProcess
spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess
spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult
spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult
}
const runtime = globalThis as typeof globalThis & { Bun?: BunSpawnRuntime }
const IS_BUN = typeof runtime.Bun !== "undefined"
function emptyReadableStream(): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.close()
},
})
}
function toReadableStream(stream: NodeJS.ReadableStream | null): ReadableStream<Uint8Array> {
if (!stream) return emptyReadableStream()
return Readable.toWeb(stream as Readable) as ReadableStream<Uint8Array>
}
function emptyWritableStream(): Writable {
return new Writable({
write(_chunk, _encoding, callback) {
callback()
},
})
}
function resolveCommand(cmdOrOpts: unknown, optsArg?: unknown): { cmd: string[]; opts: SpawnOptions } {
const isObj = !Array.isArray(cmdOrOpts)
const opts = isObj ? (cmdOrOpts as SpawnOptions) : ((optsArg ?? {}) as SpawnOptions)
return {
cmd: isObj ? ((cmdOrOpts as AnyRecord).cmd as string[]) : (cmdOrOpts as string[]),
opts,
}
}
function resolveStdio(options: SpawnOptions): StdioTuple {
if (options.stdio) return options.stdio
return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"]
}
function wrapNodeProcess(proc: ReturnType<typeof nodeSpawn>): SpawnedProcess {
let exitCode: number | null = null
const exited = new Promise<number>((resolve, reject) => {
proc.on("exit", (code) => {
exitCode = code ?? 1
resolve(exitCode)
})
proc.on("error", (error) => {
if (exitCode === null) {
exitCode = 1
reject(error)
}
})
})
return {
get exitCode() {
return exitCode
},
exited,
stdout: toReadableStream(proc.stdout),
stderr: toReadableStream(proc.stderr),
stdin: proc.stdin ?? emptyWritableStream(),
kill(signal?: NodeJS.Signals) {
if (proc.killed || exitCode !== null) return
try {
proc.kill(signal)
} catch (error) {
if (!String(error).includes("kill")) throw error
}
},
pid: proc.pid,
ref() {
proc.ref()
},
unref() {
proc.unref()
},
}
}
export function spawn(command: string[], options?: SpawnOptions): SpawnedProcess
export function spawn(options: SpawnOptions & { cmd: string[] }): SpawnedProcess
export function spawn(cmdOrOpts: unknown, opts?: unknown): SpawnedProcess {
if (IS_BUN) return runtime.Bun!.spawn(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions)
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts)
const [bin, ...args] = cmd
const proc = nodeSpawn(bin, args, {
cwd: options.cwd,
env: options.env,
stdio: resolveStdio(options),
detached: options.detached,
signal: options.signal,
})
return wrapNodeProcess(proc)
}
export function spawnSync(command: string[], options?: SpawnOptions): SpawnSyncResult
export function spawnSync(options: SpawnOptions & { cmd: string[] }): SpawnSyncResult
export function spawnSync(cmdOrOpts: unknown, opts?: unknown): SpawnSyncResult {
if (IS_BUN) return runtime.Bun!.spawnSync(cmdOrOpts as string[] & SpawnOptions & { cmd: string[] }, opts as SpawnOptions)
const { cmd, opts: options } = resolveCommand(cmdOrOpts, opts)
const [bin, ...args] = cmd
const result = nodeSpawnSync(bin, args, {
cwd: options.cwd,
env: options.env,
stdio: resolveStdio(options),
})
return {
exitCode: result.status ?? 1,
stdout: result.stdout ?? undefined,
stderr: result.stderr ?? undefined,
success: (result.status ?? 1) === 0,
pid: result.pid ?? -1,
}
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test"
import {
classifyPathEnvironment,
describePathClassification,
} from "./classify-path-environment"
describe("classifyPathEnvironment", () => {
it("classifies macOS iCloud path as icloud", () => {
expect(
classifyPathEnvironment(
"/Users/x/Library/Mobile Documents/com~apple~CloudDocs/project/file.txt",
),
).toBe("icloud")
})
it("classifies OneDrive path on unix style", () => {
expect(classifyPathEnvironment("/Users/x/OneDrive/foo")).toBe("onedrive")
})
it("classifies OneDrive path on windows style", () => {
expect(classifyPathEnvironment("C:\\Users\\x\\OneDrive\\foo")).toBe("onedrive")
})
it("classifies macOS Desktop path as desktop-sync", () => {
expect(classifyPathEnvironment("/Users/x/Desktop/foo")).toBe("desktop-sync")
})
it("classifies /Volumes path as network-drive", () => {
expect(classifyPathEnvironment("/Volumes/NetworkShare/foo")).toBe("network-drive")
})
it("classifies random path as unknown", () => {
expect(classifyPathEnvironment("/tmp/foo")).toBe("unknown")
})
it("classifies empty string as unknown", () => {
expect(classifyPathEnvironment("")).toBe("unknown")
})
it("matches OneDrive case-insensitively", () => {
expect(classifyPathEnvironment("/Users/x/oNeDrIvE/foo")).toBe("onedrive")
})
})
describe("describePathClassification", () => {
it("returns human-readable descriptions", () => {
expect(describePathClassification("icloud")).toBe("iCloud Drive")
expect(describePathClassification("onedrive")).toBe("OneDrive")
expect(describePathClassification("desktop-sync")).toBe("Desktop sync (macOS)")
expect(describePathClassification("network-drive")).toBe("Network drive")
expect(describePathClassification("unknown")).toBe(
"filesystem that does not support fsync",
)
})
})
+68
View File
@@ -0,0 +1,68 @@
import { homedir } from "node:os"
import path from "node:path"
export type PathClassification =
| "icloud"
| "onedrive"
| "desktop-sync"
| "network-drive"
| "unknown"
function normalizeInputPath(absolutePath: string): string {
return absolutePath.replaceAll("\\", "/")
}
function isUnderPath(normalizedPath: string, normalizedParentPath: string): boolean {
return normalizedPath === normalizedParentPath || normalizedPath.startsWith(`${normalizedParentPath}/`)
}
export function classifyPathEnvironment(absolutePath: string): PathClassification {
if (absolutePath.length === 0) return "unknown"
const normalizedPath = normalizeInputPath(absolutePath)
const lowercasePath = normalizedPath.toLowerCase()
if (lowercasePath.includes("/onedrive") || lowercasePath.includes("/onedrive/")) {
return "onedrive"
}
if (normalizedPath.includes("/Library/Mobile Documents/")) {
return "icloud"
}
if (isUnderPath(normalizedPath, "/Volumes")) {
return "network-drive"
}
if (
normalizedPath.startsWith("/Users/")
&& (normalizedPath.includes("/Desktop/") || normalizedPath.endsWith("/Desktop")
|| normalizedPath.includes("/Documents/") || normalizedPath.endsWith("/Documents"))
) {
return "desktop-sync"
}
const normalizedHome = normalizeInputPath(homedir())
const desktopPath = normalizeInputPath(path.join(normalizedHome, "Desktop"))
const documentsPath = normalizeInputPath(path.join(normalizedHome, "Documents"))
if (isUnderPath(normalizedPath, desktopPath) || isUnderPath(normalizedPath, documentsPath)) {
return "desktop-sync"
}
return "unknown"
}
export function describePathClassification(pathClassification: PathClassification): string {
switch (pathClassification) {
case "icloud":
return "iCloud Drive"
case "onedrive":
return "OneDrive"
case "desktop-sync":
return "Desktop sync (macOS)"
case "network-drive":
return "Network drive"
case "unknown":
return "filesystem that does not support fsync"
}
}
+1 -15
View File
@@ -1,23 +1,9 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { describe, test, expect } from "bun:test"
import { homedir } from "node:os"
import { join } from "node:path"
import { getClaudeConfigDir } from "./claude-config-dir"
describe("getClaudeConfigDir", () => {
let originalEnv: string | undefined
beforeEach(() => {
originalEnv = process.env.CLAUDE_CONFIG_DIR
})
afterEach(() => {
if (originalEnv !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalEnv
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
})
test("returns CLAUDE_CONFIG_DIR when env var is set", () => {
process.env.CLAUDE_CONFIG_DIR = "/custom/claude/path"
+12
View File
@@ -2,6 +2,10 @@ import { log } from "./logger"
import * as dataPath from "./data-path"
import { createJsonFileCacheStore } from "./json-file-cache-store"
// Track if provider models cache has been successfully written in the current process
// This helps in sandbox environments where filesystem state may not persist across contexts
let providerModelsCacheWrittenInCurrentProcess = false
const CONNECTED_PROVIDERS_CACHE_FILE = "connected-providers.json"
const PROVIDER_MODELS_CACHE_FILE = "provider-models.json"
@@ -84,6 +88,12 @@ export function createConnectedProvidersCacheStore(
}
function hasProviderModelsCache(): boolean {
// First check if we've written the cache in the current process
// This handles sandbox environments where filesystem state may not persist across contexts
if (providerModelsCacheWrittenInCurrentProcess) {
return true
}
// Fall back to the store's has() method (which also checks in-memory state)
return providerModelsCacheStore.has()
}
@@ -92,6 +102,7 @@ export function createConnectedProvidersCacheStore(
...data,
updatedAt: new Date().toISOString(),
})
providerModelsCacheWrittenInCurrentProcess = true
}
async function updateConnectedProvidersCache(client: {
@@ -161,6 +172,7 @@ export function createConnectedProvidersCacheStore(
function _resetMemCacheForTesting(): void {
connectedProvidersCacheStore.resetMemory()
providerModelsCacheStore.resetMemory()
providerModelsCacheWrittenInCurrentProcess = false
}
return {
@@ -0,0 +1,65 @@
import { existsSync } from "node:fs"
import { describe, expect, test } from "bun:test"
const DIST_INDEX = "dist/index.js"
const GLOBAL_BUN_DESTRUCTURE = /^\s*(?:var|let|const)\s*\{[^}]*\}\s*=\s*globalThis\.Bun/gm
const TOP_LEVEL_REQUIRE_CALL = "__require("
describe("dist bundle Bun globals", () => {
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no globalThis.Bun destructures remain", async () => {
const dist = await Bun.file(DIST_INDEX).text()
const matches = dist.match(GLOBAL_BUN_DESTRUCTURE) ?? []
expect(matches).toEqual([])
})
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when scanned #then no top-level __require call remains", async () => {
const dist = await Bun.file(DIST_INDEX).text()
const offending: string[] = []
let depth = 0
for (const [index, line] of dist.split("\n").entries()) {
if (depth === 0 && line.includes(TOP_LEVEL_REQUIRE_CALL)) {
offending.push(`${index + 1}: ${line.trim()}`)
}
for (const char of line) {
if (char === "{") {
depth += 1
} else if (char === "}") {
depth -= 1
if (depth < 0) depth = 0
}
}
}
expect(offending).toEqual([])
})
test.skipIf(!existsSync(DIST_INDEX))("#given dist bundle #when imported under node --input-type=module #then it loads without error", async () => {
const node = Bun.which("node")
if (!node) return
const proc = Bun.spawn({
cmd: [node, "--input-type=module", "-e", "await import('./dist/index.js'); console.log('node-esm-load-ok')"],
cwd: process.cwd(),
stdout: "pipe",
stderr: "pipe",
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
expect({
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim(),
}).toEqual({
exitCode: 0,
stdout: "node-esm-load-ok",
stderr: "",
})
})
})
+9
View File
@@ -0,0 +1,9 @@
export function extractSemverFromOutput(output: string): string | null {
const trimmed = output.trim()
if (!trimmed) return null
// The negative lookbehind `(?<![\d:])` prevents matching the milliseconds segment of timestamps
// like `00:24:25.202` that the Electron-based OpenCode binary leaks into stdout.
const semverPattern = /(?<![\d:])v?(\d+\.\d+\.\d+(?:[-+][\w.]+)*)/
const match = trimmed.match(semverPattern)
return match?.[1] ?? null
}
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it } from "bun:test"
import {
clearAllSkips,
drainSkipsAfter,
recordFsyncSkip,
} from "./fsync-skip-tracker"
type PathClassification =
| "icloud"
| "onedrive"
| "desktop-sync"
| "network-drive"
| "unknown"
function recordSkip(index: number, pathClassification: PathClassification = "unknown"): void {
recordFsyncSkip({
filePath: `/tmp/file-${index}.txt`,
contextLabel: `atomicWrite:/tmp/file-${index}.txt`,
errorCode: "EPERM",
message: "operation not permitted",
pathClassification,
})
}
describe("fsync-skip-tracker", () => {
beforeEach(() => {
clearAllSkips()
})
it("recordFsyncSkip adds entry with timestamp", () => {
const before = Date.now()
recordSkip(1)
const entries = drainSkipsAfter(0)
expect(entries).toHaveLength(1)
expect(entries[0]?.filePath).toBe("/tmp/file-1.txt")
expect(entries[0]?.timestamp).toBeGreaterThanOrEqual(before)
})
it("drainSkipsAfter(timestamp) returns entries strictly after the timestamp", async () => {
recordSkip(1)
const firstTimestamp = Date.now()
await Bun.sleep(2)
recordSkip(2)
const drained = drainSkipsAfter(firstTimestamp)
expect(drained).toHaveLength(1)
expect(drained[0]?.filePath).toBe("/tmp/file-2.txt")
})
it("drainSkipsAfter removes drained entries from buffer", () => {
recordSkip(1)
recordSkip(2)
const drained = drainSkipsAfter(0)
expect(drained).toHaveLength(2)
expect(drainSkipsAfter(0)).toEqual([])
})
it("buffer is bounded to max 200 entries and drops oldest on overflow", () => {
for (let index = 1; index <= 205; index += 1) {
recordSkip(index)
}
const drained = drainSkipsAfter(0)
expect(drained).toHaveLength(200)
expect(drained[0]?.filePath).toBe("/tmp/file-6.txt")
expect(drained[199]?.filePath).toBe("/tmp/file-205.txt")
})
it("multiple records with same path are kept", () => {
recordSkip(1)
recordFsyncSkip({
filePath: "/tmp/file-1.txt",
contextLabel: "acquireLock:/tmp/file-1.txt",
errorCode: "EPERM",
message: "second",
pathClassification: "unknown",
})
const drained = drainSkipsAfter(0)
expect(drained).toHaveLength(2)
expect(drained[0]?.filePath).toBe("/tmp/file-1.txt")
expect(drained[1]?.filePath).toBe("/tmp/file-1.txt")
})
it("drainSkipsAfter(0) returns all entries", () => {
recordSkip(1)
recordSkip(2)
const drained = drainSkipsAfter(0)
expect(drained).toHaveLength(2)
})
it("empty buffer returns empty array", () => {
expect(drainSkipsAfter(0)).toEqual([])
})
})
+42
View File
@@ -0,0 +1,42 @@
import type { PathClassification } from "./classify-path-environment"
export type FsyncSkipEntry = {
filePath: string
contextLabel: string
errorCode: string
message: string
pathClassification: PathClassification
timestamp: number
}
const MAX_SKIPS = 200
const fsyncSkips: FsyncSkipEntry[] = []
export function recordFsyncSkip(entry: Omit<FsyncSkipEntry, "timestamp">): void {
fsyncSkips.push({ ...entry, timestamp: Date.now() })
if (fsyncSkips.length > MAX_SKIPS) {
fsyncSkips.splice(0, fsyncSkips.length - MAX_SKIPS)
}
}
export function drainSkipsAfter(timestampMs: number): FsyncSkipEntry[] {
const drainedEntries: FsyncSkipEntry[] = []
const retainedEntries: FsyncSkipEntry[] = []
for (const entry of fsyncSkips) {
if (entry.timestamp > timestampMs) {
drainedEntries.push(entry)
continue
}
retainedEntries.push(entry)
}
fsyncSkips.splice(0, fsyncSkips.length, ...retainedEntries)
return drainedEntries
}
export function clearAllSkips(): void {
fsyncSkips.length = 0
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from "bun:test"
import type { FsyncSkipEntry } from "./fsync-skip-tracker"
import { formatFsyncSkipWarning } from "./fsync-skip-warning-formatter"
function makeEntry(index: number, classification: FsyncSkipEntry["pathClassification"]): FsyncSkipEntry {
return {
filePath: `/path/${index}`,
contextLabel: `atomicWrite:/path/${index}`,
errorCode: "EPERM",
message: "operation not permitted",
pathClassification: classification,
timestamp: 1000 + index,
}
}
describe("formatFsyncSkipWarning", () => {
it("returns empty string for zero entries", () => {
expect(formatFsyncSkipWarning([])).toBe("")
})
it("includes iCloud environment, path, and code for one entry", () => {
const warning = formatFsyncSkipWarning([makeEntry(1, "icloud")])
expect(warning).toContain("iCloud Drive")
expect(warning).toContain("/path/1")
expect(warning).toContain("EPERM")
})
it("shows all five paths when exactly five entries exist", () => {
const warning = formatFsyncSkipWarning([
makeEntry(1, "icloud"),
makeEntry(2, "icloud"),
makeEntry(3, "icloud"),
makeEntry(4, "icloud"),
makeEntry(5, "icloud"),
])
expect(warning).toContain("/path/1")
expect(warning).toContain("/path/5")
expect(warning).not.toContain("and 1 more")
})
it("shows five paths plus overflow summary when six entries exist", () => {
const warning = formatFsyncSkipWarning([
makeEntry(1, "icloud"),
makeEntry(2, "icloud"),
makeEntry(3, "icloud"),
makeEntry(4, "icloud"),
makeEntry(5, "icloud"),
makeEntry(6, "icloud"),
])
expect(warning).toContain("/path/5")
expect(warning).not.toContain("/path/6")
expect(warning).toContain("... and 1 more")
})
it("uses the most common classification when entries are mixed", () => {
const warning = formatFsyncSkipWarning([
makeEntry(1, "onedrive"),
makeEntry(2, "onedrive"),
makeEntry(3, "icloud"),
])
expect(warning).toContain("Detected environment: OneDrive")
})
it("matches required section format", () => {
const warning = formatFsyncSkipWarning([makeEntry(1, "unknown")])
expect(warning).toContain("[fsync-skipped] 1 write(s) bypassed fsync")
expect(warning).toContain("Affected paths:")
expect(warning).toContain("What this means:")
expect(warning).toContain("The write+rename succeeded")
expect(warning).not.toContain("Detected environment:")
expect(warning).toContain("filesystem does not support fsync")
})
})
@@ -0,0 +1,61 @@
import { describePathClassification } from "./classify-path-environment"
import type { FsyncSkipEntry } from "./fsync-skip-tracker"
const MAX_PATH_LINES = 5
function selectMostCommonClassification(
entries: FsyncSkipEntry[],
): FsyncSkipEntry["pathClassification"] {
const counts = new Map<FsyncSkipEntry["pathClassification"], number>()
for (const entry of entries) {
const currentCount = counts.get(entry.pathClassification) ?? 0
counts.set(entry.pathClassification, currentCount + 1)
}
let selected: FsyncSkipEntry["pathClassification"] = "unknown"
let selectedCount = -1
for (const [classification, count] of counts.entries()) {
if (count > selectedCount) {
selected = classification
selectedCount = count
}
}
return selected
}
export function formatFsyncSkipWarning(entries: FsyncSkipEntry[]): string {
if (entries.length === 0) return ""
const selectedClassification = selectMostCommonClassification(entries)
const selectedDescription = describePathClassification(selectedClassification)
const shownEntries = entries.slice(0, MAX_PATH_LINES)
const hiddenCount = Math.max(entries.length - shownEntries.length, 0)
const pathLines = shownEntries.map((entry) => ` - ${entry.filePath} (code: ${entry.errorCode})`)
if (hiddenCount > 0) {
pathLines.push(` ... and ${hiddenCount} more`)
}
const environmentLines = selectedClassification === "unknown"
? []
: [`Detected environment: ${selectedDescription}`]
const durabilityLine = selectedClassification === "unknown"
? " - Crash durability is best-effort because this filesystem does not support fsync."
: " - Crash durability is best-effort on this filesystem (this is normal for iCloud, OneDrive, network drives, antivirus-locked paths)."
return [
"---",
`[fsync-skipped] ${entries.length} write(s) bypassed fsync because the underlying filesystem rejected the syscall.`,
"",
...environmentLines,
"Affected paths:",
...pathLines,
"",
"What this means:",
" - The write+rename succeeded — the file is on disk, atomicity is preserved.",
durabilityLine,
" - No action required. Operation completed successfully.",
].join("\n")
}
+14
View File
@@ -27,6 +27,7 @@ export function createJsonFileCacheStore<TValue>(
options: JsonFileCacheStoreOptions<TValue>,
): JsonFileCacheStore<TValue> {
let memoryValue: TValue | null | undefined
let writtenInCurrentProcess = false
function getCacheFilePath(): string {
return join(options.getCacheDir(), options.filename)
@@ -67,6 +68,17 @@ export function createJsonFileCacheStore<TValue>(
}
function has(): boolean {
// First check if we have a valid in-memory cache value
// This handles sandbox environments where existsSync may fail across contexts
if (memoryValue !== undefined && memoryValue !== null) {
return true
}
// Check if we've written to this cache in the current process
// This helps in sandbox environments where filesystem state may not persist across contexts
if (writtenInCurrentProcess) {
return true
}
// Fall back to filesystem check
return existsSync(getCacheFilePath())
}
@@ -77,6 +89,7 @@ export function createJsonFileCacheStore<TValue>(
try {
writeFileSync(cacheFile, options.serialize?.(value) ?? JSON.stringify(value, null, 2))
memoryValue = value
writtenInCurrentProcess = true
log(`[${options.logPrefix}] ${options.cacheLabel} written`, options.describe(value))
} catch (error) {
log(`[${options.logPrefix}] Error writing ${toLogLabel(options.cacheLabel)}`, {
@@ -87,6 +100,7 @@ export function createJsonFileCacheStore<TValue>(
function resetMemory(): void {
memoryValue = undefined
writtenInCurrentProcess = false
}
return {
@@ -87,6 +87,23 @@ describe("migrateLegacyConfigFile", () => {
expect(result).toBe(false)
expect(readFileSync(canonicalPath, "utf-8")).toBe('{ "new": true }')
})
it("#then does not copy legacy team_mode.tmux_visualization into the canonical file", () => {
const legacyPath = join(testDir, "oh-my-opencode.json")
const canonicalPath = join(testDir, "oh-my-openagent.json")
writeFileSync(legacyPath, JSON.stringify({
team_mode: {
enabled: true,
tmux_visualization: true,
},
}))
writeFileSync(canonicalPath, JSON.stringify({ hashline_edit: true }))
const result = migrateLegacyConfigFile(legacyPath)
expect(result).toBe(false)
expect(readFileSync(canonicalPath, "utf-8")).toBe(JSON.stringify({ hashline_edit: true }))
})
})
})
@@ -1,20 +1,19 @@
import type { ModelCapabilitiesSnapshotEntry } from "./types"
export const SUPPLEMENTAL_MODEL_CAPABILITIES: Record<string, ModelCapabilitiesSnapshotEntry> = {
"gpt-5.4-mini-fast": {
id: "gpt-5.4-mini-fast",
family: "gpt-mini",
"kimi-k2.6": {
id: "kimi-k2.6",
family: "kimi",
reasoning: true,
temperature: false,
temperature: true,
toolCall: true,
modalities: {
input: ["text", "image"],
input: ["text", "image", "video"],
output: ["text"],
},
limit: {
context: 400000,
input: 272000,
output: 128000,
context: 262144,
output: 262144,
},
},
"gpt-5.5": {
@@ -33,4 +32,20 @@ export const SUPPLEMENTAL_MODEL_CAPABILITIES: Record<string, ModelCapabilitiesSn
output: 128000,
},
},
"gpt-5.4-mini-fast": {
id: "gpt-5.4-mini-fast",
family: "gpt-mini",
reasoning: true,
temperature: false,
toolCall: true,
modalities: {
input: ["text", "image"],
output: ["text"],
},
limit: {
context: 400000,
input: 272000,
output: 128000,
},
},
}
@@ -129,4 +129,14 @@ describe("model-capability-aliases", () => {
ruleID: "claude-thinking-legacy-alias",
})
})
test("treats claude-opus-4-6-thinking as canonical, not as a legacy alias", () => {
const result = resolveModelIDAlias("claude-opus-4-6-thinking")
expect(result).toEqual({
requestedModelID: "claude-opus-4-6-thinking",
canonicalModelID: "claude-opus-4-6-thinking",
source: "canonical",
})
})
})
+2 -2
View File
@@ -53,8 +53,8 @@ const EXACT_ALIAS_RULES_BY_MODEL: ReadonlyMap<string, ExactAliasRule> = new Map(
const PATTERN_ALIAS_RULES: ReadonlyArray<PatternAliasRule> = [
{
ruleID: "claude-thinking-legacy-alias",
description: "Normalizes legacy Claude Opus thinking suffixes (4-6, 4-7) to the canonical snapshot ID.",
match: (normalizedModelID) => /^claude-opus-4-(?:6|7)-thinking$/.test(normalizedModelID),
description: "Normalizes the legacy claude-opus-4-7-thinking id to the canonical snapshot ID.",
match: (normalizedModelID) => /^claude-opus-4-7-thinking$/.test(normalizedModelID),
canonicalize: () => "claude-opus-4-7",
},
{
+8 -1
View File
@@ -6,6 +6,7 @@ export type HeuristicModelFamilyDefinition = {
pattern?: RegExp
variants?: string[]
reasoningEfforts?: string[]
reasoningEffortAliases?: Record<string, string>
supportsThinking?: boolean
}
@@ -32,7 +33,7 @@ export const HEURISTIC_MODEL_FAMILY_REGISTRY: ReadonlyArray<HeuristicModelFamily
family: "gpt-5",
includes: ["gpt-5"],
variants: ["low", "medium", "high", "xhigh"],
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"],
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
},
{
family: "gpt-legacy",
@@ -72,6 +73,12 @@ export const HEURISTIC_MODEL_FAMILY_REGISTRY: ReadonlyArray<HeuristicModelFamily
family: "deepseek",
includes: ["deepseek"],
variants: ["low", "medium", "high"],
reasoningEfforts: ["high", "max"],
reasoningEffortAliases: {
low: "high",
medium: "high",
xhigh: "max",
},
},
{
family: "mistral",
+46 -34
View File
@@ -41,7 +41,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const second = sisyphus.fallbackChain[1]
expect(second.providers).toEqual(["opencode-go", "vercel"])
expect(second.model).toBe("kimi-k2.5")
expect(second.model).toBe("kimi-k2.6")
const third = sisyphus.fallbackChain[2]
expect(third.providers).toEqual(["kimi-for-coding"])
@@ -72,27 +72,31 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
// then - fallbackChain exists with openai/gpt-5.4-mini-fast as first entry
expect(librarian).toBeDefined()
expect(librarian.fallbackChain).toBeArray()
expect(librarian.fallbackChain).toHaveLength(5)
expect(librarian.fallbackChain).toHaveLength(6)
const primary = librarian.fallbackChain[0]
expect(primary.providers).toEqual(["openai"])
expect(primary.model).toBe("gpt-5.4-mini-fast")
const second = librarian.fallbackChain[1]
expect(second.providers[0]).toBe("opencode-go")
expect(second.model).toBe("minimax-m2.7-highspeed")
expect(second.providers).toContain("opencode-go")
expect(second.model).toBe("qwen3.5-plus")
const tertiary = librarian.fallbackChain[2]
expect(tertiary.providers[0]).toBe("opencode-go")
expect(tertiary.model).toBe("minimax-m2.7")
const third = librarian.fallbackChain[2]
expect(third.providers).toEqual(["vercel"])
expect(third.model).toBe("minimax-m2.7-highspeed")
const quaternary = librarian.fallbackChain[3]
expect(quaternary.providers).toContain("anthropic")
expect(quaternary.model).toBe("claude-haiku-4-5")
expect(quaternary.providers).toContain("opencode-go")
expect(quaternary.model).toBe("minimax-m2.7")
const fifth = librarian.fallbackChain[4]
expect(fifth.providers).toContain("openai")
expect(fifth.model).toBe("gpt-5.4-nano")
const quinary = librarian.fallbackChain[4]
expect(quinary.providers).toContain("anthropic")
expect(quinary.model).toBe("claude-haiku-4-5")
const sixth = librarian.fallbackChain[5]
expect(sixth.providers).toContain("openai")
expect(sixth.model).toBe("gpt-5.4-nano")
})
test("explore has valid fallbackChain with openai/gpt-5.4-mini-fast as primary", () => {
@@ -102,7 +106,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
// when - accessing explore requirement
expect(explore).toBeDefined()
expect(explore.fallbackChain).toBeArray()
expect(explore.fallbackChain).toHaveLength(5)
expect(explore.fallbackChain).toHaveLength(6)
const primary = explore.fallbackChain[0]
expect(primary.providers).toEqual(["openai"])
@@ -110,19 +114,23 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const secondary = explore.fallbackChain[1]
expect(secondary.providers).toContain("opencode-go")
expect(secondary.model).toBe("minimax-m2.7-highspeed")
expect(secondary.model).toBe("qwen3.5-plus")
const tertiary = explore.fallbackChain[2]
expect(tertiary.providers).toContain("opencode-go")
expect(tertiary.model).toBe("minimax-m2.7")
const third = explore.fallbackChain[2]
expect(third.providers).toEqual(["vercel"])
expect(third.model).toBe("minimax-m2.7-highspeed")
const quaternary = explore.fallbackChain[3]
expect(quaternary.providers).toContain("anthropic")
expect(quaternary.model).toBe("claude-haiku-4-5")
expect(quaternary.providers).toContain("opencode-go")
expect(quaternary.model).toBe("minimax-m2.7")
const fifth = explore.fallbackChain[4]
expect(fifth.providers).toContain("openai")
expect(fifth.model).toBe("gpt-5.4-nano")
const quinary = explore.fallbackChain[4]
expect(quinary.providers).toContain("anthropic")
expect(quinary.model).toBe("claude-haiku-4-5")
const sixth = explore.fallbackChain[5]
expect(sixth.providers).toContain("openai")
expect(sixth.model).toBe("gpt-5.4-nano")
})
test("multimodal-looker has valid fallbackChain with gpt-5.5 as primary", () => {
@@ -130,7 +138,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
// when - accessing multimodal-looker requirement
// then - fallbackChain: gpt-5.5 -> opencode-go/kimi-k2.5 -> glm-4.6v -> gpt-5-nano
// then - fallbackChain: gpt-5.5 -> opencode-go/kimi-k2.6 -> glm-4.6v -> gpt-5-nano
expect(multimodalLooker).toBeDefined()
expect(multimodalLooker.fallbackChain).toBeArray()
expect(multimodalLooker.fallbackChain).toHaveLength(4)
@@ -142,7 +150,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const secondary = multimodalLooker.fallbackChain[1]
expect(secondary.providers).toEqual(["opencode-go", "vercel"])
expect(secondary.model).toBe("kimi-k2.5")
expect(secondary.model).toBe("kimi-k2.6")
const tertiary = multimodalLooker.fallbackChain[2]
expect(tertiary.model).toBe("glm-4.6v")
@@ -168,20 +176,24 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(primary.variant).toBe("max")
})
test("metis has claude-opus-4-7 as primary", () => {
test("metis has claude-sonnet-4-6 as primary", () => {
// #given - metis agent requirement
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
// #when - accessing Metis requirement
// #then - claude-opus-4-7 is first
// #then - claude-sonnet-4-6 is first, claude-opus-4-7 max is the immediate fallback
expect(metis).toBeDefined()
expect(metis.fallbackChain).toBeArray()
expect(metis.fallbackChain.length).toBeGreaterThan(1)
const primary = metis.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-7")
expect(primary.model).toBe("claude-sonnet-4-6")
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode", "vercel"])
expect(primary.variant).toBe("max")
expect(primary.variant).toBeUndefined()
const opusFallback = metis.fallbackChain[1]
expect(opusFallback.model).toBe("claude-opus-4-7")
expect(opusFallback.variant).toBe("max")
const openAiFallback = metis.fallbackChain.find((entry) => entry.providers.includes("openai"))
expect(openAiFallback).toEqual({
@@ -222,7 +234,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("anthropic")
const secondary = atlas.fallbackChain[1]
expect(secondary.model).toBe("kimi-k2.5")
expect(secondary.model).toBe("kimi-k2.6")
expect(secondary.providers[0]).toBe("opencode-go")
const tertiary = atlas.fallbackChain[2]
@@ -345,7 +357,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
// when - accessing visual-engineering requirement
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max) → opencode-go/glm-5 → k2p5
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max) → opencode-go/glm-5.1 → k2p5
expect(visualEngineering).toBeDefined()
expect(visualEngineering.fallbackChain).toBeArray()
expect(visualEngineering.fallbackChain).toHaveLength(5)
@@ -365,7 +377,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
const fourth = visualEngineering.fallbackChain[3]
expect(fourth.providers[0]).toBe("opencode-go")
expect(fourth.model).toBe("glm-5")
expect(fourth.model).toBe("glm-5.1")
const fifth = visualEngineering.fallbackChain[4]
expect(fifth.providers[0]).toBe("kimi-for-coding")
@@ -458,7 +470,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("google")
const second = writing.fallbackChain[1]
expect(second.model).toBe("kimi-k2.5")
expect(second.model).toBe("kimi-k2.6")
expect(second.providers[0]).toBe("opencode-go")
const third = writing.fallbackChain[2]
@@ -605,12 +617,12 @@ describe("requiresModel field in categories", () => {
expect(deep.requiresModel).toBeUndefined()
})
test("artistry category has requiresModel set to gemini-3.1-pro", () => {
test("artistry category no longer hard-requires gemini-3.1-pro", () => {
// given
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
// when / #then
expect(artistry.requiresModel).toBe("gemini-3.1-pro")
expect(artistry.requiresModel).toBeUndefined()
})
})
+25 -16
View File
@@ -25,7 +25,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{
providers: [
@@ -72,13 +72,14 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
librarian: {
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
@@ -87,7 +88,8 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
explore: {
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
@@ -96,7 +98,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"multimodal-looker": {
fallbackChain: [
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.5", variant: "medium" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["zai-coding-plan", "vercel"], model: "glm-4.6v" },
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5-nano" },
],
@@ -113,7 +115,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gpt-5.5",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
@@ -122,6 +124,10 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
metis: {
fallbackChain: [
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-sonnet-4-6",
},
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-opus-4-7",
@@ -132,7 +138,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gpt-5.5",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
@@ -153,13 +159,13 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gemini-3.1-pro",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
atlas: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
@@ -171,7 +177,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"sisyphus-junior": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
@@ -197,7 +203,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
@@ -218,7 +224,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
deep: {
@@ -238,6 +244,8 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gemini-3.1-pro",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
artistry: {
@@ -253,8 +261,9 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
variant: "max",
},
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
requiresModel: "gemini-3.1-pro",
},
quick: {
fallbackChain: [
@@ -285,7 +294,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gpt-5.3-codex",
variant: "medium",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3-flash",
@@ -307,7 +316,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["opencode", "vercel"], model: "kimi-k2.5" },
{
providers: [
@@ -329,7 +338,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3-flash",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-sonnet-4-6",
@@ -257,7 +257,7 @@ describe("resolveCompatibleModelSettings", () => {
{ name: "Kimi (k2)", modelID: "k2-v2", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "GLM", modelID: "glm-5", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "Minimax", modelID: "minimax-m2.5", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "DeepSeek", modelID: "deepseek-r2", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "DeepSeek", modelID: "deepseek-r2", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: true },
{ name: "Mistral", modelID: "mistral-large-next", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "Codestral → Mistral", modelID: "codestral-2506", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
{ name: "Llama", modelID: "llama-4-maverick", expectedVariants: ["low", "medium", "high"], hasReasoningEffort: false },
@@ -320,6 +320,68 @@ describe("resolveCompatibleModelSettings", () => {
})
})
test("DeepSeek keeps canonical high and max reasoningEffort values", () => {
for (const reasoningEffort of ["high", "max"]) {
const result = resolveCompatibleModelSettings({
providerID: "openai-compatible",
modelID: "deepseek-v4-pro",
desired: { reasoningEffort },
})
expect(result.reasoningEffort).toBe(reasoningEffort)
expect(result.changes).toEqual([])
}
})
test("DeepSeek maps generic reasoningEffort levels to canonical API values", () => {
const cases = [
{ requested: "low", expected: "high" },
{ requested: "medium", expected: "high" },
{ requested: "xhigh", expected: "max" },
]
for (const { requested, expected } of cases) {
const result = resolveCompatibleModelSettings({
providerID: "openai-compatible",
modelID: "deepseek-v4-pro",
desired: { reasoningEffort: requested },
})
expect(result.reasoningEffort).toBe(expected)
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: requested,
to: expected,
reason: "unsupported-by-model-family",
},
])
}
})
test("DeepSeek maps generic reasoningEffort levels when capabilities come from heuristics", () => {
const capabilities = getModelCapabilities({
providerID: "openai-compatible",
modelID: "deepseek-v4-pro",
})
const result = resolveCompatibleModelSettings({
providerID: "openai-compatible",
modelID: "deepseek-v4-pro",
desired: { reasoningEffort: "xhigh" },
capabilities,
})
expect(result.reasoningEffort).toBe("max")
expect(result.changes).toEqual([
{
field: "reasoningEffort",
from: "xhigh",
to: "max",
reason: "unsupported-by-model-family",
},
])
})
test("GPT-5 downgrades unsupported max variant to xhigh", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
+19 -6
View File
@@ -32,10 +32,10 @@ export type ModelSettingsCompatibilityChange = {
from: string
to?: string
reason:
| "unsupported-by-model-family"
| "unknown-model-family"
| "unsupported-by-model-metadata"
| "max-output-limit"
| "unsupported-by-model-family"
| "unknown-model-family"
| "unsupported-by-model-metadata"
| "max-output-limit"
}
export type ModelSettingsCompatibilityResult = {
@@ -49,7 +49,7 @@ export type ModelSettingsCompatibilityResult = {
}
const VARIANT_LADDER = ["low", "medium", "high", "xhigh", "max"]
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh"]
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
function downgradeWithinLadder(value: string, allowed: string[], ladder: string[]): string | undefined {
const requestedIndex = ladder.indexOf(value)
@@ -86,7 +86,13 @@ function resolveField(
ladder: string[],
familyKnown: boolean,
metadataOverride?: string[],
familyAliases?: Record<string, string>,
): FieldResolution {
const aliased = familyAliases?.[normalized]
if (aliased && (metadataOverride?.includes(aliased) || familyCaps?.includes(aliased))) {
return { value: aliased, reason: "unsupported-by-model-family" }
}
if (metadataOverride) {
if (metadataOverride.includes(normalized)) return { value: normalized }
return {
@@ -132,7 +138,14 @@ export function resolveCompatibleModelSettings(
let reasoningEffort = input.desired.reasoningEffort
if (reasoningEffort !== undefined) {
const normalized = reasoningEffort.toLowerCase()
const resolved = resolveField(normalized, family?.reasoningEfforts, REASONING_LADDER, familyKnown, metadataReasoningEfforts)
const resolved = resolveField(
normalized,
family?.reasoningEfforts,
REASONING_LADDER,
familyKnown,
metadataReasoningEfforts,
family?.reasoningEffortAliases,
)
if (resolved.value !== normalized && resolved.reason) {
changes.push({ field: "reasoningEffort", from: reasoningEffort, to: resolved.value, reason: resolved.reason })
}
+48
View File
@@ -132,6 +132,54 @@ describe("opencode-version", () => {
// then returns null without executing command
expect(result).toBe(null)
})
test("reads adjacent package version before executing opencode binary", () => {
// given an opencode package next to the resolved binary
const calls: string[] = []
// when getting version
const result = getOpenCodeVersion({
getBinaryPath: () => "/tmp/opencode-ai/bin/opencode",
realpath: (filePath) => filePath,
exists: (filePath) => filePath === "/tmp/opencode-ai/package.json",
readText: (filePath) => {
calls.push(`read:${filePath}`)
return JSON.stringify({ name: "opencode-ai", version: "1.14.41" })
},
execCommand: () => {
calls.push("exec")
return "1.14.41"
},
})
// then the version is resolved without spawning the CLI
expect(result).toBe("1.14.41")
expect(calls).toEqual(["read:/tmp/opencode-ai/package.json"])
})
test("falls back to opencode binary when package version is unavailable", () => {
// given no adjacent package version can be read
const calls: string[] = []
// when getting version
const result = getOpenCodeVersion({
getBinaryPath: () => "/tmp/custom-opencode",
realpath: (filePath) => filePath,
exists: () => false,
readText: () => {
calls.push("read")
return ""
},
execCommand: () => {
calls.push("exec")
return "opencode 1.14.42"
},
})
// then the original CLI fallback remains intact
expect(result).toBe("1.14.42")
expect(calls).toEqual(["exec"])
})
})
describe("isOpenCodeVersionAtLeast", () => {
+76 -2
View File
@@ -1,4 +1,6 @@
import { execSync } from "child_process"
import { existsSync, readFileSync, realpathSync } from "fs"
import { dirname, join } from "path"
/**
* Minimum OpenCode version required for this plugin.
@@ -24,6 +26,38 @@ export const OPENCODE_SQLITE_VERSION = "1.1.53"
const NOT_CACHED = Symbol("NOT_CACHED")
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED
type RuntimeWithBun = typeof globalThis & {
Bun?: {
which(binary: string): string | null
}
}
type ExecCommandOptions = {
encoding: "utf-8"
timeout: number
stdio: ["pipe", "pipe", "pipe"]
}
export type OpenCodeVersionDeps = {
execCommand: (command: string, options: ExecCommandOptions) => string
getBinaryPath: () => string | null
exists: (filePath: string) => boolean
realpath: (filePath: string) => string
readText: (filePath: string) => string
}
const defaultDeps: OpenCodeVersionDeps = {
execCommand: (command, options) => execSync(command, options),
getBinaryPath: () => {
const envPath = process.env.OPENCODE_BIN_PATH
if (envPath) return envPath
return (globalThis as RuntimeWithBun).Bun?.which("opencode") ?? null
},
exists: existsSync,
realpath: realpathSync,
readText: (filePath) => readFileSync(filePath, "utf-8"),
}
export function parseVersion(version: string): number[] {
const cleaned = version.replace(/^v/, "").split("-")[0]
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
@@ -43,14 +77,54 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
return 0
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
export function getOpenCodeVersion(): string | null {
function parsePackageVersion(content: string): string | null {
try {
const parsed: unknown = JSON.parse(content)
if (!isRecord(parsed)) return null
const name = parsed.name
const version = parsed.version
if (typeof name !== "string" || !name.includes("opencode")) return null
if (typeof version !== "string" || version.length === 0) return null
return version
} catch {
return null
}
}
function getPackageVersionFromBinary(binaryPath: string, deps: OpenCodeVersionDeps): string | null {
try {
const realBinaryPath = deps.realpath(binaryPath)
const packagePath = join(dirname(dirname(realBinaryPath)), "package.json")
if (!deps.exists(packagePath)) return null
return parsePackageVersion(deps.readText(packagePath))
} catch {
return null
}
}
export function getOpenCodeVersion(deps: Partial<OpenCodeVersionDeps> = {}): string | null {
if (cachedVersion !== NOT_CACHED) {
return cachedVersion
}
const resolvedDeps: OpenCodeVersionDeps = { ...defaultDeps, ...deps }
const binaryPath = resolvedDeps.getBinaryPath()
if (binaryPath) {
const packageVersion = getPackageVersionFromBinary(binaryPath, resolvedDeps)
if (packageVersion) {
cachedVersion = packageVersion
return cachedVersion
}
}
try {
const result = execSync("opencode --version", {
const result = resolvedDeps.execCommand("opencode --version", {
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
@@ -4,16 +4,6 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { discoverPluginCommandDefinitions } from "./plugin-command-discovery"
const ENV_KEYS = [
"CLAUDE_CONFIG_DIR",
"CLAUDE_PLUGINS_HOME",
"CLAUDE_SETTINGS_PATH",
"OPENCODE_CONFIG_DIR",
] as const
type EnvKey = (typeof ENV_KEYS)[number]
type EnvSnapshot = Record<EnvKey, string | undefined>
function writePluginFixture(baseDir: string): void {
const claudeConfigDir = join(baseDir, "claude-config")
const pluginsHome = join(claudeConfigDir, "plugins")
@@ -94,28 +84,13 @@ Build a plan from plugin skill context.
describe("plugin command discovery utility", () => {
let tempDir = ""
let envSnapshot: EnvSnapshot
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "omo-shared-plugin-discovery-test-"))
envSnapshot = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME,
CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH,
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
}
writePluginFixture(tempDir)
})
afterEach(() => {
for (const key of ENV_KEYS) {
const previousValue = envSnapshot[key]
if (previousValue === undefined) {
delete process.env[key]
} else {
process.env[key] = previousValue
}
}
rmSync(tempDir, { recursive: true, force: true })
})
+63 -29
View File
@@ -65,10 +65,10 @@ describe("posthog client creation", () => {
// then
expect(() => cliPostHog.trackActive("cli", "run_started")).not.toThrow()
await expect(cliPostHog.shutdown()).resolves.toBeUndefined()
expect(await cliPostHog.shutdown()).toBeUndefined()
expect(() => pluginPostHog.trackActive("plugin", "plugin_loaded")).not.toThrow()
await expect(pluginPostHog.shutdown()).resolves.toBeUndefined()
expect(() => pluginPostHog.trackActive("plugin", "run_started")).not.toThrow()
expect(await pluginPostHog.shutdown()).toBeUndefined()
})
it("creates a plugin client when os.cpus throws", async () => {
@@ -77,20 +77,6 @@ describe("posthog client creation", () => {
process.env.OMO_SEND_ANONYMOUS_TELEMETRY = "1"
process.env.POSTHOG_API_KEY = "test-api-key"
mock.module("os", () => ({
default: {
arch: () => "x64",
cpus: () => {
throw new Error("Failed to get CPU information")
},
hostname: () => "test-host",
platform: () => "linux",
release: () => "6.8.0-arch1-1",
totalmem: () => 8 * 1024 * 1024 * 1024,
type: () => "Linux",
},
}))
mock.module("posthog-node", () => ({
PostHog: class {
capture() {}
@@ -98,14 +84,61 @@ describe("posthog client creation", () => {
},
}))
const { createPluginPostHog } = await importPostHogModule()
const posthogModule = await importPostHogModule()
posthogModule.__setOsProviderForTesting({
arch: () => "x64",
cpus: () => {
throw new Error("Failed to get CPU information")
},
hostname: () => "test-host",
platform: () => "linux",
release: () => "6.8.0-arch1-1",
totalmem: () => 8 * 1024 * 1024 * 1024,
type: () => "Linux",
})
// when
const pluginPostHog = createPluginPostHog()
const pluginPostHog = posthogModule.createPluginPostHog()
// then
expect(() => pluginPostHog.trackActive("plugin", "plugin_loaded")).not.toThrow()
await expect(pluginPostHog.shutdown()).resolves.toBeUndefined()
expect(() => pluginPostHog.trackActive("plugin", "run_started")).not.toThrow()
expect(await pluginPostHog.shutdown()).toBeUndefined()
posthogModule.__resetOsProviderForTesting()
})
it("passes the strict PostHog constructor options for both clients", async () => {
// given
enableTelemetryEnv()
const capturedOptions: Array<Record<string, unknown>> = []
mock.module("posthog-node", () => ({
PostHog: class {
constructor(_apiKey: string, options: Record<string, unknown>) {
capturedOptions.push(options)
}
capture() {}
async shutdown() {}
},
}))
const { createCliPostHog, createPluginPostHog } = await importPostHogModule()
// when
createCliPostHog()
createPluginPostHog()
// then
expect(capturedOptions).toHaveLength(2)
for (const options of capturedOptions) {
expect(options).toMatchObject({
enableExceptionAutocapture: false,
enableLocalEvaluation: false,
strictLocalEvaluation: true,
disableRemoteConfig: true,
flushAt: 1,
flushInterval: 0,
})
}
})
})
@@ -145,15 +178,16 @@ describe("posthog trackActive emission contract", () => {
const emittedEvents = captured.map((message) => message.event)
expect(emittedEvents).not.toContain("omo_hourly_active")
const [dailyEvent] = captured
if (!dailyEvent) {
throw new Error("Expected daily event")
}
expect(dailyEvent?.event).toBe("omo_daily_active")
expect(dailyEvent?.distinctId).toBe("distinct-cli")
expect(dailyEvent?.properties).toMatchObject({
day_utc: "2026-04-18",
reason: "run_started",
source: "cli",
$process_person_profile: false,
})
expect(dailyEvent?.properties).not.toHaveProperty("hour_utc")
expect(dailyEvent.properties?.day_utc).toBe("2026-04-18")
expect(dailyEvent.properties?.reason).toBe("run_started")
expect(dailyEvent.properties?.source).toBe("cli")
expect(dailyEvent.properties?.$process_person_profile).toBe(false)
expect(Object.prototype.hasOwnProperty.call(dailyEvent.properties ?? {}, "hour_utc")).toBe(false)
})
it("emits nothing and never omo_hourly_active when captureDaily is false", async () => {
@@ -170,7 +204,7 @@ describe("posthog trackActive emission contract", () => {
const client = posthogModule.createPluginPostHog()
// when
client.trackActive("distinct-plugin", "plugin_loaded")
client.trackActive("distinct-plugin", "run_started")
// then
expect(captured).toHaveLength(0)
+31 -8
View File
@@ -7,11 +7,17 @@ import { getPostHogActivityCaptureState } from "./posthog-activity-state"
/** @internal test-only seam: keep null in production to use the real implementation. */
let activityStateProviderOverride: typeof getPostHogActivityCaptureState | null = null
type OsProvider = Pick<typeof os, "arch" | "cpus" | "hostname" | "platform" | "release" | "totalmem" | "type">
let osProviderOverride: OsProvider | null = null
function resolveActivityState(): ReturnType<typeof getPostHogActivityCaptureState> {
return (activityStateProviderOverride ?? getPostHogActivityCaptureState)()
}
function resolveOsProvider(): OsProvider {
return osProviderOverride ?? os
}
/** @internal test-only */
export function __setActivityStateProviderForTesting(
provider: typeof getPostHogActivityCaptureState,
@@ -24,12 +30,22 @@ export function __resetActivityStateProviderForTesting(): void {
activityStateProviderOverride = null
}
/** @internal test-only */
export function __setOsProviderForTesting(provider: OsProvider): void {
osProviderOverride = provider
}
/** @internal test-only */
export function __resetOsProviderForTesting(): void {
osProviderOverride = null
}
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74"
type PostHogCaptureEvent = Parameters<PostHog["capture"]>[0]
type PostHogSource = "cli" | "plugin"
type PostHogActivityReason = "run_started" | "plugin_loaded"
type PostHogActivityReason = "run_started"
type PostHogClient = {
trackActive: (distinctId: string, reason: PostHogActivityReason) => void
@@ -67,7 +83,7 @@ function getPostHogHost(): string {
function safeCpus(): { length: number; model: string | undefined } {
try {
const cpus = os.cpus()
const cpus = resolveOsProvider().cpus()
return { length: cpus.length, model: cpus[0]?.model }
} catch {
return { length: 0, model: undefined }
@@ -76,6 +92,7 @@ function safeCpus(): { length: number; model: string | undefined } {
function getSharedProperties(source: PostHogSource): NonNullable<PostHogCaptureEvent["properties"]> {
const cpus = safeCpus()
const osProvider = resolveOsProvider()
return {
platform: "oh-my-opencode",
@@ -85,13 +102,13 @@ function getSharedProperties(source: PostHogSource): NonNullable<PostHogCaptureE
runtime: "bun",
runtime_version: process.versions.bun ?? process.version,
source,
$os: os.platform(),
$os_version: os.release(),
os_arch: os.arch(),
os_type: os.type(),
$os: osProvider.platform(),
$os_version: osProvider.release(),
os_arch: osProvider.arch(),
os_type: osProvider.type(),
cpu_count: cpus.length,
cpu_model: cpus.model,
total_memory_gb: Math.round(os.totalmem() / 1024 / 1024 / 1024),
total_memory_gb: Math.round(osProvider.totalmem() / 1024 / 1024 / 1024),
locale: Intl.DateTimeFormat().resolvedOptions().locale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
shell: process.env.SHELL,
@@ -144,13 +161,16 @@ function createPostHogClient(
export function getPostHogDistinctId(): string {
return createHash("sha256")
.update(`${PUBLISHED_PACKAGE_NAME}:${os.hostname()}`)
.update(`${PUBLISHED_PACKAGE_NAME}:${resolveOsProvider().hostname()}`)
.digest("hex")
}
export function createCliPostHog(): PostHogClient {
return createPostHogClient("cli", {
enableExceptionAutocapture: false,
enableLocalEvaluation: false,
strictLocalEvaluation: true,
disableRemoteConfig: true,
flushAt: 1,
flushInterval: 0,
})
@@ -159,6 +179,9 @@ export function createCliPostHog(): PostHogClient {
export function createPluginPostHog(): PostHogClient {
return createPostHogClient("plugin", {
enableExceptionAutocapture: false,
enableLocalEvaluation: false,
strictLocalEvaluation: true,
disableRemoteConfig: true,
flushAt: 1,
flushInterval: 0,
})
+91 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, realpathSync, rmSync } from "node:fs"
import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
@@ -121,4 +121,94 @@ describe("project-discovery-dirs", () => {
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
})
it("#given nested .opencode plugin config files #when finding plugin config files #then returns nearest-first canonical paths", async () => {
// given
const grandparentDir = join(TEST_DIR, "grandparent")
const parentDir = join(grandparentDir, "parent")
const projectDir = join(parentDir, "project")
mkdirSync(join(grandparentDir, ".opencode"), { recursive: true })
mkdirSync(join(parentDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(parentDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([
canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(parentDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc")),
])
})
it("#given a stop directory #when finding plugin config files #then walking halts at the stop boundary inclusive", async () => {
// given
const stopDir = join(TEST_DIR, "stop")
const childDir = join(stopDir, "child")
mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true })
mkdirSync(join(stopDir, ".opencode"), { recursive: true })
mkdirSync(join(childDir, ".opencode"), { recursive: true })
writeFileSync(join(TEST_DIR, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(stopDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(childDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(childDir, stopDir)
// then
expect(paths).toEqual([
canonicalPath(join(childDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(stopDir, ".opencode", "oh-my-openagent.jsonc")),
])
})
it("#given a legacy basename in an ancestor #when finding plugin config files #then detection picks up the legacy path", async () => {
// given
const projectDir = join(TEST_DIR, "project")
mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc"), "{}")
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([
canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc")),
])
})
it("#given no .opencode directories along the walk #when finding plugin config files #then returns an empty list", async () => {
// given
const projectDir = join(TEST_DIR, "project", "deep")
mkdirSync(projectDir, { recursive: true })
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([])
})
})
+34
View File
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"
import { existsSync, realpathSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
import { detectPluginConfigFile } from "./jsonc-parser"
const worktreePathCache = new Map<string, string | undefined>()
function normalizePath(path: string): string {
@@ -114,3 +116,35 @@ export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirec
stopDirectory ?? detectWorktreePath(startDirectory),
)
}
export function findProjectOpencodePluginConfigFiles(
startDirectory: string,
stopDirectory?: string,
): string[] {
const paths: string[] = []
const seen = new Set<string>()
let currentDirectory = normalizePath(startDirectory)
const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined
while (true) {
const opencodeDirectory = join(currentDirectory, ".opencode")
if (existsSync(opencodeDirectory)) {
const detected = detectPluginConfigFile(opencodeDirectory)
if (detected.format !== "none" && !seen.has(detected.path)) {
seen.add(detected.path)
paths.push(detected.path)
}
}
if (resolvedStopDirectory === currentDirectory) {
return paths
}
const parentDirectory = dirname(currentDirectory)
if (parentDirectory === currentDirectory) {
return paths
}
currentDirectory = normalizePath(parentDirectory)
}
}
+4
View File
@@ -173,3 +173,7 @@ export function shellEscapeForDoubleQuotedCommand(value: string): string {
.replace(/\(/g, "\\(") // escape parentheses
.replace(/\)/g, "\\)") // escape parentheses
}
export function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { spawn as bunSpawn } from "bun"
import { spawn as bunSpawn } from "./bun-spawn-shim"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { Readable } from "node:stream"
@@ -75,7 +75,7 @@ export function spawnWithWindowsHide(command: string[], options: SpawnOptions):
const proc = nodeSpawn(cmd, args, {
cwd: options.cwd,
env: options.env,
stdio: [options.stdin ?? "pipe", options.stdout ?? "pipe", options.stderr ?? "pipe"],
stdio: [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"],
windowsHide: true,
shell: true,
})
+8 -4
View File
@@ -1,11 +1,15 @@
// Polling interval for background session status checks
export const POLL_INTERVAL_BACKGROUND_MS = 2000
// Maximum idle time before session considered stale
export const SESSION_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
// Long-running subagent work can legitimately stay open for a while.
// The tmux-subagent stability fixes raised this guard from 10 minutes after
// polling closed active panes during long tasks.
export const SESSION_TIMEOUT_MS = 60 * 60 * 1000 // 60 minutes
// Grace period for missing session before cleanup
export const SESSION_MISSING_GRACE_MS = 6000 // 6 seconds
// Status queries can transiently miss live sessions under load.
// The tmux-subagent stability fixes raised this guard from 6 seconds after
// false missing detections closed healthy panes.
export const SESSION_MISSING_GRACE_MS = 30 * 1000 // 30 seconds
// Session readiness polling config
export const SESSION_READY_POLL_INTERVAL_MS = 500
+1
View File
@@ -1,3 +1,4 @@
export * from "./types"
export * from "./constants"
export * from "./runner"
export * from "./tmux-utils"
+127
View File
@@ -0,0 +1,127 @@
/// <reference types="bun-types" />
import { afterAll, describe, expect, test } from "bun:test"
import { randomUUID } from "node:crypto"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { runTmuxCommand } from "./runner"
const temporaryDirectories: string[] = []
async function createTemporaryDirectory(): Promise<string> {
const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-runner-"))
temporaryDirectories.push(directoryPath)
return directoryPath
}
async function readInvocationCount(counterFilePath: string): Promise<number> {
const count = await fs.readFile(counterFilePath, "utf8")
return Number.parseInt(count, 10)
}
afterAll(async () => {
for (const directoryPath of temporaryDirectories) {
await fs.rm(directoryPath, { recursive: true, force: true })
}
})
describe("runTmuxCommand", () => {
test("#given command exits 0 with stdout #when run #then success true, output and stdout equal trimmed value, stderr empty", async () => {
// given
const commandArguments = ["-c", "printf '%s\\n' '%42'"]
// when
const result = await runTmuxCommand("sh", commandArguments)
// then
expect(result).toEqual({
success: true,
output: "%42",
stdout: "%42",
stderr: "",
exitCode: 0,
})
})
test("#given command exits 1 with stderr #when run #then success false, stderr populated", async () => {
// given
const commandArguments = ["-c", "printf '%s\\n' 'some error' >&2; exit 1"]
// when
const result = await runTmuxCommand("sh", commandArguments)
// then
expect(result.success).toBe(false)
expect(result.stderr).toBe("some error")
expect(result.exitCode).toBe(1)
})
test("#given retry=2 and first exit nonzero #when run #then calls spawn twice before returning failure", async () => {
// given
const temporaryDirectory = await createTemporaryDirectory()
const counterFilePath = path.join(temporaryDirectory, `${randomUUID()}.count`)
const commandScript = `counter_file="$1"; count=0; if [ -f "$counter_file" ]; then count=$(cat "$counter_file"); fi; count=$((count + 1)); printf '%s' "$count" > "$counter_file"; printf '%s\\n' 'temporary error' >&2; exit 1`
// when
const result = await runTmuxCommand("sh", ["-c", commandScript, "sh", counterFilePath], { retry: 2 })
// then
expect(result.success).toBe(false)
expect(result.stderr).toBe("temporary error")
expect(await readInvocationCount(counterFilePath)).toBe(3)
})
test("#given retry=2 and stderr contains 'can't find pane' #when run #then does NOT retry", async () => {
// given
const temporaryDirectory = await createTemporaryDirectory()
const counterFilePath = path.join(temporaryDirectory, `${randomUUID()}.count`)
const commandScript = `counter_file="$1"; count=0; if [ -f "$counter_file" ]; then count=$(cat "$counter_file"); fi; count=$((count + 1)); printf '%s' "$count" > "$counter_file"; printf '%s\\n' "can't find pane: %1" >&2; exit 1`
// when
const result = await runTmuxCommand("sh", ["-c", commandScript, "sh", counterFilePath], { retry: 2 })
// then
expect(result.success).toBe(false)
expect(result.stderr).toContain("can't find pane")
expect(await readInvocationCount(counterFilePath)).toBe(1)
})
test("#given timeoutMs=50 and command sleeps 500ms #when run #then returns timeout failure", async () => {
// given
const commandArguments = ["-c", "sleep 0.5"]
// when
const result = await runTmuxCommand("sh", commandArguments, { timeoutMs: 50 })
// then
expect(result.success).toBe(false)
expect(result.exitCode).toBe(-1)
expect(result.stderr).toContain("timeout")
})
test("#given stdout contains trailing newline #when run #then output is trimmed", async () => {
// given
const commandArguments = ["-c", "printf '%s\\n\\n' '%7'"]
// when
const result = await runTmuxCommand("sh", commandArguments)
// then
expect(result.output).toBe("%7")
expect(result.stdout).toBe("%7")
})
test("#given backward-compat consumer destructures {success, output} #when result returned #then both fields present and correct", async () => {
// given
const commandArguments = ["-c", "printf '%s\\n' '%9'"]
// when
const { success, output } = await runTmuxCommand("sh", commandArguments)
// then
expect(success).toBe(true)
expect(output).toBe("%9")
})
})
+107
View File
@@ -0,0 +1,107 @@
import { spawn } from "../bun-spawn-shim"
type RunTmuxOptions = {
retry?: number
timeoutMs?: number
}
export type TmuxCommandResult = {
success: boolean
output: string
stdout: string
stderr: string
exitCode: number
}
const TERMINAL_TMUX_ERROR_PATTERN = /can't find (pane|session)/i
function createTmuxCommandResult(stdout: string, stderr: string, exitCode: number): TmuxCommandResult {
return {
success: exitCode === 0,
output: stdout,
stdout,
stderr,
exitCode,
}
}
function isTerminalTmuxError(stderr: string): boolean {
return TERMINAL_TMUX_ERROR_PATTERN.test(stderr)
}
/**
* Detect whether we are running inside cmux (cmux omo).
* When cmux-omo sets up the environment it injects a tmux shim and sets
* CMUX_SOCKET_PATH / TMUX. If detected, redirect tmux commands to
* `cmux __tmux-compat` so they become native cmux splits instead of
* failing because there is no real tmux server running.
*/
function resolveTmuxExecutable(tmuxPath: string): string[] {
const inCmux = Boolean(process.env.CMUX_SOCKET_PATH) ||
process.env.TMUX?.includes("cmuxterm") === true
if (inCmux) {
return ["cmux", "__tmux-compat"]
}
return [tmuxPath]
}
async function runTmuxCommandOnce(tmuxPath: string, args: Array<string>, timeoutMs?: number): Promise<TmuxCommandResult> {
const abortController = new AbortController()
const subprocess = spawn([...resolveTmuxExecutable(tmuxPath), ...args], {
stdout: "pipe",
stderr: "pipe",
signal: abortController.signal,
})
const stdoutPromise = new Response(subprocess.stdout).text()
const stderrPromise = new Response(subprocess.stderr).text()
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
const exitCodeOrTimeout = timeoutMs === undefined
? await subprocess.exited
: await Promise.race<number | "timeout">(([
subprocess.exited,
new Promise<"timeout">((resolve) => {
timeoutId = setTimeout(() => {
abortController.abort()
resolve("timeout")
}, timeoutMs)
}),
]))
if (exitCodeOrTimeout === "timeout") {
void subprocess.exited.catch(() => undefined)
void stdoutPromise.catch(() => "")
void stderrPromise.catch(() => "")
return createTmuxCommandResult("", "timeout", -1)
}
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise])
return createTmuxCommandResult(stdout.trim(), stderr.trim(), exitCodeOrTimeout)
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}
}
export async function runTmuxCommand(tmuxPath: string, args: string[], options: RunTmuxOptions = {}): Promise<TmuxCommandResult> {
const retryCount = Math.max(0, options.retry ?? 0)
let lastResult = createTmuxCommandResult("", "", 1)
for (let attempt = 0; attempt <= retryCount; attempt += 1) {
const result = await runTmuxCommandOnce(tmuxPath, args, options.timeoutMs)
lastResult = result
if (result.exitCode === 0) {
return result
}
if (attempt === retryCount || isTerminalTmuxError(result.stderr)) {
return result
}
}
return lastResult
}
+1 -1
View File
@@ -12,6 +12,6 @@ export { replaceTmuxPane } from "./tmux-utils/pane-replace"
export { spawnTmuxWindow } from "./tmux-utils/window-spawn"
export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn"
export { killTmuxSessionIfExists } from "./tmux-utils/session-kill"
export { sweepStaleOmoAgentSessions } from "./tmux-utils/stale-session-sweep"
export { sweepStaleOmoAgentSessions, sweepTmuxSessionsWith } from "./tmux-utils/stale-session-sweep"
export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout"
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxCommandResult } from "../runner"
const layoutSpecifier = import.meta.resolve("./layout")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
async function loadEnforceMainPaneWidth(): Promise<typeof import("./layout").enforceMainPaneWidth> {
const module = await import(`${layoutSpecifier}?test=${crypto.randomUUID()}`)
return module.enforceMainPaneWidth
}
function registerModuleMocks(): void {
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("enforceMainPaneWidth runner integration", () => {
beforeEach(() => {
registerModuleMocks()
runTmuxCommandMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
runTmuxCommandMock.mockResolvedValue({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given pane width inputs #when enforceMainPaneWidth called #then delegates resize-pane to shared runner", async () => {
// given
const enforceMainPaneWidth = await loadEnforceMainPaneWidth()
// when
await enforceMainPaneWidth("%42", 200, 60)
// then
expect(runTmuxCommandMock.mock.calls).toEqual([
[[expect.any(String), ["resize-pane", "-t", "%42", "-x", "119"]]][0],
])
})
})
+8 -7
View File
@@ -1,4 +1,3 @@
import { spawn } from "bun"
import type { TmuxLayout } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
@@ -46,7 +45,12 @@ export async function applyLayout(
mainPaneSize: number,
deps?: LayoutDeps,
): Promise<void> {
const spawnCommand: TmuxSpawnCommand = deps?.spawnCommand ?? spawn
const spawnCommand: TmuxSpawnCommand = deps?.spawnCommand ?? ((args) => ({
exited: (async () => {
const { runTmuxCommand } = await import("../runner")
return (await runTmuxCommand(args[0] ?? "", args.slice(1))).exitCode
})(),
}))
const layoutProc = spawnCommand([tmux, "select-layout", layout], {
stdout: "ignore",
stderr: "ignore",
@@ -78,12 +82,9 @@ export async function enforceMainPaneWidth(
? { mainPaneSize: mainPaneSizeOrOptions }
: mainPaneSizeOrOptions ?? {}
const mainWidth = calculateMainPaneWidth(windowWidth, options)
const { runTmuxCommand } = await import("../runner")
const proc = spawn([tmux, "resize-pane", "-t", mainPaneId, "-x", String(mainWidth)], {
stdout: "ignore",
stderr: "ignore",
})
await proc.exited
await runTmuxCommand(tmux, ["resize-pane", "-t", mainPaneId, "-x", String(mainWidth)])
log("[enforceMainPaneWidth] main pane resized", {
mainPaneId,
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxCommandResult } from "../runner"
const paneCloseSpecifier = import.meta.resolve("./pane-close")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
async function loadCloseTmuxPane(): Promise<typeof import("./pane-close").closeTmuxPane> {
const module = await import(`${paneCloseSpecifier}?test=${crypto.randomUUID()}`)
return module.closeTmuxPane
}
function registerModuleMocks(): void {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("closeTmuxPane runner integration", () => {
beforeEach(() => {
registerModuleMocks()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
runTmuxCommandMock.mockResolvedValue({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
})
isInsideTmuxMock.mockReturnValue(true)
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given pane exists #when closeTmuxPane called #then delegates send-keys and kill-pane to shared runner", async () => {
// given
const closeTmuxPane = await loadCloseTmuxPane()
// when
const result = await closeTmuxPane("%42")
// then
expect(result).toBe(true)
expect(runTmuxCommandMock.mock.calls).toEqual([
["sh", ["send-keys", "-t", "%42", "C-c"]],
["sh", ["kill-pane", "-t", "%42"]],
])
})
})
+42 -143
View File
@@ -1,179 +1,101 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
type CloseTmuxPane = typeof import("./pane-close").closeTmuxPane
type SpawnCall = {
command: string[]
options: {
stdout?: string
stderr?: string
}
}
type FakeSubprocess = {
exited: Promise<number>
stdout: ReadableStream<Uint8Array>
stderr: ReadableStream<Uint8Array>
}
const TIMEOUT = Symbol("timeout")
const spawnCalls: SpawnCall[] = []
const queuedProcesses: FakeSubprocess[] = []
function createClosedStream(): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.close()
},
})
}
type DrainSignal = { onPull: () => void }
function createDrainSensitiveStream(byteLength: number, signal: DrainSignal): ReadableStream<Uint8Array> {
let remainingBytes = byteLength
const chunk = new TextEncoder().encode("x".repeat(16 * 1024))
return new ReadableStream<Uint8Array>({
pull(controller) {
signal.onPull()
if (remainingBytes <= 0) {
controller.close()
return
}
const nextChunkSize = Math.min(remainingBytes, chunk.byteLength)
controller.enqueue(chunk.subarray(0, nextChunkSize))
remainingBytes -= nextChunkSize
},
})
}
function createProcess(exitCode: number): FakeSubprocess {
return {
exited: Promise.resolve(exitCode),
stdout: createClosedStream(),
stderr: createClosedStream(),
}
}
function createStdoutSensitiveProcess(exitCode: number, stdoutBytes: number): FakeSubprocess {
let resolveDrained: () => void = () => undefined
const drained = new Promise<void>((resolve) => {
resolveDrained = resolve
})
const stdout = createDrainSensitiveStream(stdoutBytes, { onPull: () => resolveDrained() })
return {
exited: drained.then(() => exitCode),
stdout,
stderr: createClosedStream(),
}
}
const spawnMock = mock((command: string[], options: { stdout?: string; stderr?: string } = {}): FakeSubprocess => {
spawnCalls.push({ command, options })
const process = queuedProcesses.shift()
if (!process) {
throw new Error(`No fake subprocess configured for ${command.join(" ")}`)
}
return process
})
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "tmux")
const logMock = mock(() => undefined)
import type { TmuxCommandResult } from "../runner"
const paneCloseSpecifier = import.meta.resolve("./pane-close")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const spawnProcessSpecifier = import.meta.resolve("./spawn-process")
const runnerSpecifier = import.meta.resolve("../runner")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
async function loadCloseTmuxPane(): Promise<CloseTmuxPane> {
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "tmux")
const logMock = mock(() => undefined)
async function loadCloseTmuxPane(): Promise<typeof import("./pane-close").closeTmuxPane> {
const module = await import(`${paneCloseSpecifier}?test=${crypto.randomUUID()}`)
return module.closeTmuxPane
}
function registerModuleMocks(): void {
mock.module(spawnProcessSpecifier, () => ({ spawn: spawnMock }))
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
}
function resolveWithin<TResult>(promise: Promise<TResult>, milliseconds: number): Promise<TResult | typeof TIMEOUT> {
return Promise.race([
promise,
new Promise<typeof TIMEOUT>((resolve) => {
setTimeout(() => resolve(TIMEOUT), milliseconds)
}),
])
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("closeTmuxPane", () => {
beforeEach(() => {
registerModuleMocks()
spawnCalls.length = 0
queuedProcesses.length = 0
spawnMock.mockClear()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
isInsideTmuxMock.mockImplementation((): boolean => true)
getTmuxPathMock.mockImplementation(async (): Promise<string | undefined> => "tmux")
runTmuxCommandMock.mockResolvedValue({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
})
isInsideTmuxMock.mockReturnValue(true)
getTmuxPathMock.mockResolvedValue("tmux")
})
it("#given pane exists #when closeTmuxPane called #then returns true and invokes send-keys + kill-pane in order", async () => {
// given
const closeTmuxPane = await loadCloseTmuxPane()
queuedProcesses.push(createProcess(0), createProcess(0))
// when
const result = await closeTmuxPane("%42")
// then
expect(result).toBe(true)
expect(spawnCalls).toEqual([
{ command: ["tmux", "send-keys", "-t", "%42", "C-c"], options: { stdout: "ignore", stderr: "ignore" } },
{ command: ["tmux", "kill-pane", "-t", "%42"], options: { stdout: "pipe", stderr: "pipe" } },
])
expect(runTmuxCommandMock).toHaveBeenCalledTimes(2)
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(1, "tmux", ["send-keys", "-t", "%42", "C-c"])
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(2, "tmux", ["kill-pane", "-t", "%42"])
})
it("#given not inside tmux #when closeTmuxPane called #then returns false without spawn", async () => {
it("#given not inside tmux #when closeTmuxPane called #then returns false without runner calls", async () => {
// given
const closeTmuxPane = await loadCloseTmuxPane()
isInsideTmuxMock.mockImplementation((): boolean => false)
isInsideTmuxMock.mockReturnValue(false)
// when
const result = await closeTmuxPane("%42")
// then
expect(result).toBe(false)
expect(spawnCalls).toHaveLength(0)
expect(runTmuxCommandMock).not.toHaveBeenCalled()
})
it("#given tmux not found #when closeTmuxPane called #then returns false without spawn", async () => {
it("#given tmux not found #when closeTmuxPane called #then returns false without runner calls", async () => {
// given
const closeTmuxPane = await loadCloseTmuxPane()
getTmuxPathMock.mockImplementation(async (): Promise<string | undefined> => undefined)
getTmuxPathMock.mockResolvedValue(undefined)
// when
const result = await closeTmuxPane("%42")
// then
expect(result).toBe(false)
expect(spawnCalls).toHaveLength(0)
expect(runTmuxCommandMock).not.toHaveBeenCalled()
})
it("#given kill-pane fails with unknown error #when closeTmuxPane called #then returns false", async () => {
// given
const closeTmuxPane = await loadCloseTmuxPane()
queuedProcesses.push(createProcess(0), createProcess(1))
runTmuxCommandMock
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "permission denied", exitCode: 1 })
// when
const result = await closeTmuxPane("%42")
@@ -182,22 +104,12 @@ describe("closeTmuxPane", () => {
expect(result).toBe(false)
})
it("#given pane already closed by Ctrl+C (kill-pane reports 'can't find pane') #when closeTmuxPane called #then returns true", async () => {
it("#given pane already closed by Ctrl+C #when kill-pane reports can't find pane #then returns true", async () => {
// given
const closeTmuxPane = await loadCloseTmuxPane()
queuedProcesses.push(
createProcess(0),
{
exited: Promise.resolve(1),
stdout: createClosedStream(),
stderr: new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("can't find pane: %42\n"))
controller.close()
},
}),
},
)
runTmuxCommandMock
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "can't find pane: %42", exitCode: 1 })
// when
const result = await closeTmuxPane("%42")
@@ -205,17 +117,4 @@ describe("closeTmuxPane", () => {
// then
expect(result).toBe(true)
})
it("#given kill-pane stdout stream waits for drain #when closeTmuxPane called #then returns true once drainer consumes stdout", async () => {
// given
const closeTmuxPane = await loadCloseTmuxPane()
queuedProcesses.push(createProcess(0), createStdoutSensitiveProcess(0, 16 * 1024))
// when
const result = await resolveWithin(closeTmuxPane("%42"), 2000)
// then
expect(result).not.toBe(TIMEOUT)
expect(result).toBe(true)
})
})
+8 -25
View File
@@ -2,16 +2,12 @@ function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
async function readStream(stream: ReadableStream<Uint8Array> | null | undefined): Promise<string> {
return stream ? new Response(stream).text() : ""
}
export async function closeTmuxPane(paneId: string): Promise<boolean> {
const [{ log }, { isInsideTmux }, { getTmuxPath }, { spawn }] = await Promise.all([
const [{ log }, { isInsideTmux }, { getTmuxPath }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("./environment"),
import("../../../tools/interactive-bash/tmux-path-resolver"),
import("./spawn-process"),
import("../runner"),
])
if (!isInsideTmux()) {
@@ -26,36 +22,23 @@ export async function closeTmuxPane(paneId: string): Promise<boolean> {
}
log("[closeTmuxPane] sending Ctrl+C for graceful shutdown", { paneId })
const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], {
stdout: "ignore",
stderr: "ignore",
})
await ctrlCProc.exited
await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"])
await delay(250)
log("[closeTmuxPane] killing pane", { paneId })
const killPaneProc = spawn([tmux, "kill-pane", "-t", paneId], {
stdout: "pipe",
stderr: "pipe",
})
const [, stderr, exitCode] = await Promise.all([
readStream(killPaneProc.stdout),
readStream(killPaneProc.stderr),
killPaneProc.exited,
])
const trimmedStderr = stderr.trim()
const paneAlreadyGone = exitCode !== 0 && /can't find pane/i.test(trimmedStderr)
const result = await runTmuxCommand(tmux, ["kill-pane", "-t", paneId])
const trimmedStderr = result.stderr.trim()
const paneAlreadyGone = result.exitCode !== 0 && /can't find pane/i.test(trimmedStderr)
if (paneAlreadyGone) {
log("[closeTmuxPane] SUCCESS (pane already closed by Ctrl+C)", { paneId })
return true
}
if (exitCode !== 0) {
log("[closeTmuxPane] FAILED", { paneId, exitCode, stderr: trimmedStderr })
if (result.exitCode !== 0) {
log("[closeTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: trimmedStderr })
return false
}
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxCommandResult } from "../runner"
const paneDimensionsSpecifier = import.meta.resolve("./pane-dimensions")
const runnerSpecifier = import.meta.resolve("../runner")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "80,160",
stdout: "80,160",
stderr: "",
exitCode: 0,
}))
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
async function loadGetPaneDimensions(): Promise<typeof import("./pane-dimensions").getPaneDimensions> {
const module = await import(`${paneDimensionsSpecifier}?test=${crypto.randomUUID()}`)
return module.getPaneDimensions
}
function registerModuleMocks(): void {
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("getPaneDimensions runner integration", () => {
beforeEach(() => {
registerModuleMocks()
runTmuxCommandMock.mockClear()
getTmuxPathMock.mockClear()
runTmuxCommandMock.mockResolvedValue({ success: true, output: "80,160", stdout: "80,160", stderr: "", exitCode: 0 })
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given pane id #when getPaneDimensions called #then delegates display to shared runner", async () => {
// given
const getPaneDimensions = await loadGetPaneDimensions()
// when
const result = await getPaneDimensions("%42")
// then
expect(result).toEqual({ paneWidth: 80, windowWidth: 160 })
expect(runTmuxCommandMock.mock.calls).toEqual([
[[expect.any(String), ["display", "-p", "-t", "%42", "#{pane_width},#{window_width}"]]][0],
])
})
})
@@ -1,4 +1,3 @@
import { spawn } from "bun"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
export interface PaneDimensions {
@@ -11,17 +10,13 @@ export async function getPaneDimensions(
): Promise<PaneDimensions | null> {
const tmux = await getTmuxPath()
if (!tmux) return null
const { runTmuxCommand } = await import("../runner")
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()
const result = await runTmuxCommand(tmux, ["display", "-p", "-t", paneId, "#{pane_width},#{window_width}"])
if (exitCode !== 0) return null
if (result.exitCode !== 0) return null
const [paneWidth, windowWidth] = stdout.trim().split(",").map(Number)
const [paneWidth, windowWidth] = result.output.trim().split(",").map(Number)
if (Number.isNaN(paneWidth) || Number.isNaN(windowWidth)) return null
return { paneWidth, windowWidth }
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import type { TmuxCommandResult } from "../runner"
const paneReplaceSpecifier = import.meta.resolve("./pane-replace")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const enabledTmuxConfig = {
enabled: true,
layout: "main-vertical",
main_pane_size: 60,
main_pane_min_width: 120,
agent_pane_min_width: 40,
isolation: "inline",
} satisfies TmuxConfig
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("Expected array value")
}
const items: string[] = []
for (const item of value) {
items.push(String(item))
}
return items
}
function getRunTmuxCommandCall(index: number): [string, string[]] {
const call = Reflect.get(runTmuxCommandMock.mock.calls, index)
const command = Reflect.get(call, 0)
const args = Reflect.get(call, 1)
if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) {
throw new Error(`Expected tmux runner call at index ${index}`)
}
return [command, toStringArray(args)]
}
function getRespawnCommand(): string {
const respawnCall = getRunTmuxCommandCall(1)
const respawnCommand = respawnCall[1][4]
if (respawnCommand === undefined) {
throw new Error("Expected respawn-pane command")
}
return respawnCommand
}
async function loadReplaceTmuxPane(): Promise<typeof import("./pane-replace").replaceTmuxPane> {
const module = await import(`${paneReplaceSpecifier}?test=${crypto.randomUUID()}`)
return module.replaceTmuxPane
}
function registerModuleMocks(): void {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("replaceTmuxPane runner integration", () => {
beforeEach(() => {
mock.restore()
registerModuleMocks()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
const tmuxCommandResults: TmuxCommandResult[] = [
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
]
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
const nextResult = tmuxCommandResults.shift()
if (!nextResult) {
throw new Error("No more tmux command results configured")
}
return nextResult
})
isInsideTmuxMock.mockReturnValue(true)
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given existing pane #when replaceTmuxPane called #then delegates send-keys, respawn-pane, and select-pane to shared runner", async () => {
// given
const replaceTmuxPane = await loadReplaceTmuxPane()
const directory = "/tmp/omo-project/(replace)"
// when
const result = await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory)
// then
const sendKeysCall = getRunTmuxCommandCall(0)
const respawnCall = getRunTmuxCommandCall(1)
const selectPaneCall = getRunTmuxCommandCall(2)
expect(result).toEqual({ success: true, paneId: "%42" })
expect(sendKeysCall[1]).toEqual(["send-keys", "-t", "%42", "C-c"])
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(getRespawnCommand()).toContain(` --dir '${directory}'`)
})
it("#given directory with spaces #when replaceTmuxPane called #then wraps --dir value in single quotes", async () => {
// given
const replaceTmuxPane = await loadReplaceTmuxPane()
// when
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here")
// then
expect(getRespawnCommand()).toContain("--dir '/path with spaces/here'")
})
it("#given empty directory #when replaceTmuxPane called #then falls back to process cwd", async () => {
// given
const replaceTmuxPane = await loadReplaceTmuxPane()
// when
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "")
// then
expect(getRespawnCommand()).toContain(`--dir '${process.cwd()}'`)
})
it("#given directory with single quotes #when replaceTmuxPane called #then escapes the value with POSIX-safe single quoting", async () => {
// given
const replaceTmuxPane = await loadReplaceTmuxPane()
// when
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote")
// then
expect(getRespawnCommand()).toContain("--dir '/path/with'\\''quote'")
})
})
+16 -29
View File
@@ -1,9 +1,8 @@
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"
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
import { shellSingleQuote } from "../../shell-env"
export async function replaceTmuxPane(
paneId: string,
@@ -11,8 +10,12 @@ export async function replaceTmuxPane(
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
): Promise<SpawnPaneResult> {
const { log } = await import("../../logger")
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
log("[replaceTmuxPane] called", { paneId, sessionId, description })
@@ -29,42 +32,26 @@ export async function replaceTmuxPane(
}
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
await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"])
const shell = process.env.SHELL || "/bin/sh"
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
const effectiveDirectory = directory || process.cwd()
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, opencodeCmd])
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() })
if (result.exitCode !== 0) {
log("[replaceTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: result.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
const titleResult = await runTmuxCommand(tmux, ["select-pane", "-t", paneId, "-T", title])
if (titleResult.exitCode !== 0) {
log("[replaceTmuxPane] WARNING: failed to set pane title", {
paneId,
title,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
exitCode: titleResult.exitCode,
stderr: titleResult.stderr.trim(),
})
}
@@ -0,0 +1,153 @@
/// <reference types="bun-types" />
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import type { TmuxCommandResult } from "../runner"
const paneSpawnSpecifier = import.meta.resolve("./pane-spawn")
const enabledTmuxConfig = {
enabled: true,
layout: "main-vertical",
main_pane_size: 60,
main_pane_min_width: 120,
agent_pane_min_width: 40,
isolation: "inline",
} satisfies TmuxConfig
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "%42",
stdout: "%42",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | null> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("Expected array value")
}
const items: string[] = []
for (const item of value) {
items.push(String(item))
}
return items
}
function getRunTmuxCommandCall(index: number): [string, string[]] {
const call = Reflect.get(runTmuxCommandMock.mock.calls, index)
const command = Reflect.get(call, 0)
const args = Reflect.get(call, 1)
if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) {
throw new Error(`Expected tmux runner call at index ${index}`)
}
return [command, toStringArray(args)]
}
function getSplitWindowCommand(): string {
const firstCall = getRunTmuxCommandCall(0)
const splitCommand = firstCall[1][8]
if (splitCommand === undefined) {
throw new Error("Expected split-window command")
}
return splitCommand
}
function createDeps(): NonNullable<Parameters<typeof import("./pane-spawn").spawnTmuxPane>[7]> {
return {
log: logMock,
runTmuxCommand: runTmuxCommandMock,
isInsideTmux: isInsideTmuxMock,
isServerRunning: isServerRunningMock,
getTmuxPath: getTmuxPathMock,
}
}
async function loadSpawnTmuxPane(): Promise<typeof import("./pane-spawn").spawnTmuxPane> {
const module = await import(`${paneSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxPane
}
describe("spawnTmuxPane runner integration", () => {
beforeEach(() => {
mock.restore()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
isServerRunningMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
const tmuxCommandResults: TmuxCommandResult[] = [
{ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
]
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
const nextResult = tmuxCommandResults.shift()
if (!nextResult) {
throw new Error("No more tmux command results configured")
}
return nextResult
})
isInsideTmuxMock.mockReturnValue(true)
isServerRunningMock.mockResolvedValue(true)
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given healthy tmux environment #when spawnTmuxPane called #then delegates split-window and select-pane to shared runner", async () => {
// given
const spawnTmuxPane = await loadSpawnTmuxPane()
const directory = "/tmp/omo-project/(pane)"
// when
const result = await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0", "-h", createDeps())
// then
const firstCall = getRunTmuxCommandCall(0)
const secondCall = getRunTmuxCommandCall(1)
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(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
expect(getSplitWindowCommand()).toContain(` --dir '${directory}'`)
})
it("#given directory with spaces #when spawnTmuxPane called #then wraps --dir value in single quotes", async () => {
// given
const spawnTmuxPane = await loadSpawnTmuxPane()
// when
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", "-h", createDeps())
// then
expect(getSplitWindowCommand()).toContain("--dir '/path with spaces/here'")
})
it("#given empty directory #when spawnTmuxPane called #then falls back to process cwd", async () => {
// given
const spawnTmuxPane = await loadSpawnTmuxPane()
// when
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", "-h", createDeps())
// then
expect(getSplitWindowCommand()).toContain(`--dir '${process.cwd()}'`)
})
it("#given directory with single quotes #when spawnTmuxPane called #then escapes the value with POSIX-safe single quoting", async () => {
// given
const spawnTmuxPane = await loadSpawnTmuxPane()
// when
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", "-h", createDeps())
// then
expect(getSplitWindowCommand()).toContain("--dir '/path/with'\\''quote'")
})
})
+42 -24
View File
@@ -1,21 +1,48 @@
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 { runTmuxCommand as RunTmuxCommand } from "../runner"
import type { SplitDirection } from "./environment"
import { isInsideTmux } from "./environment"
import { isServerRunning } from "./server-health"
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
import { shellSingleQuote } from "../../shell-env"
type SpawnTmuxPaneDeps = {
log: (message: string, data?: unknown) => void
runTmuxCommand: typeof RunTmuxCommand
isInsideTmux: typeof isInsideTmux
isServerRunning: typeof isServerRunning
getTmuxPath: typeof getTmuxPath
}
async function resolveSpawnTmuxPaneDeps(deps?: Partial<SpawnTmuxPaneDeps>): Promise<SpawnTmuxPaneDeps> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
return {
log,
runTmuxCommand,
isInsideTmux,
isServerRunning,
getTmuxPath,
...deps,
}
}
export async function spawnTmuxPane(
sessionId: string,
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
targetPaneId?: string,
splitDirection: SplitDirection = "-h",
depsInput?: Partial<SpawnTmuxPaneDeps>,
): Promise<SpawnPaneResult> {
const { log } = await import("../../logger")
const deps = await resolveSpawnTmuxPaneDeps(depsInput)
const { log, runTmuxCommand } = deps
log("[spawnTmuxPane] called", {
sessionId,
@@ -30,18 +57,18 @@ export async function spawnTmuxPane(
log("[spawnTmuxPane] SKIP: config.enabled is false")
return { success: false }
}
if (!isInsideTmux()) {
if (!deps.isInsideTmux()) {
log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX })
return { success: false }
}
const serverRunning = await isServerRunning(serverUrl)
const serverRunning = await deps.isServerRunning(serverUrl)
if (!serverRunning) {
log("[spawnTmuxPane] SKIP: server not running", { serverUrl })
return { success: false }
}
const tmux = await getTmuxPath()
const tmux = await deps.getTmuxPath()
if (!tmux) {
log("[spawnTmuxPane] SKIP: tmux not found")
return { success: false }
@@ -49,9 +76,8 @@ export async function spawnTmuxPane(
log("[spawnTmuxPane] all checks passed, spawning...")
const shell = process.env.SHELL || "/bin/sh"
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
const effectiveDirectory = directory || process.cwd()
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
const args = [
"split-window",
@@ -64,29 +90,21 @@ export async function spawnTmuxPane(
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()
const result = await runTmuxCommand(tmux, args)
const paneId = result.output
if (exitCode !== 0 || !paneId) {
if (result.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
const titleResult = await runTmuxCommand(tmux, ["select-pane", "-t", paneId, "-T", title])
if (titleResult.exitCode !== 0) {
log("[spawnTmuxPane] WARNING: failed to set pane title", {
paneId,
title,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
exitCode: titleResult.exitCode,
stderr: titleResult.stderr.trim(),
})
}
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxCommandResult } from "../runner"
const sessionKillSpecifier = import.meta.resolve("./session-kill")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
async function loadKillTmuxSessionIfExists(): Promise<typeof import("./session-kill").killTmuxSessionIfExists> {
const module = await import(`${sessionKillSpecifier}?test=${crypto.randomUUID()}`)
return module.killTmuxSessionIfExists
}
function registerModuleMocks(): void {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("killTmuxSessionIfExists runner integration", () => {
beforeEach(() => {
registerModuleMocks()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
runTmuxCommandMock
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
isInsideTmuxMock.mockReturnValue(true)
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given session exists #when killTmuxSessionIfExists called #then delegates has-session and kill-session to shared runner", async () => {
// given
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
// when
const result = await killTmuxSessionIfExists("omo-agents")
// then
expect(result).toBe(true)
expect(runTmuxCommandMock.mock.calls).toEqual([
["sh", ["has-session", "-t", "omo-agents"]],
["sh", ["kill-session", "-t", "omo-agents"]],
])
})
})
+41 -93
View File
@@ -1,134 +1,84 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
type KillTmuxSessionIfExists = typeof import("./session-kill").killTmuxSessionIfExists
type SpawnCall = {
command: string[]
options: {
stdout?: string
stderr?: string
}
}
type FakeSubprocess = {
exited: Promise<number>
stdout: ReadableStream<Uint8Array>
stderr: ReadableStream<Uint8Array>
}
const spawnCalls: SpawnCall[] = []
const queuedProcesses: FakeSubprocess[] = []
function createStream(chunks: string[] = []): ReadableStream<Uint8Array> {
const textEncoder = new TextEncoder()
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(textEncoder.encode(chunk))
}
controller.close()
},
})
}
function createProcess(exitCode: number, output: { stdout?: string[]; stderr?: string[] } = {}): FakeSubprocess {
return {
exited: Promise.resolve(exitCode),
stdout: createStream(output.stdout),
stderr: createStream(output.stderr),
}
}
const spawnMock = mock((command: string[], options: { stdout?: string; stderr?: string } = {}) => {
spawnCalls.push({ command, options })
const process = queuedProcesses.shift()
if (!process) {
throw new Error(`No fake subprocess configured for ${command.join(" ")}`)
}
return process
})
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "tmux")
const logMock = mock(() => undefined)
import type { TmuxCommandResult } from "../runner"
const sessionKillSpecifier = import.meta.resolve("./session-kill")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const spawnProcessSpecifier = import.meta.resolve("./spawn-process")
const runnerSpecifier = import.meta.resolve("../runner")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
async function loadKillTmuxSessionIfExists(): Promise<typeof KillTmuxSessionIfExists> {
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "tmux")
const logMock = mock(() => undefined)
async function loadKillTmuxSessionIfExists(): Promise<typeof import("./session-kill").killTmuxSessionIfExists> {
const module = await import(`${sessionKillSpecifier}?test=${crypto.randomUUID()}`)
return module.killTmuxSessionIfExists
}
function registerModuleMocks(): void {
mock.module(spawnProcessSpecifier, () => ({ spawn: spawnMock }))
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("killTmuxSessionIfExists", () => {
beforeEach(() => {
registerModuleMocks()
spawnCalls.length = 0
queuedProcesses.length = 0
spawnMock.mockClear()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
isInsideTmuxMock.mockImplementation((): boolean => true)
getTmuxPathMock.mockImplementation(async (): Promise<string | undefined> => "tmux")
runTmuxCommandMock.mockResolvedValue({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
})
isInsideTmuxMock.mockReturnValue(true)
getTmuxPathMock.mockResolvedValue("tmux")
})
it("#given omo-agents session exists #when killTmuxSessionIfExists called #then kill-session invoked and returns true", async () => {
// given
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
queuedProcesses.push(createProcess(0), createProcess(0, { stdout: ["killed"], stderr: [] }))
// when
const result = await killTmuxSessionIfExists("omo-agents")
// then
expect(result).toBe(true)
expect(spawnCalls).toEqual([
{
command: ["tmux", "has-session", "-t", "omo-agents"],
options: { stdout: "ignore", stderr: "ignore" },
},
{
command: ["tmux", "kill-session", "-t", "omo-agents"],
options: { stdout: "pipe", stderr: "pipe" },
},
expect(runTmuxCommandMock.mock.calls).toEqual([
["tmux", ["has-session", "-t", "omo-agents"]],
["tmux", ["kill-session", "-t", "omo-agents"]],
])
})
it("#given omo-agents session does NOT exist (has-session exits non-zero) #when killTmuxSessionIfExists called #then NO kill-session invocation and returns false", async () => {
it("#given omo-agents session does NOT exist #when killTmuxSessionIfExists called #then NO kill-session invocation and returns false", async () => {
// given
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
queuedProcesses.push(createProcess(1))
runTmuxCommandMock.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "", exitCode: 1 })
// when
const result = await killTmuxSessionIfExists("omo-agents")
// then
expect(result).toBe(false)
expect(spawnCalls).toEqual([
{
command: ["tmux", "has-session", "-t", "omo-agents"],
options: { stdout: "ignore", stderr: "ignore" },
},
])
expect(runTmuxCommandMock.mock.calls).toEqual([["tmux", ["has-session", "-t", "omo-agents"]]])
})
it("#given not inside tmux #when killTmuxSessionIfExists called #then returns false without any spawn", async () => {
it("#given not inside tmux #when killTmuxSessionIfExists called #then returns false without runner calls", async () => {
// given
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
isInsideTmuxMock.mockReturnValue(false)
@@ -138,11 +88,10 @@ describe("killTmuxSessionIfExists", () => {
// then
expect(result).toBe(false)
expect(spawnCalls).toHaveLength(0)
expect(getTmuxPathMock).toHaveBeenCalledTimes(0)
expect(runTmuxCommandMock).not.toHaveBeenCalled()
})
it("#given tmux not found #when killTmuxSessionIfExists called #then returns false without spawn", async () => {
it("#given tmux not found #when killTmuxSessionIfExists called #then returns false without runner calls", async () => {
// given
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
getTmuxPathMock.mockResolvedValue(undefined)
@@ -152,22 +101,21 @@ describe("killTmuxSessionIfExists", () => {
// then
expect(result).toBe(false)
expect(spawnCalls).toHaveLength(0)
expect(runTmuxCommandMock).not.toHaveBeenCalled()
})
it("#given kill-session itself fails (e.g., race between has-session and kill) #when killTmuxSessionIfExists called #then returns false but does not throw", async () => {
it("#given kill-session itself fails #when killTmuxSessionIfExists called #then returns false but does not throw", async () => {
// given
const killTmuxSessionIfExists = await loadKillTmuxSessionIfExists()
queuedProcesses.push(
createProcess(0),
createProcess(1, { stdout: [], stderr: ["no session"] }),
)
runTmuxCommandMock
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "no session", exitCode: 1 })
// when
const result = await killTmuxSessionIfExists("omo-agents")
// then
expect(result).toBe(false)
expect(spawnCalls).toHaveLength(2)
expect(runTmuxCommandMock).toHaveBeenCalledTimes(2)
})
})
+11 -22
View File
@@ -1,13 +1,9 @@
async function readStream(stream: ReadableStream<Uint8Array> | null | undefined): Promise<string> {
return stream ? new Response(stream).text() : ""
}
export async function killTmuxSessionIfExists(sessionName: string): Promise<boolean> {
const [{ log }, { isInsideTmux }, { getTmuxPath }, { spawn }] = await Promise.all([
const [{ log }, { isInsideTmux }, { getTmuxPath }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("./environment"),
import("../../../tools/interactive-bash/tmux-path-resolver"),
import("./spawn-process"),
import("../runner"),
])
if (!isInsideTmux()) {
@@ -21,28 +17,21 @@ export async function killTmuxSessionIfExists(sessionName: string): Promise<bool
return false
}
const hasSessionProcess = spawn([tmux, "has-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
})
const hasSessionResult = await runTmuxCommand(tmux, ["has-session", "-t", sessionName])
if ((await hasSessionProcess.exited) !== 0) {
if (hasSessionResult.exitCode !== 0) {
log("[killTmuxSessionIfExists] SKIP: session not found", { sessionName })
return false
}
const killSessionProcess = spawn([tmux, "kill-session", "-t", sessionName], {
stdout: "pipe",
stderr: "pipe",
})
const [, stderr, exitCode] = await Promise.all([
readStream(killSessionProcess.stdout),
readStream(killSessionProcess.stderr),
killSessionProcess.exited,
])
const killSessionResult = await runTmuxCommand(tmux, ["kill-session", "-t", sessionName])
if (exitCode !== 0) {
log("[killTmuxSessionIfExists] FAILED", { sessionName, exitCode, stderr: stderr.trim() })
if (killSessionResult.exitCode !== 0) {
log("[killTmuxSessionIfExists] FAILED", {
sessionName,
exitCode: killSessionResult.exitCode,
stderr: killSessionResult.stderr.trim(),
})
return false
}
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import type { TmuxCommandResult } from "../runner"
const sessionSpawnSpecifier = import.meta.resolve("./session-spawn")
const enabledTmuxConfig = {
enabled: true,
layout: "main-vertical",
main_pane_size: 60,
main_pane_min_width: 120,
agent_pane_min_width: 40,
isolation: "inline",
} satisfies TmuxConfig
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | null> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("Expected array value")
}
const items: string[] = []
for (const item of value) {
items.push(String(item))
}
return items
}
function getRunTmuxCommandCall(index: number): [string, string[]] {
const call = Reflect.get(runTmuxCommandMock.mock.calls, index)
const command = Reflect.get(call, 0)
const args = Reflect.get(call, 1)
if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) {
throw new Error(`Expected tmux runner call at index ${index}`)
}
return [command, toStringArray(args)]
}
function getSpawnCommand(): string {
const newSessionCall = getRunTmuxCommandCall(2)
const newSessionCommand = newSessionCall[1][newSessionCall[1].length - 1]
if (newSessionCommand === undefined) {
throw new Error("Expected new-session command")
}
return newSessionCommand
}
function createDeps(): NonNullable<Parameters<typeof import("./session-spawn").spawnTmuxSession>[6]> {
return {
log: logMock,
runTmuxCommand: runTmuxCommandMock,
isInsideTmux: isInsideTmuxMock,
isServerRunning: isServerRunningMock,
getTmuxPath: getTmuxPathMock,
}
}
async function loadSpawnTmuxSession(): Promise<typeof import("./session-spawn").spawnTmuxSession> {
const module = await import(`${sessionSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxSession
}
describe("spawnTmuxSession runner integration", () => {
beforeEach(() => {
mock.restore()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
isServerRunningMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
const tmuxCommandResults: TmuxCommandResult[] = [
{ success: true, output: "120,40", stdout: "120,40", stderr: "", exitCode: 0 },
{ success: false, output: "", stdout: "", stderr: "", exitCode: 1 },
{ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
]
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
const nextResult = tmuxCommandResults.shift()
if (!nextResult) {
throw new Error("No more tmux command results configured")
}
return nextResult
})
isInsideTmuxMock.mockReturnValue(true)
isServerRunningMock.mockResolvedValue(true)
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given source pane available #when spawnTmuxSession called #then delegates display, has-session, new-session, and select-pane to shared runner", async () => {
// given
const spawnTmuxSession = await loadSpawnTmuxSession()
const directory = "/tmp/omo-project/(session)"
// when
const result = await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0", createDeps())
// then
const displayCall = getRunTmuxCommandCall(0)
const hasSessionCall = getRunTmuxCommandCall(1)
const newSessionCall = getRunTmuxCommandCall(2)
const selectPaneCall = getRunTmuxCommandCall(3)
expect(result).toEqual({ success: true, paneId: "%42" })
expect(displayCall[1]).toEqual(["display", "-p", "-t", "%0", "#{window_width},#{window_height}"])
expect(hasSessionCall[1][0]).toBe("has-session")
expect(hasSessionCall[1][1]).toBe("-t")
expect(hasSessionCall[1][2]?.startsWith("omo-agents-")).toBe(true)
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(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
expect(getSpawnCommand()).toContain(` --dir '${directory}'`)
})
it("#given directory with spaces #when spawnTmuxSession called #then wraps --dir value in single quotes", async () => {
// given
const spawnTmuxSession = await loadSpawnTmuxSession()
// when
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", createDeps())
// then
expect(getSpawnCommand()).toContain("--dir '/path with spaces/here'")
})
it("#given empty directory #when spawnTmuxSession called #then falls back to process cwd", async () => {
// given
const spawnTmuxSession = await loadSpawnTmuxSession()
// when
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", createDeps())
// then
expect(getSpawnCommand()).toContain(`--dir '${process.cwd()}'`)
})
it("#given directory with single quotes #when spawnTmuxSession called #then escapes the value with POSIX-safe single quoting", async () => {
// given
const spawnTmuxSession = await loadSpawnTmuxSession()
// when
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", createDeps())
// then
expect(getSpawnCommand()).toContain("--dir '/path/with'\\''quote'")
})
})
+52 -43
View File
@@ -1,13 +1,37 @@
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 { runTmuxCommand as RunTmuxCommand } from "../runner"
import { isInsideTmux } from "./environment"
import { isServerRunning } from "./server-health"
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
import { shellSingleQuote } from "../../shell-env"
const ISOLATED_SESSION_NAME_PREFIX = "omo-agents"
type SpawnTmuxSessionDeps = {
log: (message: string, data?: unknown) => void
runTmuxCommand: typeof RunTmuxCommand
isInsideTmux: typeof isInsideTmux
isServerRunning: typeof isServerRunning
getTmuxPath: typeof getTmuxPath
}
async function resolveSpawnTmuxSessionDeps(deps?: Partial<SpawnTmuxSessionDeps>): Promise<SpawnTmuxSessionDeps> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
return {
log,
runTmuxCommand,
isInsideTmux,
isServerRunning,
getTmuxPath,
...deps,
}
}
export function getIsolatedSessionName(pid: number = process.pid): string {
return `${ISOLATED_SESSION_NAME_PREFIX}-${pid}`
}
@@ -15,28 +39,21 @@ export function getIsolatedSessionName(pid: number = process.pid): string {
async function getWindowDimensions(
tmux: string,
sourcePaneId: string,
runTmuxCommand: typeof RunTmuxCommand,
): Promise<{ width: number; height: number } | null> {
const proc = spawn(
[tmux, "display", "-p", "-t", sourcePaneId, "#{window_width},#{window_height}"],
{ stdout: "pipe", stderr: "pipe" },
)
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const result = await runTmuxCommand(tmux, ["display", "-p", "-t", sourcePaneId, "#{window_width},#{window_height}"])
if (exitCode !== 0) return null
if (result.exitCode !== 0) return null
const [width, height] = stdout.trim().split(",").map(Number)
const [width, height] = result.output.trim().split(",").map(Number)
if (Number.isNaN(width) || Number.isNaN(height)) return null
return { width, height }
}
async function sessionExists(tmux: string, sessionName: string): Promise<boolean> {
const proc = spawn([tmux, "has-session", "-t", sessionName], {
stdout: "ignore",
stderr: "ignore",
})
return (await proc.exited) === 0
async function sessionExists(tmux: string, sessionName: string, runTmuxCommand: typeof RunTmuxCommand): Promise<boolean> {
const result = await runTmuxCommand(tmux, ["has-session", "-t", sessionName])
return result.exitCode === 0
}
export async function spawnTmuxSession(
@@ -44,9 +61,12 @@ export async function spawnTmuxSession(
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
sourcePaneId?: string,
depsInput?: Partial<SpawnTmuxSessionDeps>,
): Promise<SpawnPaneResult> {
const { log } = await import("../../logger")
const deps = await resolveSpawnTmuxSessionDeps(depsInput)
const { log, runTmuxCommand } = deps
log("[spawnTmuxSession] called", {
sessionId,
@@ -59,18 +79,18 @@ export async function spawnTmuxSession(
log("[spawnTmuxSession] SKIP: config.enabled is false")
return { success: false }
}
if (!isInsideTmux()) {
if (!deps.isInsideTmux()) {
log("[spawnTmuxSession] SKIP: not inside tmux", { TMUX: process.env.TMUX })
return { success: false }
}
const serverRunning = await isServerRunning(serverUrl)
const serverRunning = await deps.isServerRunning(serverUrl)
if (!serverRunning) {
log("[spawnTmuxSession] SKIP: server not running", { serverUrl })
return { success: false }
}
const tmux = await getTmuxPath()
const tmux = await deps.getTmuxPath()
if (!tmux) {
log("[spawnTmuxSession] SKIP: tmux not found")
return { success: false }
@@ -78,21 +98,19 @@ export async function spawnTmuxSession(
log("[spawnTmuxSession] all checks passed, creating isolated session...")
const shell = process.env.SHELL || "/bin/sh"
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
const escapedSessionId = shellEscapeForDoubleQuotedCommand(sessionId)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId}"`
const effectiveDirectory = directory || process.cwd()
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
const sizeArgs: string[] = []
if (sourcePaneId) {
const dims = await getWindowDimensions(tmux, sourcePaneId)
const dims = await getWindowDimensions(tmux, sourcePaneId, runTmuxCommand)
if (dims) {
sizeArgs.push("-x", String(dims.width), "-y", String(dims.height))
}
}
const isolatedSessionName = getIsolatedSessionName()
const sessionAlreadyExists = await sessionExists(tmux, isolatedSessionName)
const sessionAlreadyExists = await sessionExists(tmux, isolatedSessionName, runTmuxCommand)
const args = sessionAlreadyExists
? [
@@ -117,31 +135,22 @@ export async function spawnTmuxSession(
sessionName: isolatedSessionName,
})
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()
const result = await runTmuxCommand(tmux, args)
const paneId = result.output
if (exitCode !== 0 || !paneId) {
const stderr = await new Response(proc.stderr).text()
log("[spawnTmuxSession] FAILED", { exitCode, stderr: stderr.trim() })
if (result.exitCode !== 0 || !paneId) {
log("[spawnTmuxSession] FAILED", { exitCode: result.exitCode, stderr: result.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
const titleResult = await runTmuxCommand(tmux, ["select-pane", "-t", paneId, "-T", title])
if (titleResult.exitCode !== 0) {
log("[spawnTmuxSession] WARNING: failed to set pane title", {
paneId,
title,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
exitCode: titleResult.exitCode,
stderr: titleResult.stderr.trim(),
})
}
+1 -1
View File
@@ -1 +1 @@
export { spawn } from "bun"
export { spawn } from "../../bun-spawn-shim"
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxCommandResult } from "../runner"
const staleSessionSweepSpecifier = import.meta.resolve("./stale-session-sweep")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const sessionKillSpecifier = import.meta.resolve("./session-kill")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const killTmuxSessionIfExistsMock = mock(async (): Promise<boolean> => true)
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
async function loadSweepStaleOmoAgentSessions(): Promise<typeof import("./stale-session-sweep").sweepStaleOmoAgentSessions> {
const module = await import(`${staleSessionSweepSpecifier}?test=${crypto.randomUUID()}`)
return module.sweepStaleOmoAgentSessions
}
function registerModuleMocks(): void {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(sessionKillSpecifier, () => ({ killTmuxSessionIfExists: killTmuxSessionIfExistsMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("sweepStaleOmoAgentSessions runtime runner integration", () => {
beforeEach(() => {
registerModuleMocks()
runTmuxCommandMock.mockClear()
killTmuxSessionIfExistsMock.mockClear()
isInsideTmuxMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
runTmuxCommandMock.mockResolvedValue({
success: true,
output: "omo-agents-99991\nomo-agents-99992",
stdout: "omo-agents-99991\nomo-agents-99992",
stderr: "",
exitCode: 0,
})
killTmuxSessionIfExistsMock.mockResolvedValue(true)
isInsideTmuxMock.mockReturnValue(true)
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given stale sessions listed by tmux #when sweepStaleOmoAgentSessions called #then delegates list-sessions to shared runner", async () => {
// given
const sweepStaleOmoAgentSessions = await loadSweepStaleOmoAgentSessions()
// when
const result = await sweepStaleOmoAgentSessions()
// then
expect(result).toBe(2)
expect(runTmuxCommandMock.mock.calls).toEqual([
["sh", ["list-sessions", "-F", "#{session_name}"]],
])
expect(killTmuxSessionIfExistsMock).toHaveBeenCalledTimes(2)
})
})
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { sweepStaleOmoAgentSessionsWith, type SweepDeps } from "./stale-session-sweep"
import { sweepStaleOmoAgentSessionsWith, sweepTmuxSessionsWith, type SweepDeps } from "./stale-session-sweep"
type SweepFixture = {
deps: SweepDeps
@@ -152,3 +152,25 @@ describe("sweepStaleOmoAgentSessionsWith", () => {
expect(fixture.killed).toEqual(["omo-agents-99999"])
})
})
describe("sweepTmuxSessionsWith", () => {
let fixture: SweepFixture
beforeEach(() => {
fixture = createFixture()
})
it("#given custom predicate for team sessions #when shared sweep called #then only matching sessions are killed", async () => {
// given
fixture.setCandidates(["omo-team-A", "omo-team-B", "main", "omo-agents-99999"])
// when
const result = await sweepTmuxSessionsWith(fixture.deps, {
predicate: (sessionName) => sessionName.startsWith("omo-team-"),
})
// then
expect(result).toEqual(["omo-team-A", "omo-team-B"])
expect(fixture.killed).toEqual(["omo-team-A", "omo-team-B"])
})
})
@@ -1,5 +1,13 @@
const STALE_SESSION_PATTERN = /^omo-agents-(\d+)$/
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
return String(error)
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
@@ -10,36 +18,48 @@ function isProcessAlive(pid: number): boolean {
}
}
async function listOmoAgentSessionsViaTmux(tmux: string): Promise<string[]> {
const { spawn } = await import("./spawn-process")
const proc = spawn([tmux, "list-sessions", "-F", "#{session_name}"], {
stdout: "pipe",
stderr: "pipe",
})
const [stdout, , exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
async function listTmuxSessionsViaTmux(tmux: string): Promise<string[]> {
const { runTmuxCommand } = await import("../runner")
const result = await runTmuxCommand(tmux, ["list-sessions", "-F", "#{session_name}"])
if (exitCode !== 0) {
if (result.exitCode !== 0) {
return []
}
return stdout
return result.output
.split("\n")
.map((line) => line.trim())
.filter((name) => STALE_SESSION_PATTERN.test(name))
.filter((name) => name.length > 0)
}
export type SweepDeps = {
export type SweepTmuxSessionsDeps = {
isInsideTmux: () => boolean
getTmuxPath: () => Promise<string | null | undefined>
listCandidateSessions: (tmux: string) => Promise<string[]>
killSession: (sessionName: string) => Promise<boolean>
log: (message: string, payload?: unknown) => void
}
export type SweepDeps = SweepTmuxSessionsDeps & {
processAlive: (pid: number) => boolean
currentPid: number
log: (message: string, payload?: unknown) => void
}
export type SweepTmuxSessionsOptions = {
prefix?: string
predicate?: (sessionName: string) => boolean
}
function matchesSweepOptions(sessionName: string, options: SweepTmuxSessionsOptions): boolean {
if (options.predicate) {
return options.predicate(sessionName)
}
if (options.prefix) {
return sessionName.startsWith(options.prefix)
}
return true
}
async function buildRuntimeDeps(): Promise<SweepDeps> {
@@ -53,7 +73,7 @@ async function buildRuntimeDeps(): Promise<SweepDeps> {
return {
isInsideTmux,
getTmuxPath,
listCandidateSessions: listOmoAgentSessionsViaTmux,
listCandidateSessions: listTmuxSessionsViaTmux,
killSession: killTmuxSessionIfExists,
processAlive: isProcessAlive,
currentPid: process.pid,
@@ -61,36 +81,75 @@ async function buildRuntimeDeps(): Promise<SweepDeps> {
}
}
export async function sweepStaleOmoAgentSessionsWith(deps: SweepDeps): Promise<number> {
export async function sweepTmuxSessionsWith(
deps: SweepTmuxSessionsDeps,
options: SweepTmuxSessionsOptions,
): Promise<string[]> {
if (!deps.isInsideTmux()) {
return 0
return []
}
const tmux = await deps.getTmuxPath()
if (!tmux) {
return 0
return []
}
const candidateSessions = await deps.listCandidateSessions(tmux)
let killedCount = 0
let candidateSessions: string[]
try {
candidateSessions = await deps.listCandidateSessions(tmux)
} catch (error) {
deps.log("[sweepTmuxSessionsWith] failed to list candidate sessions", {
error: getErrorMessage(error),
})
return []
}
const killedSessionNames: string[] = []
for (const sessionName of candidateSessions) {
const pidMatch = sessionName.match(STALE_SESSION_PATTERN)
if (!pidMatch) continue
if (!matchesSweepOptions(sessionName, options)) {
continue
}
const pid = Number.parseInt(pidMatch[1], 10)
if (!Number.isFinite(pid)) continue
if (pid === deps.currentPid) continue
if (deps.processAlive(pid)) continue
deps.log("[sweepStaleOmoAgentSessions] killing stale session", { sessionName, deadPid: pid })
const killed = await deps.killSession(sessionName)
if (killed) {
killedCount += 1
try {
const killed = await deps.killSession(sessionName)
if (killed) {
killedSessionNames.push(sessionName)
}
} catch (error) {
deps.log("[sweepTmuxSessionsWith] failed to kill stale session", {
error: getErrorMessage(error),
sessionName,
})
}
}
return killedCount
return killedSessionNames
}
export async function sweepStaleOmoAgentSessionsWith(deps: SweepDeps): Promise<number> {
const killedSessionNames = await sweepTmuxSessionsWith(deps, {
predicate: (sessionName) => {
const pidMatch = sessionName.match(STALE_SESSION_PATTERN)
if (!pidMatch) {
return false
}
const pid = Number.parseInt(pidMatch[1], 10)
if (!Number.isFinite(pid)) {
return false
}
if (pid === deps.currentPid) {
return false
}
return !deps.processAlive(pid)
},
})
return killedSessionNames.length
}
export async function sweepStaleOmoAgentSessions(): Promise<number> {
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import type { TmuxCommandResult } from "../runner"
const windowSpawnSpecifier = import.meta.resolve("./window-spawn")
const enabledTmuxConfig = {
enabled: true,
layout: "main-vertical",
main_pane_size: 60,
main_pane_min_width: 120,
agent_pane_min_width: 40,
isolation: "inline",
} satisfies TmuxConfig
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
success: true,
output: "%42",
stdout: "%42",
stderr: "",
exitCode: 0,
}))
const isInsideTmuxMock = mock((): boolean => true)
const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | null> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("Expected array value")
}
const items: string[] = []
for (const item of value) {
items.push(String(item))
}
return items
}
function getRunTmuxCommandCall(index: number): [string, string[]] {
const call = Reflect.get(runTmuxCommandMock.mock.calls, index)
const command = Reflect.get(call, 0)
const args = Reflect.get(call, 1)
if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) {
throw new Error(`Expected tmux runner call at index ${index}`)
}
return [command, toStringArray(args)]
}
function getNewWindowCommand(): string {
const firstCall = getRunTmuxCommandCall(0)
const newWindowCommand = firstCall[1][7]
if (newWindowCommand === undefined) {
throw new Error("Expected new-window command")
}
return newWindowCommand
}
function createDeps(): NonNullable<Parameters<typeof import("./window-spawn").spawnTmuxWindow>[5]> {
return {
log: logMock,
runTmuxCommand: runTmuxCommandMock,
isInsideTmux: isInsideTmuxMock,
isServerRunning: isServerRunningMock,
getTmuxPath: getTmuxPathMock,
}
}
async function loadSpawnTmuxWindow(): Promise<typeof import("./window-spawn").spawnTmuxWindow> {
const module = await import(`${windowSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxWindow
}
describe("spawnTmuxWindow runner integration", () => {
beforeEach(() => {
mock.restore()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
isServerRunningMock.mockClear()
getTmuxPathMock.mockClear()
logMock.mockClear()
const tmuxCommandResults: TmuxCommandResult[] = [
{ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 },
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
]
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
const nextResult = tmuxCommandResults.shift()
if (!nextResult) {
throw new Error("No more tmux command results configured")
}
return nextResult
})
isInsideTmuxMock.mockReturnValue(true)
isServerRunningMock.mockResolvedValue(true)
getTmuxPathMock.mockResolvedValue("sh")
})
it("#given healthy tmux environment #when spawnTmuxWindow called #then delegates new-window and select-pane to shared runner", async () => {
// given
const spawnTmuxWindow = await loadSpawnTmuxWindow()
const directory = "/tmp/omo-project/(window)"
// when
const result = await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, createDeps())
// then
const firstCall = getRunTmuxCommandCall(0)
const secondCall = getRunTmuxCommandCall(1)
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(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
expect(getNewWindowCommand()).toContain(` --dir '${directory}'`)
})
it("#given directory with spaces #when spawnTmuxWindow called #then wraps --dir value in single quotes", async () => {
// given
const spawnTmuxWindow = await loadSpawnTmuxWindow()
// when
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", createDeps())
// then
expect(getNewWindowCommand()).toContain("--dir '/path with spaces/here'")
})
it("#given empty directory #when spawnTmuxWindow called #then falls back to process cwd", async () => {
// given
const spawnTmuxWindow = await loadSpawnTmuxWindow()
// when
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", createDeps())
// then
expect(getNewWindowCommand()).toContain(`--dir '${process.cwd()}'`)
})
it("#given directory with single quotes #when spawnTmuxWindow called #then escapes the value with POSIX-safe single quoting", async () => {
// given
const spawnTmuxWindow = await loadSpawnTmuxWindow()
// when
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", createDeps())
// then
expect(getNewWindowCommand()).toContain("--dir '/path/with'\\''quote'")
})
})
+43 -27
View File
@@ -1,20 +1,47 @@
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"
import { isServerRunning } from "./server-health"
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
import { shellSingleQuote } from "../../shell-env"
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
const ISOLATED_WINDOW_NAME = "omo-agents"
type SpawnTmuxWindowDeps = {
log: (message: string, data?: unknown) => void
runTmuxCommand: typeof RunTmuxCommand
isInsideTmux: typeof isInsideTmux
isServerRunning: typeof isServerRunning
getTmuxPath: typeof getTmuxPath
}
async function resolveSpawnTmuxWindowDeps(deps?: Partial<SpawnTmuxWindowDeps>): Promise<SpawnTmuxWindowDeps> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
return {
log,
runTmuxCommand,
isInsideTmux,
isServerRunning,
getTmuxPath,
...deps,
}
}
export async function spawnTmuxWindow(
sessionId: string,
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
depsInput?: Partial<SpawnTmuxWindowDeps>,
): Promise<SpawnPaneResult> {
const { log } = await import("../../logger")
const deps = await resolveSpawnTmuxWindowDeps(depsInput)
const { log, runTmuxCommand } = deps
log("[spawnTmuxWindow] called", {
sessionId,
@@ -27,18 +54,18 @@ export async function spawnTmuxWindow(
log("[spawnTmuxWindow] SKIP: config.enabled is false")
return { success: false }
}
if (!isInsideTmux()) {
if (!deps.isInsideTmux()) {
log("[spawnTmuxWindow] SKIP: not inside tmux", { TMUX: process.env.TMUX })
return { success: false }
}
const serverRunning = await isServerRunning(serverUrl)
const serverRunning = await deps.isServerRunning(serverUrl)
if (!serverRunning) {
log("[spawnTmuxWindow] SKIP: server not running", { serverUrl })
return { success: false }
}
const tmux = await getTmuxPath()
const tmux = await deps.getTmuxPath()
if (!tmux) {
log("[spawnTmuxWindow] SKIP: tmux not found")
return { success: false }
@@ -46,10 +73,8 @@ export async function spawnTmuxWindow(
log("[spawnTmuxWindow] all checks passed, creating isolated window...")
const shell = process.env.SHELL || "/bin/sh"
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
const escapedSessionId = shellEscapeForDoubleQuotedCommand(sessionId)
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId}"`
const effectiveDirectory = directory || process.cwd()
const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}`
const args = [
"new-window",
@@ -60,31 +85,22 @@ export async function spawnTmuxWindow(
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()
const result = await runTmuxCommand(tmux, args)
const paneId = result.output
if (exitCode !== 0 || !paneId) {
const stderr = await new Response(proc.stderr).text()
log("[spawnTmuxWindow] FAILED", { exitCode, stderr: stderr.trim() })
if (result.exitCode !== 0 || !paneId) {
log("[spawnTmuxWindow] FAILED", { exitCode: result.exitCode, stderr: result.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
const titleResult = await runTmuxCommand(tmux, ["select-pane", "-t", paneId, "-T", title])
if (titleResult.exitCode !== 0) {
log("[spawnTmuxWindow] WARNING: failed to set pane title", {
paneId,
title,
exitCode: titleExitCode,
stderr: titleStderr.trim(),
exitCode: titleResult.exitCode,
stderr: titleResult.stderr.trim(),
})
}
+158
View File
@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it } from "bun:test"
import { fsyncSync } from "node:fs"
import type { FileHandle } from "node:fs/promises"
import { clearAllSkips, drainSkipsAfter } from "./fsync-skip-tracker"
import { isToleratedFsyncError, tolerantFsync, tolerantFsyncSync } from "./tolerant-fsync"
function makeFsError(code: string, message?: string): NodeJS.ErrnoException {
const error = new Error(message ?? `${code}: simulated`) as NodeJS.ErrnoException
error.code = code
return error
}
function fakeHandleWithSyncError(error: NodeJS.ErrnoException): FileHandle {
return {
sync: async () => {
throw error
},
} as FileHandle
}
describe("isToleratedFsyncError", () => {
it("#given EPERM error #when checked #then returns true", () => {
expect(isToleratedFsyncError(makeFsError("EPERM"))).toBe(true)
})
it("#given EACCES error #when checked #then returns true", () => {
expect(isToleratedFsyncError(makeFsError("EACCES"))).toBe(true)
})
it("#given ENOTSUP error #when checked #then returns true", () => {
expect(isToleratedFsyncError(makeFsError("ENOTSUP"))).toBe(true)
})
it("#given EINVAL error #when checked #then returns true", () => {
expect(isToleratedFsyncError(makeFsError("EINVAL"))).toBe(true)
})
it("#given EIO error #when checked #then returns false", () => {
expect(isToleratedFsyncError(makeFsError("EIO"))).toBe(false)
})
it("#given ENOSPC error (disk full) #when checked #then returns false", () => {
expect(isToleratedFsyncError(makeFsError("ENOSPC"))).toBe(false)
})
it("#given EBADF error (bad fd) #when checked #then returns false", () => {
expect(isToleratedFsyncError(makeFsError("EBADF"))).toBe(false)
})
it("#given non-Error value #when checked #then returns false", () => {
expect(isToleratedFsyncError("EPERM string")).toBe(false)
expect(isToleratedFsyncError(null)).toBe(false)
expect(isToleratedFsyncError(undefined)).toBe(false)
expect(isToleratedFsyncError({ code: "EPERM" })).toBe(false)
})
it("#given Error without code #when checked #then returns false", () => {
expect(isToleratedFsyncError(new Error("no code"))).toBe(false)
})
})
describe("tolerantFsync (async)", () => {
beforeEach(() => {
clearAllSkips()
})
it("#given fsync throws EPERM #when called #then resolves without throwing", async () => {
const handle = fakeHandleWithSyncError(makeFsError("EPERM", "operation not permitted, fsync"))
await expect(tolerantFsync(handle, "test:async-eperm")).resolves.toBeUndefined()
})
it("#given fsync throws EACCES #when called #then resolves without throwing", async () => {
const handle = fakeHandleWithSyncError(makeFsError("EACCES"))
await expect(tolerantFsync(handle, "test:async-eacces")).resolves.toBeUndefined()
})
it("#given fsync throws ENOTSUP #when called #then resolves without throwing", async () => {
const handle = fakeHandleWithSyncError(makeFsError("ENOTSUP"))
await expect(tolerantFsync(handle, "test:async-enotsup")).resolves.toBeUndefined()
})
it("#given fsync throws EINVAL #when called #then resolves without throwing", async () => {
const handle = fakeHandleWithSyncError(makeFsError("EINVAL"))
await expect(tolerantFsync(handle, "test:async-einval")).resolves.toBeUndefined()
})
it("#given fsync throws EIO #when called #then propagates the error", async () => {
const handle = fakeHandleWithSyncError(makeFsError("EIO"))
await expect(tolerantFsync(handle, "test:async-eio")).rejects.toThrow("EIO: simulated")
})
it("#given fsync throws ENOSPC #when called #then propagates the error", async () => {
const handle = fakeHandleWithSyncError(makeFsError("ENOSPC"))
await expect(tolerantFsync(handle, "test:async-enospc")).rejects.toThrow("ENOSPC: simulated")
})
it("#given fsync succeeds #when called #then resolves and sync was invoked", async () => {
let syncCalled = false
const handle = {
sync: async () => {
syncCalled = true
},
} as FileHandle
await tolerantFsync(handle, "test:async-success")
expect(syncCalled).toBe(true)
})
it("#given fsync throws EPERM #when called #then tracker records one skip", async () => {
const handle = fakeHandleWithSyncError(makeFsError("EPERM", "operation not permitted, fsync"))
await tolerantFsync(handle, "atomicWrite:/Users/x/Library/Mobile Documents/com~apple~CloudDocs/file.txt")
const entries = drainSkipsAfter(0)
expect(entries).toHaveLength(1)
expect(entries[0]?.errorCode).toBe("EPERM")
})
it("#given fsync throws EIO #when called #then tracker remains empty", async () => {
const handle = fakeHandleWithSyncError(makeFsError("EIO"))
await expect(tolerantFsync(handle, "atomicWrite:/tmp/file.txt")).rejects.toThrow("EIO: simulated")
expect(drainSkipsAfter(0)).toHaveLength(0)
})
})
describe("tolerantFsyncSync (synchronous)", () => {
it("#given fsyncSync throws EPERM #when called #then returns without throwing", () => {
const fakeFsync = ((_fileDescriptor: number): void => {
throw makeFsError("EPERM", "operation not permitted, fsync")
}) as typeof fsyncSync
expect(() => tolerantFsyncSync(123, "test:sync-eperm", fakeFsync)).not.toThrow()
})
it("#given fsyncSync throws EACCES #when called #then returns without throwing", () => {
const fakeFsync = ((_fileDescriptor: number): void => {
throw makeFsError("EACCES")
}) as typeof fsyncSync
expect(() => tolerantFsyncSync(123, "test:sync-eacces", fakeFsync)).not.toThrow()
})
it("#given fsyncSync throws EIO #when called #then propagates the error", () => {
const fakeFsync = ((_fileDescriptor: number): void => {
throw makeFsError("EIO")
}) as typeof fsyncSync
expect(() => tolerantFsyncSync(123, "test:sync-eio", fakeFsync)).toThrow("EIO: simulated")
})
it("#given fsyncSync succeeds #when called #then returns and impl was invoked", () => {
let called = false
const fakeFsync = ((_fileDescriptor: number): void => {
called = true
}) as typeof fsyncSync
tolerantFsyncSync(123, "test:sync-success", fakeFsync)
expect(called).toBe(true)
})
})
+85
View File
@@ -0,0 +1,85 @@
import { fsyncSync } from "node:fs"
import type { FileHandle } from "node:fs/promises"
import { classifyPathEnvironment } from "./classify-path-environment"
import { recordFsyncSkip } from "./fsync-skip-tracker"
import { log } from "./logger"
const TOLERATED_FSYNC_CODES: ReadonlySet<string> = new Set([
"EPERM",
"EACCES",
"ENOTSUP",
"EINVAL",
])
export function isToleratedFsyncError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const code = (error as NodeJS.ErrnoException).code
return code !== undefined && TOLERATED_FSYNC_CODES.has(code)
}
function extractPathFromContextLabel(contextLabel: string): string {
const separatorIndex = contextLabel.indexOf(":")
if (separatorIndex < 0) return contextLabel
return contextLabel.slice(separatorIndex + 1)
}
export async function tolerantFsync(
fileHandle: FileHandle,
contextLabel: string,
): Promise<void> {
try {
await fileHandle.sync()
} catch (error) {
if (!isToleratedFsyncError(error)) throw error
const errorCode = (error as NodeJS.ErrnoException).code ?? "UNKNOWN"
const message = error instanceof Error ? error.message : String(error)
const filePath = extractPathFromContextLabel(contextLabel)
log("fsync skipped due to filesystem limitation", {
event: "fsync-skipped",
contextLabel,
code: errorCode,
message,
})
recordFsyncSkip({
filePath,
contextLabel,
errorCode,
message,
pathClassification: classifyPathEnvironment(filePath),
})
}
}
export function tolerantFsyncSync(
fileDescriptor: number,
contextLabel: string,
fsyncImpl: typeof fsyncSync = fsyncSync,
): void {
try {
fsyncImpl(fileDescriptor)
} catch (error) {
if (!isToleratedFsyncError(error)) throw error
const errorCode = (error as NodeJS.ErrnoException).code ?? "UNKNOWN"
const message = error instanceof Error ? error.message : String(error)
const filePath = extractPathFromContextLabel(contextLabel)
log("fsync skipped due to filesystem limitation", {
event: "fsync-skipped",
contextLabel,
code: errorCode,
message,
})
recordFsyncSkip({
filePath,
contextLabel,
errorCode,
message,
pathClassification: classifyPathEnvironment(filePath),
})
}
}
+35
View File
@@ -51,4 +51,39 @@ describe("writeFileAtomically", () => {
// when/then
expect(() => writeFileAtomically(filePath, "content")).toThrow()
})
it("#given fsync fails with EPERM (synced folder) #when writeFileAtomically called #then write succeeds", () => {
// given
const filePath = join(testDir, "synced-folder.txt")
const content = "content from a synced folder where fsync is rejected"
// when
writeFileAtomically(filePath, content, {
fsyncSync: () => {
const error = new Error("EPERM: operation not permitted, fsync") as NodeJS.ErrnoException
error.code = "EPERM"
throw error
},
})
// then
expect(existsSync(filePath)).toBe(true)
expect(readFileSync(filePath, "utf-8")).toBe(content)
})
it("#given fsync fails with EIO (real I/O error) #when writeFileAtomically called #then propagates the error", () => {
// given
const filePath = join(testDir, "io-error.txt")
// when/then
expect(() =>
writeFileAtomically(filePath, "content", {
fsyncSync: () => {
const error = new Error("EIO: input/output error") as NodeJS.ErrnoException
error.code = "EIO"
throw error
},
}),
).toThrow("EIO")
})
})
+18 -5
View File
@@ -1,11 +1,24 @@
import { closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs"
import {
closeSync,
type fsyncSync as FsyncSync,
openSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs"
export function writeFileAtomically(filePath: string, content: string): void {
const tempPath = `${filePath}.tmp`
writeFileSync(tempPath, content, "utf-8")
import { tolerantFsyncSync } from "./tolerant-fsync"
export function writeFileAtomically(
filePath: string,
content: string,
deps: { fsyncSync?: typeof FsyncSync } = {},
): void {
const tempPath = `${filePath}.tmp`
writeFileSync(tempPath, content, "utf-8")
const tempFileDescriptor = openSync(tempPath, "r")
try {
fsyncSync(tempFileDescriptor)
tolerantFsyncSync(tempFileDescriptor, `writeFileAtomically:${filePath}`, deps.fsyncSync)
} finally {
closeSync(tempFileDescriptor)
}
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../bun-spawn-shim"
import type { ArchiveEntry } from "../archive-entry-validator"
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from "bun"
import { spawn, spawnSync } from "../bun-spawn-shim"
import type { ArchiveEntry } from "../archive-entry-validator"
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../bun-spawn-shim"
export async function readZipSymlinkTarget(
archivePath: string,
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../bun-spawn-shim"
import type { ArchiveEntry } from "../archive-entry-validator"
import { log } from "../logger"
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from "bun"
import { spawn, spawnSync } from "../bun-spawn-shim"
import type { ArchiveEntry } from "../archive-entry-validator"
import { readZipSymlinkTarget } from "./read-zip-symlink-target"
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from "bun"
import { spawn, spawnSync } from "./bun-spawn-shim"
import { release } from "os"
import { validateArchiveEntries } from "./archive-entry-validator"