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 0e5f6ecb..83b8803d 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: 2189bea8448ae31b -raw_hash: 1b839377f7aa43ec -vouched: 2026-10-22T15:20:51Z +body_hash: 7770844bad8c4705 +raw_hash: 5a2b69597fdf7f65 +vouched: 2026-10-22T15:24:37Z 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 d5898a48..e1c59e30 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: 2189bea8448ae31b -raw_hash: 1b839377f7aa43ec -vouched: 2026-10-22T15:20:51Z +body_hash: 7770844bad8c4705 +raw_hash: 5a2b69597fdf7f65 +vouched: 2026-10-22T15:24:37Z 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 d187d2ef..4e161dfc 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: 197cae8f51ae874a -raw_hash: 18b03b0abb19ae39 -vouched: 2026-10-22T15:09:56Z +body_hash: 3278b21f03ff7630 +raw_hash: 3e66d7913dbb2b75 +vouched: 2026-10-22T15:24:38Z 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 5db475b9..cd23abda 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: 197cae8f51ae874a -raw_hash: 18b03b0abb19ae39 -vouched: 2026-10-22T15:09:57Z +body_hash: 3278b21f03ff7630 +raw_hash: 3e66d7913dbb2b75 +vouched: 2026-10-22T15:24:38Z 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 87de043f..571b239b 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: 197cae8f51ae874a -raw_hash: 18b03b0abb19ae39 -vouched: 2026-10-22T15:09:58Z +body_hash: 3278b21f03ff7630 +raw_hash: 3e66d7913dbb2b75 +vouched: 2026-10-22T15:24:38Z 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 9aa80032..22ba3275 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: 197cae8f51ae874a -raw_hash: 18b03b0abb19ae39 -vouched: 2026-10-22T15:09:58Z +body_hash: 3278b21f03ff7630 +raw_hash: 3e66d7913dbb2b75 +vouched: 2026-10-22T15:24:38Z 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 b6609259..51941900 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: 197cae8f51ae874a -raw_hash: 18b03b0abb19ae39 -vouched: 2026-10-22T15:09:59Z +body_hash: 3278b21f03ff7630 +raw_hash: 3e66d7913dbb2b75 +vouched: 2026-10-22T15:24:39Z lines: 127-873 --- diff --git a/.lane/memory/packages/channel/src/questions.ts/01M5J2S5B9QYYBPZ8CTZNHMFCE-a-skip-is-recorded-in-questi.md b/.lane/memory/packages/channel/src/questions.ts/01M5J2S5B9QYYBPZ8CTZNHMFCE-a-skip-is-recorded-in-questi.md new file mode 100644 index 00000000..f88130c9 --- /dev/null +++ b/.lane/memory/packages/channel/src/questions.ts/01M5J2S5B9QYYBPZ8CTZNHMFCE-a-skip-is-recorded-in-questi.md @@ -0,0 +1,12 @@ +--- +id: 01M5J2S5B9QYYBPZ8CTZNHMFCE +anchor: function answer +created: 2026-10-22T15:13:25Z +norm: '1' +sig: 12d4e7669bc8cd8f +body_hash: 8bf455b25be8e34d +raw_hash: 1248ba1f61bc80be +lines: 189-243 +--- + +a skip is recorded in QuestionsDoc like an answer (skipped: true, no answers), so a rerun after a restart still resolves it instead of asking again diff --git a/apps/app/src/question.tsx b/apps/app/src/question.tsx index f84eadd6..087a99e0 100644 --- a/apps/app/src/question.tsx +++ b/apps/app/src/question.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState } from "react"; import { + Button, isAnswered, type QuestionDraft, QuestionInput, @@ -65,6 +66,17 @@ export function Question({ channel, chat, call, user, mayAnswer }: { return { ...value, [id]: { ...current, ...change(current) } }; }); + async function send(request: { op: "answer"; answers: Answer[] } | { op: "skip" }) { + setSubmitting(true); + setError(undefined); + try { + await host.channel(channel.id, { ...request, chat, call: call.call, author: user }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + setSubmitting(false); + } + } + async function submit() { const missing = questions.find((question) => !isAnswered(question, draft[question.id])); if (missing) return handle.current?.invalid(missing.id); @@ -79,20 +91,7 @@ export function Question({ channel, chat, call, user, mayAnswer }: { ), }; }); - setSubmitting(true); - setError(undefined); - try { - await host.channel(channel.id, { - op: "answer", - chat, - call: call.call, - author: user, - answers, - }); - } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); - setSubmitting(false); - } + await send({ op: "answer", answers }); } return ( @@ -108,6 +107,16 @@ export function Question({ channel, chat, call, user, mayAnswer }: { submitting, error, ...(mayAnswer ? {} : { status: "Only the channel owner can answer here" }), + actions: ( + + ), choose: (id, option, multiple, checked) => update(id, (current) => multiple diff --git a/apps/host/src/cli.ts b/apps/host/src/cli.ts index 4783397f..530b262a 100755 --- a/apps/host/src/cli.ts +++ b/apps/host/src/cli.ts @@ -57,6 +57,7 @@ const HELP = `ace — channels on this host ace say [--chat ] post without invoking the agent ace ask [--chat ] [--model ] [--detach] ace watch [--chat ] + ace answer [--chat ] answer the chat's waiting question in writing ace chat [--model ] open another chat ace stop [--chat ] stop the chat's current run ace kill stop all work in the channel now @@ -83,7 +84,7 @@ const HELP = `ace — channels on this host ace open [--port 4140] open this host's authenticated app ace archive | unarchive | delete -info, say, ask, watch, chat and stop also take a teammate's channel, by name or ID, through this +info, say, ask, answer, watch, chat and stop also take a teammate's channel, by name or ID, through this host's running gateway (open Ace or run ace serve). Their host decides who may invoke its agents. Models are provider/id, such as openai/gpt-6-astra or anthropic/claude-opus-5-5. @@ -150,8 +151,20 @@ function render(event: Event, info: ChannelInfo) { : ""; return console.log(`${tag}${bold(event.model)}: ${event.text}${ending}`); } - case "tool": - return console.log(dim(`${tag} → ${event.name} ${clip(JSON.stringify(event.args), 100)}`)); + case "tool": { + if (event.name !== "question") { + return console.log(dim(`${tag} → ${event.name} ${clip(JSON.stringify(event.args), 100)}`)); + } + const { questions } = event.args as { + questions: { question: string; options: { label: string }[] }[]; + }; + for (const item of questions) { + console.log(`${tag} ? ${item.question}`); + console.log(dim(`${tag} ${item.options.map((option) => option.label).join(" · ")}`)); + } + const flag = event.chat === 1 ? "" : ` --chat ${event.chat}`; + return console.log(dim(`${tag} (answer with: ace answer ${info.name}${flag} )`)); + } case "result": return console.log(dim(`${tag} ${event.error ? "✗" : "←"} ${clip(event.text, 100)}`)); case "run": @@ -206,7 +219,7 @@ async function isHostRunning(): Promise { /** 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"]); +const participant = new Set(["info", "say", "ask", "answer", "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, by GitHub owner/name, or by the name it was opened as. */ @@ -570,6 +583,10 @@ async function main() { if (status === "unanswered" && !ended) console.log(dim(" (stopped before answering)")); return; } + case "answer": + if (!text) throw new Error("Nothing to answer"); + await session.request({ op: "answer", chat: chat(), author: catalog.user, text }); + return; case "watch": return await (await watch(session)).ended; case "chat": { diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index 1e90280a..93bcf8b7 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -365,7 +365,14 @@ export class Channel { this.#author(request.author); await this.#mayInvoke(request.author); const { id } = await this.#conversation(request.chat); - return answer(this.#harness, id, request.call, request.author, request.answers, context); + const answers = "text" in request ? request.text : request.answers; + return answer(this.#harness, id, request.call, request.author, answers, context); + } + case "skip": { + this.#author(request.author); + await this.#mayInvoke(request.author); + const { id } = await this.#conversation(request.chat); + return answer(this.#harness, id, request.call, request.author, null, context); } case "react": { this.#author(request.author); diff --git a/packages/channel/src/protocol.ts b/packages/channel/src/protocol.ts index dd062ada..c810af43 100644 --- a/packages/channel/src/protocol.ts +++ b/packages/channel/src/protocol.ts @@ -238,6 +238,10 @@ export type Request = | { op: "chat"; author: string; model?: ModelRef } /** Answer a pending `question` tool call: each question's chosen option indices or written answer. */ | { op: "answer"; chat?: ChatId; call: string; author: string; answers: Answer[] } + /** Answer every item of `call`, or of the chat's newest open question, with one written answer. */ + | { op: "answer"; chat?: ChatId; call?: string; author: string; text: string } + /** Decline a pending `question` tool call; the agent hears that no answer was given. */ + | { op: "skip"; chat?: ChatId; call: string; author: string } /** Add, or `remove`, the author's reaction to a human message or agent reply. */ | { op: "react"; chat?: ChatId; author: string; entry: EntryId; emoji: string; remove?: boolean } /** Replace the author's own human message with a tombstone, for people and later model context. */ diff --git a/packages/channel/src/questions.ts b/packages/channel/src/questions.ts index 1a326071..7cd8a598 100644 --- a/packages/channel/src/questions.ts +++ b/packages/channel/src/questions.ts @@ -27,11 +27,14 @@ type Item = { type Answered = { question: string; choices: string[] } | { question: string; custom: string }; +/** Who answered, or with `skipped`, who declined to; a skip records no answers. */ +type Recorded = { author: string; at: number; answers: Answered[]; skipped?: true }; + /** Each `question` call's items and, once someone submits, their answers; keyed by call ID. */ export const QuestionsDoc = defineDoc<{ questions: Record; }>({ kind: "ace.questions", @@ -160,7 +163,16 @@ export class Questions { }); } -function answered(answer: { author: string; answers: Answered[] }): ToolExecutionResult { +function answered(answer: Recorded): ToolExecutionResult { + if (answer.skipped) { + return { + content: [{ + type: "text", + text: + `Not answered: ${answer.author} skipped the question. Continue without their answer, or explain what you need before asking again.`, + }], + }; + } return { content: [{ type: "text", @@ -169,38 +181,55 @@ function answered(answer: { author: string; answers: Answered[] }): ToolExecutio }; } -/** Record `answers` to a pending question; the first submission wins. */ +/** + * Record `answers` to a pending question, or with `answers` null, that `author` skipped it; the + * first submission wins. A written `answers` string answers every item with that text, and with no + * `call`, the chat's newest open question. + */ export async function answer( harness: Harness, chat: ConversationId, - call: string, + call: string | undefined, author: string, - answers: readonly Answer[], + answers: readonly Answer[] | string | null, context: Context, ): Promise { const live = await harness.snapshot(LiveDoc, chat, context); - const slot = live?.tools?.find((value) => value.callId === call); // A slot pi has not resumed yet after a restart still takes the answer its rerun will find. - if (slot?.name !== "question" || slot.status === "done") { - throw new Error("This question is no longer open"); + const open = (value: { name: string; status: string }) => + value.name === "question" && value.status !== "done"; + const slot = call === undefined + ? live?.tools?.findLast(open) + : live?.tools?.find((value) => value.callId === call); + if (!slot || !open(slot)) { + throw new Error( + call === undefined ? "No question is waiting" : "This question is no longer open", + ); } await harness.commit(async (tx) => { const doc = await tx.doc(QuestionsDoc, chat); - const pending = doc.questions[call]; + const pending = doc.questions[slot.callId]; if (!pending) throw new Error("This question is no longer open"); if (pending.answer) throw new Error(`Already answered by ${pending.answer.author}`); - if (answers.length !== pending.items.length) throw new Error("Answer every question"); + if (answers === null) { + pending.answer = { author, at: Date.now(), answers: [], skipped: true }; + return; + } + const given = typeof answers === "string" + ? pending.items.map((): Answer => ({ custom: answers })) + : answers; + if (given.length !== pending.items.length) throw new Error("Answer every question"); const recorded = pending.items.map((item, index): Answered => { - const given = answers[index]!; - if ("custom" in given) { - const custom = given.custom.trim(); + const value = given[index]!; + if ("custom" in value) { + const custom = value.custom.trim(); if (!custom) throw new Error(`${item.header} needs an answer`); if (custom.length > MAX_CUSTOM) { throw new Error(`${item.header} is longer than ${MAX_CUSTOM} characters`); } return { question: item.question, custom }; } - const choices = [...new Set(given.choices)].map((choice) => item.options[choice]?.label); + const choices = [...new Set(value.choices)].map((choice) => item.options[choice]?.label); if (!choices.length || choices.some((label) => label === undefined)) { throw new Error(`${item.header} needs an answer`); }