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
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function restore
created: 2026-10-15T14:20:42Z
norm: '1'
sig: 05c78f711a9a9228
body_hash: 61b995cf9ff2906b
raw_hash: a42fcbbc15619cac
body_hash: 4373dd894fb82afc
raw_hash: 718d009589045a72
vouched: 2026-10-22T15:17:06Z
lines: 119-158
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5J2ZT89CTF4ECFNG6YN408R
anchor: function restore
created: 2026-10-22T15:17:03Z
norm: '1'
sig: 05c78f711a9a9228
body_hash: 4373dd894fb82afc
raw_hash: 718d009589045a72
lines: 119-164
---

a restored store's MetadataDoc wins on open, so a new name must be committed there with a rename, not only in the catalog
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-15T04:47:23Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: 42c616dba5adb167
raw_hash: 1a7c366637365036
vouched: 2026-10-22T14:45:22Z
body_hash: e48607de6d665818
raw_hash: a6282bc5158525e4
vouched: 2026-10-22T15:17:06Z
lines: 253-482
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-22T14:31:16Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: 42c616dba5adb167
raw_hash: 1a7c366637365036
vouched: 2026-10-22T14:45:22Z
body_hash: e48607de6d665818
raw_hash: a6282bc5158525e4
vouched: 2026-10-22T15:17:07Z
lines: 332-614
---

Expand Down
12 changes: 9 additions & 3 deletions apps/host/src/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { openNodeSqliteDatabase } from "@earendil-works/pi-durable/storage/sqlit
import { importSnapshot, type Snapshot } from "@ace/channel/transfer";

import * as catalog from "./catalog";
import { hostedAuth } from "./client";
import { hostedAuth, request } from "./client";
import { refusal } from "./http";
import { project } from "./manage";

Expand Down Expand Up @@ -131,7 +131,7 @@ export async function restore(directory: string, dir?: string): Promise<catalog.
const original = JSON.parse(readFileSync(join(path, "channel.json"), "utf8")) as catalog.Listing;
const record: catalog.Listing = {
...catalog.draft(await project(dir || original.project), original.model),
name: original.name,
name: catalog.restored(original.name),
named: original.named !== false,
summary: original.summary,
};
Expand All @@ -150,9 +150,15 @@ export async function restore(directory: string, dir?: string): Promise<catalog.
copyFileSync(join(path, "channel.sqlite"), storage);
}
// Saved last: the catalog lists the channel only once its store is complete.
return catalog.save(record);
catalog.save(record);
} catch (error) {
rmSync(base, { recursive: true, force: true });
throw error;
}
// The restored store's own metadata still names the original, and it wins when the channel
// opens, so the new name has to be committed there too.
if (record.name !== original.name) {
await request(record.id, { op: "rename", author: catalog.user, name: record.name });
}
return catalog.read(record.id);
}
9 changes: 7 additions & 2 deletions apps/host/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ function taken(except?: string): Set<string> {
}

