fix: address PR review — extract shared helpers, fix bugs, add tests

- Extract getTmuxSessions/getTmuxActivity to lib/shell.ts (was duplicated in 5 files)
- Fix readMetadata unsafe cast: return Partial<SessionMetadata> instead
- Fix dashboard webDir path: resolve from package root, not dist/
- Fix dashboard spawn error handling: add child.on("error")
- Fix getNextSessionNumber: escape regex metacharacters in prefix
- Fix fallback worktree creation: use existing branch name, not detached HEAD
- Fix post-create hooks: run before agent launch, not after
- Fix open --new-window: pass option through to openInTerminal
- Fix cleanup dry-run: separate summary message for dry-run mode
- Add Claude session introspection to status command (TTY→PID→CWD→JSONL)
- Add vitest test suite: 72 tests covering commands and lib utilities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-13 17:56:58 +05:30
parent 2886ab2d99
commit a5259fa803
17 changed files with 1729 additions and 2209 deletions

View File

@ -0,0 +1,280 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockTmux, mockExec } = vi.hoisted(() => ({
mockTmux: vi.fn(),
mockExec: vi.fn(),
}));
vi.mock("../../src/lib/shell.js", () => ({
tmux: mockTmux,
exec: mockExec,
execSilent: vi.fn(),
git: vi.fn(),
gh: vi.fn(),
}));
import { Command } from "commander";
import { registerSend } from "../../src/commands/send.js";
let program: Command;
let consoleSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
program = new Command();
program.exitOverride();
registerSend(program);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
mockTmux.mockReset();
mockExec.mockReset();
mockExec.mockResolvedValue({ stdout: "", stderr: "" });
});
afterEach(() => {
vi.useRealTimers();
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
exitSpy.mockRestore();
});
describe("send command", () => {
describe("session existence check", () => {
it("exits with error when session does not exist", async () => {
mockTmux.mockResolvedValue(null); // has-session fails
await expect(
program.parseAsync(["node", "test", "send", "nonexistent", "hello"])
).rejects.toThrow("process.exit(1)");
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("does not exist")
);
});
});
describe("busy detection", () => {
it("detects idle session (prompt character)", async () => {
// has-session succeeds
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "has-session") return "";
if (args[0] === "capture-pane") {
// Check which -S value to determine context
const sIdx = args.indexOf("-S");
if (sIdx >= 0 && args[sIdx + 1] === "-5") {
return "some output\n ";
}
return "";
}
return "";
});
// isProcessing should detect processing after send
let captureCallCount = 0;
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "has-session") return "";
if (args[0] === "capture-pane") {
captureCallCount++;
const sIdx = args.indexOf("-S");
if (sIdx >= 0 && args[sIdx + 1] === "-5") {
return "some output\n ";
}
if (sIdx >= 0 && args[sIdx + 1] === "-10") {
return "Thinking about your request\nesc to interrupt";
}
return "";
}
return "";
});
await program.parseAsync([
"node", "test", "send", "my-session", "hello", "world",
]);
// Should have sent keys
expect(mockExec).toHaveBeenCalledWith(
"tmux",
["send-keys", "-t", "my-session", "hello world"]
);
// Should have sent Enter
expect(mockExec).toHaveBeenCalledWith(
"tmux",
["send-keys", "-t", "my-session", "Enter"]
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Message sent and processing")
);
});
it("detects busy session (esc to interrupt)", async () => {
let callCount = 0;
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "has-session") return "";
if (args[0] === "capture-pane") {
callCount++;
const sIdx = args.indexOf("-S");
// First few calls: session is busy
if (callCount <= 2) {
if (sIdx >= 0 && args[sIdx + 1] === "-5") {
return "Working on something...";
}
if (sIdx >= 0 && args[sIdx + 1] === "-3") {
return "esc to interrupt";
}
}
// Then idle
if (sIdx >= 0 && args[sIdx + 1] === "-5") {
return "Done\n ";
}
if (sIdx >= 0 && args[sIdx + 1] === "-10") {
return "Thinking\nesc to interrupt";
}
return "";
}
return "";
});
await program.parseAsync([
"node", "test", "send", "my-session", "fix", "the", "bug",
]);
// Should have eventually sent the message
expect(mockExec).toHaveBeenCalledWith(
"tmux",
["send-keys", "-t", "my-session", "fix the bug"]
);
});
it("skips busy detection with --no-wait", async () => {
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "has-session") return "";
if (args[0] === "capture-pane") {
const sIdx = args.indexOf("-S");
if (sIdx >= 0 && args[sIdx + 1] === "-10") {
return "Thinking\nesc to interrupt";
}
return "busy\nesc to interrupt";
}
return "";
});
await program.parseAsync([
"node", "test", "send", "--no-wait", "my-session", "urgent",
]);
// Should have sent the message without waiting
expect(mockExec).toHaveBeenCalledWith(
"tmux",
["send-keys", "-t", "my-session", "urgent"]
);
});
it("detects queued message state", async () => {
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "has-session") return "";
if (args[0] === "capture-pane") {
const sIdx = args.indexOf("-S");
if (sIdx >= 0 && args[sIdx + 1] === "-5") {
return "Output\n \nPress up to edit queued messages";
}
if (sIdx >= 0 && args[sIdx + 1] === "-10") {
return "";
}
return "";
}
return "";
});
await program.parseAsync([
"node", "test", "send", "my-session", "hello",
]);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Message queued")
);
});
});
describe("message delivery", () => {
it("uses load-buffer for long messages", async () => {
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "has-session") return "";
if (args[0] === "capture-pane") {
const sIdx = args.indexOf("-S");
if (sIdx >= 0 && args[sIdx + 1] === "-5") return " ";
if (sIdx >= 0 && args[sIdx + 1] === "-10")
return "esc to interrupt";
return "";
}
return "";
});
const longMsg = "x".repeat(250);
await program.parseAsync([
"node", "test", "send", "my-session", longMsg,
]);
// Should have used load-buffer for long message
expect(mockExec).toHaveBeenCalledWith(
"tmux",
expect.arrayContaining(["load-buffer"])
);
expect(mockExec).toHaveBeenCalledWith(
"tmux",
expect.arrayContaining(["paste-buffer"])
);
});
it("uses send-keys for short messages", async () => {
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "has-session") return "";
if (args[0] === "capture-pane") {
const sIdx = args.indexOf("-S");
if (sIdx >= 0 && args[sIdx + 1] === "-5") return " ";
if (sIdx >= 0 && args[sIdx + 1] === "-10")
return "esc to interrupt";
return "";
}
return "";
});
await program.parseAsync([
"node", "test", "send", "my-session", "short", "msg",
]);
expect(mockExec).toHaveBeenCalledWith(
"tmux",
["send-keys", "-t", "my-session", "short msg"]
);
});
it("clears partial input before sending", async () => {
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "has-session") return "";
if (args[0] === "capture-pane") {
const sIdx = args.indexOf("-S");
if (sIdx >= 0 && args[sIdx + 1] === "-5") return " ";
if (sIdx >= 0 && args[sIdx + 1] === "-10")
return "esc to interrupt";
return "";
}
return "";
});
await program.parseAsync([
"node", "test", "send", "my-session", "hello",
]);
// C-u should be called to clear input
expect(mockExec).toHaveBeenCalledWith(
"tmux",
["send-keys", "-t", "my-session", "C-u"]
);
});
});
});

