diff --git a/CHANGELOG.md b/CHANGELOG.md index c071d57..36efb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 0a4c070..53ec4ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.226.0", + "version": "0.227.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.226.0", + "version": "0.227.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 654860d..24b4e96 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/edge/slack-socket.ts b/src/edge/slack-socket.ts index 2ab6f25..78f3fe6 100644 --- a/src/edge/slack-socket.ts +++ b/src/edge/slack-socket.ts @@ -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(); + /** 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(); private lastError = ''; constructor( @@ -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 { + 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; + } } diff --git a/src/tenant-registry.ts b/src/tenant-registry.ts index d09d8d4..ad58fe2 100644 --- a/src/tenant-registry.ts +++ b/src/tenant-registry.ts @@ -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, discord: Pick, os: AgentOS, recipients: Member[], text: string | ((platform: ChatPlatform) => string)): Promise { +async function deliverDM(slack: Pick, discord: Pick, os: AgentOS, recipients: Member[], text: string | ((platform: ChatPlatform) => string)): Promise { // `text` may be a per-platform builder so a message can carry a masked deep-link, whose syntax differs // between Slack mrkdwn (``) 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++; } @@ -335,7 +347,7 @@ async function deliverDM(slack: Pick, discord: Pick, discord: Pick, member: Member, link: string): Promise { +export async function notifyLoginLink(os: AgentOS, slack: Pick, discord: Pick, member: Member, link: string): Promise { 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.`; @@ -350,7 +362,7 @@ export async function notifyLoginLink(os: AgentOS, slack: Pick, discord: Pick, consoleOrigin: string, notice: ApprovalNotice): Promise { +export async function notifyApprovers(os: AgentOS, slack: Pick, discord: Pick, consoleOrigin: string, notice: ApprovalNotice): Promise { // 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. @@ -373,7 +385,7 @@ export async function notifyApprovers(os: AgentOS, slack: Pick, slack: Pick, discord: Pick, consoleOrigin: string, notice: QuestionNotice): Promise { +export async function notifyQuestionAsked(os: AgentOS, tm: Pick, slack: Pick, discord: Pick, consoleOrigin: string, notice: QuestionNotice): Promise { // 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' }); @@ -401,7 +413,7 @@ export async function notifyQuestionAsked(os: AgentOS, tm: Pick, discord: Pick, consoleOrigin: string, task: Task): Promise { +export async function notifyTaskOverdue(os: AgentOS, slack: Pick, discord: Pick, consoleOrigin: string, task: Task): Promise { let targets = task.owner ? resolveRecipients(os, { kind: 'member', id: task.owner }) : []; if (!targets.length) targets = resolveRecipients(os, { kind: 'admins' }); if (!targets.length) return; @@ -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, slack: Pick, discord: Pick, consoleOrigin: string, notice: TaskNotice): Promise { +export async function notifyTaskEvent(os: AgentOS, tm: Pick, slack: Pick, discord: Pick, consoleOrigin: string, notice: TaskNotice): Promise { const card = taskCard(notice); if (!card) return; // Resolve the receiver, then drop the actor themselves — nobody needs a card for their own action. @@ -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, discord: Pick, consoleOrigin: string, alert: InsightAlert): Promise { +export async function notifyInsightAlert(os: AgentOS, slack: Pick, discord: Pick, consoleOrigin: string, alert: InsightAlert): Promise { const recipients = resolveRecipients(os, { kind: 'admins' }); if (!recipients.length) return; const url = consolePage(consoleOrigin, 'insights'); @@ -504,7 +516,7 @@ export async function notifyInsightAlert(os: AgentOS, slack: Pick, discord: Pick, notice: MemberNotice): Promise { +export async function notifyMember(os: AgentOS, slack: Pick, discord: Pick, notice: MemberNotice): Promise { const targets = resolveRecipients(os, { kind: 'member', id: notice.to }); if (!targets.length) return; const bell = notice.important ? '❗' : '📨'; @@ -524,7 +536,7 @@ export async function notifyMember(os: AgentOS, slack: Pick, discord: Pick, consoleOrigin: string, notice: SessionEventNotice): Promise { +export async function notifySessionEvent(os: AgentOS, slack: Pick, discord: Pick, consoleOrigin: string, notice: SessionEventNotice): Promise { const owner = resolveRecipients(os, { kind: 'sessionOwner', id: notice.sessionId }); const targets = notice.kind === 'crashed' ? (owner.length ? owner : resolveRecipients(os, { kind: 'admins' })) diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ecebb8..6a86078 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 (
{/* Identity */} -
+
@@ -5616,8 +5633,43 @@ function ProfilePage({ me, prefs, onSavePrefs, onProfileChange }: {
+ {/* Complete your profile — a live checklist over the sections below. Hidden once every step is done. */} + {doneCount < steps.length && ( + + +
+
Complete your profile
+ {doneCount} of {steps.length} done +
+
+
+
+
+ {steps.map((s) => ( + + ))} +
+ + + )} + {/* Personal context (the new feature) */} -
+
My context

Free text added to the system prompt of every session that runs as you — your @@ -5670,7 +5722,7 @@ function ProfilePage({ me, prefs, onSavePrefs, onProfileChange }: {

{/* Git identity — connect your own GitHub (OAuth). Reuses the same card as Connections → Mine. */} -
+
My git identity

Connect your GitHub so agents running as you commit and open PRs under your name @@ -5680,7 +5732,7 @@ function ProfilePage({ me, prefs, onSavePrefs, onProfileChange }: {

{/* Chat identities — own handles (self-service) */} -
+
My chat identities

Link your own Slack / Discord / email / GitHub handles so a chat message from you runs its agent