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. */