Skip to content
Draft
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`.

## [Unreleased]

## [2026-07-13] — v0.10.1 · Human-action visibility

### Changed

- **Runtime permission pauses are now first-class decisions.** Command Center surfaces them in its priority queue with the requested command and inline approve/reject controls, counts them in the Decisions badge, and no longer mislabels a patient approval wait as a stale run that should be abandoned.

### Fixed

- **Human-action stalls no longer disappear when an issue is unassigned.** Issue detail resolves the real active or waiting run independently of current assignment, prioritizes permission and waiting states, and shows the same actionable permission card in the main issue flow and persistent agent rail.
- **Approval state now refreshes every related surface reliably.** Connector approval capture and resolution write a deduplicated run event plus an audited activity event carrying the issue context, so Mission Control, Command Center, and issue detail converge over realtime updates. Open issue-bound asks created outside comments are also visible on the issue.

## [2026-07-13] — v0.10.0 · Rich issue rendering & delivery-safe goals

### Added
Expand Down
27 changes: 27 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

> Append-only session log. Read at session start. Update at session end.

## 2026-07-13 — AXI-102 human-action visibility

Traced AXI-102 from production: its intentionally unassigned Backlog issue had
a valid Victor RESEARCH run in WAITING with a connector permission request,
but no ActionRequest, notification, or approval-specific activity event. The
floating agent overlay read the run directly; Command Center's priority queue
only understood ActionRequests/recovery/gates; and issue detail rejected the
run because it did not match `Issue.assignedAgentId`.

Made runtime approvals a first-class Command Center decision with inline
approve/reject controls and badge counts. Issue detail now resolves live runs
from the issue relationship rather than mutable assignment, prioritizes
approval/waiting work, renders the approval in the main flow and rail, and
surfaces open issue-bound asks that were created outside a comment. Approval
waits are excluded from generic stale recovery instead of recommending an
incorrect abandon action.

Added an atomic approval lifecycle boundary: poll and subscription producers
deduplicate capture, late subscription detail enriches poll-first records,
and capture writes both the run event and audited/realtime BLOCKED event with
issue context. Provider and operator resolution races similarly produce one
refresh event.

Verification in progress for v0.10.1. Targeted approval, recovery, Command
Center, event-rollup, and unassigned-issue coverage is green (**23 passed**);
lint and typecheck are green with existing repository warnings only.

## 2026-07-13 — Rich-rendering recovery + human delivery acceptance

Recovered the six AXI-95–99 rich-rendering commits from the stale Codex bridge
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge",
"version": "0.10.0",
"version": "0.10.1",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
Expand Down
92 changes: 89 additions & 3 deletions src/app/(app)/w/[slug]/command-center/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button";
import { Picker } from "@/components/ui/modal";
import { EmptyState, SkeletonList } from "@/components/ui";
import { AgentAttentionPanel } from "@/components/agent-attention-panel";
import { RunApprovalCard } from "@/components/agents/run-approval-card";
import { WorkspaceActivityTimeline } from "@/components/workspace-activity-timeline";
import { trpc } from "@/lib/trpc";
import { useWorkspace } from "@/hooks/use-workspace";
Expand Down Expand Up @@ -108,6 +109,16 @@ export default function CommandCenterPage() {
counts: { ...prev.counts, reviewGates: Math.max(0, prev.counts.reviewGates - 1) },
});
};
const dropRuntimeApproval = (id: string) => {
const prev = utils.commandCenter.summary.getData(summaryInput);
if (!prev) return;
const runtimeApprovals = prev.runtimeApprovals.filter((run) => run.id !== id);
utils.commandCenter.summary.setData(summaryInput, {
...prev,
runtimeApprovals,
counts: { ...prev.counts, runtimeApprovals: runtimeApprovals.length },
});
};
const dropRunFailures = (ids: string[]) => {
const prev = utils.commandCenter.summary.getData(summaryInput);
if (!prev) return;
Expand Down Expand Up @@ -142,10 +153,14 @@ export default function CommandCenterPage() {
},
});
const attentionCount = data
? data.actionRequests.length + data.reviewGates.length + data.stalledRuns.length
? data.actionRequests.length +
data.runtimeApprovals.length +
data.reviewGates.length +
data.stalledRuns.length
: 0;
const activeAttentionGroups = data
? Number(data.actionRequests.length > 0) +
Number(data.runtimeApprovals.length > 0) +
Number(data.stalledRuns.length > 0) +
Number(data.reviewGates.length > 0)
: 0;
Expand All @@ -156,7 +171,7 @@ export default function CommandCenterPage() {
title="Command Center"
subtitle={
data
? `Decisions & live agent ops · ${data.counts.actionRequests} asks · ${data.counts.reviewGates} gates · ${data.counts.activeRuns} active runs`
? `Decisions & live agent ops · ${data.counts.actionRequests} asks · ${data.counts.runtimeApprovals} approvals · ${data.counts.reviewGates} gates · ${data.counts.activeRuns} active runs`
: "Decisions & live agent ops"
}
/>
Expand Down Expand Up @@ -208,6 +223,22 @@ export default function CommandCenterPage() {
))}
</AttentionGroup>
) : null}
{data.runtimeApprovals.length > 0 ? (
<AttentionGroup
title="Runtime approvals"
count={data.runtimeApprovals.length}
empty="No runtime approvals."
>
{data.runtimeApprovals.map((run) => (
<RuntimeApprovalDecisionCard
key={run.id}
run={run}
slug={ws.slug}
onResolved={() => dropRuntimeApproval(run.id)}
/>
))}
</AttentionGroup>
) : null}
{data.stalledRuns.length > 0 ? (
<AttentionGroup
title="Stalled runs"
Expand Down Expand Up @@ -257,7 +288,6 @@ export default function CommandCenterPage() {
</section>

<section className="space-y-3" data-testid="command-center-live-operations">
<CommandZoneDivider label="Live operations" />
<AgentAttentionPanel
slug={ws.slug}
showEmpty
Expand Down Expand Up @@ -486,6 +516,62 @@ type CCActionRequest = {
requestedByUser: { name: string | null } | null;
};

type CCRuntimeApproval = {
id: string;
currentStep: string | null;
awaitingApprovalAt: Date | string | null;
pendingApproval: unknown;
agent: { name: string; profileKey: string };
issue: {
id: string;
number: number;
title: string;
workspace: { slug: string; key: string };
};
};

function RuntimeApprovalDecisionCard({
run,
slug,
onResolved,
}: {
run: CCRuntimeApproval;
slug: string;
onResolved: () => void;
}) {
const utils = trpc.useUtils();
const href = `/w/${run.issue.workspace.slug || slug}/i/${run.issue.workspace.key}-${run.issue.number}`;
return (
<div className="space-y-2 rounded-md border border-warning/35 bg-warning/[0.04] p-2">
<Link href={href} className="block min-w-0 hover:text-ember">
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate text-sm font-medium">
{run.issue.workspace.key}-{run.issue.number} · {run.issue.title}
</span>
<span className="text-meta shrink-0 text-warning">@{run.agent.profileKey}</span>
</div>
{run.currentStep ? (
<p className="text-meta mt-0.5 line-clamp-2 text-muted-foreground">
{run.currentStep}
</p>
) : null}
</Link>
<RunApprovalCard
runId={run.id}
agentName={run.agent.name}
pendingApproval={run.pendingApproval}
onResolved={() => {
onResolved();
void utils.commandCenter.summary.invalidate();
void utils.commandCenter.decisionsCount.invalidate();
void utils.issue.byId.invalidate({ id: run.issue.id });
void utils.agentRun.activeForIssue.invalidate({ issueId: run.issue.id });
}}
/>
</div>
);
}

function ActionRequestDecisionCard({
request,
slug,
Expand Down
78 changes: 77 additions & 1 deletion src/components/action-requests/action-request-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import { useWorkspace } from "@/hooks/use-workspace";
*/
export type ActionRequestCardProps =
| ActionRequestCardCommentProps
| ActionRequestCardPlanProps;
| ActionRequestCardPlanProps
| ActionRequestCardDirectProps;

interface ActionRequestCardCommentProps {
/** Comment row's id — the bound ActionRequest is fetched by this. */
Expand Down Expand Up @@ -75,13 +76,88 @@ interface ActionRequestCardPlanProps {
onResolved?: () => void;
}

interface ActionRequestCardDirectProps {
/** Render an issue-bound request that was not created from a comment. */
requestId: string;
commentId?: never;
planId?: never;
issueId: string;
canResolve?: boolean;
onResolved?: () => void;
}

export function ActionRequestCard(props: ActionRequestCardProps) {
if ("requestId" in props && props.requestId) {
return <ActionRequestCardForRequest {...props} />;
}
if ("planId" in props && props.planId) {
return <ActionRequestCardForPlan {...props} />;
}
return <ActionRequestCardForComment {...(props as ActionRequestCardCommentProps)} />;
}

function ActionRequestCardForRequest({
requestId,
issueId,
canResolve,
onResolved,
}: ActionRequestCardDirectProps) {
const utils = trpc.useUtils();
const ws = useWorkspace();
const isAdmin = ws.role === ("OWNER" as Role) || ws.role === ("ADMIN" as Role);
const visibleCanResolve = canResolve || isAdmin;
const { data: request, isLoading } = trpc.actionRequest.get.useQuery(
{ id: requestId },
{ staleTime: 30_000 },
);
const [showDeclineReason, setShowDeclineReason] = useState(false);
const [declineReason, setDeclineReason] = useState("");
const settle = () => {
void utils.actionRequest.get.invalidate({ id: requestId });
void utils.actionRequest.list.invalidate();
void utils.issue.byId.invalidate({ id: issueId });
void utils.commandCenter.summary.invalidate();
void utils.commandCenter.decisionsCount.invalidate();
onResolved?.();
};
const accept = trpc.actionRequest.accept.useMutation({
onError: (error) => toast.error(error.message),
onSuccess: settle,
});
const decline = trpc.actionRequest.decline.useMutation({
onError: (error) => toast.error(error.message),
onSuccess: () => {
setShowDeclineReason(false);
setDeclineReason("");
settle();
},
});

if (isLoading || !request) return null;
return (
<ActionRequestCardView
request={request}
visibleCanResolve={visibleCanResolve}
showDeclineReason={showDeclineReason}
declineReason={declineReason}
onDeclineReasonChange={setDeclineReason}
onAccept={() => accept.mutate({ id: request.id })}
onDecline={() => {
if (!showDeclineReason) {
setShowDeclineReason(true);
return;
}
decline.mutate({ id: request.id, reason: declineReason || null });
}}
onCancelDecline={() => {
setShowDeclineReason(false);
setDeclineReason("");
}}
pending={accept.isPending || decline.isPending}
/>
);
}

function ActionRequestCardForComment({
commentId,
canResolve,
Expand Down
15 changes: 11 additions & 4 deletions src/components/agents/run-approval-card.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"use client";
import { Check, ShieldAlert, X } from "lucide-react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";

/**
* Operator approval card for a connector-driven run paused awaiting
* permission (Codex/Hermes flagged a command or file change). Shared by the
* Mission Control Live tab (`RunRow`) and the issue right-rail
* (`IssueAgentPanel`) so an approval is actionable wherever the operator is
* looking — not buried in one surface.
* Mission Control Live tab (`RunRow`), Command Center priority queue, and
* issue detail so an approval is actionable wherever the operator is looking
* — not buried in one surface.
*
* Approve grants **session** scope by default (`acceptForSession`) so a
* research sweep doesn't re-prompt on every command; the per-command "once"
Expand Down Expand Up @@ -42,10 +43,16 @@ export function RunApprovalCard({
const utils = trpc.useUtils();
const approval = readPendingApproval(pendingApproval);
const respond = trpc.agentRun.respondApproval.useMutation({
onSuccess: (_result, variables) => {
toast.success(variables.decision === "approve" ? "Permission granted" : "Run stopped");
onResolved?.();
},
onError: (error) => toast.error(error.message),
onSettled: () => {
void utils.agentRun.activeAll.invalidate();
void utils.agentRun.activeForIssue.invalidate();
onResolved?.();
void utils.commandCenter.summary.invalidate();
void utils.commandCenter.decisionsCount.invalidate();
},
});

Expand Down
14 changes: 14 additions & 0 deletions src/components/issue-detail/agent-run-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { trpc } from "@/lib/trpc";
import { useRealtime } from "@/hooks/use-realtime";
import { relativeTime, cn } from "@/lib/utils";
import { RuntimePolicyBadges } from "@/components/runtime-tool-surface";
import { RunApprovalCard } from "@/components/agents/run-approval-card";
import type { RuntimePolicySnapshot } from "@/lib/runtime-enforcement";

/**
Expand Down Expand Up @@ -193,6 +194,19 @@ export function AgentRunStrip({ issueId }: { issueId: string }) {
</span>
</div>
</div>
{run.awaitingApprovalAt ? (
<RunApprovalCard
runId={run.id}
agentName={run.agent.name}
pendingApproval={run.pendingApproval}
onResolved={() => {
void utils.agentRun.activeForIssue.invalidate({ issueId });
void utils.issue.byId.invalidate({ id: issueId });
void utils.commandCenter.summary.invalidate();
void utils.commandCenter.decisionsCount.invalidate();
}}
/>
) : null}
</div>
);
}
Expand Down
Loading