Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -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
37 changes: 23 additions & 14 deletions apps/app/src/question.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo, useRef, useState } from "react";

import {
Button,
isAnswered,
type QuestionDraft,
QuestionInput,
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
Expand All @@ -108,6 +107,16 @@ export function Question({ channel, chat, call, user, mayAnswer }: {
submitting,
error,
...(mayAnswer ? {} : { status: "Only the channel owner can answer here" }),
actions: (
<Button
size="sm"
variant="secondary"
disabled={!mayAnswer || submitting}
onClick={() => void send({ op: "skip" })}
>
Skip
</Button>
),
choose: (id, option, multiple, checked) =>
update(id, (current) =>
multiple
Expand Down
25 changes: 21 additions & 4 deletions apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const HELP = `ace — channels on this host
ace say <channel> [--chat <id>] <text…> post without invoking the agent
ace ask <channel> [--chat <id>] [--model <provider/id>] [--detach] <text…>
ace watch <channel> [--chat <id>]
ace answer <channel> [--chat <id>] <text…> answer the chat's waiting question in writing
ace chat <channel> [--model <provider/id>] open another chat
ace stop <channel> [--chat <id>] stop the chat's current run
ace kill <channel> stop all work in the channel now
Expand All @@ -83,7 +84,7 @@ const HELP = `ace — channels on this host
ace open [--port 4140] open this host's authenticated app
ace archive <channel> | unarchive <channel> | delete <channel>

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.
Expand Down Expand Up @@ -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} <text…>)`));
}
case "result":
return console.log(dim(`${tag} ${event.error ? "✗" : "←"} ${clip(event.text, 100)}`));
case "run":
Expand Down Expand Up @@ -206,7 +219,7 @@ async function isHostRunning(): Promise<boolean> {
/** 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. */
Expand Down Expand Up @@ -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": {
Expand Down
9 changes: 8 additions & 1 deletion packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/channel/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
57 changes: 43 additions & 14 deletions packages/channel/src/questions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
items: Item[];
answer?: { author: string; at: number; answers: Answered[] };
answer?: Recorded;
}>;
}>({
kind: "ace.questions",
Expand Down Expand Up @@ -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",
Expand All @@ -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<void> {
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`);
}
Expand Down