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:
parent
2886ab2d99
commit
a5259fa803
|
|
@ -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"]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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:");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.`));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["__tests__/**/*.test.ts"],
|
||||
testTimeout: 10000,
|
||||
},
|
||||
});
|
||||
2148
pnpm-lock.yaml
2148
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue