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`);
}