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 App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 825caa43ddeedb08
raw_hash: a06c00e058bd3a88
vouched: 2026-10-22T16:18:52Z
body_hash: cf62711c75c66568
raw_hash: 38568af4c890e786
vouched: 2026-10-22T20:27:44Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function useAttention
created: 2026-10-22T15:40:01Z
norm: '1'
sig: 764bb872be391a4f
body_hash: 5ea3135588da2978
raw_hash: b0b3e34b9fb9b707
body_hash: eaaf5ba9249c1195
raw_hash: 9ac21a4b149b612a
vouched: 2026-10-22T20:27:44Z
lines: 100-195
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-22T15:40:05Z
norm: '1'
sig: 9d222e7bbdcbbb9b
body_hash: a48e796f66372ad6
raw_hash: 99a80a221071bf27
body_hash: 07a9546c62fc409f
raw_hash: e6e88b5690c07577
vouched: 2026-10-22T20:27:44Z
lines: 1-195
supersedes: 01M4XQJSWS1YHDS9TRD27C41WG
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function ChannelAccess
created: 2026-10-15T05:30:36Z
norm: '1'
sig: d9bb90f2534024f3
body_hash: 5fc15bf520c64275
raw_hash: 45ec44a5e2b7a0ee
vouched: 2026-10-22T14:31:33Z
body_hash: a28e4418a2be76f8
raw_hash: e87bd58f58d5f996
vouched: 2026-10-22T20:27:44Z
lines: 63-136
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5JMRG2XHCM6WY83SQ38Z626
anchor: function pulse
created: 2026-10-22T20:27:38Z
norm: '1'
sig: c46787aa6b1081f9
body_hash: 6ee37d54ef745cd2
raw_hash: b53f8efb5128c61e
lines: 21-58
---

a run end belongs to ran.author, not the channel owner; guests run agents in others' channels
1 change: 1 addition & 0 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ export function App() {
hello.user,
names,
page === "channels" ? channel?.id : undefined,
people,
);
const online = (channel: Listing) =>
present(presence[channelProject(channel)], people[hello.user], channel.id);
Expand Down
29 changes: 21 additions & 8 deletions apps/app/src/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";

import { useLocalStorage } from "@ace/ui";
import type { RunEnd } from "@ace/channel/protocol";
import type { Listing } from "@ace/host/protocol";
import type { Listing, People } from "@ace/host/protocol";

import { desktop } from "./desktop";
import { isFocused, subscribe } from "./focus";
Expand Down Expand Up @@ -62,6 +62,14 @@ function pending(channel: Listing, user: string, state: State): Outcome | undefi
return ran.outcome;
}

/** A run a teammate asked for in this person's channel, which ran with their machine and key. */
function isTeammateRun(channel: Listing, user: string, state: State): boolean {
const { ran } = channel;
if (!ran || channel.owner !== user || ran.author === user) return false;
if (ran.outcome !== "done" && ran.outcome !== "failed") return false;
return ran.at > seenAt(channel, state);
}

function mark(channel: Listing, user: string, state: State): Mark | undefined {
const outcome = pending(channel, user, state);
if (outcome) return outcome;
Expand Down Expand Up @@ -92,16 +100,17 @@ function missed(found: { channel: Listing; count: number }[]): [string, string]

/**
* Runs this person invoked that ended, or that wait on a question, while they were not watching the
* channel, and new @mentions of `names` (lowercased GitHub logins). Each pulses the window's glow or
* sends one desktop notification, then marks its row until they open it; mentions that arrived
* while Ace was closed or disconnected notify once together. Others' messages mark the row too,
* without a notification.
* channel, teammates' runs that ended in their channels, and new @mentions of `names` (lowercased
* GitHub logins). Each pulses the window's glow or sends one desktop notification, then marks its
* row until they open it; mentions that arrived while Ace was closed or disconnected notify once
* together. Others' messages mark the row too, without a notification.
*/
export function useAttention(
channels: Listing[],
user: string,
names: string[],
open: string | undefined,
people: People,
) {
const [state, setState] = useLocalStorage<State | null>(
`ace:attention:${host.url}:${user}`,
Expand Down Expand Up @@ -141,11 +150,15 @@ export function useAttention(
}
if (!ran) continue;
const outcome = pending(channel, user, state);
if (!outcome || ran.at <= (state.told[id] || 0)) continue;
if (!outcome && !isTeammateRun(channel, user, state)) continue;
if (ran.at <= (state.told[id] || 0)) continue;
told[id] = ran.at;
if (watching) setPulse("on");
else if (outcome) desktop?.notify(channel.name, NOTICES[outcome]).catch(() => {});
else {
desktop?.notify(channel.name, NOTICES[outcome]).catch(() => {});
const who = people[ran.author] || ran.author.split("@")[0];
const failed = ran.outcome === "failed" ? ", and the run failed" : "";
desktop?.notify(channel.name, `${who} used your agent${failed}`).catch(() => {});
}
}
if (away && mentions.length) {
Expand All @@ -169,7 +182,7 @@ export function useAttention(
called: { ...value.called, ...called },
}
);
}, [channels, user, names, state, setState, watching, open]);
}, [channels, user, names, state, setState, watching, open, people]);

