Standalone per-card browser model/security/view modules for HUX-01..10 plus node+pytest suites that read the hux.v1 contract schemas directly. Reconciled drift found on integration: the activity model now accepts all 32 hux.event.v1 kinds (delegation.*, memory.suppressed, memory.retrieval_removed, budget.exhausted, side_effect.*), the autonomy model carries the external_side_effect capability, and the foundation boundary test now asserts the shipped static HUX surface exists on disk and that images never bake activated HUX_FLAGS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
182 lines
9.7 KiB
TypeScript
182 lines
9.7 KiB
TypeScript
/** Accessible HUX-05 UI. Parent callbacks own every request and side effect. */
|
|
|
|
import { Ban, Hand, Octagon, ShieldCheck } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import {
|
|
AUTONOMY_LEVELS, BUDGET_MAX, CAPABILITIES, GRANT_DECISIONS,
|
|
autonomyControlsEnabled, buildApprovalDecision, buildPolicyUpdate,
|
|
buildStopIntent, normalizeAutonomyPage, normalizeStopReceipt,
|
|
} from "./model.ts";
|
|
import type {
|
|
ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel,
|
|
BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyUpdateIntent,
|
|
RawAutonomyEnvelope, StopIntent,
|
|
} from "./types.ts";
|
|
|
|
interface AutonomyControlsProps {
|
|
flags?: Iterable<string>;
|
|
identity: HuxIdentity;
|
|
conversationId: string;
|
|
page: RawAutonomyEnvelope;
|
|
activeRun?: ActiveRun | null;
|
|
stopReceipt?: Record<string, unknown> | null;
|
|
now?: number;
|
|
busy?: boolean;
|
|
onUpdatePolicy?: (intent: PolicyUpdateIntent) => void;
|
|
onDecideApproval?: (intent: ApprovalDecisionIntent) => void;
|
|
onStop?: (intent: StopIntent) => void;
|
|
}
|
|
|
|
const LABELS: Readonly<Record<Capability, string>> = {
|
|
read_files: "Read files", write_files: "Write files", shell: "Run commands",
|
|
network: "Use network", web_search: "Search the web", send_message: "Send messages",
|
|
memory_write: "Save memory", artifact_write: "Change artifacts",
|
|
spend_tokens: "Spend model tokens", delegate: "Delegate to agents", deploy: "Deploy changes",
|
|
};
|
|
|
|
const MODES: Readonly<Record<AutonomyLevel, {label: string; help: string}>> = {
|
|
ask_first: {label: "Ask First", help: "Pause before actions until you approve."},
|
|
safe: {label: "Safe Actions", help: "Read-only work may proceed; external effects still pause."},
|
|
autonomous: {label: "Autonomous", help: "Explicitly allowed capabilities may proceed within every budget."},
|
|
};
|
|
|
|
function startingBudget(page: ReturnType<typeof normalizeAutonomyPage>): BudgetDraft {
|
|
return {...page!.policy.budgets, spendCentsPerRun: 0, scopeLimit: "current_task"};
|
|
}
|
|
|
|
function CapabilityMatrix({grants, setGrant, busy}: {
|
|
grants: Readonly<Record<Capability, GrantDecision>>;
|
|
setGrant: (capability: Capability, decision: GrantDecision) => void;
|
|
busy: boolean;
|
|
}) {
|
|
return <fieldset className="hux-autonomy-matrix"><legend>Capability matrix</legend>
|
|
<p id="hux-autonomy-matrix-help">Missing or invalid grants are denied.</p>
|
|
<table><thead><tr><th scope="col">Capability</th><th scope="col">Decision</th></tr></thead>
|
|
<tbody>{CAPABILITIES.map((capability) => <tr key={capability}>
|
|
<th scope="row">{LABELS[capability]}</th><td><select
|
|
aria-label={`${LABELS[capability]} decision`} aria-describedby="hux-autonomy-matrix-help"
|
|
value={grants[capability]} disabled={busy}
|
|
onChange={(event) => setGrant(capability, event.target.value as GrantDecision)}>
|
|
{GRANT_DECISIONS.map((decision) => <option key={decision} value={decision}>
|
|
{decision === "allow" ? "Allow" : decision === "ask" ? "Ask" : "Deny"}
|
|
</option>)}</select></td>
|
|
</tr>)}</tbody></table>
|
|
</fieldset>;
|
|
}
|
|
|
|
function BudgetControls({budget, setBudget, busy}: {
|
|
budget: BudgetDraft;
|
|
setBudget: (next: BudgetDraft) => void;
|
|
busy: boolean;
|
|
}) {
|
|
const number = (field: keyof typeof BUDGET_MAX, label: string, step = 1) =>
|
|
<label>{label}<input type="number" min="0" max={BUDGET_MAX[field]} step={step}
|
|
value={budget[field]} disabled={busy} onChange={(event) => setBudget({...budget,
|
|
[field]: Number(event.target.value)})} /></label>;
|
|
return <fieldset className="hux-autonomy-budgets"><legend>Per-run budgets</legend>
|
|
<p id="hux-autonomy-budget-help">Zero blocks that resource. Limits are enforced together.</p>
|
|
<div aria-describedby="hux-autonomy-budget-help">
|
|
{number("wallClockSeconds", "Time (seconds)")}
|
|
{number("spendCentsPerRun", "Spend (cents)")}
|
|
{number("tokensPerRun", "Tokens", 1000)}
|
|
{number("toolCallsPerRun", "Tool calls")}
|
|
{number("delegationsPerRun", "Subagents")}
|
|
<label>Maximum scope<select value={budget.scopeLimit} disabled={busy}
|
|
onChange={(event) => setBudget({...budget, scopeLimit: event.target.value as BudgetDraft["scopeLimit"]})}>
|
|
<option value="current_task">Current task</option><option value="conversation">Conversation</option>
|
|
<option value="project">Project</option></select></label>
|
|
</div>
|
|
</fieldset>;
|
|
}
|
|
|
|
function ApprovalQueue({items, busy, decide}: {
|
|
items: readonly ApprovalView[];
|
|
busy: boolean;
|
|
decide: (approval: ApprovalView, choice: ApprovalChoice) => void;
|
|
}) {
|
|
return <section className="hux-autonomy-approvals" aria-labelledby="hux-approval-title">
|
|
<h3 id="hux-approval-title"><Hand aria-hidden /> Review external side effects</h3>
|
|
{items.length ? <ol>{items.map((approval) => <li key={approval.id} data-risk={approval.risk}>
|
|
<strong>{LABELS[approval.capability]}</strong><p>{approval.summary}</p>
|
|
<small>Risk: {approval.risk}. Expires <time dateTime={approval.expiresAt}>
|
|
{new Date(approval.expiresAt).toLocaleTimeString()}</time>.</small>
|
|
<div aria-label="Approval choices">
|
|
<button type="button" disabled={busy} onClick={() => decide(approval, "once")}>Allow once</button>
|
|
<button type="button" disabled={busy} onClick={() => decide(approval, "session")}>This session</button>
|
|
<button type="button" disabled={busy} onClick={() => decide(approval, "always")}>Always</button>
|
|
<button type="button" disabled={busy} onClick={() => decide(approval, "deny")}><Ban aria-hidden /> Deny</button>
|
|
</div>
|
|
</li>)}</ol> : <p>No actions are waiting for approval.</p>}
|
|
</section>;
|
|
}
|
|
|
|
/** Render nothing unless foundation, timeline, and autonomy are negotiated. */
|
|
export function AutonomyControls(props: AutonomyControlsProps) {
|
|
const enabled = autonomyControlsEnabled(props.flags);
|
|
const page = useMemo(() => enabled ? normalizeAutonomyPage(props.page, props.identity,
|
|
props.conversationId, props.now) : null,
|
|
[enabled, props.page, props.identity, props.conversationId, props.now]);
|
|
const [autonomy, setAutonomy] = useState<AutonomyLevel>("ask_first");
|
|
const [grants, setGrants] = useState<Readonly<Record<Capability, GrantDecision>> | null>(null);
|
|
const [budget, setBudget] = useState<BudgetDraft | null>(null);
|
|
if (!enabled) return null;
|
|
if (!page) return <section className="hux-autonomy" role="alert">
|
|
Autonomy controls are unavailable because identity or policy scope could not be verified.
|
|
</section>;
|
|
const selectedAutonomy = grants ? autonomy : page.policy.autonomy;
|
|
const selectedGrants = grants || page.policy.grants;
|
|
const selectedBudget = budget || startingBudget(page);
|
|
const receipt = props.stopReceipt && props.activeRun ?
|
|
normalizeStopReceipt(props.stopReceipt, props.activeRun, props.identity) : null;
|
|
|
|
function decide(approval: ApprovalView, choice: ApprovalChoice) {
|
|
try { props.onDecideApproval?.(buildApprovalDecision(approval, choice, props.identity,
|
|
approval.requestedAt, props.now)); } catch { /* a stale approval remains denied */ }
|
|
}
|
|
|
|
function stop() {
|
|
if (!props.activeRun) return;
|
|
try { props.onStop?.(buildStopIntent(props.activeRun, props.identity, props.activeRun.runId)); }
|
|
catch { /* an unowned run is never stopped */ }
|
|
}
|
|
|
|
return <section className="hux-autonomy" aria-labelledby="hux-autonomy-title" aria-busy={props.busy}>
|
|
<header><div><h2 id="hux-autonomy-title"><ShieldCheck aria-hidden /> Autonomy</h2>
|
|
<p>Control what Hermes may do, how far it may go, and what always pauses.</p></div>
|
|
<button className="hux-autonomy-stop" type="button" disabled={props.busy || !props.activeRun}
|
|
onClick={stop}><Octagon aria-hidden /> Stop current run</button>
|
|
</header>
|
|
<form onSubmit={(event) => { event.preventDefault();
|
|
try { props.onUpdatePolicy?.(buildPolicyUpdate(page.policy, props.identity,
|
|
selectedAutonomy, selectedGrants, selectedBudget)); } catch { /* invalid budgets fail closed */ }
|
|
}}>
|
|
<fieldset className="hux-autonomy-levels"><legend>Autonomy level</legend>
|
|
{AUTONOMY_LEVELS.map((level) => <label key={level}>
|
|
<input type="radio" name="hux-autonomy-level" value={level}
|
|
checked={selectedAutonomy === level} disabled={props.busy}
|
|
onChange={() => {setAutonomy(level); setGrants({...selectedGrants});}} />
|
|
<span><strong>{MODES[level].label}</strong><small>{MODES[level].help}</small></span>
|
|
</label>)}
|
|
</fieldset>
|
|
<CapabilityMatrix grants={selectedGrants} busy={!!props.busy}
|
|
setGrant={(capability, decision) => setGrants({...selectedGrants, [capability]: decision})} />
|
|
<BudgetControls budget={selectedBudget} busy={!!props.busy} setBudget={setBudget} />
|
|
<button className="hux-autonomy-save" type="submit" disabled={props.busy}>Save controls</button>
|
|
</form>
|
|
{page.rejectedApprovals > 0 && <p role="status" className="hux-autonomy-warning">
|
|
Some approval requests were withheld because they were stale or out of scope.</p>}
|
|
<ApprovalQueue items={page.approvals} busy={!!props.busy} decide={decide} />
|
|
{receipt && <section className="hux-autonomy-receipt" aria-labelledby="hux-stop-receipt-title">
|
|
<h3 id="hux-stop-receipt-title">Stop receipt</h3><p>Outcome: {receipt.outcome}.</p>
|
|
<p>{receipt.stopped} effects reverted; {receipt.remaining} remain.</p>
|
|
<ul>{receipt.effects.map((effect, index) => <li key={index}>
|
|
{effect.description} — {effect.reverted ? "reverted" : "remaining"}</li>)}</ul>
|
|
{receipt.omittedEffects > 0 && <p>{receipt.omittedEffects} additional effects omitted.</p>}
|
|
</section>}
|
|
<p className="sr-only" role="status" aria-live="polite">
|
|
{props.busy ? "Updating autonomy controls" : `${page.approvals.length} approvals waiting`}
|
|
</p>
|
|
</section>;
|
|
}
|