View File

@ -0,0 +1,314 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync } from "node:fs";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { mockTmux, mockGit, mockGh, mockExec, mockConfigRef } = vi.hoisted(() => ({
mockTmux: vi.fn(),
mockGit: vi.fn(),
mockGh: vi.fn(),
mockExec: vi.fn(),
mockConfigRef: { current: null as Record<string, unknown> | null },
}));
vi.mock("../../src/lib/shell.js", () => ({
tmux: mockTmux,
exec: mockExec,
execSilent: vi.fn(),
git: mockGit,
gh: mockGh,
getTmuxSessions: async () => {
const output = await mockTmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
},
getTmuxActivity: async (session: string) => {
const output = await mockTmux("display-message", "-t", session, "-p", "#{session_activity}");
if (!output) return null;
const ts = parseInt(output, 10);
return isNaN(ts) ? null : ts * 1000;
},
}));
vi.mock("@agent-orchestrator/core", () => ({
loadConfig: () => mockConfigRef.current,
}));
let tmpDir: string;
import { Command } from "commander";
import { registerSession } from "../../src/commands/session.js";
let program: Command;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "ao-session-test-"));
mockConfigRef.current = {
dataDir: tmpDir,
worktreeDir: join(tmpDir, "worktrees"),
port: 3000,
defaults: {
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
"my-app": {
name: "My App",
repo: "org/my-app",
path: join(tmpDir, "main-repo"),
defaultBranch: "main",
sessionPrefix: "app",
},
},
notifiers: {},
notificationRouting: {},
reactions: {},
} as Record<string, unknown>;
mkdirSync(join(tmpDir, "main-repo"), { recursive: true });
program = new Command();
program.exitOverride();
registerSession(program);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
mockTmux.mockReset();
mockGit.mockReset();
mockGh.mockReset();
mockExec.mockReset();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("session ls", () => {
it("shows project name as header", async () => {
mockTmux.mockResolvedValue(null);
await program.parseAsync(["node", "test", "session", "ls"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("My App");
});
it("shows 'no active sessions' when none exist", async () => {
mockTmux.mockResolvedValue(null);
await program.parseAsync(["node", "test", "session", "ls"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("no active sessions");
});
it("lists sessions with metadata", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"branch=feat/INT-100\nstatus=working\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") {
return String(Math.floor(Date.now() / 1000) - 60);
}
return null;
});
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "session", "ls"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("app-1");
expect(output).toContain("feat/INT-100");
expect(output).toContain("[working]");
});
it("gets live branch from worktree", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=old\nstatus=idle\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
return null;
});
mockGit.mockResolvedValue("live-branch");
await program.parseAsync(["node", "test", "session", "ls"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("live-branch");
});
it("shows PR URL when available", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"branch=fix\nstatus=pr_open\npr=https://github.com/org/repo/pull/42\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
return null;
});
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "session", "ls"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("https://github.com/org/repo/pull/42");
});
});
describe("session kill", () => {
it("rejects unknown session (no matching project)", async () => {
await expect(
program.parseAsync(["node", "test", "session", "kill", "unknown-1"])
).rejects.toThrow("process.exit(1)");
});
it("kills tmux session and archives metadata", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=feat/fix\nstatus=working\n"
);
mockTmux.mockResolvedValue("");
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "session", "kill", "app-1"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("Killed tmux session: app-1");
expect(output).toContain("Archived metadata");
// Original file should be gone, archive should exist
expect(existsSync(join(sessionDir, "app-1"))).toBe(false);
const archiveDir = join(sessionDir, "archive");
expect(existsSync(archiveDir)).toBe(true);
const archived = readdirSync(archiveDir);
expect(archived.length).toBe(1);
expect(archived[0]).toMatch(/^app-1_/);
});
it("removes worktree when metadata has worktree path", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/test-wt\nbranch=main\n"
);
mockTmux.mockResolvedValue("");
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "session", "kill", "app-1"]);
expect(mockGit).toHaveBeenCalledWith(
["worktree", "remove", "--force", "/tmp/test-wt"],
expect.any(String)
);
});
});
describe("session cleanup", () => {
it("kills sessions with merged PRs", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"branch=feat/fix\nstatus=merged\npr=https://github.com/org/repo/pull/42\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "kill-session") return "";
return null;
});
mockGh.mockResolvedValue("MERGED");
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "session", "cleanup"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("Killing app-1: PR #42 merged");
expect(output).toContain("Cleanup complete. 1 sessions cleaned");
});
it("does not kill sessions with open PRs", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"branch=feat/fix\nstatus=pr_open\npr=https://github.com/org/repo/pull/42\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
return null;
});
mockGh.mockResolvedValue("OPEN");
await program.parseAsync(["node", "test", "session", "cleanup"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("No sessions to clean up");
});
it("dry run shows what would be cleaned without doing it", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"branch=feat/fix\npr=https://github.com/org/repo/pull/42\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
return null;
});
mockGh.mockResolvedValue("MERGED");
await program.parseAsync([
"node", "test", "session", "cleanup", "--dry-run",
]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("Would kill app-1");
// Metadata should still exist
expect(existsSync(join(sessionDir, "app-1"))).toBe(true);
});
it("skips sessions without metadata", async () => {
// No metadata files exist
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
return null;
});
await program.parseAsync(["node", "test", "session", "cleanup"]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("No sessions to clean up");
});
});

