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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ new version heading in the same commit.

## [Unreleased]

## [0.449.0] - 2026-09-30
### Added
- **Pin agents to the top of the Agents list.** Each member can pin the agents they use most; pinned
agents lift out of their category into a leading "Pinned" group (in pin order), in both the gallery
and list views, and become the default selection on a bare `#/agents` when you have no last-used
agent. Toggle from the hover pin on any row/card or the pin button in the agent's composer header.
Stored per member in `member_prefs.agentPins` (beside `navPins`), shipped on `/api/auth/me`, saved via
`PUT /api/me/agent-pins`. Display-only — pinning grants nothing.
**For users:** You can now pin your go-to agents so they always sit at the top of the Agents list. [Open Agents](#/agents)

## [0.448.7] - 2026-09-29
### Fixed
- **The gate now reads the scripts an agent runs, not just the command that runs them.** On globex a
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.448.7",
"version": "0.449.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
16 changes: 15 additions & 1 deletion src/governance/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { randomBytes } from 'crypto';
import { newId } from '../id';
import { Db } from '../state/db';
import { AgentAccess, Member, MemberIdentity, IdentityProvider, Role, ApprovalLevel, canApprove, NotificationPrefs, sanitizeNotificationPrefs, sanitizeNavPins, PromptShortcut, sanitizePromptShortcuts } from '../types';
import { AgentAccess, Member, MemberIdentity, IdentityProvider, Role, ApprovalLevel, canApprove, NotificationPrefs, sanitizeNotificationPrefs, sanitizeNavPins, sanitizeAgentPins, PromptShortcut, sanitizePromptShortcuts } from '../types';

const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // magic links valid for 7 days
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // login cookie valid for 30 days
Expand Down Expand Up @@ -123,6 +123,20 @@ export class TeamStore {
return clean;
}

/** The agents this member pinned to the top of their Agents list, in pin order ([] when never set).
* See `sanitizeAgentPins`. */
agentPins(memberId: string): string[] {
return sanitizeAgentPins(this.rawPrefs(memberId).agentPins);
}

/** Persist the member's pinned agents (sanitized, deduped, order kept), preserving sibling prefs in
* the same blob. Returns the resolved list. */
setAgentPins(memberId: string, pins: unknown): string[] {
const clean = sanitizeAgentPins(pins);
this.writeRawPrefs(memberId, { ...this.rawPrefs(memberId), agentPins: clean });
return clean;
}

/** This member's saved prompt shortcuts (the Quick Shortcuts strip in the terminal). Personal, stored
* alongside the rest of their prefs; empty list when never set. */
promptShortcuts(memberId: string): PromptShortcut[] {
Expand Down
11 changes: 9 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,9 +696,9 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
const headers: Record<string, string> = { 'content-type': 'application/json; charset=utf-8' };
if (sid) headers['set-cookie'] = sessionCookie(sid);
res.writeHead(200, headers);
// navPins rides along on the auth payload (not a separate fetch) so the sidebar's pinned layout is
// navPins + agentPins ride along on the auth payload (not a separate fetch) so the sidebar's pinned layout is
// known at first shell paint — no flash of the default nav before a follow-up request lands.
res.end(JSON.stringify({ member: m, navPins: os.team.navPins(m.id) }));
res.end(JSON.stringify({ member: m, navPins: os.team.navPins(m.id), agentPins: os.team.agentPins(m.id) }));
return;
}
// Per-tenant console branding (accent colour + favicon badge). PUBLIC + display-only (no secrets):
Expand Down Expand Up @@ -3887,6 +3887,13 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
return sendJson(res, 200, { pinned: os.team.setNavPins(me.id, (b as { pinned?: unknown }).pinned) });
}

// This member's pinned agents (floated to the top of their Agents list). Per person, not admin-gated;
// display-only — pinning an agent you can't run grants nothing. Initial value ships on /api/auth/me.
if (method === 'PUT' && p === '/api/me/agent-pins') {
const b = await readBody(req);
return sendJson(res, 200, { pinned: os.team.setAgentPins(me.id, (b as { pinned?: unknown }).pinned) });
}

