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 useTranscript
created: 2026-10-15T02:55:20Z
norm: '1'
sig: 3961cde46070e655
body_hash: 715b52c056e02eac
raw_hash: 0c5f8df4fea08fca
vouched: 2026-10-22T20:30:45Z
body_hash: 835435c05643b8c8
raw_hash: 4c294bc99f24c95b
vouched: 2026-10-22T21:41:25Z
lines: 174-275
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function useTranscript
created: 2026-10-15T04:57:01Z
norm: '1'
sig: 3961cde46070e655
body_hash: 715b52c056e02eac
raw_hash: 0c5f8df4fea08fca
vouched: 2026-10-22T20:30:45Z
body_hash: 835435c05643b8c8
raw_hash: 4c294bc99f24c95b
vouched: 2026-10-22T21:41:26Z
lines: 197-314
---

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: c6d2a615cf4deee2
raw_hash: e9f2af52a8b6629d
vouched: 2026-10-22T21:13:56Z
body_hash: edc1045f02d80405
raw_hash: 95969dbe14ea6781
vouched: 2026-10-22T21:41:26Z
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: c6d2a615cf4deee2
raw_hash: e9f2af52a8b6629d
vouched: 2026-10-22T21:13:56Z
body_hash: edc1045f02d80405
raw_hash: 95969dbe14ea6781
vouched: 2026-10-22T21:41:26Z
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: c6d2a615cf4deee2
raw_hash: e9f2af52a8b6629d
vouched: 2026-10-22T21:13:56Z
body_hash: edc1045f02d80405
raw_hash: 95969dbe14ea6781
vouched: 2026-10-22T21:41:26Z
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: c6d2a615cf4deee2
raw_hash: e9f2af52a8b6629d
vouched: 2026-10-22T21:13:56Z
body_hash: edc1045f02d80405
raw_hash: 95969dbe14ea6781
vouched: 2026-10-22T21:41:26Z
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: c6d2a615cf4deee2
raw_hash: e9f2af52a8b6629d
vouched: 2026-10-22T21:13:56Z
body_hash: edc1045f02d80405
raw_hash: 95969dbe14ea6781
vouched: 2026-10-22T21:41:26Z
lines: 127-873
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5JRZAPC72D0EX9F0DSEYGDY
anchor: const StopEntry
created: 2026-10-22T21:41:16Z
norm: '1'
sig: ff54ace4b8f574ba
body_hash: e3b0c44298fc1c14
raw_hash: 69408999a208ec7e
lines: 27-27
---

