diff --git a/CHANGELOG.md b/CHANGELOG.md index 5605619b..738e76d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 674b987c..96cf0b00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.333.0", + "version": "0.334.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.333.0", + "version": "0.334.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index dfa6be41..ccfe1c9a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index 37901845..28707fe4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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' @@ -4555,7 +4556,7 @@ function ChatBubble({ turn, agentIcon }: { turn: Extract{turn.text} ) : ( -
{turn.text}
+
{turn.text}
)} @@ -4725,11 +4726,11 @@ function CockpitPage({ sessions, onOpenChat, onOpenTerminal, nav }: { {preview.source === 'state' ? 'Answered from your workspace' : preview.run ? 'Concierge · looked it up for you' : 'Answered'} {preview.answer && ( -
{preview.answer}
+
{preview.answer}
)} {preview.run && ( runAnswer - ?
{runAnswer}
+ ?
{runAnswer}
:
Looking into it…
)}
@@ -4751,7 +4752,7 @@ function CockpitPage({ sessions, onOpenChat, onOpenTerminal, nav }: {
{runId ? ( runAnswer - ?
{runAnswer}
+ ?
{runAnswer}
@@ -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 = { @@ -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 @@ -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 @@ -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( e.stopPropagation()} @@ -6069,7 +6093,8 @@ const mdComponents = { ul: (p: any) =>