= { eq: "=", gte: ">=", lte: "<=" };
+
+export interface RunListState {
+ workers?: readonly string[];
+ statuses?: readonly string[];
+ outcomes?: readonly string[];
+ models?: readonly string[];
+ os?: readonly string[];
+ priorities?: readonly string[];
+ agentVersions?: readonly string[];
+ profiles?: readonly string[];
+ task?: string | null;
+ criteria?: string | null;
+ search?: string | null;
+ /** ISO-8601 datetime (inclusive lower bound). */
+ createdAfter?: string | null;
+ /** ISO-8601 datetime (inclusive upper bound). */
+ createdBefore?: string | null;
+ submissionId?: string | null;
+ turns?: string | null;
+ turnsOp?: IterationOp | null;
+ maxIter?: string | null;
+ maxIterOp?: IterationOp | null;
+ sortBy?: string | null;
+ sortDir?: "asc" | "desc" | null;
+ includeDeleted?: boolean;
+ /** Active UI filters the CLI `run list` cannot express, for honest notes. */
+ unsupportedFilters?: readonly string[];
+}
+
+export function buildRunList(state: RunListState): CliCommand {
+ const tokens = [BINARY, "run", "list"];
+ tokens.push(...variadicFlag("-w", state.workers));
+ tokens.push(...variadicFlag("--status", state.statuses));
+ tokens.push(...variadicFlag("--outcome", state.outcomes));
+ tokens.push(...flag("--task", state.task));
+ tokens.push(...variadicFlag("--profile", state.profiles));
+ tokens.push(...flag("--criteria", state.criteria));
+ tokens.push(...variadicFlag("--model", state.models));
+ tokens.push(...variadicFlag("--os", state.os));
+ tokens.push(...variadicFlag("--priority", state.priorities));
+ tokens.push(...variadicFlag("--agent-version", state.agentVersions));
+ tokens.push(...flag("--search", state.search?.trim()));
+ tokens.push(...flag("--created-after", state.createdAfter));
+ tokens.push(...flag("--created-before", state.createdBefore));
+ tokens.push(...flag("--submission-id", state.submissionId));
+ if (state.turns) {
+ const op = OP_SYMBOL[state.turnsOp ?? "gte"];
+ tokens.push(...flag("--turns", `${op}${state.turns}`));
+ }
+ if (state.maxIter) {
+ const op = OP_SYMBOL[state.maxIterOp ?? "gte"];
+ tokens.push(...flag("--max-iterations", `${op}${state.maxIter}`));
+ }
+ tokens.push(...flag("--sort-by", state.sortBy));
+ if (state.sortBy) tokens.push(...flag("--sort-dir", state.sortDir));
+ tokens.push(...boolFlag("--include-deleted", state.includeDeleted));
+
+ const notes: string[] = [];
+ if (state.unsupportedFilters && state.unsupportedFilters.length > 0) {
+ notes.push(
+ `Filtered in the Portal only (no \`run list\` flag): ${state.unsupportedFilters.join(", ")}.`,
+ );
+ }
+ return assemble(tokens, notes);
+}
+
+export function buildRunGet(id: string): CliCommand {
+ return assemble([BINARY, "run", "get", "-i", shellQuote(id)]);
+}
+
+export type RunAction = "retry" | "cancel" | "delete" | "download";
+
+export function buildRunAction(action: RunAction, id: string, opts?: { force?: boolean }): CliCommand {
+ const tokens = [BINARY, "run", action, "-i", shellQuote(id)];
+ if (action === "retry" && opts?.force) tokens.push("-f");
+ return assemble(tokens);
+}
+
+/**
+ * Build a command for a bulk action over a selection of run IDs.
+ * `cancel` is variadic and `download` has a `download-batch` sibling, so both
+ * become one command; `delete` and `retry` take a single id, so >1 selection
+ * becomes a loop.
+ */
+export function buildRunBulk(action: RunAction, ids: readonly string[], opts?: { force?: boolean }): CliCommand {
+ const quoted = ids.map(shellQuote);
+ if (ids.length === 0) {
+ return assemble([BINARY, "run", action, "-i", "RUN_ID"]);
+ }
+ if (action === "cancel") {
+ return assemble([BINARY, "run", "cancel", "-i", ...quoted]);
+ }
+ if (ids.length === 1) {
+ return buildRunAction(action, ids[0], opts);
+ }
+ if (action === "download") {
+ return assemble([BINARY, "run", "download-batch", "-i", ...quoted]);
+ }
+ const force = action === "retry" && opts?.force ? " -f" : "";
+ const list = quoted.join(" ");
+ const command = `for id in ${list}; do ${BINARY} run ${action} -i "$id"${force}; done`;
+ return { command, display: command, notes: [] };
+}
diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx
index 77ac6b0fa..c68112849 100644
--- a/apps/portal/src/pages/RunDetail.tsx
+++ b/apps/portal/src/pages/RunDetail.tsx
@@ -48,6 +48,8 @@ import type { RunState, LogEvent, ResourceBinding, ResourceRunOutcome } from "@/
import { useShiftModifier } from "@/hooks/useShiftModifier";
import { getRetryButtonState } from "@/components/RetryButton";
import { getRunSkillReferences } from "@/lib/skill-spec";
+import { CliCommand } from "@/components/CliCommand";
+import { buildRunGet } from "@/lib/cli/buildCommand";
/** A compact labeled stat: a micro uppercase label above its value. */
function MetaItem({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
@@ -628,6 +630,7 @@ export function RunDetail() {
{copied ? : }
Copy run ID
+
{/* Tier 1 — semantic status pills */}
diff --git a/apps/portal/src/pages/RunPreviewPanel.tsx b/apps/portal/src/pages/RunPreviewPanel.tsx
index 1a6bacee2..e2f66dac0 100644
--- a/apps/portal/src/pages/RunPreviewPanel.tsx
+++ b/apps/portal/src/pages/RunPreviewPanel.tsx
@@ -13,6 +13,8 @@ import { StatusBadge, OutcomeBadge } from "@/components/StatusBadge";
import { TaskPromptBadge } from "@/components/TaskPromptBadge";
import { AgentBadge, agentDisplayName, useAgentCatalog } from "@/components/AgentBadge";
import { GATE_METADATA } from "@/lib/gates";
+import { CliCommand } from "@/components/CliCommand";
+import { buildRunGet } from "@/lib/cli/buildCommand";
import { formatDate, formatId, formatDuration, truncate } from "@/lib/utils";
export function RunPreviewPanel() {
@@ -79,6 +81,7 @@ export function RunPreviewPanel() {
onClose={closePanel}
headerActions={
+
}
filterRail={
@@ -2192,6 +2261,7 @@ export function RunsList() {
>
+
{groupBy === "none" ? (
diff --git a/apps/portal/src/pages/SubmitRun.tsx b/apps/portal/src/pages/SubmitRun.tsx
index 54beb26ed..714c8d42f 100644
--- a/apps/portal/src/pages/SubmitRun.tsx
+++ b/apps/portal/src/pages/SubmitRun.tsx
@@ -52,6 +52,8 @@ import {
type GateConfig,
type GateId,
} from "@/lib/gates";
+import { CliCommand } from "@/components/CliCommand";
+import { buildRunSubmit } from "@/lib/cli/buildCommand";
import { toast } from "sonner";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
@@ -746,6 +748,44 @@ export function SubmitRun() {
const submitRunCount = expandedRunCount;
const lastVariationDraft = variationDrafts.length > 0 ? variationDrafts[variationDrafts.length - 1] : null;
+ // Equivalent `scope run submit` command for the "Copy as CLI" affordance.
+ // Mirrors the payload built in doSubmit() so the CLI submits the same run(s).
+ const submitCli = useMemo(() => {
+ const baseProfileSpec = selectedProfileId
+ ? selectedProfileVersion
+ ? `${selectedProfileId}@${selectedProfileVersion}`
+ : selectedProfileId
+ : null;
+ return buildRunSubmit({
+ task,
+ criteria: pickedCriteria,
+ worker,
+ model,
+ reasoningEffort,
+ maxIterations,
+ mcpServers: selectedMcpServers,
+ skills: selectedSkills,
+ extensions: selectedExtensions,
+ agentVersion: selectedAgentVersion || undefined,
+ codebase: selectedCodebaseSpec,
+ resources: selectedResourceSpecs,
+ agentsMd,
+ gates: gateConfigs,
+ baseProfileId: baseProfileSpec,
+ profileVariations: variationDrafts
+ .filter((v) => v.profileId.trim().length > 0)
+ .map((v) => (v.profileVersion ? `${v.profileId}@${v.profileVersion}` : v.profileId)),
+ occurrences,
+ priority,
+ });
+ // gateConfigs is rebuilt every render; key it by content instead.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ task, pickedCriteria, worker, model, reasoningEffort, maxIterations,
+ selectedMcpServers, selectedSkills, selectedExtensions, selectedAgentVersion,
+ selectedCodebaseSpec, selectedResourceSpecs, agentsMd, JSON.stringify(gateConfigs),
+ selectedProfileId, selectedProfileVersion, variationDrafts, occurrences, priority,
+ ]);
const lastVariationName = lastVariationDraft
? profileList.find((p) => p._id === lastVariationDraft.profileId)?.name
: null;
@@ -1990,6 +2030,7 @@ export function SubmitRun() {
)}
+