Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,23 @@ These are primarily invoked by the orchestrator agent running inside a runtime s
```bash
ao spawn [issue] # Spawn an agent (project auto-detected from cwd)
ao spawn 123 --agent codex # Override agent for this session
ao spawn --claim-pr 42 --claim-pr-repo org/repo
ao batch-spawn 101 102 103 # Spawn agents for multiple issues at once
ao send <session> "Fix the tests" # Send instructions to a running agent
ao session ls # List active sessions (terminated hidden)
ao session ls --include-terminated # Include killed/done/merged/errored/cleanup sessions
ao session ls --json # Machine-readable session inventory (see note below)
ao session claim-pr 42 app-1 --repo org/repo
ao session kill <session> # Kill a session
ao session restore <session> # Revive a crashed agent
```

`ao session claim-pr` accepts a bare PR number or a PR URL. When the PR lives
outside the configured project repository, pass `--repo owner/repo`; AO records
the replaced primary PR in `prHistory` metadata before switching the session to
the new PR. `ao spawn --claim-pr` supports the same repository override via
`--claim-pr-repo`.

> **JSON output:** `ao session ls --json` and `ao status --json` emit
> `{ "data": [...], "meta": { "hiddenTerminatedCount": N } }`. Terminated sessions
> (`killed`, `terminated`, `done`, `merged`, `errored`, `cleanup`) are filtered from
Expand Down
54 changes: 36 additions & 18 deletions packages/cli/__tests__/commands/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ beforeEach(() => {
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: false,
takenOverFrom: [],
Expand Down Expand Up @@ -475,13 +476,7 @@ describe("session ls", () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue(null);

await program.parseAsync([
"node",
"test",
"session",
"ls",
"--include-terminated",
]);
await program.parseAsync(["node", "test", "session", "ls", "--include-terminated"]);

const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(output).toContain("app-1");
Expand Down Expand Up @@ -513,14 +508,7 @@ describe("session ls", () => {
mockTmux.mockResolvedValue(null);
mockGit.mockResolvedValue(null);

await program.parseAsync([
"node",
"test",
"session",
"ls",
"--json",
"--include-terminated",
]);
await program.parseAsync(["node", "test", "session", "ls", "--json", "--include-terminated"]);

expect(consoleSpy).toHaveBeenCalledTimes(1);
const parsed = JSON.parse(String(consoleSpy.mock.calls[0][0]));
Expand Down Expand Up @@ -769,7 +757,11 @@ describe("session attach", () => {
issueId: null,
pr: null,
workspacePath: null,
runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" } },
runtimeHandle: {
id: "hash-app-1",
runtimeName: "process",
data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" },
},
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
Expand Down Expand Up @@ -803,7 +795,9 @@ describe("session attach", () => {
const inputData = Buffer.from("ls\r");
process.stdin.emit("data", inputData);
expect((mockSocket as { write: ReturnType<typeof vi.fn> }).write).toHaveBeenCalled();
const written = (mockSocket as { write: ReturnType<typeof vi.fn> }).write.mock.calls.at(-1)![0] as Buffer;
const written = (mockSocket as { write: ReturnType<typeof vi.fn> }).write.mock.calls.at(
-1,
)![0] as Buffer;
expect(written.readUInt8(0)).toBe(0x02); // MSG_TERMINAL_INPUT
expect(written.subarray(5).toString()).toBe("ls\r");

Expand Down Expand Up @@ -939,7 +933,11 @@ describe("session attach", () => {
issueId: null,
pr: null,
workspacePath: null,
runtimeHandle: { id: "hash-app-1", runtimeName: "process", data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" } },
runtimeHandle: {
id: "hash-app-1",
runtimeName: "process",
data: { pipePath: "\\\\.\\pipe\\ao-pty-hash-app-1" },
},
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
Expand Down Expand Up @@ -979,6 +977,7 @@ describe("session claim-pr", () => {

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-2", "42", {
assignOnGithub: true,
repoOverride: undefined,
});

const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n");
Expand All @@ -993,6 +992,25 @@ describe("session claim-pr", () => {

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-7", "42", {
assignOnGithub: undefined,
repoOverride: undefined,
});
});

it("passes --repo through to claimPR", async () => {
await program.parseAsync([
"node",
"test",
"session",
"claim-pr",
"42",
"app-2",
"--repo",
"ComposioHQ/agent-orchestrator",
]);

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-2", "42", {
assignOnGithub: undefined,
repoOverride: "ComposioHQ/agent-orchestrator",
});
});

Expand Down
79 changes: 72 additions & 7 deletions packages/cli/__tests__/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,7 @@ describe("spawn command", () => {
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: false,
takenOverFrom: [],
Expand All @@ -590,6 +591,7 @@ describe("spawn command", () => {
});
expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
assignOnGithub: undefined,
repoOverride: undefined,
});

const succeedMsg = String(mockSpinner.succeed.mock.calls[0]?.[0] ?? "");
Expand Down Expand Up @@ -629,18 +631,84 @@ describe("spawn command", () => {
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: true,
takenOverFrom: ["app-9"],
});

await program.parseAsync(["node", "test", "spawn", "--claim-pr", "123", "--assign-on-github"]);
expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
assignOnGithub: true,
repoOverride: undefined,
});
});

it("passes --claim-pr-repo through to claimPR", async () => {
const fakeSession: Session = {
id: "app-1",
projectId: "my-app",
status: "spawning",
activity: null,
branch: null,
issueId: null,
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};

mockSessionManager.spawn.mockResolvedValue(fakeSession);
mockSessionManager.claimPR.mockResolvedValue({
sessionId: "app-1",
projectId: "my-app",
pr: {
number: 123,
url: "https://github.com/org/repo/pull/123",
title: "Existing PR",
owner: "org",
repo: "repo",
branch: "feat/claimed-pr",
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: false,
takenOverFrom: [],
});

await program.parseAsync([
"node",
"test",
"spawn",
"--claim-pr",
"123",
"--claim-pr-repo",
"ComposioHQ/agent-orchestrator",
]);

expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
assignOnGithub: true,
assignOnGithub: undefined,
repoOverride: "ComposioHQ/agent-orchestrator",
});
});

it("rejects --claim-pr-repo without --claim-pr", async () => {
await expect(
program.parseAsync(["node", "test", "spawn", "--claim-pr-repo", "org/repo"]),
).rejects.toThrow("process.exit(1)");

const errors = vi
.mocked(console.error)
.mock.calls.map((c) => String(c[0]))
.join("\n");
expect(errors).toContain("--claim-pr-repo requires --claim-pr");
});

it("rejects --assign-on-github without --claim-pr", async () => {
await expect(
program.parseAsync(["node", "test", "spawn", "--assign-on-github"]),
Expand Down Expand Up @@ -803,6 +871,7 @@ describe("spawn pre-flight checks", () => {
baseBranch: "main",
isDraft: false,
},
previousPr: null,
branchChanged: true,
githubAssigned: false,
takenOverFrom: [],
Expand Down Expand Up @@ -1015,9 +1084,7 @@ describe("spawn daemon-polling enforcement", () => {
it("refuses to spawn when no AO daemon is running", async () => {
mockGetRunning.mockResolvedValue(null);

await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");

const errors = vi
.mocked(console.error)
Expand All @@ -1036,9 +1103,7 @@ describe("spawn daemon-polling enforcement", () => {
projects: ["other-project"],
});

await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
"process.exit(1)",
);
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");

const errors = vi
.mocked(console.error)
Expand Down
Loading
Loading