// Dismiss the whole Activity feed at once (soft hide). Leaves action-required items (pending
// approvals/questions, waiting notifications) in place. Same per-viewer visibility as the feed.
if (method === 'POST' && p === '/api/messages/dismiss-all') {
Expand Down
19 changes: 19 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,25 @@ export function sanitizeNavPins(input: unknown): string[] | null {
return out;
}

/**
* Which agents a member has pinned to the top of their Agents list — a per-member preference stored in
* `member_prefs` beside `navPins`. Values are agent ids in the member's chosen order; the server only
* validates shape (short, deduped strings) and never prunes ids of agents that were since deleted or
* unshared — the console simply skips an id it can't see, so a re-shared agent comes back pinned.
* Personal display state only: pinning grants nothing.
*/
export function sanitizeAgentPins(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const out: string[] = [];
for (const v of input) {
if (typeof v !== 'string') continue;
const s = v.trim();
if (s && s.length <= 128 && !out.includes(s)) out.push(s);
if (out.length >= 100) break;
}
return out;
}

/**
* A member-defined prompt shortcut — a named canned prompt they can fire into a live terminal session
* with one click (the console's Quick Shortcuts strip). Purely a personal convenience stored in
Expand Down
61 changes: 53 additions & 8 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,26 @@ type AgentsView = 'grid' | 'split'

/** Bucket agents by their category label for the grouped picker. Uncategorised agents fall into a
* trailing "Uncategorized" group; named categories sort alphabetically, each group keeping list order. */
/** Header of the Agents list's leading group — the member's pinned agents, lifted out of their categories. */
const PINNED_GROUP = 'Pinned'

/** Hover pin toggle overlaid on an agent row/card. A sibling of the row's link (never nested inside it),
* so clicking it doesn't navigate. Hover-only: the "Pinned" group header already shows the state. */
function AgentPinButton({ pinned, label, onToggle, className = '' }: { pinned: boolean; label: string; onToggle: () => void; className?: string }) {
return (
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onToggle() }}
className={`absolute rounded p-1 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 opacity-0 ${pinned ? 'text-primary' : 'text-muted-foreground'} ${className}`}
title={pinned ? 'Unpin — return it to its category' : 'Pin to the top of your agent list'}
aria-label={pinned ? `Unpin ${label}` : `Pin ${label}`}
aria-pressed={pinned}
>
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
)
}

