From 49322e148a7fd53a8750445692a7a59482bc501f Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 11:13:25 -0400 Subject: [PATCH 1/3] feat: skip an agent's question, and answer it from the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question card gains Skip, which tells the agent no answer was given. ace answer answers the chat's newest waiting question in writing, and ace watch prints questions and their options instead of the raw tool call. Why: packages/channel/src/questions.ts#function answer | 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 --- ...8CTZNHMFCE-a-skip-is-recorded-in-questi.md | 8 +++ apps/app/src/question.tsx | 37 +++++++----- apps/host/src/cli.ts | 25 ++++++-- packages/channel/src/channel.ts | 9 ++- packages/channel/src/protocol.ts | 4 ++ packages/channel/src/questions.ts | 57 ++++++++++++++----- 6 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 .lane/memory/packages/channel/src/questions.ts/01M5J2S5B9QYYBPZ8CTZNHMFCE-a-skip-is-recorded-in-questi.md 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..b4b983dd --- /dev/null +++ b/.lane/memory/packages/channel/src/questions.ts/01M5J2S5B9QYYBPZ8CTZNHMFCE-a-skip-is-recorded-in-questi.md @@ -0,0 +1,8 @@ +--- +id: 01M5J2S5B9QYYBPZ8CTZNHMFCE +anchor: function answer +created: 2026-10-22T15:13:25Z +norm: '1' +--- + +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`); } From 0cfeb95e440ab0465f9126296089000608e7eafe Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 11:13:32 -0400 Subject: [PATCH 2/3] lane: sync fix-question memory --- ...01M5J2S5B9QYYBPZ8CTZNHMFCE-a-skip-is-recorded-in-questi.md | 4 ++++ 1 file changed, 4 insertions(+) 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 index b4b983dd..f88130c9 100644 --- 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 @@ -3,6 +3,10 @@ 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 From 2648c833f1dd148135c2c3397ee89f8bbc1929e5 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 11:24:39 -0400 Subject: [PATCH 3/3] chore: confirm notes after rebase --- ...M4YY5SXJFQK8VXT78X1E0KAE-every-command-but-serve-must.md | 6 +++--- ...M5J0BZWP8SCBK522YT2HMZDZ-ace-info-prints-agent-access.md | 6 +++--- ...M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md | 6 +++--- ...1M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md | 6 +++--- ...1M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md | 6 +++--- ...M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md | 6 +++--- ...M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) 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 ---