From ab6d9cac78740d2154b0217db6d978b0700b0011 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 16:27:38 -0400 Subject: [PATCH 1/3] fix(app): say who asked for a teammate's run and whose machine and key ran it Why: apps/app/src/pulse.ts#function pulse | a run end belongs to ran.author, not the channel owner; guests run agents in others' channels --- apps/app/src/app.tsx | 1 + apps/app/src/attention.ts | 29 +++++++++---- apps/app/src/channel-access.tsx | 22 +++++++--- apps/app/src/conversation.tsx | 5 ++- apps/app/src/pulse.ts | 19 ++++---- apps/app/src/timeline.ts | 43 ++++++++++++++++++- docs/architecture.md | 4 +- .../ui/src/components/timeline/timeline.tsx | 9 +++- packages/ui/src/lib/timeline.ts | 4 ++ packages/ui/src/types.ts | 2 + 10 files changed, 109 insertions(+), 29 deletions(-) diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index ea310689..7ec76f20 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -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); diff --git a/apps/app/src/attention.ts b/apps/app/src/attention.ts index 53babb0a..78ac846c 100644 --- a/apps/app/src/attention.ts +++ b/apps/app/src/attention.ts @@ -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"; @@ -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; @@ -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( `ace:attention:${host.url}:${user}`, @@ -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) { @@ -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; diff --git a/apps/app/src/channel-access.tsx b/apps/app/src/channel-access.tsx index 85a9ed58..fe61a9c5 100644 --- a/apps/app/src/channel-access.tsx +++ b/apps/app/src/channel-access.tsx @@ -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, @@ -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 { @@ -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, @@ -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() { diff --git a/apps/app/src/conversation.tsx b/apps/app/src/conversation.tsx index e8aa4dc7..ec2c374b 100644 --- a/apps/app/src/conversation.tsx +++ b/apps/app/src/conversation.tsx @@ -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"; @@ -95,6 +95,7 @@ export function Conversation( transcript.captions, transcript.recaps, transcript.reactions, + (asker) => credit(asker, channel, user, people, senders), ), [ transcript.items, @@ -107,6 +108,8 @@ export function Conversation( transcript.captions, transcript.recaps, transcript.reactions, + channel, + user, ], ); const byKey = useMemo( diff --git a/apps/app/src/pulse.ts b/apps/app/src/pulse.ts index 973604a8..ceb2d275 100644 --- a/apps/app/src/pulse.ts +++ b/apps/app/src/pulse.ts @@ -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[], @@ -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) => diff --git a/apps/app/src/timeline.ts b/apps/app/src/timeline.ts index 74dff326..42765aff 100644 --- a/apps/app/src/timeline.ts +++ b/apps/app/src/timeline.ts @@ -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"; @@ -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 | 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, @@ -126,7 +152,19 @@ export function toEvents( captions: Record, recaps: Record, 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 = { @@ -146,6 +184,7 @@ export function toEvents( : {}), } as const; if (item.kind === "message") { + if (item.invoked) asker = item.author; return { ...base, sender: { @@ -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; }); @@ -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); diff --git a/docs/architecture.md b/docs/architecture.md index 4b8ca99f..2ceefa10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/packages/ui/src/components/timeline/timeline.tsx b/packages/ui/src/components/timeline/timeline.tsx index 1752d3f0..1da4eaec 100644 --- a/packages/ui/src/components/timeline/timeline.tsx +++ b/packages/ui/src/components/timeline/timeline.tsx @@ -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; @@ -694,6 +695,7 @@ function frame( workingSettling, label, name, + credit, open, hasByline, own, @@ -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 && ( @@ -844,6 +846,11 @@ function GroupByline({ state }: { state: GroupState }) { {name} )} + {credit && ( + + {credit} + + )} {agent && ( Date: Thu, 22 Oct 2026 16:27:49 -0400 Subject: [PATCH 2/3] chore: record lane notes for teammate run credit --- ...YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md | 6 +++--- ...J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md | 5 +++-- ...5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md | 5 +++-- ...Z0MYCGYFJFDF7MQ7VKM91D-access-copy-must-not-imply-t.md | 6 +++--- ...JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md | 8 ++++++++ 5 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 .lane/memory/apps/app/src/pulse.ts/01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md index bb1e1719..6eb5da72 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md b/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md index 49584821..82021425 100644 --- a/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md +++ b/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md b/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md index 0a9dc39c..fcbf6acd 100644 --- a/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md +++ b/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/channel-access.tsx/01M4Z0MYCGYFJFDF7MQ7VKM91D-access-copy-must-not-imply-t.md b/.lane/memory/apps/app/src/channel-access.tsx/01M4Z0MYCGYFJFDF7MQ7VKM91D-access-copy-must-not-imply-t.md index 65752d42..bccd3b01 100644 --- a/.lane/memory/apps/app/src/channel-access.tsx/01M4Z0MYCGYFJFDF7MQ7VKM91D-access-copy-must-not-imply-t.md +++ b/.lane/memory/apps/app/src/channel-access.tsx/01M4Z0MYCGYFJFDF7MQ7VKM91D-access-copy-must-not-imply-t.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/pulse.ts/01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md b/.lane/memory/apps/app/src/pulse.ts/01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md new file mode 100644 index 00000000..cfee88c4 --- /dev/null +++ b/.lane/memory/apps/app/src/pulse.ts/01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md @@ -0,0 +1,8 @@ +--- +id: 01M5JMRG2XHCM6WY83SQ38Z626 +anchor: function pulse +created: 2026-10-22T20:27:38Z +norm: '1' +--- + +a run end belongs to ran.author, not the channel owner; guests run agents in others' channels From ca84b5f595877abf3ac1e1c71805de398d0887bd Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 16:27:50 -0400 Subject: [PATCH 3/3] lane: sync fix-who-ran memory --- ...01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.lane/memory/apps/app/src/pulse.ts/01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md b/.lane/memory/apps/app/src/pulse.ts/01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md index cfee88c4..76b8a249 100644 --- a/.lane/memory/apps/app/src/pulse.ts/01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md +++ b/.lane/memory/apps/app/src/pulse.ts/01M5JMRG2XHCM6WY83SQ38Z626-a-run-end-belongs-to-ran-aut.md @@ -3,6 +3,10 @@ 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