/** `base`, or the first of `base-2`, `base-3`, … not in `names`, within the 63-character limit. */
function unused(base: string, names: Set<string>): string {
function unused(base: string, names: Set<string>, first = 2): string {
let name = base;
for (let count = 2; names.has(name); count++) {
for (let count = first; names.has(name); count++) {
name = `${base.slice(0, 62 - String(count).length).replace(/-+$/, "")}-${count}`;
}
return name;
Expand Down Expand Up @@ -180,6 +180,11 @@ export function claim(id: string, name: string, exact: boolean): string {
});
}

/** A restored copy's name: its original's, or Finder-style `<name>-1`, `<name>-2`, … when taken. */
export function restored(name: string): string {
return unused(name, taken(), 1);
}

/**
* A fork's record, not yet saved, named for its source and numbered past names in use. `branches`
* holds lane prefixes left in the project by deleted channels. The name is not deliberate, so the
Expand Down
52 changes: 41 additions & 11 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ import {
type HostRequest,
type Listing,
type Person,
type Project,
type ProjectAccess,
projectId,
TEAM,
type WindowInfo,
} from "./protocol";
import { setModel } from "./settings";
import { checkModel, setModel } from "./settings";
import { archive, fork, isRunning, kill, models, parseModel, project, remove } from "./manage";
import { move } from "./move";

Expand Down Expand Up @@ -203,17 +204,29 @@ async function isHostRunning(): Promise<boolean> {
}

/** What a teammate can do in a channel from the app; everything else acts on this host's own. */
const SWITCH = new Set(["on", "off"]);

const participant = new Set(["info", "say", "ask", "watch", "chat", "stop"]);

/** What only a channel's owner can do, phrased for refusing a teammate's channel. */
/** A project by its folder on this host, or by GitHub owner/name. */
/** A project by its folder on this host, by GitHub owner/name, or by the name it was opened as. */
async function projectRef(value: string): Promise<string> {
if (!existsSync(value)) {
let path = existsSync(value) ? value : undefined;
if (!path) {
const repo = githubRepo(value);
if (!repo) throw new Error(`${value} is neither a folder here nor a GitHub owner/name`);
return projectId("", "", repo);
if (repo) return projectId("", "", repo);
const named = (await local<Project[]>({ op: "projects" })).filter((project) =>
project.name.toLowerCase() === value.toLowerCase()
);
if (named.length > 1) {
throw new Error(`More than one project is named ${value}; use its folder`);
}
if (!named[0]) {
throw new Error(`${value} is neither a project, a folder here, nor a GitHub owner/name`);
}
path = named[0].path;
}
const { root, repo } = await projects.checkout(realpathSync(resolve(value)));
const { root, repo } = await projects.checkout(realpathSync(resolve(path)));
const { host } = await local<Hello>({ op: "hello" });
return projectId(host, root, repo);
}
Expand Down Expand Up @@ -365,7 +378,12 @@ async function main() {
}
if (flags.trace) filter.trace = flags.trace;
if (flags.level) filter.level = flags.level;
if (flags.since) filter.since = since(flags.since);
if (flags.since) {
filter.since = since(flags.since);
if (Number.isNaN(filter.since)) {
throw new Error("--since takes a duration such as 10m, 2h or 1d, or a date");
}
}
if (flags.grep) filter.grep = flags.grep;
if (flags.follow) return follow(filter, !!flags.json);
print(filter, Number(flags.lines || 200), !!flags.json);
Expand All @@ -375,6 +393,7 @@ async function main() {
case "new": {
const dir = await project(resolve(flags.project || "."));
const model = flags.model ? parseModel(flags.model) : undefined;
if (model) await checkModel(model);
const record = catalog.create(dir, model, flags.name);
return console.log(`${record.name}\t${record.id}`);
}
Expand Down Expand Up @@ -437,7 +456,11 @@ async function main() {
const person = who === "team" ? TEAM : findPerson(access.people, who).person;
const shared = ref === "share";
await local({ op: "project-share", project, person, shared });
if (!shared) return;
if (!shared) {
return console.log(
`${person === TEAM ? "The team" : who} no longer sees it through a share.`,
);
}
const channels = await local<Listing[]>({ op: "channels" });
const others = new Set(
channels.filter((value) => channelProject(value) === project).map((value) => value.owner),
Expand Down Expand Up @@ -509,6 +532,9 @@ async function main() {
}
}

if (!participant.has(command) && !owned[command]) {
throw new Error(`Unknown command ${command}\n\n${HELP}`);
}
if (!ref) throw new Error(`ace ${command} needs a channel`);
const text = rest.join(" ");
if (participant.has(command)) {
Expand Down Expand Up @@ -561,9 +587,7 @@ async function main() {
session.close();
}
}
const action = owned[command];
if (!action) throw new Error(`Unknown command ${command}\n\n${HELP}`);
const record = await own(ref, action);
const record = await own(ref, owned[command]!);
switch (command) {
case "backup":
if (rest.length !== 1) throw new Error("ace backup <channel> <new-directory>");
Expand Down Expand Up @@ -593,6 +617,9 @@ async function main() {
);
}
case "agents": {
if (rest.length !== 1 || !SWITCH.has(rest[0]!)) {
throw new Error("ace agents <channel> on|off");
}
const shared = rest[0] === "on";
await request(record.id, { op: "share", author: catalog.user, shared });
if (!shared) return;
Expand All @@ -601,6 +628,9 @@ async function main() {
);
}
case "desktop": {
if (rest.length !== 1 || !SWITCH.has(rest[0]!)) {
throw new Error("ace desktop <channel> on|off");
}
const enabled = rest[0] === "on";
await request(record.id, { op: "desktop", author: catalog.user, enabled });
if (!enabled) return;
Expand Down
19 changes: 10 additions & 9 deletions apps/host/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,17 @@ export async function removeKey(id: ProviderId): Promise<void> {
}
}

export async function setModel(model: ModelRef | null): Promise<void> {
if (model !== null) {
if (!isModel(model)) throw new Error("Choose a provider and model");
const found = await models();
if (
!found.some((item) => item.provider === model.provider && item.modelId === model.modelId)
) {
throw new Error("This model is unavailable. Add its provider key first.");
}
/** A model this host has credentials for, as the default and `ace new --model` both need. */
export async function checkModel(model: ModelRef): Promise<void> {
if (!isModel(model)) throw new Error("Choose a provider and model");
const found = await models();
if (!found.some((item) => item.provider === model.provider && item.modelId === model.modelId)) {
throw new Error("This model is unavailable. Add its provider key first.");
}
}

export async function setModel(model: ModelRef | null): Promise<void> {
if (model !== null) await checkModel(model);
savePreference(model);
}

Expand Down