Skip to content
Merged
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
10 changes: 5 additions & 5 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { host } from "./host";
import { Rename } from "./layout/rename";
import { Navigation, type Page, WindowControls } from "./navigation";
import { EmptyProjects, OpenProject } from "./open-project";
import { projectId, projects } from "./projects";
import { projectId, projects, root } from "./projects";
import { Settings } from "./settings";
import { UpdateNotice } from "./updates";

Expand Down Expand Up @@ -171,15 +171,15 @@ export function App() {
useGithubRefresh(available);
const current = available.find((value) => value.id === project) || available[0];
const visible = channels.filter((channel) =>
channel.host === current?.host && channel.project === current.path
channel.host === current?.host && root(channel) === current.path
);
const channel = current ? visible.find((value) => value.id === selected[current.id]) : undefined;
const connected = status === "open" && !!hello.host;
const local = current?.host === hello.host;
const repos: SessionSidebarRepo[] = available.map((value) => ({
id: value.id,
name: value.host === hello.host ? value.name : `${value.name} · ${value.host}`,
org: "",
org: value.repo?.split("/")[0] || "",
}));
const groups: SessionSidebarGroup[] = [
{
Expand Down Expand Up @@ -208,8 +208,8 @@ export function App() {
},
];

function select(value: Pick<Listing, "id" | "host" | "project">) {
const id = projectId(value.host, value.project);
function select(value: Pick<Listing, "id" | "host" | "project" | "root">) {
const id = projectId(value.host, root(value));
setProject(id);
setSelected((previous) => ({ ...previous, [id]: value.id }));
setPage("channels");
Expand Down
16 changes: 11 additions & 5 deletions apps/app/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { Listing, Project } from "@ace/host/protocol";

export type AppProject = Project & { id: string; host: string };

/** Hosts before project roots list each checkout as its own project. */
export const root = (channel: Pick<Listing, "project" | "root">) => channel.root || channel.project;

export const projectId = (host: string, path: string) => JSON.stringify([host, path]);

/** The same path on two hosts represents two different project folders. */
Expand All @@ -14,14 +17,17 @@ export function projects(opened: Project[], channels: Listing[], host: string):
}
}
for (const channel of channels) {
const id = projectId(channel.host, channel.project);
const path = root(channel);
const id = projectId(channel.host, path);
if (values.has(id)) continue;
values.set(id, {
const value: AppProject = {
id,
host: channel.host,
path: channel.project,
name: channel.project.replace(/\/+$/, "").split("/").pop() || channel.project,
});
path,
name: channel.repo?.split("/")[1] || path.replace(/\/+$/, "").split("/").pop() || path,
};
if (channel.repo) value.repo = channel.repo;
values.set(id, value);
}
return [...values.values()];
}
7 changes: 6 additions & 1 deletion apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ function local(): Listing[] {
created,
state,
};
const { root, repo } = projects.checkout(project);
listing.root = root;
if (repo) listing.repo = repo;
if (hosted) listing.hosted = hosted;
return listing;
});
Expand Down Expand Up @@ -240,7 +243,9 @@ async function handle(
);
}
const known = client.peer
? catalog.list().some((channel) => channel.project === request.project)
? catalog.list().some((channel) =>
projects.checkout(channel.project).root === request.project
)
: projects.list().some((project) => project.path === request.project);
if (!known) throw new Error("Open this project in Ace first");
return github.project(request.project);
Expand Down
36 changes: 17 additions & 19 deletions apps/host/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,27 +101,13 @@ async function gh<T>(args: string[]): Promise<T> {
return JSON.parse(out) as T;
}

/** Reading a remote needs no GitHub credentials, including on a teammate's host. */
export async function project(path: string): Promise<string | null> {
const child = Bun.spawn([
"git",
"-C",
path,
"config",
"--local",
"--get-regexp",
"^remote\\..*\\.(url|gh-resolved)$",
], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
timeout: 5000,
});
const [out, code] = await Promise.all([new Response(child.stdout).text(), child.exited]);
if (code !== 0) return null;
export const REMOTES = ["config", "--local", "--get-regexp", "^remote\\..*\\.(url|gh-resolved)$"];