function groupByCategory(agents: AgentInfo[]): [string, AgentInfo[]][] {
const UNCATEGORIZED = 'Uncategorized'
const buckets = new Map<string, AgentInfo[]>()
Expand Down Expand Up @@ -2602,6 +2622,15 @@ function AgentsPage({
const [view, setView] = useState<AgentsView>(() => (localStorage.getItem(AGENTS_VIEW_KEY) === 'grid' ? 'grid' : 'split'))
const setViewPersist = (v: AgentsView) => { setView(v); localStorage.setItem(AGENTS_VIEW_KEY, v) }
const [query, setQuery] = useState('')
// Per-member pinned agents — floated into a "Pinned" group above the categories, in pin order.
// Seeded from /api/auth/me; toggled optimistically and saved (a failed save rolls back).
const [pins, setPins] = useState<string[]>(() => me.agentPins ?? [])
const togglePin = (id: string) => {
const prev = pins
const next = pins.includes(id) ? pins.filter((x) => x !== id) : [...pins, id]
setPins(next)
api.saveAgentPins(next).then((r) => setPins(r.pinned)).catch(() => setPins(prev))
}
// Fleet-wide maturity, keyed by agent id — the trust-at-a-glance signal on each agent chip.
const [maturity, setMaturity] = useState<Record<string, AgentStats>>({})
useEffect(() => {
Expand Down Expand Up @@ -2647,11 +2676,12 @@ function AgentsPage({
}, [selected])

// The chosen agent is driven by the URL (`#/agents/<id>`) so a refresh keeps it. When the URL names
// no agent (a bare `#/agents`), fall back to the last one you used (remembered across visits) then
// the first in the list — without rewriting the URL, so the default doesn't spam history.
// no agent (a bare `#/agents`), fall back to the last one you used (remembered across visits), then
// your first pinned agent, then the first in the list — without rewriting the URL, so the default doesn't spam history.
const has = (id: string) => agents.some((a) => a.id === id)
const lastUsed = localStorage.getItem(LAST_AGENT_KEY)
const agentId = has(selected) ? selected : (lastUsed && has(lastUsed) ? lastUsed : (agents[0]?.id ?? ''))
const firstPinned = pins.find(has)
const agentId = has(selected) ? selected : (lastUsed && has(lastUsed) ? lastUsed : (firstPinned ?? agents[0]?.id ?? ''))
const agent = agents.find((a) => a.id === agentId)
const pick = (id: string) => { localStorage.setItem(LAST_AGENT_KEY, id); onSelect(id) }

Expand Down Expand Up @@ -2719,7 +2749,13 @@ function AgentsPage({
const filtered = q
? agents.filter((a) => a.id.toLowerCase().includes(q) || (a.description ?? '').toLowerCase().includes(q) || (a.category ?? '').toLowerCase().includes(q))
: agents
const groups = groupByCategory(filtered)
// Pinned agents lift out of their category into a leading "Pinned" group (pin order); an id for an
// agent that's gone or no longer shared with you is simply skipped.
const pinnedList = pins.map((id) => filtered.find((a) => a.id === id)).filter((a): a is AgentInfo => !!a)
const groups: [string, AgentInfo[]][] = [
...(pinnedList.length ? [[PINNED_GROUP, pinnedList] as [string, AgentInfo[]]] : []),
...groupByCategory(filtered.filter((a) => !pins.includes(a.id))),
]

// The task composer for the selected agent — shared by both layouts (the gallery puts it below the
// cards; the split view puts it in the right pane). Its per-agent Edit/Delete actions live here.
Expand All @@ -2733,6 +2769,9 @@ function AgentsPage({
<RuntimeBadge runtime={agent.runtime} />
{agent.builtIn && <BuiltInBadge />}
<div className="ml-auto flex items-center gap-1">
<Button size="icon" variant="ghost" className={'h-8 w-8 shrink-0 ' + (pins.includes(agent.id) ? 'text-primary' : 'text-muted-foreground')} onClick={() => togglePin(agent.id)} title={pins.includes(agent.id) ? 'unpin — return it to its category' : 'pin to the top of your agent list'} aria-pressed={pins.includes(agent.id)}>
{pins.includes(agent.id) ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</Button>
{(proposalCounts[agent.id] ?? 0) > 0 && (
<Button
render={<a href={navHref('agent', agent.id)} />}
Expand Down Expand Up @@ -2850,12 +2889,13 @@ function AgentsPage({
{groups.length === 0 && <p className="text-sm text-muted-foreground">No agents match “{query}”.</p>}
{groups.map(([cat, list]) => (
<div key={cat} className="space-y-1.5">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{cat}</div>
<div className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{cat === PINNED_GROUP && <Pin className="h-3 w-3" />}{cat}</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{list.map((a) => {
const active = a.id === agentId
return (
<a key={a.id} href={navHref('agents', a.id)} onClick={onNavClick(() => pick(a.id))} className={'flex flex-col gap-1.5 rounded-lg border p-3 text-left text-foreground no-underline transition hover:border-primary/40 hover:bg-muted/40 ' + (active ? 'border-primary bg-primary/5 ring-1 ring-primary' : '')}>
<div key={a.id} className="group relative">
<a href={navHref('agents', a.id)} onClick={onNavClick(() => pick(a.id))} className={'flex h-full flex-col gap-1.5 rounded-lg border p-3 pr-8 text-left text-foreground no-underline transition hover:border-primary/40 hover:bg-muted/40 ' + (active ? 'border-primary bg-primary/5 ring-1 ring-primary' : '')}>
<span className="flex items-center gap-1.5">
<AgentIcon icon={a.icon} className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">{a.id}</span>
Expand All @@ -2869,6 +2909,8 @@ function AgentsPage({
</span>
{a.description && <span className="line-clamp-2 text-[11px] text-muted-foreground">{a.description}</span>}
</a>
<AgentPinButton pinned={pins.includes(a.id)} label={a.id} onToggle={() => togglePin(a.id)} className="right-1.5 top-1.5" />
</div>
)
})}
</div>
Expand All @@ -2885,11 +2927,12 @@ function AgentsPage({
{groups.length === 0 && <p className="px-1 text-sm text-muted-foreground">No matches.</p>}
{groups.map(([cat, list]) => (
<div key={cat} className="space-y-0.5">
<div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{cat}</div>
<div className="flex items-center gap-1 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{cat === PINNED_GROUP && <Pin className="h-3 w-3" />}{cat}</div>
{list.map((a) => {
const active = a.id === agentId
return (
<a key={a.id} href={navHref('agents', a.id)} onClick={onNavClick(() => pick(a.id))} title={a.description} className={'flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-sm no-underline transition ' + (active ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground')}>
<div key={a.id} className="group relative">
<a href={navHref('agents', a.id)} onClick={onNavClick(() => pick(a.id))} title={a.description} className={'flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-sm no-underline transition ' + (active ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground')}>
<AgentIcon icon={a.icon} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{a.id}</span>
<span className="ml-auto flex shrink-0 items-center gap-1">
Expand All @@ -2899,6 +2942,8 @@ function AgentsPage({
{a.builtIn && <BuiltInBadge />}
</span>
</a>
<AgentPinButton pinned={pins.includes(a.id)} label={a.id} onToggle={() => togglePin(a.id)} className="right-1 top-1/2 -translate-y-1/2 bg-background" />
</div>
)
})}
</div>
Expand Down
7 changes: 6 additions & 1 deletion web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export interface Member {
* /api/auth/me for `me`, never populated for other members. `null`/absent → apply the default layout;
* `[]` → the member explicitly pinned nothing. */
navPins?: string[] | null
/** Agent ids this member pinned to the top of their Agents list, in pin order. Client-only, like
* `navPins`: delivered on /api/auth/me for `me`. */
agentPins?: string[]
}
/** A member-defined canned prompt fired into a live session from the Quick Shortcuts strip. */
export interface PromptShortcut {
Expand Down Expand Up @@ -1926,7 +1929,7 @@ export const api = {
const body = await res.json()
// navPins ships beside `member` on this payload — fold it onto the member so the sidebar has the
// pinned layout at first paint without a second request.
return { ...(body.member as Member), navPins: body.navPins ?? null }
return { ...(body.member as Member), navPins: body.navPins ?? null, agentPins: body.agentPins ?? [] }
},
logout: () => call<{ ok: boolean }>('POST', '/api/auth/logout'),
/** Self-service recovery: ask the server to send a fresh sign-in link. Always resolves ok (neutral
Expand Down Expand Up @@ -2071,6 +2074,8 @@ export const api = {
saveMyContext: (context: string) => call<{ context: string }>('PUT', '/api/me/context', { context }),
/** Persist this member's pinned sidebar nav (the keys promoted to Main). Returns the resolved list. */
saveNavPins: (pinned: string[]) => call<{ pinned: string[] }>('PUT', '/api/me/nav', { pinned }),
/** Persist this member's pinned agents (floated to the top of the Agents list), in order. */
saveAgentPins: (pinned: string[]) => call<{ pinned: string[] }>('PUT', '/api/me/agent-pins', { pinned }),
/** This member's saved Quick Shortcuts (canned prompts for a live terminal session). */
promptShortcuts: () => call<{ shortcuts: PromptShortcut[] }>('GET', '/api/me/shortcuts'),
savePromptShortcuts: (shortcuts: PromptShortcut[]) => call<{ shortcuts: PromptShortcut[] }>('PUT', '/api/me/shortcuts', { shortcuts }),
Expand Down
Loading