Merge remote-tracking branch 'origin/dev' into refactor/modular-code-enforcement

# Conflicts:
#	src/agents/utils.ts
#	src/config/schema.ts
#	src/features/background-agent/spawner/background-session-creator.ts
#	src/features/background-agent/spawner/parent-directory-resolver.ts
#	src/features/background-agent/spawner/tmux-callback-invoker.ts
#	src/features/tmux-subagent/manager.ts
#	src/hooks/interactive-bash-session/index.ts
#	src/hooks/task-continuation-enforcer.test.ts
#	src/index.ts
#	src/plugin-handlers/config-handler.test.ts
#	src/tools/background-task/tools.ts
#	src/tools/call-omo-agent/tools.ts
#	src/tools/delegate-task/executor.ts
This commit is contained in:
YeonGyu-Kim
2026-02-08 19:05:41 +09:00
47 changed files with 2767 additions and 1376 deletions
+20 -52
View File
@@ -2,61 +2,29 @@
## OVERVIEW
17 feature modules: background agents, skill MCPs, builtin skills/commands, Claude Code compatibility layer, task management.
**Feature Types**: Task orchestration, Skill definitions, Command templates, Claude Code loaders, Supporting utilities
Background agents, skills, Claude Code compat, builtin commands, MCP managers, etc.
## STRUCTURE
```
features/
├── background-agent/ # Task lifecycle (1556 lines)
│ ├── manager.ts # Launch → poll → complete
│ └── concurrency.ts # Per-provider limits
├── builtin-skills/ # Core skills
│ └── skills/ # playwright, agent-browser, frontend-ui-ux, git-master, dev-browser
├── builtin-commands/ # ralph-loop, refactor, ulw-loop, init-deep, start-work, cancel-ralph, stop-continuation
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
├── claude-code-command-loader/ # ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion
├── claude-code-plugin-loader/ # installed_plugins.json (486 lines)
├── claude-code-session-state/ # Session persistence
├── opencode-skill-loader/ # Skills from 6 directories (loader.ts 311 lines)
├── context-injector/ # AGENTS.md/README.md injection
├── boulder-state/ # Todo state persistence
├── hook-message-injector/ # Message injection
├── task-toast-manager/ # Background task notifications
├── skill-mcp-manager/ # MCP client lifecycle (640 lines)
├── tmux-subagent/ # Tmux session management (472 lines)
├── mcp-oauth/ # MCP OAuth handling
└── claude-tasks/ # Task schema/storage - see AGENTS.md
```
├── background-agent/ # Task lifecycle, concurrency (manager.ts 1642 lines)
├── builtin-skills/ # Skills like git-master (1107 lines)
├── builtin-commands/ # Commands like refactor (619 lines)
├── skill-mcp-manager/ # MCP client lifecycle (640 lines)
├── claude-code-plugin-loader/ # Plugin loading
├── claude-code-mcp-loader/ # MCP loading
├── claude-code-session-state/ # Session state
├── claude-code-command-loader/ # Command loading
├── claude-code-agent-loader/ # Agent loading
├── context-injector/ # Context injection
├── hook-message-injector/ # Message injection
├── task-toast-manager/ # Task toasts
├── boulder-state/ # State management
├── tmux-subagent/ # Tmux subagent
├── mcp-oauth/ # OAuth for MCP
├── opencode-skill-loader/ # Skill loading
├── tool-metadata-store/ # Tool metadata
## LOADER PRIORITY
## HOW TO ADD
| Type | Priority (highest first) |
|------|--------------------------|
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` |
| Skills | `.opencode/skills/` > `~/.config/opencode/skills/` > `.claude/skills/` |
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
## BACKGROUND AGENT
- **Lifecycle**: `launch``poll` (2s) → `complete`
- **Stability**: 3 consecutive polls = idle
- **Concurrency**: Per-provider/model limits via `ConcurrencyManager`
- **Cleanup**: 30m TTL, 3m stale timeout
- **State**: Per-session Maps, cleaned on `session.deleted`
## SKILL MCP
- **Lazy**: Clients created on first call
- **Transports**: stdio, http (SSE/Streamable)
- **Lifecycle**: 5m idle cleanup
## ANTI-PATTERNS
- **Sequential delegation**: Use `task` parallel
- **Trust self-reports**: ALWAYS verify
- **Main thread blocks**: No heavy I/O in loader init
- **Direct state mutation**: Use managers for boulder/session state
Create dir with index.ts, types.ts, etc.
@@ -15,6 +15,7 @@ export async function createBackgroundSession(options: {
const body = {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
permission: [{ permission: "question", action: "deny" as const, pattern: "*" }],
}
const createResult = await client.session
+1
View File
@@ -1,2 +1,3 @@
export * from "./types"
export * from "./storage"
export * from "./session-storage"
@@ -0,0 +1,204 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from "fs"
import { join } from "path"
import type { OhMyOpenCodeConfig } from "../../config/schema"
import {
getSessionTaskDir,
listSessionTaskFiles,
listAllSessionDirs,
findTaskAcrossSessions,
} from "./session-storage"
const TEST_DIR = ".test-session-storage"
const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
function makeConfig(storagePath: string): Partial<OhMyOpenCodeConfig> {
return {
sisyphus: {
tasks: { storage_path: storagePath, claude_code_compat: false },
},
}
}
describe("getSessionTaskDir", () => {
test("returns session-scoped subdirectory under base task dir", () => {
//#given
const config = makeConfig("/tmp/tasks")
const sessionID = "ses_abc123"
//#when
const result = getSessionTaskDir(config, sessionID)
//#then
expect(result).toBe("/tmp/tasks/ses_abc123")
})
test("uses relative storage path joined with cwd", () => {
//#given
const config = makeConfig(TEST_DIR)
const sessionID = "ses_xyz"
//#when
const result = getSessionTaskDir(config, sessionID)
//#then
expect(result).toBe(join(TEST_DIR_ABS, "ses_xyz"))
})
})
describe("listSessionTaskFiles", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns empty array when session directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = listSessionTaskFiles(config, "nonexistent-session")
//#then
expect(result).toEqual([])
})
test("lists only T-*.json files in the session directory", () => {
//#given
const config = makeConfig(TEST_DIR)
const sessionDir = join(TEST_DIR_ABS, "ses_001")
mkdirSync(sessionDir, { recursive: true })
writeFileSync(join(sessionDir, "T-aaa.json"), "{}", "utf-8")
writeFileSync(join(sessionDir, "T-bbb.json"), "{}", "utf-8")
writeFileSync(join(sessionDir, "other.txt"), "nope", "utf-8")
//#when
const result = listSessionTaskFiles(config, "ses_001")
//#then
expect(result).toHaveLength(2)
expect(result).toContain("T-aaa")
expect(result).toContain("T-bbb")
})
test("does not list tasks from other sessions", () => {
//#given
const config = makeConfig(TEST_DIR)
const session1Dir = join(TEST_DIR_ABS, "ses_001")
const session2Dir = join(TEST_DIR_ABS, "ses_002")
mkdirSync(session1Dir, { recursive: true })
mkdirSync(session2Dir, { recursive: true })
writeFileSync(join(session1Dir, "T-from-s1.json"), "{}", "utf-8")
writeFileSync(join(session2Dir, "T-from-s2.json"), "{}", "utf-8")
//#when
const result = listSessionTaskFiles(config, "ses_001")
//#then
expect(result).toEqual(["T-from-s1"])
})
})
describe("listAllSessionDirs", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns empty array when base directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = listAllSessionDirs(config)
//#then
expect(result).toEqual([])
})
test("returns only directory entries (not files)", () => {
//#given
const config = makeConfig(TEST_DIR)
mkdirSync(TEST_DIR_ABS, { recursive: true })
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
mkdirSync(join(TEST_DIR_ABS, "ses_002"), { recursive: true })
writeFileSync(join(TEST_DIR_ABS, ".lock"), "{}", "utf-8")
writeFileSync(join(TEST_DIR_ABS, "T-legacy.json"), "{}", "utf-8")
//#when
const result = listAllSessionDirs(config)
//#then
expect(result).toHaveLength(2)
expect(result).toContain("ses_001")
expect(result).toContain("ses_002")
})
})
describe("findTaskAcrossSessions", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns null when task does not exist in any session", () => {
//#given
const config = makeConfig(TEST_DIR)
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
//#when
const result = findTaskAcrossSessions(config, "T-nonexistent")
//#then
expect(result).toBeNull()
})
test("finds task in the correct session directory", () => {
//#given
const config = makeConfig(TEST_DIR)
const session2Dir = join(TEST_DIR_ABS, "ses_002")
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
mkdirSync(session2Dir, { recursive: true })
writeFileSync(join(session2Dir, "T-target.json"), '{"id":"T-target"}', "utf-8")
//#when
const result = findTaskAcrossSessions(config, "T-target")
//#then
expect(result).not.toBeNull()
expect(result!.sessionID).toBe("ses_002")
expect(result!.path).toBe(join(session2Dir, "T-target.json"))
})
test("returns null when base directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = findTaskAcrossSessions(config, "T-any")
//#then
expect(result).toBeNull()
})
})
@@ -0,0 +1,52 @@
import { join } from "path"
import { existsSync, readdirSync, statSync } from "fs"
import { getTaskDir } from "./storage"
import type { OhMyOpenCodeConfig } from "../../config/schema"
export function getSessionTaskDir(
config: Partial<OhMyOpenCodeConfig>,
sessionID: string,
): string {
return join(getTaskDir(config), sessionID)
}
export function listSessionTaskFiles(
config: Partial<OhMyOpenCodeConfig>,
sessionID: string,
): string[] {
const dir = getSessionTaskDir(config, sessionID)
if (!existsSync(dir)) return []
return readdirSync(dir)
.filter((f) => f.endsWith(".json") && f.startsWith("T-"))
.map((f) => f.replace(".json", ""))
}
export function listAllSessionDirs(
config: Partial<OhMyOpenCodeConfig>,
): string[] {
const baseDir = getTaskDir(config)
if (!existsSync(baseDir)) return []
return readdirSync(baseDir).filter((entry) => {
const fullPath = join(baseDir, entry)
return statSync(fullPath).isDirectory()
})
}
export interface TaskLocation {
path: string
sessionID: string
}
export function findTaskAcrossSessions(
config: Partial<OhMyOpenCodeConfig>,
taskId: string,
): TaskLocation | null {
const sessionDirs = listAllSessionDirs(config)
for (const sessionID of sessionDirs) {
const taskPath = join(getSessionTaskDir(config, sessionID), `${taskId}.json`)
if (existsSync(taskPath)) {
return { path: taskPath, sessionID }
}
}
return null
}
+63
View File
@@ -20,6 +20,7 @@ const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
describe("getTaskDir", () => {
const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID
const originalClaudeTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
beforeEach(() => {
if (originalTaskListId === undefined) {
@@ -27,6 +28,12 @@ describe("getTaskDir", () => {
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
if (originalClaudeTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId
}
})
afterEach(() => {
@@ -35,6 +42,12 @@ describe("getTaskDir", () => {
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
if (originalClaudeTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId
}
})
test("returns global config path for default config", () => {
@@ -62,6 +75,19 @@ describe("getTaskDir", () => {
expect(result).toBe(join(configDir, "tasks", "custom-list-id"))
})
test("respects CLAUDE_CODE_TASK_LIST_ID env var when ULTRAWORK_TASK_LIST_ID not set", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
process.env.CLAUDE_CODE_TASK_LIST_ID = "claude list/id"
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
//#when
const result = getTaskDir()
//#then
expect(result).toBe(join(configDir, "tasks", "claude-list-id"))
})
test("falls back to sanitized cwd basename when env var not set", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
@@ -114,6 +140,7 @@ describe("getTaskDir", () => {
describe("resolveTaskListId", () => {
const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID
const originalClaudeTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
beforeEach(() => {
if (originalTaskListId === undefined) {
@@ -121,6 +148,12 @@ describe("resolveTaskListId", () => {
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
if (originalClaudeTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId
}
})
afterEach(() => {
@@ -129,6 +162,12 @@ describe("resolveTaskListId", () => {
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
if (originalClaudeTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
} else {
process.env.CLAUDE_CODE_TASK_LIST_ID = originalClaudeTaskListId
}
})
test("returns env var when set", () => {
@@ -142,6 +181,30 @@ describe("resolveTaskListId", () => {
expect(result).toBe("custom-list")
})
test("returns CLAUDE_CODE_TASK_LIST_ID when ULTRAWORK_TASK_LIST_ID not set", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
process.env.CLAUDE_CODE_TASK_LIST_ID = "claude-list"
//#when
const result = resolveTaskListId()
//#then
expect(result).toBe("claude-list")
})
test("sanitizes CLAUDE_CODE_TASK_LIST_ID special characters", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
process.env.CLAUDE_CODE_TASK_LIST_ID = "claude list/id"
//#when
const result = resolveTaskListId()
//#then
expect(result).toBe("claude-list-id")
})
test("sanitizes special characters", () => {
//#given
process.env.ULTRAWORK_TASK_LIST_ID = "custom list/id"
@@ -0,0 +1,43 @@
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession } from "./types"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { executeAction } from "./action-executor"
import { TmuxPollingManager } from "./polling-manager"
export class ManagerCleanup {
constructor(
private sessions: Map<string, TrackedSession>,
private sourcePaneId: string | undefined,
private pollingManager: TmuxPollingManager,
private tmuxConfig: TmuxConfig,
private serverUrl: string
) {}
async cleanup(): Promise<void> {
this.pollingManager.stopPolling()
if (this.sessions.size > 0) {
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
if (state) {
const closePromises = Array.from(this.sessions.values()).map((s) =>
executeAction(
{ type: "close", paneId: s.paneId, sessionId: s.sessionId },
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
).catch((err) =>
log("[tmux-session-manager] cleanup error for pane", {
paneId: s.paneId,
error: String(err),
}),
),
)
await Promise.all(closePromises)
}
this.sessions.clear()
}
log("[tmux-session-manager] cleanup complete")
}
}
@@ -0,0 +1,139 @@
import type { OpencodeClient } from "../../tools/delegate-task/types"
import { POLL_INTERVAL_BACKGROUND_MS } from "../../shared/tmux"
import type { TrackedSession } from "./types"
import { SESSION_MISSING_GRACE_MS } from "../../shared/tmux"
import { log } from "../../shared"
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000
const STABLE_POLLS_REQUIRED = 3
export class TmuxPollingManager {
private pollInterval?: ReturnType<typeof setInterval>
constructor(
private client: OpencodeClient,
private sessions: Map<string, TrackedSession>,
private closeSessionById: (sessionId: string) => Promise<void>
) {}
startPolling(): void {
if (this.pollInterval) return
this.pollInterval = setInterval(
() => this.pollSessions(),
POLL_INTERVAL_BACKGROUND_MS, // POLL_INTERVAL_BACKGROUND_MS
)
log("[tmux-session-manager] polling started")
}
stopPolling(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval)
this.pollInterval = undefined
log("[tmux-session-manager] polling stopped")
}
}
private async pollSessions(): Promise<void> {
if (this.sessions.size === 0) {
this.stopPolling()
return
}
try {
const statusResult = await this.client.session.status({ path: undefined })
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
log("[tmux-session-manager] pollSessions", {
trackedSessions: Array.from(this.sessions.keys()),
allStatusKeys: Object.keys(allStatuses),
})
const now = Date.now()
const sessionsToClose: string[] = []
for (const [sessionId, tracked] of this.sessions.entries()) {
const status = allStatuses[sessionId]
const isIdle = status?.type === "idle"
if (status) {
tracked.lastSeenAt = new Date(now)
}
const missingSince = !status ? now - tracked.lastSeenAt.getTime() : 0
const missingTooLong = missingSince >= SESSION_MISSING_GRACE_MS
const isTimedOut = now - tracked.createdAt.getTime() > SESSION_TIMEOUT_MS
const elapsedMs = now - tracked.createdAt.getTime()
let shouldCloseViaStability = false
if (isIdle && elapsedMs >= MIN_STABILITY_TIME_MS) {
try {
const messagesResult = await this.client.session.messages({
path: { id: sessionId }
})
const currentMsgCount = Array.isArray(messagesResult.data)
? messagesResult.data.length
: 0
if (tracked.lastMessageCount === currentMsgCount) {
tracked.stableIdlePolls = (tracked.stableIdlePolls ?? 0) + 1
if (tracked.stableIdlePolls >= STABLE_POLLS_REQUIRED) {
const recheckResult = await this.client.session.status({ path: undefined })
const recheckStatuses = (recheckResult.data ?? {}) as Record<string, { type: string }>
const recheckStatus = recheckStatuses[sessionId]
if (recheckStatus?.type === "idle") {
shouldCloseViaStability = true
} else {
tracked.stableIdlePolls = 0
log("[tmux-session-manager] stability reached but session not idle on recheck, resetting", {
sessionId,
recheckStatus: recheckStatus?.type,
})
}
}
} else {
tracked.stableIdlePolls = 0
}
tracked.lastMessageCount = currentMsgCount
} catch (msgErr) {
log("[tmux-session-manager] failed to fetch messages for stability check", {
sessionId,
error: String(msgErr),
})
}
} else if (!isIdle) {
tracked.stableIdlePolls = 0
}
log("[tmux-session-manager] session check", {
sessionId,
statusType: status?.type,
isIdle,
elapsedMs,
stableIdlePolls: tracked.stableIdlePolls,
lastMessageCount: tracked.lastMessageCount,
missingSince,
missingTooLong,
isTimedOut,
shouldCloseViaStability,
})
if (shouldCloseViaStability || missingTooLong || isTimedOut) {
sessionsToClose.push(sessionId)
}
}
for (const sessionId of sessionsToClose) {
log("[tmux-session-manager] closing session due to poll", { sessionId })
await this.closeSessionById(sessionId)
}
} catch (err) {
log("[tmux-session-manager] poll error", { error: String(err) })
}
}
}
@@ -0,0 +1,80 @@
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession } from "./types"
import type { SessionMapping } from "./decision-engine"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { decideCloseAction } from "./decision-engine"
import { executeAction } from "./action-executor"
import { TmuxPollingManager } from "./polling-manager"
export interface TmuxUtilDeps {
isInsideTmux: () => boolean
getCurrentPaneId: () => string | undefined
}
export class SessionCleaner {
constructor(
private tmuxConfig: TmuxConfig,
private deps: TmuxUtilDeps,
private sessions: Map<string, TrackedSession>,
private sourcePaneId: string | undefined,
private getSessionMappings: () => SessionMapping[],
private pollingManager: TmuxPollingManager,
private serverUrl: string
) {}
private isEnabled(): boolean {
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
}
async onSessionDeleted(event: { sessionID: string }): Promise<void> {
if (!this.isEnabled()) return
if (!this.sourcePaneId) return
const tracked = this.sessions.get(event.sessionID)
if (!tracked) return
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
const state = await queryWindowState(this.sourcePaneId)
if (!state) {
this.sessions.delete(event.sessionID)
return
}
const closeAction = decideCloseAction(state, event.sessionID, this.getSessionMappings())
if (closeAction) {
await executeAction(closeAction, { config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state })
}
this.sessions.delete(event.sessionID)
if (this.sessions.size === 0) {
this.pollingManager.stopPolling()
}
}
async closeSessionById(sessionId: string): Promise<void> {
const tracked = this.sessions.get(sessionId)
if (!tracked) return
log("[tmux-session-manager] closing session pane", {
sessionId,
paneId: tracked.paneId,
})
const state = this.sourcePaneId ? await queryWindowState(this.sourcePaneId) : null
if (state) {
await executeAction(
{ type: "close", paneId: tracked.paneId, sessionId },
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
)
}
this.sessions.delete(sessionId)
if (this.sessions.size === 0) {
this.pollingManager.stopPolling()
}
}
}
@@ -0,0 +1,166 @@
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig } from "./types"
import { log } from "../../shared"
import { queryWindowState } from "./pane-state-querier"
import { decideSpawnActions, type SessionMapping } from "./decision-engine"
import { executeActions } from "./action-executor"
import { TmuxPollingManager } from "./polling-manager"
interface SessionCreatedEvent {
type: string
properties?: { info?: { id?: string; parentID?: string; title?: string } }
}
export interface TmuxUtilDeps {
isInsideTmux: () => boolean
getCurrentPaneId: () => string | undefined
}
export class SessionSpawner {
constructor(
private tmuxConfig: TmuxConfig,
private deps: TmuxUtilDeps,
private sessions: Map<string, TrackedSession>,
private pendingSessions: Set<string>,
private sourcePaneId: string | undefined,
private getCapacityConfig: () => CapacityConfig,
private getSessionMappings: () => SessionMapping[],
private waitForSessionReady: (sessionId: string) => Promise<boolean>,
private pollingManager: TmuxPollingManager,
private serverUrl: string
) {}
private isEnabled(): boolean {
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
}
async onSessionCreated(event: SessionCreatedEvent): Promise<void> {
const enabled = this.isEnabled()
log("[tmux-session-manager] onSessionCreated called", {
enabled,
tmuxConfigEnabled: this.tmuxConfig.enabled,
isInsideTmux: this.deps.isInsideTmux(),
eventType: event.type,
infoId: event.properties?.info?.id,
infoParentID: event.properties?.info?.parentID,
})
if (!enabled) return
if (event.type !== "session.created") return
const info = event.properties?.info
if (!info?.id || !info?.parentID) return
const sessionId = info.id
const title = info.title ?? "Subagent"
if (this.sessions.has(sessionId) || this.pendingSessions.has(sessionId)) {
log("[tmux-session-manager] session already tracked or pending", { sessionId })
return
}
if (!this.sourcePaneId) {
log("[tmux-session-manager] no source pane id")
return
}
this.pendingSessions.add(sessionId)
try {
const state = await queryWindowState(this.sourcePaneId)
if (!state) {
log("[tmux-session-manager] failed to query window state")
return
}
log("[tmux-session-manager] window state queried", {
windowWidth: state.windowWidth,
mainPane: state.mainPane?.paneId,
agentPaneCount: state.agentPanes.length,
agentPanes: state.agentPanes.map((p) => p.paneId),
})
const decision = decideSpawnActions(
state,
sessionId,
title,
this.getCapacityConfig(),
this.getSessionMappings()
)
log("[tmux-session-manager] spawn decision", {
canSpawn: decision.canSpawn,
reason: decision.reason,
actionCount: decision.actions.length,
actions: decision.actions.map((a) => {
if (a.type === "close") return { type: "close", paneId: a.paneId }
if (a.type === "replace") return { type: "replace", paneId: a.paneId, newSessionId: a.newSessionId }
return { type: "spawn", sessionId: a.sessionId }
}),
})
if (!decision.canSpawn) {
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
return
}
const result = await executeActions(
decision.actions,
{ config: this.tmuxConfig, serverUrl: this.serverUrl, windowState: state }
)
for (const { action, result: actionResult } of result.results) {
if (action.type === "close" && actionResult.success) {
this.sessions.delete(action.sessionId)
log("[tmux-session-manager] removed closed session from cache", {
sessionId: action.sessionId,
})
}
if (action.type === "replace" && actionResult.success) {
this.sessions.delete(action.oldSessionId)
log("[tmux-session-manager] removed replaced session from cache", {
oldSessionId: action.oldSessionId,
newSessionId: action.newSessionId,
})
}
}
if (result.success && result.spawnedPaneId) {
const sessionReady = await this.waitForSessionReady(sessionId)
if (!sessionReady) {
log("[tmux-session-manager] session not ready after timeout, tracking anyway", {
sessionId,
paneId: result.spawnedPaneId,
})
}
const now = Date.now()
this.sessions.set(sessionId, {
sessionId,
paneId: result.spawnedPaneId,
description: title,
createdAt: new Date(now),
lastSeenAt: new Date(now),
})
log("[tmux-session-manager] pane spawned and tracked", {
sessionId,
paneId: result.spawnedPaneId,
sessionReady,
})
this.pollingManager.startPolling()
} else {
log("[tmux-session-manager] spawn failed", {
success: result.success,
results: result.results.map((r) => ({
type: r.action.type,
success: r.result.success,
error: r.result.error,
})),
})
}
} finally {
this.pendingSessions.delete(sessionId)
}
}
}