useEffect(() => {
if (!pulse) return;
Expand Down
22 changes: 15 additions & 7 deletions apps/app/src/channel-access.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ReactNode, useState } from "react";
import { type ReactNode, useState, useSyncExternalStore } from "react";

import type { Listing } from "@ace/host/protocol";
import type { Listing, People } from "@ace/host/protocol";
import {
Button,
Dialog,
Expand All @@ -22,9 +22,13 @@ import { placement } from "./placement";

/**
* What teammates' runs can reach. A cloud container has no desktop tools and no macOS
* permissions, so its copy names neither.
* permissions, so its copy names neither. A guest reads whose account and machine they would use.
*/
function reach(channel: Listing): { agents: string; confirm: string; desktop: boolean } {
function reach(
channel: Listing,
owner: boolean,
people: People,
): { agents: string; confirm: string; desktop: boolean } {
const place = placement(channel);
if (place.kind === "cloud") {
return {
Expand All @@ -35,9 +39,12 @@ function reach(channel: Listing): { agents: string; confirm: string; desktop: bo
desktop: false,
};
}
const name = people[channel.owner] || channel.owner.split("@")[0];
const machine = place.kind === "local" ? place.host : place.workspace;
return {
agents:
"Let teammates invoke this channel's agents, which run commands as you on this machine with Ace's macOS permissions, such as Screen Recording. Turning this off leaves chat available and active runs continue.",
agents: owner
? "Let teammates invoke this channel's agents, which run commands as you on this machine with Ace's macOS permissions, such as Screen Recording. Turning this off leaves chat available and active runs continue."
: `Teammates can invoke this channel's agents, which run commands as ${name} on ${machine}, with ${name}'s model keys and Ace's macOS permissions there, such as Screen Recording.`,
confirm:
"Teammates can run commands as you on this machine, with Ace's macOS permissions such as Screen Recording.",
desktop: true,
Expand Down Expand Up @@ -118,8 +125,9 @@ export function ChannelAccess({ channel, user }: { channel: Listing; user: strin
const [sharing, setSharing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [changingDesktop, setChangingDesktop] = useState(false);
const people = useSyncExternalStore(host.subscribe, () => host.people);
const owner = channel.owner === user;
const copy = reach(channel);
const copy = reach(channel, owner, people);

// Render only watch-confirmed values, never an optimistic permission grant.
function share() {
Expand Down
5 changes: 4 additions & 1 deletion apps/app/src/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { MessageQueue } from "./message-queue";
import { OfflineHost, useRoster } from "./onboarding";
import { present } from "./presence";
import { pendingQuestion, Question } from "./question";
import { sources, toEvents } from "./timeline";
import { credit, sources, toEvents } from "./timeline";
import { useTranscript, useTyping } from "./transcript";
import { Usage } from "./usage";

Expand Down Expand Up @@ -95,6 +95,7 @@ export function Conversation(
transcript.captions,
transcript.recaps,
transcript.reactions,
(asker) => credit(asker, channel, user, people, senders),
),
[
transcript.items,
Expand All @@ -107,6 +108,8 @@ export function Conversation(
transcript.captions,
transcript.recaps,
transcript.reactions,
channel,
user,
],
);
const byKey = useMemo(
Expand Down
19 changes: 11 additions & 8 deletions apps/app/src/pulse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const ACTIVE = 3;

/**
* Deterministic team pulse from listings and the lobby: who is here, their active channels, and
* runs that ended in their channels after `since`. Teammates with none of these are left out.
* runs they asked for that ended after `since`, in anyone's channel. Teammates with none of these
* are left out.
*/
export function pulse(
channels: Listing[],
Expand All @@ -39,14 +40,16 @@ export function pulse(
}
const latest = (channel: Listing) => channel.active || channel.created;
for (const channel of [...channels].sort((a, b) => latest(b) - latest(a))) {
if (channel.owner === user) continue;
const ended = channel.ran && channel.ran.at > since ? channel.ran.outcome : undefined;
const live = channel.state !== "archived" && !isInactive(channel, now);
if (!live && ended !== "done" && ended !== "failed") continue;
const teammate = get(people[channel.owner] || channel.owner);
if (live && teammate.active.length < ACTIVE) teammate.active.push(channel);
if (ended === "done") teammate.finished.push(channel);
if (ended === "failed") teammate.failed.push(channel);
if (live && channel.owner !== user) {
const owner = get(people[channel.owner] || channel.owner);
if (owner.active.length < ACTIVE) owner.active.push(channel);
}
// A run ended belongs to whoever asked for it, which may be a guest in someone else's channel.
const { ran } = channel;
if (!ran || ran.at <= since || ran.author === user) continue;
if (ran.outcome === "done") get(people[ran.author] || ran.author).finished.push(channel);
if (ran.outcome === "failed") get(people[ran.author] || ran.author).failed.push(channel);
}
const order = { here: 0, away: 1 };
return [...team.values()].sort((a, b) =>
Expand Down
43 changes: 41 additions & 2 deletions apps/app/src/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MENTION, type QueuedMessage, type Reactions } from "@ace/channel/protoc
import type { Listing, People } from "@ace/host/protocol";
import type { Event } from "@ace/ui";

import { placement } from "./placement";
import type { Item, Tool } from "./transcript";

const AGENT = "ace:agent";
Expand Down Expand Up @@ -62,6 +63,31 @@ function withdrawal(
return lines.join(" ");
}

/**
* Who asked for a teammate's run and what ran it, written for the viewer. A local channel's agents
* run as its owner on the owner's host with the owner's model keys; a hosted or cloud channel's
* service runs them with its own keys.
*/
export function credit(
asker: string,
channel: Listing,
user: string,
people: People,
channels: Map<string, Listing>,
): string | undefined {
if (asker === channel.owner || asker.startsWith("agent@")) return;
const name = (login: string) => login === user ? "you" : authorName(login, people, channels);
const place = placement(channel);
const where = place.kind === "cloud"
? `ran in a cloud container on ${place.service} with its key`
: place.kind === "hosted"
? `ran on ${place.workspace} with ${place.service}'s key`
: channel.owner === user
? `ran on your ${place.host} with your key`
: `ran on ${name(channel.owner)}'s ${place.host} with their key`;
return `asked by ${name(asker)} · ${where}`;
}

function text(value: string): Event.Message.Content.Text {
const parts = [...value.matchAll(MENTION)].map((match) => ({
type: "mention" as const,
Expand Down Expand Up @@ -126,7 +152,19 @@ export function toEvents(
captions: Record<string, string>,
recaps: Record<string, string>,
reactions: Reactions,
by: (asker: string) => string | undefined,
): Event[] {
// A run answers the newest message that invoked it, as the channel's run end reports.
let asker: string | undefined;
const sender = (model: string | undefined): Event.Sender.Agent => {
const value = asker && by(asker);
return {
kind: "agent",
value: AGENT,
display: model || "Agent",
...(value ? { credit: value } : {}),
};
};
const events = items.map((item) => {
const reacted = item.entry === undefined ? undefined : reactions[item.entry];
const base = {
Expand All @@ -146,6 +184,7 @@ export function toEvents(
: {}),
} as const;
if (item.kind === "message") {
if (item.invoked) asker = item.author;
return {
...base,
sender: {
Expand Down Expand Up @@ -190,7 +229,7 @@ export function toEvents(
if (item.withdrawn) content.push(text(withdrawal(item.withdrawn, people, channels)));
return {
...base,
sender: { kind: "agent", value: AGENT, display: item.model || "Agent" },
sender: sender(item.model),
content,
} as Event;
});
Expand All @@ -201,7 +240,7 @@ export function toEvents(
type: "message",
topic,
created_at: Math.floor(Date.now() / 1000),
sender: { kind: "agent", value: AGENT, display: draft.model || "Agent" },
sender: sender(draft.model),
content: [text(draft.text)],
streaming: true,
} as Event);
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ requesting their review. Pick back up ranks, as old Ace did, the viewer's open p
assigned issues, read with `@me` searches through the same account; a pull request whose head
branch starts with a channel's original name is that channel's lane, so its row continues the
channel, including a teammate's, and other rows offer Open in a channel. Team pulse shows each
teammate who is here, their active channels, and runs that ended in their channels since this
browser last opened the project's dashboard.
teammate who is here, their active channels, and runs they asked for, in anyone's channel, that
ended since this browser last opened the project's dashboard.
The shared project picker also offers All Projects, which opens Channels with collapsible sections
named for each project and its ten most recently active, non-archived channels. Opening a channel
keeps that grouped view; channel creation and GitHub pages retain a concrete project selection.
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/components/timeline/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ function frame(
? group.display || String(group.sender || "User")
: group.display || (group.run ? "Ace" : "ace");
let name = label;
let credit = group.credit;
let open = group.role === "user" && group.sender != null
? openSender?.(String(group.sender))
: undefined;
Expand Down Expand Up @@ -694,6 +695,7 @@ function frame(
workingSettling,
label,
name,
credit,
open,
hasByline,
own,
Expand Down Expand Up @@ -812,7 +814,7 @@ function GroupAvatar({ props, state }: GroupRender) {
}

function GroupByline({ state }: { state: GroupState }) {
let { hasByline, mine, name, open, agent, tone, title, date, edit, editTitle } = state;
let { hasByline, mine, name, credit, open, agent, tone, title, date, edit, editTitle } = state;
return (
<>
{hasByline && (
Expand Down Expand Up @@ -844,6 +846,11 @@ function GroupByline({ state }: { state: GroupState }) {
{name}
</span>
)}
{credit && (
<span className="min-w-0 truncate text-[0.625rem]" title={credit}>
{credit}
</span>
)}
{agent && (
<span
className="min-w-0 truncate text-[0.625rem] font-medium"
Expand Down
Loading