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
@@ -0,0 +1,8 @@
---
id: 01M4XQJSWBSNDX2QZ1CQRBB9FH
anchor: 'fn #ended'
created: 2026-10-14T17:32:54Z
norm: '1'
---

pi settles a run's inputs in the same commit that removes LiveDoc.run; read the outcome from that commit's submission changes, not from the transcript
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4XQK5G3879T8FK6RJH5RCFF
anchor: '#ended'
created: 2026-10-14T17:33:06Z
norm: '1'
---

pi settles a run's inputs in the same commit that removes LiveDoc.run; read the outcome from that commit's submission changes, not from the transcript
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4XQK9XWF9WF94C1AJP4MSKF
anchor: 'async #ended'
created: 2026-10-14T17:33:10Z
norm: '1'
---

pi settles a run's inputs in the same commit that removes LiveDoc.run; read the outcome from that commit's submission changes, not from the transcript
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4XQKEF88070PYNF6AYDVZPF
anchor: subscribeCommits
created: 2026-10-14T17:33:15Z
norm: '1'
---

a run's end is read from the same commit that removes LiveDoc.run: pi settles its inputs there (done / aborted -> stopped / else failed); don't scan transcripts for it
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4XQKK8PW5KE1QZN53ZM6DY8
anchor: static async open
created: 2026-10-14T17:33:20Z
norm: '1'
---

a run's end is read from the same commit that removes LiveDoc.run: pi settles its inputs there (done / aborted -> stopped / else failed); don't scan transcripts for it
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
id: 01M4XQJSWJMMPMH6PYZ4TWVZYF
anchor: fn isAgent
created: 2026-10-14T17:32:54Z
norm: '1'
---

subagent and cross-channel inputs are authored agent@...; runs they start must not notify a person
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4XQJSWS1YHDS9TRD27C41WG
anchor: '@file'
created: 2026-10-14T17:32:54Z
norm: '1'
sig: 9d222e7bbdcbbb9b
body_hash: 2de2b2790d826036
raw_hash: b5ee961f9825a9a0
lines: 1-102
---

`told` persists per host and user so reloads and reconnects never re-notify the same run end; `since` keeps history from lighting up on a first visit
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4XQKND0900E94CG3BXBEJP7
anchor: class Channel
created: 2026-10-14T17:33:22Z
norm: '1'
sig: c905677fa4be33c1
body_hash: c04286fc994309b7
raw_hash: 0c9639c44cd13600
lines: 114-792
---

a run's end is read from the same commit that removes LiveDoc.run: pi settles its inputs there (done / aborted -> stopped / else failed); don't scan transcripts for it
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M4XQK5FJQNG3VN0KFFCCYX3R
anchor: function isAgent
created: 2026-10-14T17:33:06Z
norm: '1'
sig: b5c0961e8f085c4e
body_hash: 3956f4ef2be16694
raw_hash: 1240d13ae4e3898c
lines: 23-25
---

subagent and cross-channel inputs are authored agent@...; runs they start must not notify a person
23 changes: 17 additions & 6 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { IconHash, IconPlus } from "@ace/ui/icons";
import type { Hello, HostRequest, Listing, MoveTarget, People, Project } from "@ace/host/protocol";

import { chosen, deployed, remember } from "./address";
import { glow, type Outcome, useAttention } from "./attention";
import { Channel, ChannelDetails, type ChannelDraft } from "./channel";
import { Dashboard } from "./dashboard";
import { Github, GithubSidebar } from "./github";
Expand Down Expand Up @@ -107,7 +108,7 @@ const INACTIVE_AFTER = 6 * 60 * 60_000;
const ALL_PROJECTS = "all";
const RECENT_CHANNELS = 10;