a person's Stop is a durable ace.stopped entry whose user message reaches the next run; stopped events go only to watchers that ask with stops, since older clients' reducers can't take an unknown event kind
10 changes: 9 additions & 1 deletion apps/app/src/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,15 @@ export function toEvents(
}
}
if (item.error) content.push(text(`Run failed: ${item.error}`));
if (item.stopped) content.push(text("Stopped."));
if (item.stopped) {
content.push(
text(
item.stopper
? `Stopped by ${authorName(item.stopper, people, channels)}.`
: "Stopped.",
),
);
}
if (item.withdrawn) content.push(text(withdrawal(item.withdrawn, people, channels)));
return {
...base,
Expand Down
21 changes: 21 additions & 0 deletions apps/app/src/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export type Item =
tools: Tool[];
error?: string;
stopped?: boolean;
/** Who stopped the run, once the channel records it. */
stopper?: string;
/** Queued messages withdrawn when the run was stopped. */
withdrawn?: QueuedMessage[];
};
Expand Down Expand Up @@ -232,6 +234,24 @@ export function apply(state: Transcript, event: Event): Transcript {
const items = withdraw(state.items, state.withdrawn);
return { ...state, busy, draft: "", items, withdrawn: [] };
}
case "stopped": {
const last = state.items.at(-1);
if (last?.kind === "reply") {
const item: Item = { ...last, stopped: true, stopper: event.author };
return { ...state, items: [...state.items.slice(0, -1), item] };
}
const item: Item = {
kind: "reply",
key: `s${event.at}`,
at: event.at,
model: "",
text: "",
tools: [],
stopped: true,
stopper: event.author,
};
return { ...state, items: [...state.items, item] };
}
case "live":
return { ...state, live: true };
case "agent":
Expand Down Expand Up @@ -377,6 +397,7 @@ export function useTranscript(
actions: true,
typing: true,
agent: true,
stops: true,
...(chat === undefined ? {} : { chat }),
}, {
event(event) {
Expand Down
4 changes: 3 additions & 1 deletion apps/host/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ function render(event: Event, info: ChannelInfo) {
switch (event.kind) {
case "message":
return console.log(`${tag}${bold(event.author)}: ${event.text}`);
case "stopped":
return console.log(dim(`${tag}Stopped by ${event.author}.`));
case "reply": {
const ending = event.error
? `\x1b[31m${event.text ? "\n" : ""}Run failed: ${event.error}\x1b[0m`
Expand Down Expand Up @@ -212,7 +214,7 @@ async function watch(session: Session, replay = true): Promise<{ ended: Promise<
const info = await session.request<ChannelInfo>({ op: "info" });
let live = false;
const ended = Promise.withResolvers<void>();
await session.request({ op: "watch", chat: chat() }, {
await session.request({ op: "watch", chat: chat(), stops: true }, {
event(event) {
if (event.kind === "live") return void (live = true);
if (live || replay) render(event, info);
Expand Down
15 changes: 14 additions & 1 deletion packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,15 @@ export class Channel {
this.#author(request.author);
// Whoever can't start a run here can't end the owner's either.
await this.#mayInvoke(request.author);
return (await this.#conversation(request.chat)).abort(context);
const conversation = await this.#conversation(request.chat);
const running = this.#runs.has(conversation.id);
await conversation.abort(context);
if (!running) return;
await conversation.submit(
{ type: "write", entry: room.stop(request.author, Date.now()) },
context,
);
return;
}
case "kill":
this.#author(request.author);
Expand Down Expand Up @@ -1034,6 +1042,7 @@ export class Channel {
// a deleted message's content and shows an edited one's newest text.
const forward = (event: Event) => {
if ((event.kind === "deleted" || event.kind === "edited") && !actions) return;
if (event.kind === "stopped" && !request.stops) return;
if (event.kind !== "message" || !deleted[event.entry]) return send(event);
const { images: _, files: __, edited: ___, ...rest } = event;
send({ ...rest, text: "", deleted: true });
Expand Down Expand Up @@ -1190,6 +1199,10 @@ function events(entry: EntryRecord): Event[] {
const { target, text, at } = entry.data;
return [{ kind: "edited", chat, entry: target, text, at }];
}
if (room.StopEntry.is(entry)) {
const at = entry.model![0]!.timestamp;
return [{ kind: "stopped", chat, at, author: entry.data.author }];
}
const human = room.human(entry);
if (human) return [{ kind: "message", chat, entry: entry.id, ...human }];
const message = entry.model?.[0];
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 @@ -297,6 +297,8 @@ export type Request =
typing?: boolean;
/** Include agent events; older clients do not understand them. */
agent?: boolean;
/** Include stopped events; older clients do not understand them. */
stops?: boolean;
};

export type Event =
Expand Down Expand Up @@ -385,6 +387,8 @@ export type Event =
*/
| { kind: "recap"; chat: ChatId; calls: string[]; text: string }
| { kind: "run"; chat: ChatId; state: "start" | "end" }
/** `author` stopped the run, whose aborted reply came before this. */
| { kind: "stopped"; chat: ChatId; at: number; author: string }
/** The chat's model or effort changed, so the next run uses these. */
| { kind: "agent"; chat: ChatId; model: ModelRef; effort: Effort }
/**
Expand Down
21 changes: 21 additions & 0 deletions packages/channel/src/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ import { type Image, MAX_IMAGES, type TextFile } from "./protocol";
/** A human message that did not invoke an agent. */
export const MessageEntry = defineEntry<{ author: string; text: string }>("ace.message");

/**
* A person stopped the run before this entry. Its user message tells the chat's next run, which
* otherwise sees only an aborted reply and can't tell a person's Stop from a failure.
*/
export const StopEntry = defineEntry<{ author: string }>("ace.stopped");

export function stop(author: string, timestamp: number) {
return {
kind: StopEntry.kind,
data: { author } satisfies JsonObject,
model: [
{
role: "user",
content:
`[${author} stopped your previous run before it finished. Anything it was doing when stopped may not have completed.]`,
timestamp,
} satisfies UserMessage,
],
};
}

const AUTHOR = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/;

export function isAuthor(value: string): boolean {
Expand Down