From e5d84aba8d9f3b87ee1e6e05789ed5f67cf4cb1a Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 23 Oct 2026 08:24:59 -0400 Subject: [PATCH] fix(host): exit 1 from a failed ask, name agent access in a refusal, and tell unknown providers and models apart ace info names the channel's host and its owner's GitHub login, and ace directory says whether this Mac serves it and when it last synced. --- ...WK6JSKPDZG-teammates-channels-go-throug.md | 8 +-- ...T78X1E0KAE-every-command-but-serve-must.md | 6 +- ...22YT2HMZDZ-ace-info-prints-agent-access.md | 6 +- ...CG3BXBEJP7-a-run-s-end-is-read-from-the.md | 6 +- ...NJAD1W10HJJ-the-desktop-default-changes.md | 6 +- ...YTRD08YCYJY-only-gateways-admit-clients.md | 6 +- ...C97DP84ZKX-handoffs-learns-queued-hando.md | 6 +- ...F5BCJRTZX2-a-fork-s-starting-lane-is-re.md | 6 +- apps/host/src/cli.ts | 61 ++++++++++++++++--- apps/host/src/settings.ts | 13 +++- packages/channel/src/channel.ts | 4 +- 11 files changed, 92 insertions(+), 36 deletions(-) diff --git a/.lane/memory/apps/host/src/cli.ts/01M4YFWABA5884D6WK6JSKPDZG-teammates-channels-go-throug.md b/.lane/memory/apps/host/src/cli.ts/01M4YFWABA5884D6WK6JSKPDZG-teammates-channels-go-throug.md index 025ba456..cddef80f 100644 --- a/.lane/memory/apps/host/src/cli.ts/01M4YFWABA5884D6WK6JSKPDZG-teammates-channels-go-throug.md +++ b/.lane/memory/apps/host/src/cli.ts/01M4YFWABA5884D6WK6JSKPDZG-teammates-channels-go-throug.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md b/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md index 050d30e2..9ea4e10a 100644 --- a/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md +++ b/.lane/memory/apps/host/src/cli.ts/01M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md b/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md index f79929eb..09ce6f84 100644 --- a/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md +++ b/.lane/memory/apps/host/src/cli.ts/01M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md index 38669504..f10e145f 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md index 186b3d44..e3aedc42 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md index bc2f0705..076c70e7 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md index 39d3bb66..0fb9276e 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md index 4b16556a..eb11f881 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md @@ -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 --- diff --git a/apps/host/src/cli.ts b/apps/host/src/cli.ts index f53f1719..fccb3b5e 100755 --- a/apps/host/src/cli.ts +++ b/apps/host/src/cli.ts @@ -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, @@ -68,6 +70,7 @@ const USAGE = `ace — channels on this host newest message to now ace say [--chat ] post without invoking the agent ace ask [--chat ] [--model ] [--detach] + exits 1 if the run fails or is stopped ace watch [--chat ] ace answer [--chat ] answer the chat's waiting question in writing ace chat [--model ] open another chat @@ -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}] `); @@ -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 @@ -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); @@ -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) }; @@ -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; @@ -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({ 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); @@ -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({ op: "info" }); - return console.log(JSON.stringify({ ...info, agents: shared }, null, 2)); + const { shared, owner, ...info } = await session.request({ op: "info" }); + // The running host knows each owner's GitHub login, as the app shows it. + const roster = await isHostRunning() + ? await local({ 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"); @@ -634,6 +678,7 @@ async function main() { }); const status = await session.request({ op: "wait", submission }); if (status === "unanswered" && !ended) console.log(dim(" (stopped before answering)")); + if (status === "unanswered" || unanswered) process.exit(1); return; } case "answer": diff --git a/apps/host/src/settings.ts b/apps/host/src/settings.ts index 464e8a79..39aa3480 100644 --- a/apps/host/src/settings.ts +++ b/apps/host/src/settings.ts @@ -11,6 +11,7 @@ import { key, KEYCHAIN_WAITING, latest, + models as keyed, overriding, settled, store, @@ -151,10 +152,18 @@ export async function removeKey(id: ProviderId): Promise { /** A model this host has credentials for, as the default and `ace new --model` both need. */ export async function checkModel(model: ModelRef): Promise { 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 { diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index a1927dad..003d4a5b 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -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. */