/** The GitHub repository in `git config` output for REMOTES, preferring gh's chosen base. */
export function remote(config: string): string | null {
const remotes = new Map<string, string>();
let preferred: string | undefined;
for (const line of out.trim().split("\n")) {
for (const line of config.trim().split("\n")) {
const match = /^remote\.(.+)\.(url|gh-resolved)\s+(.+)$/.exec(line);
if (!match) continue;
const name = match[1]!;
Expand All @@ -143,6 +129,18 @@ export async function project(path: string): Promise<string | null> {
return null;
}

/** Reading a remote needs no GitHub credentials, including on a teammate's host. */
export async function project(path: string): Promise<string | null> {
const child = Bun.spawn(["git", "-C", path, ...REMOTES], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
timeout: 5000,
});
const [out, code] = await Promise.all([new Response(child.stdout).text(), child.exited]);
return code === 0 ? remote(out) : null;
}

function item(value: Item, kind: GithubKind): GithubItem {
return {
kind,
Expand Down
44 changes: 39 additions & 5 deletions apps/host/src/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,39 @@ import { basename, join, resolve } from "node:path";

import * as catalog from "./catalog";
import { config } from "./config";
import { remote, REMOTES } from "./github";
import type { Project } from "./protocol";

type Checkout = { root: string; repo?: string };

/** Worktrees and remotes rarely move, and listings resolve every channel's checkout. */
const checkouts = new Map<string, Checkout>();

/**
* A Git worktree belongs to its repository's main checkout, so lanes and other agents' worktrees
* join that project instead of appearing as their own.
*/
export function checkout(path: string): Checkout {
const known = checkouts.get(path);
if (known) return known;
const git = (args: string[]) => spawnSync("git", ["-C", path, ...args], { encoding: "utf8" });
const worktrees = git(["worktree", "list", "--porcelain"]);
const main = worktrees.status === 0 ? /^worktree (.+)$/m.exec(worktrees.stdout)?.[1] : undefined;
const value: Checkout = { root: main || path };
const remotes = git(REMOTES);
const repo = remotes.status === 0 ? remote(remotes.stdout) : null;
if (repo) value.repo = repo;
checkouts.set(path, value);
return value;
}

function project(path: string): Project {
const { root, repo } = checkout(path);
const value: Project = { path: root, name: repo?.split("/")[1] || basename(root) || root };
if (repo) value.repo = repo;
return value;
}

function paths(): string[] {
try {
const value: unknown = JSON.parse(readFileSync(join(config.home, "projects.json"), "utf8"));
Expand All @@ -31,8 +62,12 @@ function paths(): string[] {

/** Existing CLI channels remain visible without having to open their projects again. */
export function list(): Project[] {
return [...new Set([...paths(), ...catalog.list().map((channel) => channel.project)])]
.map((path) => ({ path, name: basename(path) || path }));
const values = new Map<string, Project>();
for (const path of new Set([...paths(), ...catalog.list().map((channel) => channel.project)])) {
const value = project(path);
values.set(value.path, value);
}
return [...values.values()];
}

/** Opening a folder must not require model credentials or create a channel. */
Expand All @@ -44,8 +79,7 @@ export function open(value: string): Project {
: value;
let path = realpathSync(resolve(expanded));
if (!statSync(path).isDirectory()) throw new Error("Choose a folder to open as a project");
const git = spawnSync("git", ["-C", path, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
if (git.status === 0) path = realpathSync(git.stdout.trim());
path = realpathSync(checkout(path).root);
const opened = paths();
if (!opened.includes(path)) {
mkdirSync(config.home, { recursive: true, mode: 0o700 });
Expand All @@ -59,5 +93,5 @@ export function open(value: string): Project {
rmSync(temporary, { force: true });
}
}
return { path, name: basename(path) || path };
return project(path);
}
13 changes: 12 additions & 1 deletion apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ export type Listing = {
/** Revision of the channel's projected name and summary. */
revision?: number;
owner: string;
/** The checkout the channel works in, which may be a Git worktree of its project. */
project: string;
/** The project `project` belongs to: its repository's main checkout on the channel's host. */
root?: string;
/** The project's GitHub repository as owner/name. */
repo?: string;
model?: ModelRef;
created: number;
state: ChannelState;
Expand All @@ -54,7 +59,13 @@ export type Listing = {
export type Hello = { user: string; host: string };

/** A folder opened on this host, independent of its channels and provider setup. */
export type Project = { path: string; name: string };
export type Project = {
/** The repository's main checkout, or the folder itself outside Git. */
path: string;
name: string;
/** The GitHub repository as owner/name. */
repo?: string;
};

export type ProviderId = "anthropic" | "openai";

Expand Down
10 changes: 8 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ or model credentials. This is a list of local folders, not channel history. Exis
channels also contribute their project paths. Only local owner connections can read or change
the opened-project list; teammates see the project information already present in channel listings.

A channel's project path is the checkout it works in: its agents' working directory and the base
of its lanes. That checkout may be a Git worktree, such as a lane or another tool's worktree. The
host groups every worktree under its repository's main checkout and labels it with the GitHub
remote, so the picker shows one project per repository on a host without rewriting channel
records. Opening a worktree adds its main checkout.

Model credentials come from `ACE_<NAME>`, `<NAME>`, then the OS keychain, for each name pi-ai asks
for, such as `OPENAI_API_KEY`. Agent shells inherit the process environment. Participants allowed
to invoke an agent can use all of its tools, so processes that run tools first move credential-like
Expand Down Expand Up @@ -252,5 +258,5 @@ or kills.
## Not yet built

Terminals and previews, attachments, lobby cells, and external harnesses (Claude Code, Codex).
Hosted channels cannot message other channels yet. Projects are still a local path, so a
teammate's checkout of the same repository shows as a separate project.
Hosted channels cannot message other channels yet. Projects are still keyed by a host's main
checkout, so a teammate's checkout of the same repository shows as a separate project.