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:
+20
-52
@@ -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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user