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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,23 @@ new version heading in the same commit.

## [Unreleased]

## [0.226.0] — 2026-07-16
## [0.227.0] — 2026-07-17
### Added
- **Slack chat IDs now auto-link from a member's email.** When a notification (task assignment, approval,
question, session event, …) needs to DM a member who has **no linked Slack handle**, `deliverDM`
(`tenant-registry.ts`) now looks their Slack user up by their (verified) account email via
`users.lookupByEmail` (new `SlackSocket.userIdForEmail`, in-process hit/miss cache), **DMs them, and
persists the discovered `U…` id to the identity map** (`created_by = auto:slack-email`, audited
`identity.autolinked`). So the first notification reaches an unlinked-but-in-Slack member AND every
later run-as/DM lookup is already resolved — no manual **Team → Chat IDs** step. Slack only: Discord
exposes no email, so an unlinked Discord member stays manual (see below). Needs the `users:read.email`
bot scope (already required for the reverse run-as lookup).
- **"Complete your profile" checklist on the Profile page.** A live, self-dismissing card at the top of
**Profile** derived from what the member has already filled in — profile picture, a linked chat account
(Slack/Discord), a connected GitHub, and their working context. Each unfinished step shows a one-line
why and **scroll-jumps** to the relevant section below; the card hides once all four are done. This is
the manual fallback for what auto-link can't cover — chiefly **Discord**, which has no email to resolve
from — nudging members to link it themselves so task/approval DMs reach them.
### Added
- **Distraction-free terminal + "Pop out" to its own tab.** The individual terminal view gets two new
affordances in its top-right toolbar. **Focus** (⤢) lifts the pane to a full-viewport overlay (`fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.226.0",
"version": "0.227.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand Down
22 changes: 22 additions & 0 deletions src/edge/slack-socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export class SlackSocket {
private generation = 0; // bumped on every (re)start so a stale socket's handlers no-op
/** slack user id → resolved email, cached for the process to avoid hammering users.info. */
private readonly emailCache = new Map<string, string>();
/** email → slack user id (or null when the address isn't in the workspace), cached for the process.
* Backs the identity-map auto-link so a notification never re-queries users.lookupByEmail per send. */
private readonly userByEmailCache = new Map<string, string | null>();
private lastError = '';

constructor(
Expand Down Expand Up @@ -351,4 +354,23 @@ export class SlackSocket {
this.emailCache.set(userId, email);
return email;
}

/**
* Resolve a team member's Slack user id from their email (`users.lookupByEmail`) — the discovery half
* of the identity-map auto-link. Both hits and misses are cached in-process, so an unlinked-and-absent
* member is queried at most once per process, not on every notification. Returns null when Slack isn't
* configured or the address isn't in the workspace. Best-effort; never throws.
*/
async userIdForEmail(email: string): Promise<string | null> {
const key = (email || '').trim().toLowerCase();
if (!key) return null;
const token = this.os.settings.slackBotToken();
if (!token) return null;
const cached = this.userByEmailCache.get(key);
if (cached !== undefined) return cached;
const found = await lookupUserByEmail(token, key);
const uid = 'error' in found ? null : found.user;
this.userByEmailCache.set(key, uid);
return uid;
}
}
32 changes: 22 additions & 10 deletions src/tenant-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,15 +314,27 @@ export class TenantRegistry {
* callers pass an already-resolved set (see {@link resolveRecipients}) so WHO and HOW-to-reach stay
* separate concerns.
*/
async function deliverDM(slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, os: AgentOS, recipients: Member[], text: string | ((platform: ChatPlatform) => string)): Promise<number> {
async function deliverDM(slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, os: AgentOS, recipients: Member[], text: string | ((platform: ChatPlatform) => string)): Promise<number> {
// `text` may be a per-platform builder so a message can carry a masked deep-link, whose syntax differs
// between Slack mrkdwn (`<url|label>`) and Discord markdown (`[label](url)`). A plain string is sent as-is.
const render = (platform: ChatPlatform) => (typeof text === 'function' ? text(platform) : text);
let dms = 0;
for (const m of recipients) {
const ids = os.team.externalIdsFor(m.id);
const slackId = ids.find((i) => i.provider === 'slack')?.externalId;
let slackId = ids.find((i) => i.provider === 'slack')?.externalId;
const discordId = ids.find((i) => i.provider === 'discord')?.externalId;
// No linked Slack handle? Discover it from the member's (verified) email via users.lookupByEmail and
// persist it to the identity map, so this reaches them now AND never needs the lookup again. Slack is
// the only platform we can auto-link — Discord exposes no email, so an unlinked Discord member stays
// manual (the "Complete your profile" nudge asks them to link it). Discord has no email fallback.
if (!slackId && m.email) {
const found = await slack.userIdForEmail(m.email);
if (found) {
os.team.setIdentity(m.id, 'slack', found, 'auto:slack-email');
os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: 'system', type: 'identity.autolinked', data: { member: m.id, provider: 'slack', externalId: found } });
slackId = found;
}
}
if (slackId && (await slack.dmUser(slackId, render('slack'))).ok) dms++;
if (discordId && (await discord.dmUser(discordId, render('discord'))).ok) dms++;
}
Expand All @@ -335,7 +347,7 @@ async function deliverDM(slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<Disco
* /api/auth/request-link`). Best-effort; the caller ALSO logs the link to server.log so an owner with
* box access can always recover even with no chat identity linked. Returns the delivered-DM count.
*/
export async function notifyLoginLink(os: AgentOS, slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, member: Member, link: string): Promise<number> {
export async function notifyLoginLink(os: AgentOS, slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, member: Member, link: string): Promise<number> {
const text =
`🔑 Your Agent OS sign-in link (valid 7 days, single use):\n${link}\n` +
`If you didn't request this, you can ignore it — the link is harmless until it's opened.`;
Expand All @@ -350,7 +362,7 @@ export async function notifyLoginLink(os: AgentOS, slack: Pick<SlackSocket, 'dmU
* (`canApprove(role, level)`); `deliverDM` reaches them via the identity map. Off the gate's hot path
* (the caller fires-and-forgets). Audited once.
*/
export async function notifyApprovers(os: AgentOS, slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, notice: ApprovalNotice): Promise<void> {
export async function notifyApprovers(os: AgentOS, slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, notice: ApprovalNotice): Promise<void> {
// Route to the SAME audience the inbox card uses (approvalAudience): the session owner alone when they
// can clear this level (an admin self-approving their own run), else the full approver tier — so we
// stop DMing every admin about every other admin's self-approvable session.
Expand All @@ -373,7 +385,7 @@ export async function notifyApprovers(os: AgentOS, slack: Pick<SlackSocket, 'dmU
* else a member who spawned it); if the run has no human owner (a pure automation), falls back to the
* `admins` audience so the question still reaches someone. Best-effort, off the ask hot path. Audited once.
*/
export async function notifyQuestionAsked(os: AgentOS, tm: Pick<TerminalManager, 'bindQuestionDm'>, slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, notice: QuestionNotice): Promise<void> {
export async function notifyQuestionAsked(os: AgentOS, tm: Pick<TerminalManager, 'bindQuestionDm'>, slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, notice: QuestionNotice): Promise<void> {
// A question `ask`ed to a SPECIFIC teammate DMs that member; otherwise the run's operator.
let targets = resolveRecipients(os, notice.to ? { kind: 'member', id: notice.to } : { kind: 'sessionOwner', id: notice.sessionId });
if (!targets.length) targets = resolveRecipients(os, { kind: 'admins' });
Expand Down Expand Up @@ -401,7 +413,7 @@ export async function notifyQuestionAsked(os: AgentOS, tm: Pick<TerminalManager,
* an owner-less (or deleted-owner) task falls back to the `admins` audience so the miss still reaches
* someone. Best-effort, fired once per task from the scheduler sweep (the once-guard lives in the DB).
*/
export async function notifyTaskOverdue(os: AgentOS, slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, task: Task): Promise<void> {
export async function notifyTaskOverdue(os: AgentOS, slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, task: Task): Promise<void> {
let targets = task.owner ? resolveRecipients(os, { kind: 'member', id: task.owner }) : [];
if (!targets.length) targets = resolveRecipients(os, { kind: 'admins' });
if (!targets.length) return;
Expand Down Expand Up @@ -445,7 +457,7 @@ function taskCard(n: TaskNotice): { audience: Audience; title: string; event: st
* the awaited DM) so it's durable even if the process exits right after; the DM is best-effort. Skips
* entirely when the change warrants no card or the only recipient is the actor who made it.
*/
export async function notifyTaskEvent(os: AgentOS, tm: Pick<TerminalManager, 'postTaskCard'>, slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, notice: TaskNotice): Promise<void> {
export async function notifyTaskEvent(os: AgentOS, tm: Pick<TerminalManager, 'postTaskCard'>, slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, notice: TaskNotice): Promise<void> {
const card = taskCard(notice);
if (!card) return;
// Resolve the receiver, then drop the actor themselves — nobody needs a card for their own action.
Expand Down Expand Up @@ -489,7 +501,7 @@ function maybePokeCaller(autos: Automations, os: AgentOS, notice: TaskNotice): v
* DM the admins about a proactive insight alert (the Inbox card is posted separately by the tick). The
* intelligence layer coming to the human — a struggling agent, a capability that keeps getting rejected.
*/
export async function notifyInsightAlert(os: AgentOS, slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, alert: InsightAlert): Promise<void> {
export async function notifyInsightAlert(os: AgentOS, slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, alert: InsightAlert): Promise<void> {
const recipients = resolveRecipients(os, { kind: 'admins' });
if (!recipients.length) return;
const url = consolePage(consoleOrigin, 'insights');
Expand All @@ -504,7 +516,7 @@ export async function notifyInsightAlert(os: AgentOS, slack: Pick<SlackSocket, '
* written (addressed to that member) by {@link TerminalManager.notifyMember}; this is the out-of-band
* push to their linked Slack/Discord. Single named recipient — never a broadcast. Best-effort, audited.
*/
export async function notifyMember(os: AgentOS, slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, notice: MemberNotice): Promise<void> {
export async function notifyMember(os: AgentOS, slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, notice: MemberNotice): Promise<void> {
const targets = resolveRecipients(os, { kind: 'member', id: notice.to });
if (!targets.length) return;
const bell = notice.important ? '❗' : '📨';
Expand All @@ -524,7 +536,7 @@ export async function notifyMember(os: AgentOS, slack: Pick<SlackSocket, 'dmUser
* instead of failing silently (the largest silent class before this).
* Best-effort, audited.
*/
export async function notifySessionEvent(os: AgentOS, slack: Pick<SlackSocket, 'dmUser'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, notice: SessionEventNotice): Promise<void> {
export async function notifySessionEvent(os: AgentOS, slack: Pick<SlackSocket, 'dmUser' | 'userIdForEmail'>, discord: Pick<DiscordSocket, 'dmUser'>, consoleOrigin: string, notice: SessionEventNotice): Promise<void> {
const owner = resolveRecipients(os, { kind: 'sessionOwner', id: notice.sessionId });
const targets = notice.kind === 'crashed'
? (owner.length ? owner : resolveRecipients(os, { kind: 'admins' }))
Expand Down
60 changes: 56 additions & 4 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5602,10 +5602,27 @@ function ProfilePage({ me, prefs, onSavePrefs, onProfileChange }: {
const toggleEvent = (k: NotifyKind) => onSavePrefs({ ...prefs, events: { ...prefs.events, [k]: !prefs.events[k] } })
const dirty = context !== savedContext

// Profile completeness — nudge the member to fill the parts we can't derive for them. Slack now
// auto-links from their email on the first notification, so what's left is genuinely manual: a
// picture, their Discord/chat handle, GitHub, and their working context. Read from already-loaded
// state (savedContext = the persisted value, identities = their linked handles).
const hasAvatar = !!me.avatar
const hasContext = savedContext.trim().length > 0
const hasGithub = identities.some((i) => i.provider === 'github')
const hasChat = identities.some((i) => i.provider === 'slack' || i.provider === 'discord')
const steps = [
{ key: 'avatar', done: hasAvatar, label: 'Add a profile picture', hint: 'So teammates recognise you across sessions and the inbox.', to: 'pf-avatar' },
{ key: 'chat', done: hasChat, label: 'Link a chat account (Slack / Discord)', hint: 'A message from you runs its agent as you — and task & approval DMs reach you.', to: 'pf-chat' },
{ key: 'git', done: hasGithub, label: 'Connect your GitHub', hint: 'Agents commit and open PRs under your name, not a shared bot.', to: 'pf-git' },
{ key: 'context', done: hasContext, label: 'Add your working context', hint: 'Standing preferences injected into every session that runs as you.', to: 'pf-context' },
]
const doneCount = steps.filter((s) => s.done).length
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })

return (
<div className="max-w-3xl space-y-8">
{/* Identity */}
<section className="flex items-center gap-4">
<section id="pf-avatar" className="flex items-center gap-4 scroll-mt-4">
<EditableAvatar member={me} canEdit sizeClass="h-16 w-16 text-xl" onChanged={onProfileChange} />
<div className="min-w-0">
<div className="flex items-center gap-2 text-lg font-semibold">
Expand All @@ -5616,8 +5633,43 @@ function ProfilePage({ me, prefs, onSavePrefs, onProfileChange }: {
</div>
</section>

{/* Complete your profile — a live checklist over the sections below. Hidden once every step is done. */}
{doneCount < steps.length && (
<Card>
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold"><Sparkles className="h-4 w-4 text-sky-500" /> Complete your profile</div>
<span className="text-xs text-muted-foreground">{doneCount} of {steps.length} done</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-sky-500 transition-all" style={{ width: `${Math.round((doneCount / steps.length) * 100)}%` }} />
</div>
<div className="space-y-1">
{steps.map((s) => (
<button
key={s.key}
type="button"
onClick={() => !s.done && jump(s.to)}
disabled={s.done}
className={`flex w-full items-start gap-2.5 rounded-md px-2 py-1.5 text-left ${s.done ? 'cursor-default opacity-60' : 'hover:bg-muted/60'}`}
>
{s.done
? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
: <span className="mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 border-muted-foreground/40" />}
<span className="min-w-0 flex-1">
<span className={`block text-sm ${s.done ? 'text-muted-foreground line-through' : 'font-medium'}`}>{s.label}</span>
{!s.done && <span className="block text-xs text-muted-foreground">{s.hint}</span>}
</span>
{!s.done && <ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />}
</button>
))}
</div>
</CardContent>
</Card>
)}

{/* Personal context (the new feature) */}
<section className="space-y-2">
<section id="pf-context" className="space-y-2 scroll-mt-4">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">My context</div>
<p className="text-sm text-muted-foreground">
Free text added to the system prompt of every session that runs <strong>as you</strong> — your
Expand Down Expand Up @@ -5670,7 +5722,7 @@ function ProfilePage({ me, prefs, onSavePrefs, onProfileChange }: {
</section>

{/* Git identity — connect your own GitHub (OAuth). Reuses the same card as Connections → Mine. */}
<section className="space-y-2">
<section id="pf-git" className="space-y-2 scroll-mt-4">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">My git identity</div>
<p className="text-sm text-muted-foreground">
Connect your GitHub so agents running as <strong>you</strong> commit and open PRs under your name
Expand All @@ -5680,7 +5732,7 @@ function ProfilePage({ me, prefs, onSavePrefs, onProfileChange }: {
</section>

{/* Chat identities — own handles (self-service) */}
<section className="space-y-2">
<section id="pf-chat" className="space-y-2 scroll-mt-4">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">My chat identities</div>
<p className="text-sm text-muted-foreground">
Link your own Slack / Discord / email / GitHub handles so a chat message from you runs its agent
Expand Down
Loading