View File

@ -0,0 +1,325 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { mockTmux, mockExec, mockGit, mockConfigRef } = vi.hoisted(() => ({
mockTmux: vi.fn(),
mockExec: vi.fn(),
mockGit: vi.fn(),
mockConfigRef: { current: null as Record<string, unknown> | null },
}));
vi.mock("../../src/lib/shell.js", () => ({
tmux: mockTmux,
exec: mockExec,
execSilent: vi.fn(),
git: mockGit,
gh: vi.fn(),
getTmuxSessions: async () => {
const output = await mockTmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
},
getTmuxActivity: async (session: string) => {
const output = await mockTmux("display-message", "-t", session, "-p", "#{session_activity}");
if (!output) return null;
const ts = parseInt(output, 10);
return isNaN(ts) ? null : ts * 1000;
},
}));
vi.mock("ora", () => ({
default: () => ({
start: vi.fn().mockReturnThis(),
stop: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
text: "",
}),
}));
vi.mock("@agent-orchestrator/core", () => ({
loadConfig: () => mockConfigRef.current,
}));
let tmpDir: string;
import { Command } from "commander";
import { registerSpawn, registerBatchSpawn } from "../../src/commands/spawn.js";
let program: Command;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "ao-spawn-test-"));
mockConfigRef.current = {
dataDir: tmpDir,
worktreeDir: join(tmpDir, "worktrees"),
port: 3000,
defaults: {
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
"my-app": {
name: "My App",
repo: "org/my-app",
path: join(tmpDir, "main-repo"),
defaultBranch: "main",
sessionPrefix: "app",
},
},
notifiers: {},
notificationRouting: {},
reactions: {},
} as Record<string, unknown>;
// Create main repo dir for git operations
mkdirSync(join(tmpDir, "main-repo"), { recursive: true });
program = new Command();
program.exitOverride();
registerSpawn(program);
registerBatchSpawn(program);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
mockTmux.mockReset();
mockExec.mockReset();
mockGit.mockReset();
mockExec.mockResolvedValue({ stdout: "", stderr: "" });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("spawn command", () => {
it("rejects unknown project", async () => {
await expect(
program.parseAsync(["node", "test", "spawn", "nonexistent"])
).rejects.toThrow("process.exit(1)");
});
it("creates session with correct naming (prefix-N)", async () => {
// No existing sessions
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue("main");
await program.parseAsync(["node", "test", "spawn", "my-app"]);
// Should have called tmux new-session with "app-1"
expect(mockExec).toHaveBeenCalledWith(
"tmux",
expect.arrayContaining(["new-session", "-d", "-s", "app-1"])
);
});
it("increments session number based on existing sessions", async () => {
// Existing sessions: app-1, app-3
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") {
return "app-1\napp-3\nother-5";
}
return null;
});
mockGit.mockResolvedValue("main");
await program.parseAsync(["node", "test", "spawn", "my-app"]);
// Next number should be 4 (max of 1,3 + 1)
expect(mockExec).toHaveBeenCalledWith(
"tmux",
expect.arrayContaining(["-s", "app-4"])
);
});
it("creates feature branch from issue ID", async () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue("feat/INT-123");
await program.parseAsync([
"node", "test", "spawn", "my-app", "INT-123",
]);
// Should have created worktree with branch
expect(mockGit).toHaveBeenCalledWith(
expect.arrayContaining(["worktree", "add", "-b", "feat/INT-123"]),
expect.any(String)
);
});
it("creates detached worktree when no issue provided", async () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "spawn", "my-app"]);
expect(mockGit).toHaveBeenCalledWith(
expect.arrayContaining(["worktree", "add", expect.any(String), "origin/main", "--detach"]),
expect.any(String)
);
});
it("writes metadata file for new session", async () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue("feat/INT-100");
await program.parseAsync([
"node", "test", "spawn", "my-app", "INT-100",
]);
const sessionDir = join(tmpDir, "my-app-sessions");
expect(existsSync(sessionDir)).toBe(true);
const metaFile = join(sessionDir, "app-1");
expect(existsSync(metaFile)).toBe(true);
const content = readFileSync(metaFile, "utf-8");
expect(content).toContain("branch=feat/INT-100");
expect(content).toContain("status=starting");
expect(content).toContain("project=my-app");
expect(content).toContain("issue=INT-100");
});
it("launches claude-code agent by default", async () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue("main");
await program.parseAsync(["node", "test", "spawn", "my-app"]);
expect(mockExec).toHaveBeenCalledWith(
"tmux",
["send-keys", "-t", "app-1", "unset CLAUDECODE && claude", "Enter"]
);
});
it("fetches from remote before creating worktree", async () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue("main");
await program.parseAsync(["node", "test", "spawn", "my-app"]);
expect(mockGit).toHaveBeenCalledWith(
["fetch", "origin", "--quiet"],
expect.any(String)
);
});
it("sends initial prompt when issue ID is provided", async () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue("feat/INT-100");
await program.parseAsync([
"node", "test", "spawn", "my-app", "INT-100",
]);
expect(mockExec).toHaveBeenCalledWith(
"tmux",
[
"send-keys",
"-t",
"app-1",
expect.stringContaining("INT-100"),
"Enter",
]
);
});
it("outputs SESSION= for scripting", async () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue("main");
await program.parseAsync(["node", "test", "spawn", "my-app"]);
expect(consoleSpy).toHaveBeenCalledWith("SESSION=app-1");
});
});
describe("batch-spawn command", () => {
it("rejects unknown project", async () => {
await expect(
program.parseAsync([
"node", "test", "batch-spawn", "nonexistent", "ISSUE-1",
])
).rejects.toThrow("process.exit(1)");
});
it("spawns sessions for multiple issues", async () => {
// Track created sessions so second spawn increments correctly
const createdSessions: string[] = [];
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") {
return createdSessions.length > 0
? createdSessions.join("\n")
: null;
}
return null;
});
mockGit.mockResolvedValue("feat/branch");
// Track tmux new-session calls to update our list
mockExec.mockImplementation(async (cmd: string, args: string[]) => {
if (cmd === "tmux" && args[0] === "new-session") {
const sIdx = args.indexOf("-s");
if (sIdx >= 0) createdSessions.push(args[sIdx + 1]);
}
return { stdout: "", stderr: "" };
});
await program.parseAsync([
"node", "test", "batch-spawn", "my-app", "INT-1", "INT-2",
]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("Created:");
expect(output).toContain("SESSION=app-1");
expect(output).toContain("SESSION=app-2");
});
it("skips issues that already have sessions (duplicate detection)", async () => {
// Create existing session metadata
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"branch=feat/INT-100\nissue=INT-100\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
return null;
});
mockGit.mockResolvedValue("feat/branch");
await program.parseAsync([
"node", "test", "batch-spawn", "my-app", "INT-100", "INT-200",
]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("Skip INT-100");
expect(output).toContain("Skipped:");
// INT-200 should be spawned
expect(output).toContain("SESSION=app-2");
});
it("shows summary with counts", async () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue("main");
await program.parseAsync([
"node", "test", "batch-spawn", "my-app", "INT-1",
]);
const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("Summary:");
});
});

