diff --git a/packages/core/src/__tests__/tmux.test.ts b/packages/core/src/__tests__/tmux.test.ts index 5fe28abe6..bbbed3ae1 100644 --- a/packages/core/src/__tests__/tmux.test.ts +++ b/packages/core/src/__tests__/tmux.test.ts @@ -159,48 +159,63 @@ describe("newSession", () => { }); it("sends initial command after creation", async () => { - // First call: new-session, Second call: send-keys, Third call: send-keys Enter + // Calls: new-session, send-keys Escape, send-keys text, send-keys Enter mockTmuxSequence([ { stdout: "" }, { stdout: "" }, { stdout: "" }, + { stdout: "" }, ]); await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" }); - // Should have called new-session then send-keys twice (text + Enter) - expect(mockExecFile).toHaveBeenCalledTimes(3); - const secondCallArgs = mockExecFile.mock.calls[1][1] as string[]; - expect(secondCallArgs).toContain("send-keys"); - expect(secondCallArgs).toContain("echo hello"); + expect(mockExecFile).toHaveBeenCalledTimes(4); + // Call 0: new-session + // Call 1: send-keys Escape (clear partial input) + const escapeArgs = mockExecFile.mock.calls[1][1] as string[]; + expect(escapeArgs).toEqual(["send-keys", "-t", "test-4", "Escape"]); + // Call 2: send-keys text + const textArgs = mockExecFile.mock.calls[2][1] as string[]; + expect(textArgs).toContain("send-keys"); + expect(textArgs).toContain("echo hello"); }); }); describe("sendKeys", () => { it("sends short text with send-keys", async () => { - mockTmuxSequence([{ stdout: "" }, { stdout: "" }]); + // Calls: send-keys Escape, send-keys text, send-keys Enter + mockTmuxSequence([{ stdout: "" }, { stdout: "" }, { stdout: "" }]); await sendKeys("app-1", "hello world"); - expect(mockExecFile).toHaveBeenCalledTimes(2); - const firstArgs = mockExecFile.mock.calls[0][1] as string[]; - expect(firstArgs).toEqual(["send-keys", "-t", "app-1", "hello world"]); - // Second call is Enter - const secondArgs = mockExecFile.mock.calls[1][1] as string[]; - expect(secondArgs).toEqual(["send-keys", "-t", "app-1", "Enter"]); + expect(mockExecFile).toHaveBeenCalledTimes(3); + // Call 0: Escape to clear partial input + const escapeArgs = mockExecFile.mock.calls[0][1] as string[]; + expect(escapeArgs).toEqual(["send-keys", "-t", "app-1", "Escape"]); + // Call 1: text + const textArgs = mockExecFile.mock.calls[1][1] as string[]; + expect(textArgs).toEqual(["send-keys", "-t", "app-1", "hello world"]); + // Call 2: Enter + const enterArgs = mockExecFile.mock.calls[2][1] as string[]; + expect(enterArgs).toEqual(["send-keys", "-t", "app-1", "Enter"]); }); it("skips Enter when pressEnter=false", async () => { - mockTmuxSuccess(""); + // Calls: send-keys Escape, send-keys text (no Enter) + mockTmuxSequence([{ stdout: "" }, { stdout: "" }]); await sendKeys("app-1", "hello", false); - expect(mockExecFile).toHaveBeenCalledTimes(1); + expect(mockExecFile).toHaveBeenCalledTimes(2); + const escapeArgs = mockExecFile.mock.calls[0][1] as string[]; + expect(escapeArgs).toEqual(["send-keys", "-t", "app-1", "Escape"]); }); - it("uses load-buffer for long text", async () => { + it("uses load-buffer with named buffer for long text", async () => { const longText = "a".repeat(250); + // Calls: send-keys Escape, load-buffer -b name, paste-buffer -b name -d, send-keys Enter mockTmuxSequence([ + { stdout: "" }, // send-keys Escape { stdout: "" }, // load-buffer { stdout: "" }, // paste-buffer { stdout: "" }, // send-keys Enter @@ -208,15 +223,32 @@ describe("sendKeys", () => { await sendKeys("app-1", longText); - const firstArgs = mockExecFile.mock.calls[0][1] as string[]; - expect(firstArgs[0]).toBe("load-buffer"); + expect(mockExecFile).toHaveBeenCalledTimes(4); - const secondArgs = mockExecFile.mock.calls[1][1] as string[]; - expect(secondArgs).toEqual(["paste-buffer", "-t", "app-1"]); + // Call 0: Escape + const escapeArgs = mockExecFile.mock.calls[0][1] as string[]; + expect(escapeArgs).toEqual(["send-keys", "-t", "app-1", "Escape"]); + + // Call 1: load-buffer with named buffer + const loadArgs = mockExecFile.mock.calls[1][1] as string[]; + expect(loadArgs[0]).toBe("load-buffer"); + expect(loadArgs[1]).toBe("-b"); + expect(loadArgs[2]).toMatch(/^ao-/); // named buffer + + // Call 2: paste-buffer with named buffer and -d (delete after paste) + const pasteArgs = mockExecFile.mock.calls[2][1] as string[]; + expect(pasteArgs[0]).toBe("paste-buffer"); + expect(pasteArgs[1]).toBe("-b"); + expect(pasteArgs[2]).toMatch(/^ao-/); + expect(pasteArgs).toContain("-d"); + expect(pasteArgs).toContain("-t"); + expect(pasteArgs).toContain("app-1"); }); it("uses load-buffer for multiline text", async () => { + // Calls: send-keys Escape, load-buffer, paste-buffer, send-keys Enter mockTmuxSequence([ + { stdout: "" }, // send-keys Escape { stdout: "" }, // load-buffer { stdout: "" }, // paste-buffer { stdout: "" }, // send-keys Enter @@ -224,8 +256,11 @@ describe("sendKeys", () => { await sendKeys("app-1", "line1\nline2"); - const firstArgs = mockExecFile.mock.calls[0][1] as string[]; - expect(firstArgs[0]).toBe("load-buffer"); + expect(mockExecFile).toHaveBeenCalledTimes(4); + // Call 1 (after Escape) should be load-buffer + const loadArgs = mockExecFile.mock.calls[1][1] as string[]; + expect(loadArgs[0]).toBe("load-buffer"); + expect(loadArgs[1]).toBe("-b"); // named buffer }); }); diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 9f6282fce..b78625340 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -19,10 +19,10 @@ import type { EventBus, OrchestratorEvent, OrchestratorConfig, - ProjectConfig, ReactionConfig, ReactionResult, PluginRegistry, + Runtime, Agent, SCM, Notifier, @@ -47,25 +47,9 @@ function parseDuration(str: string): number { } } -/** Map from event type prefix to session status. */ -const STATUS_FROM_EVENT: Partial> = { - "session.spawned": "spawning", - "session.working": "working", - "session.stuck": "stuck", - "session.needs_input": "needs_input", - "session.errored": "errored", - "session.killed": "killed", - "pr.created": "pr_open", - "ci.failing": "ci_failed", - "review.changes_requested": "changes_requested", - "review.approved": "approved", - "merge.ready": "mergeable", - "merge.completed": "merged", -}; - /** Determine which event type corresponds to a status transition. */ function statusToEventType( - from: SessionStatus | undefined, + _from: SessionStatus | undefined, to: SessionStatus ): EventType | null { switch (to) { @@ -124,6 +108,8 @@ export function createLifecycleManager( const handlers = new Map>(); const reactionTrackers = new Map(); // "sessionId:reactionKey" let pollTimer: ReturnType | null = null; + let polling = false; // re-entrancy guard + let allCompleteEmitted = false; // guard against repeated all_complete function getOrCreateSet(key: string): Set { let set = handlers.get(key); @@ -164,7 +150,7 @@ export function createLifecycleManager( // 1. Check if runtime is alive if (session.runtimeHandle) { - const runtime = registry.get( + const runtime = registry.get( "runtime", project.runtime ?? config.defaults.runtime ); @@ -260,7 +246,9 @@ export function createLifecycleManager( } // Execute the reaction action - switch (reactionConfig.action) { + const action = reactionConfig.action ?? "notify"; + + switch (action) { case "send-to-agent": { if (reactionConfig.message) { try { @@ -323,7 +311,7 @@ export function createLifecycleManager( return { reactionType: reactionKey, success: false, - action: reactionConfig.action, + action, escalated: false, }; } @@ -360,6 +348,11 @@ export function createLifecycleManager( // Update metadata updateMetadata(config.dataDir, session.id, { status: newStatus }); + // Reset allCompleteEmitted when any session becomes active again + if (newStatus !== "merged" && newStatus !== "killed") { + allCompleteEmitted = false; + } + // Emit transition event const eventType = statusToEventType(oldStatus, newStatus); if (eventType) { @@ -382,7 +375,7 @@ export function createLifecycleManager( project?.reactions?.[reactionKey] ?? config.reactions[reactionKey]; - if (reactionConfig && (reactionConfig as ReactionConfig).auto !== false) { + if (reactionConfig && reactionConfig.auto !== false && reactionConfig.action) { await executeReaction( event, reactionKey, @@ -399,19 +392,25 @@ export function createLifecycleManager( /** Run one polling cycle across all sessions. */ async function pollAll(): Promise { + // Re-entrancy guard: skip if previous poll is still running + if (polling) return; + polling = true; + try { const sessions = await sessionManager.list(); - for (const session of sessions) { - if (session.status === "merged" || session.status === "killed") continue; - await checkSession(session); - } - - // Check if all sessions are complete const activeSessions = sessions.filter( (s) => s.status !== "merged" && s.status !== "killed" ); - if (sessions.length > 0 && activeSessions.length === 0) { + + // Poll all active sessions concurrently + await Promise.allSettled( + activeSessions.map((s) => checkSession(s)) + ); + + // Check if all sessions are complete (emit only once) + if (sessions.length > 0 && activeSessions.length === 0 && !allCompleteEmitted) { + allCompleteEmitted = true; const event = createEvent("summary.all_complete", { sessionId: "system", projectId: "all", @@ -423,6 +422,8 @@ export function createLifecycleManager( } } catch { // Poll cycle failed — will retry next interval + } finally { + polling = false; } } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index dffd06726..e88a913f7 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -11,7 +11,6 @@ * Reference: scripts/claude-ao-session, scripts/send-to-session */ -import { execFile } from "node:child_process"; import type { SessionManager, Session, @@ -28,23 +27,19 @@ import type { SCM, EventBus, PluginRegistry, + RuntimeHandle, } from "./types.js"; import { - readMetadata, + readMetadataRaw, writeMetadata, - updateMetadata, deleteMetadata, listMetadata, } from "./metadata.js"; import { createEvent } from "./event-bus.js"; -/** Get the current git branch in a directory. */ -function getGitBranch(dir: string): Promise { - return new Promise((resolve) => { - execFile("git", ["-C", dir, "branch", "--show-current"], (error, stdout) => { - resolve(error ? "unknown" : stdout.trim()); - }); - }); +/** Escape regex metacharacters in a string. */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** Get the next session number for a project. */ @@ -53,7 +48,7 @@ function getNextSessionNumber( prefix: string ): number { let max = 0; - const pattern = new RegExp(`^${prefix}-(\\d+)$`); + const pattern = new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`); for (const name of existingSessions) { const match = name.match(pattern); if (match) { @@ -64,7 +59,16 @@ function getNextSessionNumber( return max + 1; } -/** Reconstruct a Session object from metadata + live data. */ +/** Safely parse JSON, returning null on failure. */ +function safeJsonParse(str: string): T | null { + try { + return JSON.parse(str) as T; + } catch { + return null; + } +} + +/** Reconstruct a Session object from raw metadata key=value pairs. */ function metadataToSession( sessionId: SessionId, meta: Record @@ -90,7 +94,7 @@ function metadataToSession( : null, workspacePath: meta["worktree"] || null, runtimeHandle: meta["runtimeHandle"] - ? JSON.parse(meta["runtimeHandle"]) + ? safeJsonParse(meta["runtimeHandle"]) : null, agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } @@ -135,294 +139,305 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { return { runtime, agent, workspace, tracker, scm }; } - return { - async spawn(spawnConfig: SessionSpawnConfig): Promise { - const project = config.projects[spawnConfig.projectId]; - if (!project) { - throw new Error(`Unknown project: ${spawnConfig.projectId}`); - } + // Define methods as local functions so `this` is not needed + async function spawn(spawnConfig: SessionSpawnConfig): Promise { + const project = config.projects[spawnConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${spawnConfig.projectId}`); + } - const plugins = resolvePlugins(project); - if (!plugins.runtime) { - throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); - } - if (!plugins.agent) { - throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`); - } + const plugins = resolvePlugins(project); + if (!plugins.runtime) { + throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`); + } + if (!plugins.agent) { + throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`); + } - // Determine session ID - const existingSessions = listMetadata(config.dataDir); - const num = getNextSessionNumber(existingSessions, project.sessionPrefix); - const sessionId = `${project.sessionPrefix}-${num}`; + // Determine session ID + const existingSessions = listMetadata(config.dataDir); + const num = getNextSessionNumber(existingSessions, project.sessionPrefix); + const sessionId = `${project.sessionPrefix}-${num}`; - // Determine branch name - let branch = spawnConfig.branch ?? project.defaultBranch; - if (spawnConfig.issueId && plugins.tracker) { - branch = plugins.tracker.branchName(spawnConfig.issueId, project); - } else if (spawnConfig.issueId) { - branch = `feat/${spawnConfig.issueId}`; - } + // Determine branch name — explicit branch always takes priority + let branch: string; + if (spawnConfig.branch) { + branch = spawnConfig.branch; + } else if (spawnConfig.issueId && plugins.tracker) { + branch = plugins.tracker.branchName(spawnConfig.issueId, project); + } else if (spawnConfig.issueId) { + branch = `feat/${spawnConfig.issueId}`; + } else { + branch = project.defaultBranch; + } - // Create workspace (if workspace plugin is available) - let workspacePath = project.path; - if (plugins.workspace) { - const wsInfo = await plugins.workspace.create({ - projectId: spawnConfig.projectId, - project, - sessionId, - branch, - }); - workspacePath = wsInfo.path; - - // Run post-create hooks - if (plugins.workspace.postCreate) { - await plugins.workspace.postCreate(wsInfo, project); - } - } - - // Get agent launch config - const agentLaunchConfig = { - sessionId, - projectConfig: project, - issueId: spawnConfig.issueId, - prompt: spawnConfig.prompt, - permissions: project.agentConfig?.permissions, - model: project.agentConfig?.model, - }; - - const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); - const environment = plugins.agent.getEnvironment(agentLaunchConfig); - - // Create runtime session - const handle = await plugins.runtime.create({ - sessionId, - workspacePath, - launchCommand, - environment: { - ...environment, - AO_SESSION: sessionId, - }, - }); - - // Write metadata - writeMetadata(config.dataDir, sessionId, { - worktree: workspacePath, - branch, - status: "spawning", - issue: spawnConfig.issueId, - project: spawnConfig.projectId, - createdAt: new Date().toISOString(), - runtimeHandle: JSON.stringify(handle), - }); - - // Post-launch setup (if agent supports it) - const session: Session = { - id: sessionId, + // Create workspace (if workspace plugin is available) + let workspacePath = project.path; + if (plugins.workspace) { + const wsInfo = await plugins.workspace.create({ projectId: spawnConfig.projectId, - status: "spawning", - activity: "active", + project, + sessionId, branch, - issueId: spawnConfig.issueId ?? null, - pr: null, - workspacePath, - runtimeHandle: handle, - agentInfo: null, - createdAt: new Date(), - lastActivityAt: new Date(), - metadata: {}, - }; + }); + workspacePath = wsInfo.path; - if (plugins.agent.postLaunchSetup) { - await plugins.agent.postLaunchSetup(session); + // Run post-create hooks + if (plugins.workspace.postCreate) { + await plugins.workspace.postCreate(wsInfo, project); } + } - // Emit event - eventBus.emit( - createEvent("session.spawned", { - sessionId, - projectId: spawnConfig.projectId, - message: `Session ${sessionId} spawned${spawnConfig.issueId ? ` for ${spawnConfig.issueId}` : ""}`, - data: { branch, workspacePath, issueId: spawnConfig.issueId }, - }) - ); + // Get agent launch config + const agentLaunchConfig = { + sessionId, + projectConfig: project, + issueId: spawnConfig.issueId, + prompt: spawnConfig.prompt, + permissions: project.agentConfig?.permissions, + model: project.agentConfig?.model, + }; - return session; - }, + const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); + const environment = plugins.agent.getEnvironment(agentLaunchConfig); - async list(projectId?: string): Promise { - const sessionIds = listMetadata(config.dataDir); - const sessions: Session[] = []; + // Create runtime session + const handle = await plugins.runtime.create({ + sessionId, + workspacePath, + launchCommand, + environment: { + ...environment, + AO_SESSION: sessionId, + }, + }); - for (const sid of sessionIds) { - const raw = readMetadata(config.dataDir, sid); - if (!raw) continue; + // Write metadata + writeMetadata(config.dataDir, sessionId, { + worktree: workspacePath, + branch, + status: "spawning", + issue: spawnConfig.issueId, + project: spawnConfig.projectId, + createdAt: new Date().toISOString(), + runtimeHandle: JSON.stringify(handle), + }); - // Filter by project if specified - if (projectId && raw.project !== projectId) continue; + // Post-launch setup (if agent supports it) + const session: Session = { + id: sessionId, + projectId: spawnConfig.projectId, + status: "spawning", + activity: "active", + branch, + issueId: spawnConfig.issueId ?? null, + pr: null, + workspacePath, + runtimeHandle: handle, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; - const session = metadataToSession(sid, raw as unknown as Record); + if (plugins.agent.postLaunchSetup) { + await plugins.agent.postLaunchSetup(session); + } - // Check if runtime is still alive - if (session.runtimeHandle) { - const project = config.projects[session.projectId]; - if (project) { - const plugins = resolvePlugins(project); - if (plugins.runtime) { - try { - const alive = await plugins.runtime.isAlive(session.runtimeHandle); - if (!alive) { - session.status = "killed"; - session.activity = "exited"; - } - } catch { - // Can't check — assume still alive + // Emit event + eventBus.emit( + createEvent("session.spawned", { + sessionId, + projectId: spawnConfig.projectId, + message: `Session ${sessionId} spawned${spawnConfig.issueId ? ` for ${spawnConfig.issueId}` : ""}`, + data: { branch, workspacePath, issueId: spawnConfig.issueId }, + }) + ); + + return session; + } + + async function list(projectId?: string): Promise { + const sessionIds = listMetadata(config.dataDir); + const sessions: Session[] = []; + + for (const sid of sessionIds) { + const raw = readMetadataRaw(config.dataDir, sid); + if (!raw) continue; + + // Filter by project if specified + if (projectId && raw["project"] !== projectId) continue; + + const session = metadataToSession(sid, raw); + + // Check if runtime is still alive + if (session.runtimeHandle) { + const project = config.projects[session.projectId]; + if (project) { + const plugins = resolvePlugins(project); + if (plugins.runtime) { + try { + const alive = await plugins.runtime.isAlive(session.runtimeHandle); + if (!alive) { + session.status = "killed"; + session.activity = "exited"; } + } catch { + // Can't check — assume still alive } } } - - sessions.push(session); } - return sessions; - }, + sessions.push(session); + } - async get(sessionId: SessionId): Promise { - const raw = readMetadata(config.dataDir, sessionId); - if (!raw) return null; - return metadataToSession(sessionId, raw as unknown as Record); - }, + return sessions; + } - async kill(sessionId: SessionId): Promise { - const raw = readMetadata(config.dataDir, sessionId); - if (!raw) throw new Error(`Session ${sessionId} not found`); + async function get(sessionId: SessionId): Promise { + const raw = readMetadataRaw(config.dataDir, sessionId); + if (!raw) return null; + return metadataToSession(sessionId, raw); + } - const projectId = raw.project ?? ""; - const project = config.projects[projectId]; + async function kill(sessionId: SessionId): Promise { + const raw = readMetadataRaw(config.dataDir, sessionId); + if (!raw) throw new Error(`Session ${sessionId} not found`); - // Destroy runtime - if (raw.runtimeHandle && project) { - const plugins = resolvePlugins(project); - if (plugins.runtime) { + const projectId = raw["project"] ?? ""; + const project = config.projects[projectId]; + + // Destroy runtime + if (raw["runtimeHandle"] && project) { + const plugins = resolvePlugins(project); + if (plugins.runtime) { + const handle = safeJsonParse(raw["runtimeHandle"]); + if (handle) { try { - const handle = JSON.parse(raw.runtimeHandle); await plugins.runtime.destroy(handle); } catch { // Runtime might already be gone } } } + } - // Destroy workspace - if (raw.worktree && project) { - const plugins = resolvePlugins(project); - if (plugins.workspace) { - try { - await plugins.workspace.destroy(raw.worktree); - } catch { - // Workspace might already be gone - } - } - } - - // Archive metadata - deleteMetadata(config.dataDir, sessionId, true); - - // Emit event - eventBus.emit( - createEvent("session.killed", { - sessionId, - projectId, - message: `Session ${sessionId} killed`, - }) - ); - }, - - async cleanup(projectId?: string): Promise { - const result: CleanupResult = { killed: [], skipped: [], errors: [] }; - const sessions = await this.list(projectId); - - for (const session of sessions) { - try { - const project = config.projects[session.projectId]; - if (!project) { - result.skipped.push(session.id); - continue; - } - - const plugins = resolvePlugins(project); - let shouldKill = false; - - // Check if PR is merged - if (session.pr && plugins.scm) { - try { - const prState = await plugins.scm.getPRState(session.pr); - if (prState === "merged" || prState === "closed") { - shouldKill = true; - } - } catch { - // Can't check PR — skip - } - } - - // Check if issue is completed - if (!shouldKill && session.issueId && plugins.tracker) { - try { - const completed = await plugins.tracker.isCompleted( - session.issueId, - project - ); - if (completed) shouldKill = true; - } catch { - // Can't check issue — skip - } - } - - // Check if runtime is dead - if (!shouldKill && session.runtimeHandle && plugins.runtime) { - try { - const alive = await plugins.runtime.isAlive(session.runtimeHandle); - if (!alive) shouldKill = true; - } catch { - // Can't check — skip - } - } - - if (shouldKill) { - await this.kill(session.id); - result.killed.push(session.id); - } else { - result.skipped.push(session.id); - } - } catch (err) { - result.errors.push({ - sessionId: session.id, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - return result; - }, - - async send(sessionId: SessionId, message: string): Promise { - const raw = readMetadata(config.dataDir, sessionId); - if (!raw) throw new Error(`Session ${sessionId} not found`); - - const project = config.projects[raw.project ?? ""]; - if (!project) throw new Error(`Project not found for session ${sessionId}`); - + // Destroy workspace + if (raw["worktree"] && project) { const plugins = resolvePlugins(project); - if (!plugins.runtime) { - throw new Error(`No runtime plugin for session ${sessionId}`); + if (plugins.workspace) { + try { + await plugins.workspace.destroy(raw["worktree"]); + } catch { + // Workspace might already be gone + } } + } - if (!raw.runtimeHandle) { - throw new Error(`No runtime handle for session ${sessionId}`); + // Archive metadata + deleteMetadata(config.dataDir, sessionId, true); + + // Emit event + eventBus.emit( + createEvent("session.killed", { + sessionId, + projectId, + message: `Session ${sessionId} killed`, + }) + ); + } + + async function cleanup(projectId?: string): Promise { + const result: CleanupResult = { killed: [], skipped: [], errors: [] }; + const sessions = await list(projectId); + + for (const session of sessions) { + try { + const project = config.projects[session.projectId]; + if (!project) { + result.skipped.push(session.id); + continue; + } + + const plugins = resolvePlugins(project); + let shouldKill = false; + + // Check if PR is merged + if (session.pr && plugins.scm) { + try { + const prState = await plugins.scm.getPRState(session.pr); + if (prState === "merged" || prState === "closed") { + shouldKill = true; + } + } catch { + // Can't check PR — skip + } + } + + // Check if issue is completed + if (!shouldKill && session.issueId && plugins.tracker) { + try { + const completed = await plugins.tracker.isCompleted( + session.issueId, + project + ); + if (completed) shouldKill = true; + } catch { + // Can't check issue — skip + } + } + + // Check if runtime is dead + if (!shouldKill && session.runtimeHandle && plugins.runtime) { + try { + const alive = await plugins.runtime.isAlive(session.runtimeHandle); + if (!alive) shouldKill = true; + } catch { + // Can't check — skip + } + } + + if (shouldKill) { + await kill(session.id); + result.killed.push(session.id); + } else { + result.skipped.push(session.id); + } + } catch (err) { + result.errors.push({ + sessionId: session.id, + error: err instanceof Error ? err.message : String(err), + }); } + } - const handle = JSON.parse(raw.runtimeHandle); - await plugins.runtime.sendMessage(handle, message); - }, - }; + return result; + } + + async function send(sessionId: SessionId, message: string): Promise { + const raw = readMetadataRaw(config.dataDir, sessionId); + if (!raw) throw new Error(`Session ${sessionId} not found`); + + const project = config.projects[raw["project"] ?? ""]; + if (!project) throw new Error(`Project not found for session ${sessionId}`); + + const plugins = resolvePlugins(project); + if (!plugins.runtime) { + throw new Error(`No runtime plugin for session ${sessionId}`); + } + + if (!raw["runtimeHandle"]) { + throw new Error(`No runtime handle for session ${sessionId}`); + } + + const handle = safeJsonParse(raw["runtimeHandle"]); + if (!handle) { + throw new Error(`Corrupted runtime handle for session ${sessionId}`); + } + + await plugins.runtime.sendMessage(handle, message); + } + + return { spawn, list, get, kill, cleanup, send }; } diff --git a/packages/core/src/tmux.ts b/packages/core/src/tmux.ts index 767bdecf7..62ebcf453 100644 --- a/packages/core/src/tmux.ts +++ b/packages/core/src/tmux.ts @@ -118,26 +118,32 @@ export async function newSession(opts: NewSessionOptions): Promise { /** * Send keys (text + Enter) to a tmux session. - * For long/multiline messages, uses load-buffer + paste-buffer. + * For long/multiline messages, uses load-buffer + paste-buffer with + * a named buffer to avoid racing on the global paste buffer. + * Sends Escape first to clear any partial input in the agent. */ export async function sendKeys( sessionName: string, text: string, pressEnter = true ): Promise { + // Clear any partial input first (matches bash reference scripts) + await tmux("send-keys", "-t", sessionName, "Escape"); + if (text.includes("\n") || text.length > 200) { - // Use load-buffer for long/multiline text (avoids tmux escaping issues) + // Use a named buffer to avoid global paste buffer race conditions const { writeFileSync, unlinkSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const { randomUUID } = await import("node:crypto"); - const tmpFile = join(tmpdir(), `ao-tmux-${randomUUID()}.txt`); + const bufferName = `ao-${randomUUID().slice(0, 8)}`; + const tmpFile = join(tmpdir(), `ao-tmux-${bufferName}.txt`); writeFileSync(tmpFile, text, "utf-8"); try { - await tmux("load-buffer", tmpFile); - await tmux("paste-buffer", "-t", sessionName); + await tmux("load-buffer", "-b", bufferName, tmpFile); + await tmux("paste-buffer", "-b", bufferName, "-d", "-t", sessionName); } finally { try { unlinkSync(tmpFile); } catch { /* ignore cleanup errors */ } }