function row(channel: Listing, user: string, host: string): SidebarRow {
function row(channel: Listing, user: string, host: string, outcome?: Outcome): SidebarRow {
return {
uid: channel.id,
kind: "session",
Expand All @@ -130,7 +131,8 @@ function row(channel: Listing, user: string, host: string): SidebarRow {
? "offline"
: "idle",
agent: channel.state === "running" && channel.busy ? "thinking" : "idle",
unreadCount: 0,
unreadCount: outcome === "done" ? 1 : 0,
needsAttention: outcome === "failed",
mentionCount: 0,
lastActivityAt: Math.floor((channel.active || channel.created) / 1000),
online: [],
Expand All @@ -153,13 +155,14 @@ function projectGroups(
people: People,
now: number,
collapsed: Partial<Record<SessionSidebarGroupId, boolean>>,
attention: Map<string, Outcome>,
): SessionSidebarGroup[] {
const mine: SidebarRow[] = [];
const inactive: SidebarRow[] = [];
const archived: SidebarRow[] = [];
const team = new Map<string, { name: string; rows: SidebarRow[]; archived: SidebarRow[] }>();
for (const channel of channels) {
const next = row(channel, hello.user, hello.host);
const next = row(channel, hello.user, hello.host, attention.get(channel.id));
const stored = channel.state === "archived";
if (channel.owner === hello.user) {
const idle = !channel.busy && now - (channel.active || channel.created) > INACTIVE_AFTER;
Expand Down Expand Up @@ -297,7 +300,12 @@ export function App() {
org: value.repo?.split("/")[0] || "",
})),
];
let groups = projectGroups(visible, hello, people, now, collapsed);
const { attention, pulse } = useAttention(
channels,
hello.user,
page === "channels" ? channel?.id : undefined,
);
let groups = projectGroups(visible, hello, people, now, collapsed, attention);

if (allProjects) {
const byProject = new Map<string, Listing[]>();
Expand All @@ -313,7 +321,9 @@ export function App() {
const rows = (byProject.get(value.id) || []).sort((a, b) =>
(b.active || b.created) - (a.active || a.created)
|| b.created - a.created || b.id.localeCompare(a.id)
).slice(0, RECENT_CHANNELS).map((value) => row(value, hello.user, hello.host));
).slice(0, RECENT_CHANNELS).map((value) =>
row(value, hello.user, hello.host, attention.get(value.id))
);
return {
id,
label: label(value, hello.host),
Expand Down Expand Up @@ -484,7 +494,8 @@ export function App() {
onLeftChange={setLeft}
leftWidth={width}
onLeftWidthChange={setWidth}
loading={status === "connecting"}
loading={status === "connecting" || pulse === "on"}
{...(pulse && glow.props)}
onDoubleClick={titlebar}
nav={
<Navigation
Expand Down
102 changes: 102 additions & 0 deletions apps/app/src/attention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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 { desktop } from "./desktop";
import { host } from "./host";

/**
* Run ends this person has seen or been told about, by channel. Ends before `since` predate this
* browser profile's first visit, so history never lights up.
*/
type State = { since: number; seen: Record<string, number>; told: Record<string, number> };

export type Outcome = Exclude<RunEnd["outcome"], "stopped">;

/** The pulse holds its glow this long, then keeps these props until it has faded out. */
export const glow = { hold: 1800, props: { glowOpacity: 0.6, glowFadeIn: 0.8, glowFadeOut: 1.3 } };

const EVENTS = ["focus", "blur", "visibilitychange"];

function subscribe(listener: () => void) {
for (const event of EVENTS) addEventListener(event, listener);
return () => {
for (const event of EVENTS) removeEventListener(event, listener);
};
}

const isWatching = () => document.visibilityState === "visible" && document.hasFocus();

/** A stopped run was a participant's own doing, so it needs no attention. */
function pending(channel: Listing, user: string, state: State): Outcome | undefined {
const { ran } = channel;
if (!ran || ran.author !== user || ran.outcome === "stopped") return;
if (ran.at <= Math.max(state.since, state.seen[channel.id] || 0)) return;
return ran.outcome;
}

/**
* Runs this person invoked that ended while they were not watching the channel. Each end pulses
* the window's glow or sends one desktop notification, then marks its row until they open it.
*/
export function useAttention(channels: Listing[], user: string, open: string | undefined) {
const [state, setState] = useLocalStorage<State | null>(
`ace:attention:${host.url}:${user}`,
null,
);
const watching = useSyncExternalStore(subscribe, isWatching);
const [pulse, setPulse] = useState<"on" | "fading">();

useEffect(() => {
if (user && !state) setState({ since: Date.now(), seen: {}, told: {} });
}, [user, state, setState]);

useEffect(() => {
if (!user || !state) return;
const seen: Record<string, number> = {};
const told: Record<string, number> = {};
for (const channel of channels) {
const { id, ran } = channel;
if (!ran) continue;
if (watching && id === open) {
if ((state.seen[id] || 0) < ran.at) seen[id] = ran.at;
continue;
}
const outcome = pending(channel, user, state);
if (!outcome || ran.at <= (state.told[id] || 0)) continue;
told[id] = ran.at;
if (watching) setPulse("on");
else {
desktop?.notify(channel.name, outcome === "failed" ? "Agent run failed" : "Agent finished")
.catch(() => {});
}
}
if (!Object.keys(seen).length && !Object.keys(told).length) return;
setState((value) =>
value && { ...value, seen: { ...value.seen, ...seen }, told: { ...value.told, ...told } }
);
}, [channels, user, state, setState, watching, open]);

useEffect(() => {
if (!pulse) return;
const timer = setTimeout(
() => setPulse(pulse === "on" ? "fading" : undefined),
pulse === "on" ? glow.hold : glow.props.glowFadeOut * 1000,
);
return () => clearTimeout(timer);
}, [pulse]);

const attention = useMemo(() => {
const found = new Map<string, Outcome>();
if (!state) return found;
for (const channel of channels) {
const outcome = pending(channel, user, state);
if (outcome) found.set(channel.id, outcome);
}
return found;
}, [channels, user, state]);

return { attention, pulse };
}
1 change: 1 addition & 0 deletions apps/app/src/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const desktop = view && token
helper: (action: HelperAction) => rpc.request.helper({ token, action }),
native: (action: NativeAction) => rpc.request.native({ token, action }),
updates: (action: UpdateAction) => rpc.request.updates({ token, action }),
notify: (title: string, body: string) => rpc.request.notify({ token, title, body }),
}
: undefined;

Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ const rpc = BrowserView.defineRPC<DesktopRPC>({
authorize(token);
return native.act(action);
},
notify: ({ token, title, body }) => {
authorize(token);
Utils.showNotification({ title, body });
return null;
},
},
messages: {},
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type DesktopRPC = {
helper: { params: { token: string; action: HelperAction }; response: HelperState };
native: { params: { token: string; action: NativeAction }; response: NativeState };
updates: { params: { token: string; action: UpdateAction }; response: UpdateState };
notify: { params: { token: string; title: string; body: string }; response: null };
};
messages: {};
};
Expand Down
8 changes: 7 additions & 1 deletion apps/host/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { userInfo } from "node:os";
import { join } from "node:path";

import { type Metadata, type ModelRef, validateName } from "@ace/channel";
import { type Metadata, type ModelRef, type RunEnd, validateName } from "@ace/channel";

import { config } from "./config";
import { login } from "./tailnet";
Expand All @@ -36,6 +36,8 @@ export type Listing = {
active?: number;
/** Rebuildable live-run projection, not history; valid only while its source is connected. */
busy?: boolean;
/** The latest invoked run's end, a hint for its author's attention; pi keeps the run itself. */
ran?: RunEnd;
archived?: boolean;
/** Base URL of the service hosting the channel; this host serves its workspace. */
hosted?: string;
Expand Down Expand Up @@ -185,6 +187,10 @@ export function busy(id: string, busy: boolean): void {
write({ ...record, busy });
}

export function ran(id: string, ran: RunEnd): void {
write({ ...read(id), ran });
}

export function list(): Listing[] {
if (!existsSync(root)) return [];
return readdirSync(root)
Expand Down
16 changes: 14 additions & 2 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,19 @@ const deleting = new Set<string>();
function local(): Listing[] {
return catalog.list().map((record) => {
const state = record.archived ? "archived" : isRunning(record.id) ? "running" : "dormant";
const { id, name: channel, summary, revision, owner, project, model, created, active, hosted } =
record;
const {
id,
name: channel,
summary,
revision,
owner,
project,
model,
created,
active,
ran,
hosted,
} = record;
const listing: Listing = {
id,
host: name,
Expand All @@ -84,6 +95,7 @@ function local(): Listing[] {
busy: state === "running" && !!record.busy && (!hosted || workspace.isConnected(id)),
};
if (active) listing.active = active;
if (ran) listing.ran = ran;
const { root, repo } = projects.checkout(project);
listing.root = root;
if (repo) listing.repo = repo;
Expand Down
3 changes: 3 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
GithubKind,
ModelRef,
Request,
RunEnd,
} from "@ace/channel/protocol";

export const HOST_PROTOCOL = 9;
Expand Down Expand Up @@ -59,6 +60,8 @@ export type Listing = {
active?: number;
/** Live channel-wide run projection; absent on older hosts, never authoritative. */
busy?: boolean;
/** The latest invoked run's end; absent on older hosts and before a channel's first one. */
ran?: RunEnd;
state: ChannelState;
/** The hosting service's base URL, for a hosted channel; `host` is then its workspace. */
hosted?: string;
Expand Down
1 change: 1 addition & 0 deletions apps/host/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const channel = await Channel.open({
onMetadata: (value) => catalog.metadata(id, value),
onActivity: (at) => catalog.activity(id, at),
onBusy: (busy) => catalog.busy(id, busy),
onRun: (ran) => catalog.ran(id, ran),
owner: record.owner,
project: record.project,
model: record.model,
Expand Down
1 change: 1 addition & 0 deletions apps/host/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function link(record: catalog.Listing): () => Promise<void> {
if ("metadata" in call) return catalog.metadata(record.id, call.metadata);
if ("active" in call) return catalog.activity(record.id, call.active);
if ("busy" in call) return catalog.busy(record.id, call.busy);
if ("ran" in call) return catalog.ran(record.id, call.ran);
log("debug", "workspace.call", {
channel: record.id,
...("cancel" in call
Expand Down
Loading