View File

@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { mockTmux, mockGit, mockConfigRef } = vi.hoisted(() => ({
mockTmux: vi.fn(),
mockGit: vi.fn(),
mockConfigRef: { current: null as Record<string, unknown> | null },
}));
vi.mock("../../src/lib/shell.js", () => ({
tmux: mockTmux,
exec: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
execSilent: vi.fn(),
git: mockGit,
gh: vi.fn(),
getTmuxSessions: async () => {
const output = await mockTmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
},
getTmuxActivity: async (session: string) => {
const output = await mockTmux("display-message", "-t", session, "-p", "#{session_activity}");
if (!output) return null;
const ts = parseInt(output, 10);
return isNaN(ts) ? null : ts * 1000;
},
}));
vi.mock("@agent-orchestrator/core", () => ({
loadConfig: () => mockConfigRef.current,
}));
let tmpDir: string;
import { Command } from "commander";
import { registerStatus } from "../../src/commands/status.js";
let program: Command;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "ao-status-test-"));
mockConfigRef.current = {
dataDir: tmpDir,
worktreeDir: join(tmpDir, "worktrees"),
port: 3000,
defaults: {
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
"my-app": {
name: "My App",
repo: "org/my-app",
path: "/home/user/my-app",
defaultBranch: "main",
sessionPrefix: "app",
},
},
notifiers: {},
notificationRouting: {},
reactions: {},
} as Record<string, unknown>;
program = new Command();
program.exitOverride();
registerStatus(program);
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
mockTmux.mockReset();
mockGit.mockReset();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("status command", () => {
it("shows banner and project header", async () => {
mockTmux.mockResolvedValue(null);
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("AGENT ORCHESTRATOR STATUS");
expect(output).toContain("My App");
});
it("shows no active sessions when tmux returns nothing", async () => {
mockTmux.mockResolvedValue(null);
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("no active sessions");
});
it("displays sessions from tmux with metadata", async () => {
// Create metadata files
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt/app-1\nbranch=feat/INT-100\nstatus=working\nissue=INT-100\n"
);
writeFileSync(
join(sessionDir, "app-2"),
"worktree=/tmp/wt/app-2\nbranch=feat/INT-200\nstatus=pr_open\npr=https://github.com/org/repo/pull/42\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") {
return "app-1\napp-2\nother-session";
}
if (args[0] === "display-message") {
return String(Math.floor(Date.now() / 1000) - 120); // 2 min ago
}
return null;
});
mockGit.mockResolvedValue("feat/INT-100"); // live branch
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("app-1");
expect(output).toContain("app-2");
expect(output).toContain("INT-100");
// other-session should not appear (doesn't match prefix)
expect(output).not.toContain("other-session");
});
it("counts total sessions correctly", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, "app-1"), "branch=main\nstatus=idle\n");
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return null;
return null;
});
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("1 active session");
});
it("shows plural for multiple sessions", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, "app-1"), "branch=a\nstatus=idle\n");
writeFileSync(join(sessionDir, "app-2"), "branch=b\nstatus=idle\n");
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1\napp-2";
if (args[0] === "display-message") return null;
return null;
});
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("2 active sessions");
});
it("prefers live branch over metadata branch", async () => {
const sessionDir = join(tmpDir, "my-app-sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "app-1"),
"worktree=/tmp/wt\nbranch=old-branch\nstatus=working\n"
);
mockTmux.mockImplementation(async (...args: string[]) => {
if (args[0] === "list-sessions") return "app-1";
if (args[0] === "display-message") return null;
return null;
});
mockGit.mockResolvedValue("live-branch");
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("live-branch");
});
});

View File

