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:
@@ -1,6 +1,6 @@
|
||||
# src/shared/ — 100+ Utility Files
|
||||
|
||||
**Generated:** 2026-04-18
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -214,6 +214,10 @@ describe("stripAgentListSortPrefix", () => {
|
||||
it("strips legacy zero-width sort prefixes baked into v3.14.0–v3.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", () => {
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "'\\''")}'`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
export * from "./runner"
|
||||
export * from "./tmux-utils"
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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"]],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
@@ -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"]],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
@@ -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 @@
|
||||
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'")
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
Reference in New Issue
Block a user