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 @@ -3,10 +3,10 @@ id: 01M4YFWABA5884D6WK6JSKPDZG
anchor: function join
created: 2026-10-15T00:37:32Z
norm: '1'
sig: 2e2a490821164beb
body_hash: cfd68c1e1f71ebcd
raw_hash: bd472575a375bbf3
vouched: 2026-10-15T04:47:29Z
sig: 1683bb1d686bff45
body_hash: a20ba5ce5033aa66
raw_hash: 235168b7f499b1ce
vouched: 2026-10-23T12:25:09Z
lines: 201-223
---

Expand Down
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: f49f2a7319f14703
raw_hash: 8fcaafdae8a2bc74
vouched: 2026-10-22T22:15:45Z
body_hash: cb848608fe3526ea
raw_hash: 97a687d2dea1a6e3
vouched: 2026-10-23T12:25:08Z
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: f49f2a7319f14703
raw_hash: 8fcaafdae8a2bc74
vouched: 2026-10-22T22:15:45Z
body_hash: cb848608fe3526ea
raw_hash: 97a687d2dea1a6e3
vouched: 2026-10-23T12:25:08Z
lines: 332-614
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-14T17:33:22Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
body_hash: 55e43086fdbf8ac7
raw_hash: e79cdef3abfda83c
vouched: 2026-10-23T12:25:30Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T00:30:42Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
body_hash: 55e43086fdbf8ac7
raw_hash: e79cdef3abfda83c
vouched: 2026-10-23T12:25:30Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T02:34:37Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
body_hash: 55e43086fdbf8ac7
raw_hash: e79cdef3abfda83c
vouched: 2026-10-23T12:25:30Z
lines: 114-794
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T10:17:55Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
body_hash: 55e43086fdbf8ac7
raw_hash: e79cdef3abfda83c
vouched: 2026-10-23T12:25:30Z
lines: 121-817
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-16T22:11:35Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
body_hash: 55e43086fdbf8ac7
raw_hash: e79cdef3abfda83c
vouched: 2026-10-23T12:25:31Z
lines: 127-873
---