@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { formatAge, statusColor, activityIndicator, header, banner } from "../../src/lib/format.js";
describe("formatAge", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("formats seconds ago", () => {
const thirtySecsAgo = Date.now() - 30_000;
expect(formatAge(thirtySecsAgo)).toBe("30s ago");
});
it("formats minutes ago", () => {
const fiveMinsAgo = Date.now() - 5 * 60_000;
expect(formatAge(fiveMinsAgo)).toBe("5m ago");
});
it("formats hours ago", () => {
const twoHoursAgo = Date.now() - 2 * 3600_000;
expect(formatAge(twoHoursAgo)).toBe("2h ago");
});
it("formats days ago", () => {
const threeDaysAgo = Date.now() - 3 * 86400_000;
expect(formatAge(threeDaysAgo)).toBe("3d ago");
});
it("handles zero difference", () => {
expect(formatAge(Date.now())).toBe("0s ago");
});
});
describe("statusColor", () => {
it("returns colored string for known statuses", () => {
// We just check it returns a non-empty string (chalk will wrap it)
expect(statusColor("working")).toBeTruthy();
expect(statusColor("idle")).toBeTruthy();
expect(statusColor("ci_failed")).toBeTruthy();
expect(statusColor("approved")).toBeTruthy();
expect(statusColor("merged")).toBeTruthy();
expect(statusColor("spawning")).toBeTruthy();
expect(statusColor("killed")).toBeTruthy();
expect(statusColor("needs_input")).toBeTruthy();
expect(statusColor("pr_open")).toBeTruthy();
expect(statusColor("review_pending")).toBeTruthy();
expect(statusColor("changes_requested")).toBeTruthy();
});
it("returns the raw string for unknown statuses", () => {
expect(statusColor("unknown_state")).toBe("unknown_state");
});
});
describe("activityIndicator", () => {
it("returns indicator for each state", () => {
expect(activityIndicator("active")).toBeTruthy();
expect(activityIndicator("idle")).toBeTruthy();
expect(activityIndicator("waiting_input")).toBeTruthy();
expect(activityIndicator("blocked")).toBeTruthy();
expect(activityIndicator("exited")).toBeTruthy();
});
it("returns fallback for unknown state", () => {
expect(activityIndicator("unknown")).toBeTruthy();
});
});
describe("header", () => {
it("returns multiline box drawing string", () => {
const result = header("My Project");
expect(result).toContain("My Project");
// Should have 3 lines (top border, content, bottom border)
const lines = result.split("\n");
expect(lines.length).toBe(3);
});
});
describe("banner", () => {
it("returns multiline double-line box string", () => {
const result = banner("STATUS");
expect(result).toContain("STATUS");
const lines = result.split("\n");
expect(lines.length).toBe(3);
});
});

View File

@ -0,0 +1,248 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync } from "node:fs";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
getSessionDir,
readMetadata,
writeMetadata,
updateMetadataField,
archiveMetadata,
listSessionFiles,
findSessionForIssue,
} from "../../src/lib/metadata.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "ao-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("getSessionDir", () => {
it("returns correct path for project", () => {
expect(getSessionDir("/data", "my-app")).toBe("/data/my-app-sessions");
});
it("handles nested data dirs", () => {
expect(getSessionDir("/home/user/.ao", "backend")).toBe(
"/home/user/.ao/backend-sessions"
);
});
});
describe("readMetadata", () => {
it("returns null for non-existent file", () => {
expect(readMetadata(join(tmpDir, "nonexistent"))).toBeNull();
});
it("parses key=value format", () => {
const file = join(tmpDir, "session-1");
writeFileSync(
file,
"worktree=/home/user/.worktrees/app/session-1\nbranch=feat/INT-123\nstatus=working\nissue=INT-123\n"
);
const meta = readMetadata(file);
expect(meta).not.toBeNull();
expect(meta!.worktree).toBe("/home/user/.worktrees/app/session-1");
expect(meta!.branch).toBe("feat/INT-123");
expect(meta!.status).toBe("working");
expect(meta!.issue).toBe("INT-123");
});
it("handles values containing equals signs", () => {
const file = join(tmpDir, "session-2");
writeFileSync(file, "summary=key=value pair in desc\n");
const meta = readMetadata(file);
expect(meta).not.toBeNull();
expect(meta!.summary).toBe("key=value pair in desc");
});
it("ignores empty lines", () => {
const file = join(tmpDir, "session-3");
writeFileSync(file, "branch=main\n\nstatus=idle\n\n");
const meta = readMetadata(file);
expect(meta!.branch).toBe("main");
expect(meta!.status).toBe("idle");
});
it("handles PR URLs with embedded numbers", () => {
const file = join(tmpDir, "session-4");
writeFileSync(
file,
"pr=https://github.com/org/repo/pull/42\nbranch=feat/fix\n"
);
const meta = readMetadata(file);
expect(meta!.pr).toBe("https://github.com/org/repo/pull/42");
});
});
describe("writeMetadata", () => {
it("creates metadata file with key=value pairs", () => {
const file = join(tmpDir, "subdir", "session-1");
writeMetadata(file, {
worktree: "/path/to/worktree",
branch: "feat/test",
status: "starting",
});
expect(existsSync(file)).toBe(true);
const content = readFileSync(file, "utf-8");
expect(content).toContain("worktree=/path/to/worktree\n");
expect(content).toContain("branch=feat/test\n");
expect(content).toContain("status=starting\n");
});
it("skips undefined and null values", () => {
const file = join(tmpDir, "session-2");
writeMetadata(file, {
branch: "main",
status: "working",
pr: undefined,
issue: undefined,
});
const content = readFileSync(file, "utf-8");
expect(content).not.toContain("pr=");
expect(content).not.toContain("issue=");
expect(content).toContain("branch=main");
});
it("creates parent directories if needed", () => {
const file = join(tmpDir, "deep", "nested", "dir", "session-1");
writeMetadata(file, { branch: "main", status: "idle" });
expect(existsSync(file)).toBe(true);
});
});
describe("updateMetadataField", () => {
it("updates existing field", () => {
const file = join(tmpDir, "session-1");
writeFileSync(file, "branch=old-branch\nstatus=working\n");
updateMetadataField(file, "branch", "new-branch");
const content = readFileSync(file, "utf-8");
expect(content).toContain("branch=new-branch");
expect(content).not.toContain("old-branch");
expect(content).toContain("status=working");
});
it("appends new field if not found", () => {
const file = join(tmpDir, "session-2");
writeFileSync(file, "branch=main\n");
updateMetadataField(file, "pr", "https://github.com/org/repo/pull/1");
const content = readFileSync(file, "utf-8");
expect(content).toContain("branch=main");
expect(content).toContain("pr=https://github.com/org/repo/pull/1");
});
it("does nothing for non-existent file", () => {
updateMetadataField(join(tmpDir, "nonexistent"), "branch", "main");
// Should not throw
});
});
describe("archiveMetadata", () => {
it("moves metadata to archive dir with timestamp", () => {
const sessionDir = join(tmpDir, "sessions");
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, "app-1"), "branch=main\n");
archiveMetadata(sessionDir, "app-1");
expect(existsSync(join(sessionDir, "app-1"))).toBe(false);
const archiveDir = join(sessionDir, "archive");
expect(existsSync(archiveDir)).toBe(true);
const archived = readdirSync(archiveDir);
expect(archived.length).toBe(1);
expect(archived[0]).toMatch(/^app-1_\d{4}-\d{2}-\d{2}T/);
});
it("does nothing for non-existent metadata", () => {
archiveMetadata(join(tmpDir, "sessions"), "nonexistent");
// Should not throw
});
});
describe("listSessionFiles", () => {
it("returns empty array for non-existent directory", async () => {
const result = await listSessionFiles(join(tmpDir, "nonexistent"));
expect(result).toEqual([]);
});
it("returns session files, excluding dotfiles and archive", async () => {
const sessionDir = join(tmpDir, "sessions");
mkdirSync(sessionDir);
mkdirSync(join(sessionDir, "archive"));
writeFileSync(join(sessionDir, "app-1"), "");
writeFileSync(join(sessionDir, "app-2"), "");
writeFileSync(join(sessionDir, ".hidden"), "");
const result = await listSessionFiles(sessionDir);
expect(result.sort()).toEqual(["app-1", "app-2"]);
});
});
describe("findSessionForIssue", () => {
it("finds session by issue ID match", async () => {
const sessionDir = join(tmpDir, "sessions");
mkdirSync(sessionDir);
writeFileSync(
join(sessionDir, "app-1"),
"branch=feat/INT-100\nissue=INT-100\n"
);
writeFileSync(
join(sessionDir, "app-2"),
"branch=feat/INT-200\nissue=INT-200\n"
);
const result = await findSessionForIssue(
sessionDir,
"INT-200",
["app-1", "app-2"]
);
expect(result).toBe("app-2");
});
it("returns null when no match found", async () => {
const sessionDir = join(tmpDir, "sessions");
mkdirSync(sessionDir);
writeFileSync(join(sessionDir, "app-1"), "branch=main\nissue=INT-100\n");
const result = await findSessionForIssue(
sessionDir,
"INT-999",
["app-1"]
);
expect(result).toBeNull();
});
it("is case-insensitive", async () => {
const sessionDir = join(tmpDir, "sessions");
mkdirSync(sessionDir);
writeFileSync(join(sessionDir, "app-1"), "issue=int-100\n");
const result = await findSessionForIssue(
sessionDir,
"INT-100",
["app-1"]
);
expect(result).toBe("app-1");
});
it("only matches sessions that are in the tmux list", async () => {
const sessionDir = join(tmpDir, "sessions");
mkdirSync(sessionDir);
writeFileSync(join(sessionDir, "app-1"), "issue=INT-100\n");
writeFileSync(join(sessionDir, "app-2"), "issue=INT-200\n");
// app-2 is NOT in tmux sessions list
const result = await findSessionForIssue(
sessionDir,
"INT-200",
["app-1"]
);
expect(result).toBeNull();
});
});

