From 6884039493c2949929499ef2e07436b1eb82d25e Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 6 Oct 2026 17:11:52 +0530 Subject: [PATCH] feat(bets): the goal's number, with the bets that ran under it (v0.456.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bets tab opens with the readings against the target, and a rail beneath it on the same x scale drawing each bet as the WINDOW it occupied. A bet is a period, not a moment, so a marker at its start cannot answer the question people actually have: which attempt was live while the number did that? Colour carries state and the wrapped key underneath carries identity — which is also what stops a label being clipped when a bar ends near the right edge (the first cut drew titles in the SVG and lost two of them). No trend line and no projection, deliberately: drawing a line nobody measured over seventeen noisy daily readings is the same sin as letting an agent grade its own bet. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 ++ package-lock.json | 4 +- package.json | 2 +- web/src/App.tsx | 157 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62994326..23ab9560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ new version heading in the same commit. ## [Unreleased] +## [0.456.0] - 2026-10-06 +### Added +- **The goal's number, with the bets that ran under it.** The Bets tab opens with a chart of the goal's readings against its target, and a rail beneath it on the same x scale showing each bet as the WINDOW it occupied — a bet is a period, not a moment, so the question "which attempt was live while the number did that?" is answerable at a glance. Crosshair snaps to the nearest reading; bar colour carries state and the wrapped key underneath carries identity (never colour alone), which is also what keeps a label from being clipped by the right edge. Deliberately no trend line and no projection: inventing a line nobody measured is the same sin as letting an agent grade its own bet. + **For users:** Open a goal's Bets tab to see the number's whole history with each bet drawn under it — it's immediately visible whether a bet was even running when something moved. [Open Goals](#/goals) + ## [0.455.1] - 2026-10-06 ### Fixed - **An agent's question and its blocked task were two cards for one decision, and answering one left the other.** A run that `ask`s and then parks its task `blocked` raises both "needs your input" and "task blocked — needs you". They weren't linked: answering the question left the task blocked with its card open, and unblocking the task left the question pending. Worse, the answer could be lost: on the live case (instapods `tsk_5f40…`) the human answered 3s after the run parked the task, while its pane was still winding down, so `answerQuestion` assumed a live asker would poll for it — but the run had stopped waiting, ended, and never heard. Now answering the question (Inbox or DM, or a durable default firing) files the answer on the task, returns it to the board (closing the blocked card) and delivers it by dispatching THE TASK with the Q/A in its prompt — retrying while the asking pane is still up, never a second run beside it, and never via the wake queue, whose resume the task's one-live-run guard couldn't see. The reverse: a human unblocking the task (card, board, DM) settles the run's pending questions — the note typed on the card becomes the answer, no note cancels it (`TerminalManager.resolveTaskQuestions`). An agent leaving its own block settles nothing, and a park behind unfinished dependencies is never unblocked by an answer. Pinned by `scripts/ask-block-link-test.cjs`. diff --git a/package-lock.json b/package-lock.json index 3008983f..94e80c61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.455.1", + "version": "0.456.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.455.1", + "version": "0.456.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 77348270..3b522eeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.455.1", + "version": "0.456.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 03eacd22..d2077c55 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { api, isDraftTask, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type HostMetrics, type RequestMetricsSnapshot, 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 TaskChild, type TaskRun, type TaskPr, type TaskPrSummary, type TaskWorkers, type TaskTimelineEntry, type TaskDiscussionSummary, type TaskDiscussionDelivery, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalMetricStatus, type GoalReading, 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 PolicyDrift, 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 OutputStylesResp, type OutputStyleAdoption, type Concurrency, type RuntimeAccount, type RuntimeAccountKind, type RuntimeAccountsResp, type RuntimePresence, type RuntimeLogin, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type UpdateWatchConfig, type UpdateWatchMode, 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, type SessionProgress, type WhatsNewEntry, type DriftMode } from '@/lib/api' +import { api, isDraftTask, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type HostMetrics, type RequestMetricsSnapshot, 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 TaskChild, type TaskRun, type TaskPr, type TaskPrSummary, type TaskWorkers, type TaskTimelineEntry, type TaskDiscussionSummary, type TaskDiscussionDelivery, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalMetric, type GoalMetricStatus, type GoalReading, 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 PolicyDrift, 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 OutputStylesResp, type OutputStyleAdoption, type Concurrency, type RuntimeAccount, type RuntimeAccountKind, type RuntimeAccountsResp, type RuntimePresence, type RuntimeLogin, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type UpdateWatchConfig, type UpdateWatchMode, 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, type SessionProgress, type WhatsNewEntry, type DriftMode } from '@/lib/api' import { type Branding, type PublicBranding, type NotificationPrefs, DEFAULT_NOTIFICATION_PREFS, type PromptShortcut, type SessionMetrics, type Brief, type AutoApproval, type FeedItem, type FeedResponse, type FeedFilter, type TaskRunState, type GoalChatState, type Bet, type BetAsset, type BetState, type BetVerdict } from '@/lib/api' import { applyAccent, applyFavicon, faviconDataUri, readableOn } from '@/lib/branding' import { ENTITY_ID_SRC, entityHref, isEntityId } from '@/lib/entity-links' @@ -9908,6 +9908,133 @@ const BET_CLOSE: { state: BetState; label: string; icon: LucideIcon; variant: 'd { state: 'killed', label: 'Kill', icon: X, variant: 'destructive' }, ] +/** + * The goal's number over time, with the bets that were running under it. + * + * One series (the readings) against one reference line (the target) — so no legend box: the caption + * names it. The bets ride a RAIL under the same x scale rather than as markers on the line, because a + * bet is a WINDOW, not a moment: the useful question at a glance is which attempt was live while the + * number did what it did, and a dot at its start cannot answer that. + * + * It deliberately does not draw a trend line or a projection. A projection on seventeen noisy daily + * readings would be a confident drawing of something nobody measured (docs/bets-plan.md §3 — the number + * is the server's, and inventing one in SVG is the same sin as letting an agent grade itself). + */ +function GoalChart({ metric, readings, bets }: { metric: GoalMetric; readings: GoalReading[]; bets: Bet[] }) { + const [hover, setHover] = useState(null) + const pts = [...readings].filter((r) => Number.isFinite(r.value)).sort((a, b) => a.at - b.at) + // Bets with a real window; the rail is ordered oldest-first so its numbering reads with the timeline. + const rail = bets.filter((b) => b.startedAt).sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)) + if (pts.length < 2) return null + + const W = 760, H = 210, L = 42, R = 14, T = 14, B = 26 + const railH = rail.length ? rail.length * 16 + 14 : 0 + const now = Date.now() + const x0 = Math.min(pts[0].at, ...rail.map((b) => b.startedAt ?? Infinity)) + const x1 = Math.max(now, ...rail.map((b) => b.judgeAt ?? 0)) + const target = metric.target + const top = Math.max(target ?? 0, ...pts.map((p) => p.value)) * 1.08 || 1 + const X = (t: number) => L + ((t - x0) / Math.max(1, x1 - x0)) * (W - L - R) + const Y = (v: number) => T + (1 - v / top) * (H - T - B) + const line = pts.map((p, i) => `${i ? 'L' : 'M'}${X(p.at).toFixed(1)} ${Y(p.value).toFixed(1)}`).join(' ') + const area = `${line} L${X(pts[pts.length - 1].at).toFixed(1)} ${Y(0).toFixed(1)} L${X(pts[0].at).toFixed(1)} ${Y(0).toFixed(1)} Z` + const ticks = [0, 0.5, 1].map((f) => +( (target ?? top) * f ).toFixed(2)) + const day = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + const unit = metric.unit ? ` ${metric.unit}` : '' + const last = pts[pts.length - 1] + const hp = hover != null ? pts[hover] : null + // Nearest reading to the pointer, in x — a crosshair that snaps to data rather than floating between it. + const onMove = (e: React.MouseEvent) => { + const box = e.currentTarget.getBoundingClientRect() + const vx = ((e.clientX - box.left) / box.width) * W + let best = 0 + pts.forEach((p, i) => { if (Math.abs(X(p.at) - vx) < Math.abs(X(pts[best].at) - vx)) best = i }) + setHover(best) + } + + return ( +
+
+
{metric.name}
+
+ {last.value}{unit} + {target != null && <> of {target}{unit} · {pts.length} readings} +
+
+ setHover(null)}> + {/* Grid + y labels. Recessive: the data is the only thing with weight. */} + {ticks.map((v) => ( + + + {v} + + ))} + {/* The target, named on the line itself rather than in a legend. */} + {target != null && target <= top && ( + <> + + target {target}{unit} + + )} + + + {pts.map((p, i) => )} + {/* x ends only — a dated label per reading is noise at this density. */} + {day(pts[0].at)} + {day(x1)} + + {/* Crosshair + tooltip. */} + {hp && ( + + + + + {day(hp.at)} · {hp.value}{unit} + + + )} + + {/* The bets, as windows on the same x scale. Colour carries state, the number and the title in the + board below carry identity — never colour alone. */} + {rail.map((b, i) => { + const y = H + 6 + i * 16 + const bx = X(b.startedAt ?? x0) + const bw = Math.max(6, X(Math.min(b.judgeAt ?? now, x1)) - bx) + const tone = b.state === 'running' ? 'fill-emerald-500/70' : b.state === 'judging' ? 'fill-amber-500/70' : b.verdict === 'met' ? 'fill-emerald-500/40' : 'fill-muted-foreground/35' + return ( + + {`${b.title} — ${b.state}${b.verdict ? ` (${b.verdict})` : ''}`} + + {i + 1} + + ) + })} + + {/* The rail's key lives in HTML, not in the SVG: a label drawn beside a bar that ends near the right + edge gets clipped, and wrapped text is the one thing SVG cannot do for you. */} + {rail.length > 0 && ( +
+ {rail.map((b, i) => ( + + + {i + 1} + {b.title} + {b.verdict && · {b.verdict === 'no_signal' ? 'no signal' : b.verdict}} + + ))} +
+ )} + {rail.length > 0 && ( +
+ Bars are each bet's window — when it was live against this number. Lift is measured on the bet's own assets, not on this line. +
+ )} +
+ ) +} + /** The BETS BOARD — every falsifiable attempt at this goal's number, in the four lanes a human acts on. * * Two things it deliberately does not do. Lift is summed over the bet's OWN assets, never over the goal's @@ -9915,7 +10042,7 @@ const BET_CLOSE: { state: BetState; label: string; icon: LucideIcon; variant: 'd * `observedLift` are the server's arithmetic — displayed, never offered as an input. The half a human owns * is the decision and the lesson, and a terminal state without a lesson is refused server-side, so the * error that comes back is shown verbatim rather than guessed at here. */ -function BetsBoard({ goalId, onCount }: { goalId: string; onCount?: (n: number) => void }) { +function BetsBoard({ goalId, metric, readings, onCount }: { goalId: string; metric?: GoalMetric; readings?: GoalReading[]; onCount?: (n: number) => void }) { const [bets, setBets] = useState<(Bet & { assets: BetAsset[] })[]>([]) const [counts, setCounts] = useState>({}) const [canEdit, setCanEdit] = useState(false) @@ -10065,9 +10192,12 @@ function BetsBoard({ goalId, onCount }: { goalId: string; onCount?: (n: number) // broken board, one line reads as the invitation it is. if (bets.length === 0) { return ( -
-
Bets
-

No bets have been opened on this goal yet.

+
+ {metric && readings && readings.length > 1 && } +
+
Bets
+

No bets have been opened on this goal yet.

+
) } @@ -10077,7 +10207,9 @@ function BetsBoard({ goalId, onCount }: { goalId: string; onCount?: (n: number) // Tasks board's `sm:/lg:` viewport breakpoints would lay four 70px columns on a wide screen. Keyed on // its OWN width it is one stacked column here and in the phone layout, and a real four-lane board the // moment it is given the room. -
+
+ {metric && readings && readings.length > 1 && } +
Bets
{bets.length} on this goal
@@ -10104,11 +10236,12 @@ function BetsBoard({ goalId, onCount }: { goalId: string; onCount?: (n: number) ) })}
- {bets.length > 0 && BET_COLUMNS.some((col) => !bets.some((b) => col.states.includes(b.state))) && ( -
- Nothing {BET_COLUMNS.filter((col) => !bets.some((b) => col.states.includes(b.state))).map((c) => c.label.toLowerCase()).join(' or ')}. -
- )} + {bets.length > 0 && BET_COLUMNS.some((col) => !bets.some((b) => col.states.includes(b.state))) && ( +
+ Nothing {BET_COLUMNS.filter((col) => !bets.some((b) => col.states.includes(b.state))).map((c) => c.label.toLowerCase()).join(' or ')}. +
+ )} +
) } @@ -10791,7 +10924,7 @@ function GoalsPage({ me, goalId, nav, backTo }: { me: Member; goalId: string; na
{roomTab === 'tasks' && tasksTab()} - {roomTab === 'bets' &&
} + {roomTab === 'bets' &&
} {roomTab === 'chat' && (isAdmin ? { void refreshDetail(g.id); void load() }} nav={nav} /> :
Owner or admin required.
)}