Expand Down
61 changes: 53 additions & 8 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ import { preference } from "./preferences";
import * as projects from "./projects";
import {
channelProject,
type Diagnostics,
githubRepo,
type Hello,
type HostRequest,
type Listing,
type Member,
type Person,
type Project,
type ProjectAccess,
Expand Down Expand Up @@ -68,6 +70,7 @@ const USAGE = `ace — channels on this host
newest message to now
ace say <channel> [--chat <id>] <text…> post without invoking the agent
ace ask <channel> [--chat <id>] [--model <provider/id>] [--detach] <text…>
exits 1 if the run fails or is stopped
ace watch <channel> [--chat <id>]
ace answer <channel> [--chat <id>] <text…> answer the chat's waiting question in writing
ace chat <channel> [--model <provider/id>] open another chat
Expand Down Expand Up @@ -168,6 +171,8 @@ function clip(text: string, length: number): string {

/** Whether `(run ended)` already printed, so `ask` doesn't print a second ending. */
let ended = false;
/** Whether a reply failed or was stopped, so `ask` exits 1 for a script. */
let unanswered = false;

function render(event: Event, info: ChannelInfo) {
const tag = event.chat === 1 && info.chats.length === 1 ? "" : dim(`[${event.chat}] `);
Expand All @@ -177,6 +182,7 @@ function render(event: Event, info: ChannelInfo) {
case "stopped":
return console.log(dim(`${tag}Stopped by ${event.author}.`));
case "reply": {
if (event.error || event.stopped) unanswered = true;
const ending = event.error
? `\x1b[31m${event.text ? "\n" : ""}Run failed: ${event.error}\x1b[0m`
: event.stopped
Expand Down Expand Up @@ -226,6 +232,13 @@ async function watch(session: Session, replay = true): Promise<{ ended: Promise<
return { ended: ended.promise };
}

function ago(at: number): string {
const minutes = Math.round((Date.now() - at) / 6e4);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
return `${Math.round(minutes / 60)}h ago`;
}

/** `10m`, `2h`, `1d`, or an ISO time. */
function since(value: string): number {
const match = /^(\d+)([smhd])$/.exec(value);
Expand Down Expand Up @@ -330,8 +343,8 @@ function listed(listings: Listing[], ref: string): Listing | undefined {
return matches[0];
}

/** A channel this host runs, or else one a reachable teammate's host runs. */
async function join(ref: string): Promise<{ name: string; session: Session }> {
/** A channel this host runs, or else one a reachable teammate's host runs, with that host's name. */
async function join(ref: string): Promise<{ name: string; host?: string; session: Session }> {
if (mine(ref)) {
const record = catalog.find(ref);
return { name: record.name, session: await Connection.open(record.id) };
Expand All @@ -347,7 +360,7 @@ async function join(ref: string): Promise<{ name: string; session: Session }> {
request: (request, watch) => client.channel(listing.id, request, watch),
close: () => client.close(),
};
return { name: listing.name, session };
return { name: listing.name, host: listing.host, session };
} catch (error) {
client.close();
throw error;
Expand Down Expand Up @@ -534,9 +547,26 @@ async function main() {
}
case "directory": {
const url = ref === "off" ? null : ref;
if (ref && await isHostRunning()) await local({ op: "directory-set", url });
const running = await isHostRunning();
if (ref && running) await local({ op: "directory-set", url });
else if (ref) await directory.configure(url || undefined);
return console.log(directory.url() ?? "No directory");
if (!running) {
const joined = directory.url();
if (!joined) return console.log("No directory");
console.log(joined);
return console.log(dim("Ace isn't running here, so this Mac isn't syncing it."));
}
const { directory: status, tailscale } = await local<Diagnostics>({ op: "diagnostics" });
if (!status.url) return console.log("No directory");
console.log(status.url);
if (tailscale.loopback) {
return console.log(dim("This host runs with --loopback, so it doesn't sync it."));
}
if (status.serving) console.log("This Mac serves it.");
if (status.error) {
return console.log(`Last sync failed ${ago(status.failed!)}: ${status.error}`);
}
return console.log(status.synced ? `Synced ${ago(status.synced)}.` : "Not synced yet.");
}
case "web": {
if (ref) web.configure(ref === "off" ? undefined : ref);
Expand Down Expand Up @@ -599,13 +629,27 @@ async function main() {
if (!ref) throw new Error(`ace ${command} needs a channel`);
const text = rest.join(" ");
if (participant.has(command)) {
const { name, session } = await join(ref);
const { name, host, session } = await join(ref);
try {
switch (command) {
case "info": {
// docs/terms.md names it agent access, after `ace agents`; `shared` is the wire name.
const { shared, ...info } = await session.request<ChannelInfo>({ op: "info" });
return console.log(JSON.stringify({ ...info, agents: shared }, null, 2));
const { shared, owner, ...info } = await session.request<ChannelInfo>({ op: "info" });
// The running host knows each owner's GitHub login, as the app shows it.
const roster = await isHostRunning()
? await local<Member[]>({ op: "team-roster" })
: [];
const self = roster.find((member) => member.self);
return console.log(JSON.stringify(
{
...info,
host: host || self?.host,
owner: roster.find((member) => member.login === owner)?.github || owner,
agents: shared,
},
null,
2,
));
}
case "say":
if (!text) throw new Error("Nothing to say");
Expand Down Expand Up @@ -634,6 +678,7 @@ async function main() {
});
const status = await session.request<string>({ op: "wait", submission });
if (status === "unanswered" && !ended) console.log(dim(" (stopped before answering)"));
if (status === "unanswered" || unanswered) process.exit(1);
return;
}
case "answer":
Expand Down
13 changes: 11 additions & 2 deletions apps/host/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
key,
KEYCHAIN_WAITING,
latest,
models as keyed,
overriding,
settled,
store,
Expand Down Expand Up @@ -151,10 +152,18 @@ export async function removeKey(id: ProviderId): Promise<void> {
/** 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");
if (!keyed().getProvider(model.provider)) {
throw new Error(`There's no model provider named ${model.provider}.`);
}
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.");
if (found.some((item) => item.provider === model.provider && item.modelId === model.modelId)) {
return;
}
// With its key set, the provider lists every model it has.
if (found.some((item) => item.provider === model.provider)) {
throw new Error(`${model.provider} has no model named ${model.modelId}.`);
}
throw new Error("This model is unavailable. Add its provider key first.");
}

export async function setModel(model: ModelRef | null): Promise<void> {
Expand Down
4 changes: 3 additions & 1 deletion packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,9 @@ export class Channel {
if (author === this.#options.owner || room.isAgent(author) || settings?.shared !== false) {
return;
}
throw new Error("Only the owner can invoke agents here");
throw new Error(
`Agent access is off in this channel, so only its owner, ${this.#options.owner}, can ask its agents.`,
);
}

/** Model and effort apply to a whole run, never just its later turns. */
Expand Down