View File

@ -10,6 +10,8 @@
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
@ -23,6 +25,7 @@
"devDependencies": {
"@types/node": "^25.2.3",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -1,10 +1,37 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "@agent-orchestrator/core";
/**
* Locate the @agent-orchestrator/web package directory.
* We resolve from the package root (two levels up from dist/commands/)
* using require.resolve-style lookup so it works both in dev and after tsc.
*/
function findWebDir(): string {
// Try to resolve from node_modules first (installed as workspace dep)
try {
const pkgJson = require.resolve("@agent-orchestrator/web/package.json");
return resolve(pkgJson, "..");
} catch {
// Fallback: sibling package in monorepo (works both from src/ and dist/)
// packages/cli/src/commands/ → packages/web
// packages/cli/dist/commands/ → packages/web
const candidates = [
resolve(__dirname, "../../../web"),
resolve(__dirname, "../../../../packages/web"),
];
for (const candidate of candidates) {
if (existsSync(resolve(candidate, "package.json"))) {
return candidate;
}
}
return candidates[0];
}
}
export function registerDashboard(program: Command): void {
program
.command("dashboard")
@ -19,36 +46,41 @@ export function registerDashboard(program: Command): void {
chalk.bold(`Starting dashboard on http://localhost:${port}\n`)
);
// The web package handles the actual server.
// Locate it relative to this package.
const thisDir = dirname(fileURLToPath(import.meta.url));
const webDir = resolve(thisDir, "../../web");
const webDir = findWebDir();
try {
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
cwd: webDir,
stdio: "inherit",
});
if (opts.open !== false) {
setTimeout(() => {
spawn("open", [`http://localhost:${port}`], {
stdio: "ignore",
});
}, 3000);
}
child.on("exit", (code) => {
process.exit(code ?? 0);
});
} catch (err) {
if (!existsSync(resolve(webDir, "package.json"))) {
console.error(
chalk.red(
"Could not start dashboard. Ensure @agent-orchestrator/web is installed."
"Could not find @agent-orchestrator/web package.\n" +
"Ensure it is installed: pnpm install"
)
);
process.exit(1);
}
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
cwd: webDir,
stdio: "inherit",
});
child.on("error", (err) => {
console.error(
chalk.red("Could not start dashboard. Ensure Next.js is installed.")
);
console.error(chalk.dim(String(err)));
process.exit(1);
});
if (opts.open !== false) {
setTimeout(() => {
spawn("open", [`http://localhost:${port}`], {
stdio: "ignore",
});
}, 3000);
}
child.on("exit", (code) => {
process.exit(code ?? 0);
});
});
}

