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

## [Unreleased]

## [0.334.0] — 2026-08-10
### Added
- **A bare entity id is now a link wherever it is displayed.** Agents constantly reference a row by its
id in prose ("tsk_dcde91f9e81af705 is in progress with an engineer") — which left the reader holding a
token they could only follow by hand-searching a list. `tsk_…`, `goal_…` and `art_…` now render as
clickable links to that task / goal / deliverable in rendered markdown (including an id written in
`backticks`, which is how agents usually write one), in raw-text surfaces like Inbox cards and memory
notes, and **in the terminal itself** — the xterm linkifier gained an entity-id matcher alongside its
URL ones, so an id printed in an agent's narration is clickable in the live pane. In-app markdown links
(`#/…`) also stop opening a pointless new tab. Session ids are deliberately excluded: a session's route
is keyed by its tmux name, not `ses_…`.

## [0.333.0] — 2026-08-10
### Added
- **A task room's Discussion is now a two-way channel to the agent actually working the task.** It used to
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.333.0",
"version": "0.334.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
51 changes: 38 additions & 13 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
import { api, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type AgentInfo, type Session, type Msg, type Member, type Role, type TeamResp, type AgentAccess, type MemberIdentity, type IdentityProvider, IDENTITY_PROVIDERS, type Automation, type Task, type TaskEvent, type TaskAttachment, type TaskRun, type TaskTimelineEntry, type TaskDiscussionSummary, type TaskDiscussionDelivery, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalStatus, type GoalCounts, type GoalProgress, type AddGoalReq, type MemoryRecord, type MemoryHealth, type MemoryBackend, type MemorySettings, type MemorySettingsReq, type OllamaStatus, type KbPage, type KbRevision, type AgentRevision, type AgentStats, type AgentProposalTrust, type Recommendation, type DigestConfig, type DigestModel, type DreamingState, type Measurement, type Insights, type ImprovementTile, type MemoryCleanupPlan, type KbTidyPlan, type TaskReconcilePlan, type LibraryTidyPlan, type SessionTidyPlan, type StuckGoal, type TroubledAutomation, type PolicyDocument, type PolicyRule, type PolicyOutcome, type PolicyOp, type PolicyProposal, type PolicyRevision, type AutomationProposal, type AgentUpdateProposal, type GoalUpdateProposal, type DirListing, type FileEntry, type FileContent, type Artifact, type AppInfo, type AppFile, type AppCapabilities, type SkillSummary, type SkillsResp, type CatalogSkill, type CatalogAgent, type SkillSource, type RemoteSkill, type SkillshHit, type SkillRequest, type SecretRequest, type IntegrationsResp, type SlackStatus, type DiscordStatus, type TelegramStatus, type AuditEvent, type Effort, type RuntimeTuning, type RuntimeTuningPatch, type Verbosity, type VerbositySavings, type Concurrency, type RuntimeAccount, type RuntimeAccountKind, type RuntimeAccountsResp, type RuntimeLogin, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type ActivityEvent, type ActivitySummaryRow, type SystemMetrics, type DepsReport, type DepStatus, type DepsInstallResult, type ChatTurn, type ChatArtifactRef, type ChatKbRef, type ChatAppRef, type RouterPreviewResp, type RouterCard, type SessionChain, type ChainNode, type ChainPending } from '@/lib/api'
import { type Branding, type PublicBranding, type NotificationPrefs, DEFAULT_NOTIFICATION_PREFS, type PromptShortcut, type SessionMetrics, type Brief, type AutoApproval } from '@/lib/api'
import { applyAccent, applyFavicon, faviconDataUri, readableOn } from '@/lib/branding'
import { ENTITY_ID_SRC, entityHref, isEntityId } from '@/lib/entity-links'
import { ConnectorsPage, GithubMineCard } from '@/connectors'
import { docPages } from '@/docs'
import { Xterm } from './Xterm'
Expand Down Expand Up @@ -4555,7 +4556,7 @@ function ChatBubble({ turn, agentIcon }: { turn: Extract<ChatTurn, { kind: 'user
{mine ? (
<span className="whitespace-pre-wrap">{turn.text}</span>
) : (
<div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1 prose-pre:my-1"><ReactMarkdown remarkPlugins={[remarkGfm]}>{turn.text}</ReactMarkdown></div>
<div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1 prose-pre:my-1"><ReactMarkdown remarkPlugins={[remarkGfm, remarkWikiLinks]}>{turn.text}</ReactMarkdown></div>
)}
</div>
</div>
Expand Down Expand Up @@ -4725,11 +4726,11 @@ function CockpitPage({ sessions, onOpenChat, onOpenTerminal, nav }: {
<span>{preview.source === 'state' ? 'Answered from your workspace' : preview.run ? 'Concierge · looked it up for you' : 'Answered'}</span>
</div>
{preview.answer && (
<div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{preview.answer}</ReactMarkdown></div>
<div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm, remarkWikiLinks]}>{preview.answer}</ReactMarkdown></div>
)}
{preview.run && (
runAnswer
? <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{runAnswer}</ReactMarkdown></div>
? <div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1.5"><ReactMarkdown remarkPlugins={[remarkGfm, remarkWikiLinks]}>{runAnswer}</ReactMarkdown></div>
: <div className="flex items-center gap-2 py-2 text-sm text-muted-foreground"><RefreshCw className="h-3.5 w-3.5 animate-spin" />Looking into it…</div>
)}
<div className="mt-3 flex items-center gap-3 text-xs">
Expand All @@ -4751,7 +4752,7 @@ function CockpitPage({ sessions, onOpenChat, onOpenTerminal, nav }: {
</div>
{runId ? (
runAnswer
? <div className="mt-3 rounded-lg border bg-muted/40 p-3"><div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1"><ReactMarkdown remarkPlugins={[remarkGfm]}>{runAnswer}</ReactMarkdown></div>
? <div className="mt-3 rounded-lg border bg-muted/40 p-3"><div className="prose prose-sm max-w-none dark:prose-invert prose-p:my-1"><ReactMarkdown remarkPlugins={[remarkGfm, remarkWikiLinks]}>{runAnswer}</ReactMarkdown></div>
<div className="mt-2 flex gap-3 text-xs">
<button onClick={() => nav(preview.surface === 'automations' ? 'inbox' : 'tasks')} className="text-muted-foreground underline-offset-2 hover:underline">{preview.surface === 'automations' ? 'Review in Inbox →' : 'Open Tasks →'}</button>
<button onClick={() => onOpenChat(runId)} className="text-muted-foreground underline-offset-2 hover:underline">Open full session →</button>
Expand Down Expand Up @@ -5979,6 +5980,8 @@ const isTextMime = (m: string) => m.startsWith('text/') || m === 'application/js
// the KB page; a bare `[[area]]` (library, tasks, …) → that nav route. Two renderers share the same
// target resolver: `remarkWikiLinks` for full-markdown surfaces (KB/task/goal/artifact/docs) and
// `InlineLinks` for raw-text surfaces (inbox cards, memory notes) that must stay pre-formatted.
// Both also autolink a bare ENTITY ID (`tsk_…`, `goal_…`, `art_…` — see `@/lib/entity-links`), which
// is what agents actually write when they reference a task or goal in prose.

/** Known single-word wiki targets that map to a top-level route rather than a KB page. */
const WIKI_AREA: Record<string, string> = {
Expand All @@ -5996,18 +5999,28 @@ function wikiHref(target: string): string {
return '#/kb/' + inner.split('/').map(encodeURIComponent).join('/')
}

const WIKI_RE = /\[\[([^\]]+?)\]\]/g
// `[[wiki]]` or a bare entity id, in one pass so neither can split the other's match.
const WIKI_RE = new RegExp(String.raw`\[\[([^\]]+?)\]\]` + '|' + `(${ENTITY_ID_SRC})`, 'g')
const HAS_LINKABLE_RE = new RegExp(String.raw`\[\[` + '|' + ENTITY_ID_SRC)

/** remark plugin: rewrite `[[section/slug]]` / `[[section/slug|label]]` into real links, skipping code
* spans and existing links. Runs alongside remark-gfm (which already autolinks bare URLs). */
/** remark plugin: rewrite `[[section/slug]]` / `[[section/slug|label]]` and bare entity ids
* (`tsk_…`) into real links, skipping fenced code and existing links. An entity id written as an
* inline `code` span IS linked — backticks are how agents usually write an id — by wrapping the
* span rather than unwrapping it, so it keeps its monospace look. Runs alongside remark-gfm
* (which already autolinks bare URLs). */
function remarkWikiLinks() {
return (tree: any) => {
const walk = (node: any) => {
if (!node || !Array.isArray(node.children)) return
const out: any[] = []
for (const child of node.children) {
if (child.type === 'inlineCode' && isEntityId(child.value)) {
const href = entityHref(child.value.trim())
out.push(href ? { type: 'link', url: href, children: [child] } : child)
continue
}
if (child.type === 'code' || child.type === 'inlineCode' || child.type === 'link') { out.push(child); continue }
if (child.type === 'text' && child.value.includes('[[')) { out.push(...splitWikiNodes(child.value)); continue }
if (child.type === 'text' && HAS_LINKABLE_RE.test(child.value)) { out.push(...splitWikiNodes(child.value)); continue }
walk(child); out.push(child)
}
node.children = out
Expand All @@ -6022,16 +6035,26 @@ function splitWikiNodes(value: string): any[] {
let m: RegExpExecArray | null
while ((m = WIKI_RE.exec(value))) {
if (m.index > last) nodes.push({ type: 'text', value: value.slice(last, m.index) })
const [target, label] = m[1].split('|')
nodes.push({ type: 'link', url: wikiHref(target), children: [{ type: 'text', value: (label ?? target).trim() }] })
if (m[2] !== undefined) {
const href = entityHref(m[2])
nodes.push(href ? { type: 'link', url: href, children: [{ type: 'text', value: m[2] }] } : { type: 'text', value: m[2] })
} else {
const [target, label] = m[1].split('|')
nodes.push({ type: 'link', url: wikiHref(target), children: [{ type: 'text', value: (label ?? target).trim() }] })
}
last = m.index + m[0].length
}
if (last < value.length) nodes.push({ type: 'text', value: value.slice(last) })
return nodes.length ? nodes : [{ type: 'text', value }]
}

// Markdown link · `[[wiki]]` · bare http(s) URL · in-app `#/route`, in priority order.
const INLINE_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^\s)]+|#\/[^\s)]+)\)|\[\[([^\]]+?)\]\]|(https?:\/\/[^\s<>()]+)|(#\/[A-Za-z0-9/_%-]+)/g
// Markdown link · `[[wiki]]` · bare http(s) URL · in-app `#/route` · bare entity id, in priority order.
const INLINE_LINK_RE = new RegExp(
String.raw`\[([^\]]+)\]\((https?:\/\/[^\s)]+|#\/[^\s)]+)\)`
+ String.raw`|\[\[([^\]]+?)\]\]`
+ String.raw`|(https?:\/\/[^\s<>()]+)`
+ String.raw`|(#\/[A-Za-z0-9/_%-]+)`
+ `|(${ENTITY_ID_SRC})`, 'g')

/** Render a plain string with clickable links (markdown links, `[[wiki]]` refs, bare URLs, in-app
* `#/route` paths) for surfaces that show raw text rather than full markdown — inbox cards, memory
Expand All @@ -6049,6 +6072,7 @@ function InlineLinks({ text }: { text?: string | null }): ReactNode {
else if (m[3] !== undefined) { const [t, l] = m[3].split('|'); href = wikiHref(t); label = (l ?? t).trim() } // [[wiki]]
else if (m[4] !== undefined) { href = label = m[4] } // bare URL
else if (m[5] !== undefined) { href = label = m[5] } // #/route
else if (m[6] !== undefined) { label = m[6]; href = entityHref(m[6]) ?? m[6] } // tsk_… / goal_…
const external = /^https?:/.test(href)
parts.push(
<a key={`il${key++}`} href={href} className="text-primary underline" onClick={(e) => e.stopPropagation()}
Expand All @@ -6069,7 +6093,8 @@ const mdComponents = {
ul: (p: any) => <ul className="my-2 list-disc space-y-1 pl-5" {...p} />,
ol: (p: any) => <ol className="my-2 list-decimal space-y-1 pl-5" {...p} />,
li: (p: any) => <li className="leading-relaxed" {...p} />,
a: (p: any) => <a className="text-primary underline" target="_blank" rel="noreferrer" {...p} />,
// In-app targets (`#/tasks/<id>`, `#/kb/…`) route in place; only real external links get a new tab.
a: ({ href, ...p }: any) => <a href={href} className="text-primary underline" {...(String(href ?? '').startsWith('#') ? {} : { target: '_blank', rel: 'noreferrer' })} {...p} />,
blockquote: (p: any) => <blockquote className="my-2 border-l-2 pl-3 text-muted-foreground" {...p} />,
code: ({ inline, ...p }: any) => inline
? <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" {...p} />
Expand Down
8 changes: 6 additions & 2 deletions web/src/Xterm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { CanvasAddon } from '@xterm/addon-canvas'
import '@xterm/xterm/css/xterm.css'
import { entityIdRe, entityUrl } from '@/lib/entity-links'

// ttyd command bytes.
const C_INPUT = '0'
Expand Down Expand Up @@ -98,6 +99,9 @@ const TLD = 'com|net|org|io|ai|dev|app|sh|co|gg|xyz|me|so|to|tv|cloud|tech|edu|g
const TAIL = `[^\\s"'\`<>)\\]}]` // a link body char — stops at whitespace and common wrappers
const HOST = `[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9-]+)+` // a multi-label host: at least one dot
const MATCHERS: { re: RegExp; url: (m: string) => string }[] = [
// 0. a bare console entity id (`tsk_…`, `goal_…`) the agent printed in its narration — link it to the
// page that shows that row. First so nothing else can claim those columns.
{ re: entityIdRe(), url: (m) => entityUrl(m) ?? m },
// 1. explicit scheme — take the whole thing verbatim.
{ re: new RegExp(`(?:https?|ftp|file)://${TAIL}+`, 'gi'), url: (m) => m },
// 2. www.… — no scheme, assume https.
Expand All @@ -121,7 +125,7 @@ function trimTrail(s: string): { text: string; shaved: number } {
return { text: shaved ? s.slice(0, -shaved) : s, shaved }
}

/** A link provider spanning the four matchers above over a single buffer row. xterm asks per row; we read
/** A link provider spanning the matchers above over a single buffer row. xterm asks per row; we read
* that row's text, find every non-overlapping match, and hand back clickable ranges (1-based, inclusive)
* that open in a new tab. Wrapped links that span rows are matched on whichever row they start — good
* enough for the URLs claude prints; keeps this simple and allocation-light. */
Expand All @@ -132,7 +136,7 @@ function makeLinkProvider(term: Terminal): ILinkProvider {
const line = term.buffer.active.getLine(row - 1)
if (!line) return cb(undefined)
const text = line.translateToString(true)
if (!text || !/[.:/]/.test(text)) return cb(undefined)
if (!text || !/[.:/_]/.test(text)) return cb(undefined) // '_' keeps bare entity ids in scope
const taken: boolean[] = []
const links: ILink[] = []
for (const { re, url } of MATCHERS) {
Expand Down
43 changes: 43 additions & 0 deletions web/src/lib/entity-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// ── Entity-id autolinking ──────────────────────────────────────────────────────────────────────
// Every referenceable row carries a Stripe-style prefixed id (`src/id.ts` — `tsk_…`, `goal_…`,
// `art_…`). Agents quote those ids constantly in prose ("tsk_dcde91f9e81af705 is in progress"),
// which leaves the reader with a token they can only follow by hand-searching a list. So wherever
// an id is DISPLAYED — rendered markdown, raw-text cards, even the terminal buffer — a bare id is
// turned into a link to the page that shows it.
//
// Only prefixes whose console route takes the id ITSELF as its detail segment belong here: a
// session's route detail is its tmux name, not `ses_…`, so sessions are deliberately absent.
export const ENTITY_ROUTES: Record<string, string> = {
tsk: 'tasks',
goal: 'goals',
art: 'artifacts',
}

/** Regex SOURCE (no flags) for a bare entity id, so callers can embed it in a bigger alternation.
* `\b` keeps it from firing mid-token (`x_tsk_…`, `atsk_…`), and the ≥4 hex tail keeps ordinary
* identifiers out (`goal_id` has no hex run). */
export const ENTITY_ID_SRC = `\\b(?:${Object.keys(ENTITY_ROUTES).join('|')})_[0-9a-f]{4,}\\b`

/** A fresh global matcher per call — a shared `g` regex carries `lastIndex` between callers. */
export const entityIdRe = () => new RegExp(ENTITY_ID_SRC, 'g')

const EXACT_ID_RE = new RegExp(`^${ENTITY_ID_SRC}$`)

/** True when the whole string is one entity id (used to linkify an id written as `code`). */
export const isEntityId = (s: string) => EXACT_ID_RE.test(s.trim())

/** In-app hash route for an entity id, or null when the prefix isn't one we route. */
export function entityHref(id: string): string | null {
const route = ENTITY_ROUTES[id.slice(0, id.indexOf('_'))]
return route ? `#/${route}/${encodeURIComponent(id)}` : null
}

/** Absolute URL for the same target — for surfaces outside the SPA's own anchors (the terminal
* linkifier opens a new tab, which needs a full URL rather than a bare `#/…` fragment). */
export function entityUrl(id: string): string | null {
const href = entityHref(id)
// Keep any deployment sub-path, but drop a trailing `*.html` (the terminal test bed) — the console
// SPA that owns these routes is served from the directory, not from that page.
const base = window.location.pathname.replace(/[^/]*\.html$/, '')
return href ? `${window.location.origin}${base}${href}` : null
}
Loading