View File

@ -1,18 +1,17 @@
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "@agent-orchestrator/core";
import { exec, tmux } from "../lib/shell.js";
import { exec, getTmuxSessions } from "../lib/shell.js";
async function getTmuxSessions(): Promise<string[]> {
const output = await tmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
}
async function openInTerminal(sessionName: string): Promise<boolean> {
async function openInTerminal(
sessionName: string,
newWindow?: boolean
): Promise<boolean> {
try {
// Try open-iterm-tab script first (user may have it installed)
await exec("open-iterm-tab", [sessionName]);
const args = newWindow
? ["--new-window", sessionName]
: [sessionName];
await exec("open-iterm-tab", args);
return true;
} catch {
// Fall back to tmux attach hint
@ -75,7 +74,7 @@ export function registerOpen(program: Command): void {
);
for (const session of sessionsToOpen.sort()) {
const opened = await openInTerminal(session);
const opened = await openInTerminal(session, opts.newWindow);
if (opened) {
console.log(chalk.green(` Opened: ${session}`));
} else {

View File

@ -2,15 +2,9 @@ import chalk from "chalk";
import ora from "ora";
import type { Command } from "commander";
import { loadConfig } from "@agent-orchestrator/core";
import { exec, tmux, gh } from "../lib/shell.js";
import { exec, gh, getTmuxSessions } from "../lib/shell.js";
import { getSessionDir, readMetadata } from "../lib/metadata.js";
async function getTmuxSessions(): Promise<string[]> {
const output = await tmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
}
interface ReviewInfo {
session: string;
prNumber: string;

View File

@ -2,29 +2,10 @@ import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "@agent-orchestrator/core";
import type { OrchestratorConfig } from "@agent-orchestrator/core";
import { exec, tmux, git, gh } from "../lib/shell.js";
import { tmux, git, gh, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
import { getSessionDir, readMetadata, archiveMetadata } from "../lib/metadata.js";
import { formatAge } from "../lib/format.js";
async function getTmuxSessions(): Promise<string[]> {
const output = await tmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
}
async function getTmuxActivity(session: string): Promise<number | null> {
const output = await tmux(
"display-message",
"-t",
session,
"-p",
"#{session_activity}"
);
if (!output) return null;
const ts = parseInt(output, 10);
return isNaN(ts) ? null : ts * 1000;
}
async function killSession(
config: OrchestratorConfig,
projectId: string,
@ -155,6 +136,7 @@ export function registerSession(program: Command): void {
console.log(chalk.bold("Checking for completed sessions...\n"));
let cleaned = 0;
let found = 0;
for (const [projectId, project] of Object.entries(projects)) {
const prefix = project.sessionPrefix || projectId;
@ -193,6 +175,7 @@ export function registerSession(program: Command): void {
}
if (shouldKill) {
found++;
if (opts.dryRun) {
console.log(
chalk.yellow(
@ -210,9 +193,16 @@ export function registerSession(program: Command): void {
}
}
if (cleaned === 0 && !opts.dryRun) {
if (opts.dryRun) {
if (found === 0) {
console.log(chalk.dim(" No sessions to clean up."));
} else {
console.log(chalk.dim(`\nDry run complete. ${found} session${found !== 1 ? "s" : ""} would be cleaned.`));
}
} else if (cleaned === 0) {
console.log(chalk.dim(" No sessions to clean up."));
} else {
console.log(chalk.green(`\nCleanup complete. ${cleaned} sessions cleaned.`));
}
console.log(chalk.green(`\nCleanup complete. ${cleaned} sessions cleaned.`));
});
}

View File

@ -5,20 +5,18 @@ import ora from "ora";
import type { Command } from "commander";
import { loadConfig } from "@agent-orchestrator/core";
import type { OrchestratorConfig, ProjectConfig } from "@agent-orchestrator/core";
import { exec, tmux, git } from "../lib/shell.js";
import { exec, git, getTmuxSessions } from "../lib/shell.js";
import { getSessionDir, writeMetadata, findSessionForIssue } from "../lib/metadata.js";
import { banner } from "../lib/format.js";
async function getTmuxSessions(): Promise<string[]> {
const output = await tmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function getNextSessionNumber(prefix: string): Promise<number> {
const sessions = await getTmuxSessions();
let max = 0;
const pattern = new RegExp(`^${prefix}-(\\d+)$`);
const pattern = new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`);
for (const s of sessions) {
const m = s.match(pattern);
if (m) {
@ -56,9 +54,9 @@ async function spawnSession(
project.path
);
if (!result) {
// Branch might already exist
// Branch already exists — check it out in the new worktree
await git(
["worktree", "add", worktreePath, defaultRef],
["worktree", "add", worktreePath, branch],
project.path
);
}
@ -117,6 +115,21 @@ async function spawnSession(
"DIRENV_LOG_FORMAT=",
]);
// Run post-create hooks before agent launch (so environment is ready)
if (project.postCreate) {
for (const cmd of project.postCreate) {
await exec("tmux", [
"send-keys",
"-t",
sessionName,
cmd,
"Enter",
]);
}
// Allow hooks to complete before starting agent
await new Promise((resolve) => setTimeout(resolve, 1000));
}
// Start agent
const agentName = project.agent || config.defaults.agent;
let launchCmd: string;
@ -166,19 +179,6 @@ async function spawnSession(
await exec("tmux", ["send-keys", "-t", sessionName, prompt, "Enter"]);
}
// Run post-create hooks
if (project.postCreate) {
for (const cmd of project.postCreate) {
await exec("tmux", [
"send-keys",
"-t",
sessionName,
cmd,
"Enter",
]);
}
}
// Open terminal tab if requested
if (openTab) {
try {

View File

@ -1,8 +1,10 @@
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import chalk from "chalk";
import type { Command } from "commander";
import type { OrchestratorConfig } from "@agent-orchestrator/core";
import { loadConfig } from "@agent-orchestrator/core";
import { tmux, git } from "../lib/shell.js";
import { exec, git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
import { getSessionDir, readMetadata } from "../lib/metadata.js";
import { banner, header, formatAge, statusColor } from "../lib/format.js";
@ -11,35 +13,92 @@ interface SessionInfo {
branch: string | null;
status: string | null;
summary: string | null;
claudeSummary: string | null;
pr: string | null;
issue: string | null;
lastActivity: string;
project: string | null;
}
async function getTmuxSessions(): Promise<string[]> {
const output = await tmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
}
/**
* Extracts Claude's auto-generated summary from its internal session data.
* Maps: tmux session TTY Claude PID CWD .claude/projects/ JSONL summary
*/
async function getClaudeSessionInfo(
sessionName: string
): Promise<{ summary: string | null; sessionId: string | null }> {
try {
// Get the TTY for this tmux session's pane
const ttyOutput = await exec("tmux", [
"display-message", "-t", sessionName, "-p", "#{pane_tty}",
]);
const tty = ttyOutput.stdout.trim();
if (!tty) return { summary: null, sessionId: null };
async function getTmuxActivity(session: string): Promise<number | null> {
const output = await tmux(
"display-message",
"-t",
session,
"-p",
"#{session_activity}"
);
if (!output) return null;
const ts = parseInt(output, 10);
return isNaN(ts) ? null : ts * 1000;
// Find Claude PID running on that TTY
const psOutput = await exec("bash", [
"-c",
`ps -eo pid,tty,comm | grep claude | grep "${tty.replace("/dev/", "")}" | head -1 | awk '{print $1}'`,
]);
const pid = psOutput.stdout.trim();
if (!pid) return { summary: null, sessionId: null };
// Get Claude's working directory
const cwdOutput = await exec("lsof", [
"-p", pid, "-d", "cwd", "-Fn",
]);
const cwdMatch = cwdOutput.stdout.match(/n(.+)/);
const cwd = cwdMatch?.[1];
if (!cwd) return { summary: null, sessionId: null };
// Encode path for Claude's project directory naming
const encodedPath = cwd.replace(/\//g, "-").replace(/^-/, "");
const claudeProjectDir = join(
process.env.HOME || "~",
".claude",
"projects",
encodedPath
);
if (!existsSync(claudeProjectDir)) return { summary: null, sessionId: null };
// Find the most recent session file
const files = readdirSync(claudeProjectDir)
.filter((f) => f.endsWith(".jsonl"))
.sort()
.reverse();
if (files.length === 0) return { summary: null, sessionId: null };
const sessionFile = join(claudeProjectDir, files[0]);
const sessionId = files[0].replace(".jsonl", "").slice(0, 8);
// Read last few lines to find auto-generated summary
const content = readFileSync(sessionFile, "utf-8");
const lines = content.trim().split("\n").slice(-20);
let summary: string | null = null;
for (const line of lines.reverse()) {
try {
const entry = JSON.parse(line);
if (entry.type === "summary" || entry.summary) {
summary = entry.summary || entry.message;
break;
}
} catch {
// Skip non-JSON lines
}
}
return { summary, sessionId };
} catch {
return { summary: null, sessionId: null };
}
}
async function gatherSessionInfo(
sessionName: string,
sessionDir: string,
worktreeDir?: string
): Promise<SessionInfo> {
const metaFile = `${sessionDir}/${sessionName}`;
const meta = readMetadata(metaFile);
@ -62,7 +121,14 @@ async function gatherSessionInfo(
const activityTs = await getTmuxActivity(sessionName);
const lastActivity = activityTs ? formatAge(activityTs) : "-";
return { name: sessionName, branch, status, summary, pr, issue, lastActivity, project };
// Get Claude's auto-generated summary
const claudeInfo = await getClaudeSessionInfo(sessionName);
return {
name: sessionName, branch, status, summary,
claudeSummary: claudeInfo.summary,
pr, issue, lastActivity, project,
};
}
function printSession(info: SessionInfo): void {
@ -79,7 +145,11 @@ function printSession(info: SessionInfo): void {
if (info.pr) {
console.log(` ${chalk.dim("PR:")} ${chalk.blue(info.pr)}`);
}
if (info.summary) {
if (info.claudeSummary) {
console.log(
` ${chalk.dim("Claude:")} ${info.claudeSummary.slice(0, 65)}`
);
} else if (info.summary) {
console.log(
` ${chalk.dim("Summary:")} ${info.summary.slice(0, 65)}`
);

View File

@ -7,7 +7,7 @@ export function getSessionDir(dataDir: string, projectId: string): string {
return join(dataDir, `${projectId}-sessions`);
}
export function readMetadata(filePath: string): SessionMetadata | null {
export function readMetadata(filePath: string): Partial<SessionMetadata> | null {
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, "utf-8");
const meta: Record<string, string> = {};
@ -17,7 +17,7 @@ export function readMetadata(filePath: string): SessionMetadata | null {
meta[line.slice(0, eq)] = line.slice(eq + 1);
}
}
return meta as unknown as SessionMetadata;
return meta as Partial<SessionMetadata>;
}
export function writeMetadata(

View File

@ -52,3 +52,22 @@ export async function git(
export async function gh(args: string[]): Promise<string | null> {
return execSilent("gh", args);
}
export async function getTmuxSessions(): Promise<string[]> {
const output = await tmux("list-sessions", "-F", "#{session_name}");
if (!output) return [];
return output.split("\n").filter(Boolean);
}
export async function getTmuxActivity(session: string): Promise<number | null> {
const output = await tmux(
"display-message",
"-t",
session,
"-p",
"#{session_activity}"
);
if (!output) return null;
const ts = parseInt(output, 10);
return isNaN(ts) ? null : ts * 1000;
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["__tests__/**/*.test.ts"],
testTimeout: 10000,
},
});

File diff suppressed because it is too large Load Diff