const state = {
config: null,
manifests: [],
sessions: [],
sessionMetadata: [],
runs: [],
selectedSessionId: "",
selectedManifestPath: "",
selectedNodeId: "",
graph: null,
activeRunId: "",
manifestDraft: null,
};
const dom = {
serverStatus: document.querySelector("#server-status"),
sessionSelect: document.querySelector("#session-select"),
graphManifestSelect: document.querySelector("#graph-manifest-select"),
graphRefresh: document.querySelector("#graph-refresh"),
graphSvg: document.querySelector("#graph-svg"),
graphBanner: document.querySelector("#graph-banner"),
runForm: document.querySelector("#run-form"),
runPrompt: document.querySelector("#run-prompt"),
runManifestSelect: document.querySelector("#run-manifest-select"),
runExecutionMode: document.querySelector("#run-execution-mode"),
runProvider: document.querySelector("#run-provider"),
runTopologyHint: document.querySelector("#run-topology-hint"),
runFlags: document.querySelector("#run-flags"),
runRuntimeContext: document.querySelector("#run-runtime-context"),
runValidationNodes: document.querySelector("#run-validation-nodes"),
killRun: document.querySelector("#kill-run"),
runStatus: document.querySelector("#run-status"),
sessionForm: document.querySelector("#session-form"),
sessionProjectPath: document.querySelector("#session-project-path"),
sessionCreate: document.querySelector("#session-create"),
sessionClose: document.querySelector("#session-close"),
sessionCloseMerge: document.querySelector("#session-close-merge"),
nodeInspector: document.querySelector("#node-inspector"),
eventsLimit: document.querySelector("#events-limit"),
eventsRefresh: document.querySelector("#events-refresh"),
eventFeed: document.querySelector("#event-feed"),
claudeEventsLimit: document.querySelector("#claude-events-limit"),
claudeEventsRefresh: document.querySelector("#claude-events-refresh"),
claudeEventFeed: document.querySelector("#claude-event-feed"),
historyRefresh: document.querySelector("#history-refresh"),
historyBody: document.querySelector("#history-body"),
notificationsForm: document.querySelector("#notifications-form"),
securityForm: document.querySelector("#security-form"),
limitsForm: document.querySelector("#limits-form"),
manifestForm: document.querySelector("#manifest-form"),
manifestPath: document.querySelector("#manifest-path"),
manifestLoad: document.querySelector("#manifest-load"),
manifestValidate: document.querySelector("#manifest-validate"),
manifestEditor: document.querySelector("#manifest-editor"),
manifestStatus: document.querySelector("#manifest-status"),
manifestTemplateMinimal: document.querySelector("#manifest-template-minimal"),
manifestTemplateTwoStage: document.querySelector("#manifest-template-two-stage"),
manifestSyncFromEditor: document.querySelector("#manifest-sync-from-editor"),
manifestApplyToEditor: document.querySelector("#manifest-apply-to-editor"),
manifestHelperStatus: document.querySelector("#manifest-helper-status"),
helperTopologySequential: document.querySelector("#helper-topology-sequential"),
helperTopologyParallel: document.querySelector("#helper-topology-parallel"),
helperTopologyHierarchical: document.querySelector("#helper-topology-hierarchical"),
helperTopologyRetryUnrolled: document.querySelector("#helper-topology-retry-unrolled"),
helperTopologyMaxDepth: document.querySelector("#helper-topology-max-depth"),
helperTopologyMaxRetries: document.querySelector("#helper-topology-max-retries"),
helperEntryNodeId: document.querySelector("#helper-entry-node-id"),
helperPersonas: document.querySelector("#helper-personas"),
helperRelationships: document.querySelector("#helper-relationships"),
helperNodes: document.querySelector("#helper-nodes"),
helperEdges: document.querySelector("#helper-edges"),
helperAddPersona: document.querySelector("#helper-add-persona"),
helperAddRelationship: document.querySelector("#helper-add-relationship"),
helperAddNode: document.querySelector("#helper-add-node"),
helperAddEdge: document.querySelector("#helper-add-edge"),
cfgWebhookUrl: document.querySelector("#cfg-webhook-url"),
cfgWebhookSeverity: document.querySelector("#cfg-webhook-severity"),
cfgWebhookAlways: document.querySelector("#cfg-webhook-always"),
cfgSecurityMode: document.querySelector("#cfg-security-mode"),
cfgSecurityBinaries: document.querySelector("#cfg-security-binaries"),
cfgSecurityTimeout: document.querySelector("#cfg-security-timeout"),
cfgSecurityInherit: document.querySelector("#cfg-security-inherit"),
cfgSecurityScrub: document.querySelector("#cfg-security-scrub"),
cfgLimitConcurrent: document.querySelector("#cfg-limit-concurrent"),
cfgLimitSession: document.querySelector("#cfg-limit-session"),
cfgLimitDepth: document.querySelector("#cfg-limit-depth"),
cfgTopologyDepth: document.querySelector("#cfg-topology-depth"),
cfgTopologyRetries: document.querySelector("#cfg-topology-retries"),
cfgRelationshipChildren: document.querySelector("#cfg-relationship-children"),
cfgMergeConflictAttempts: document.querySelector("#cfg-merge-conflict-attempts"),
cfgPortBase: document.querySelector("#cfg-port-base"),
cfgPortBlockSize: document.querySelector("#cfg-port-block-size"),
cfgPortBlockCount: document.querySelector("#cfg-port-block-count"),
cfgPortPrimaryOffset: document.querySelector("#cfg-port-primary-offset"),
};
const TOPOLOGY_COLORS = {
sequential: "#56c3ff",
parallel: "#f6b73c",
hierarchical: "#44d38f",
"retry-unrolled": "#ff8a5b",
};
const STATUS_COLORS = {
success: "#44d38f",
validation_fail: "#ffc94a",
failure: "#ff6e4a",
running: "#ffc94a",
cancelled: "#ff6e4a",
unknown: "#9bb8cf",
};
const MANIFEST_TOPOLOGY_ORDER = [
"sequential",
"parallel",
"hierarchical",
"retry-unrolled",
];
const MANIFEST_ON_TRIGGERS = ["success", "validation_fail", "failure", "always"];
const MANIFEST_EVENT_TRIGGERS = [
"requirements_defined",
"tasks_planned",
"code_committed",
"task_ready_for_review",
"task_blocked",
"validation_passed",
"validation_failed",
"branch_merged",
"merge_conflict_detected",
"merge_conflict_resolved",
"merge_conflict_unresolved",
"merge_retry_started",
];
const RUN_MANIFEST_EDITOR_VALUE = "__editor__";
const RUN_MANIFEST_EDITOR_LABEL = "[Use Manifest Editor JSON]";
const LABEL_HELP_BY_CONTROL = Object.freeze({
"session-select": "Select which session the graph and feed should focus on.",
"graph-manifest-select": "Choose the manifest context used when rendering the selected session graph.",
"run-prompt": "Describe the task objective you want the run to complete.",
"run-manifest-select": "Choose a saved manifest or use the JSON currently in the editor.",
"run-execution-mode": "Use provider for live model execution or mock for simulated execution.",
"run-provider": "Choose which model provider backend handles provider-mode runs.",
"run-topology-hint": "Optional hint that nudges orchestration toward a topology strategy.",
"run-flags": "Optional JSON object passed in as initial run flags.",
"run-runtime-context": "Optional JSON object of template values injected into persona prompts (for example repo or ticket).",
"run-validation-nodes": "Optional comma-separated node IDs to simulate validation outcomes for.",
"session-project-path": "Absolute project path used when creating an explicit managed session.",
"session-close-merge": "When enabled, close will merge the session base branch back into the project branch.",
"events-limit": "Set how many recent runtime events are loaded per refresh.",
"claude-events-limit": "Set how many Claude SDK trace records are loaded per refresh.",
"cfg-webhook-url": "Webhook endpoint that receives runtime event notifications.",
"cfg-webhook-severity": "Minimum severity level that triggers webhook notifications.",
"cfg-webhook-always": "Event types that should always notify, regardless of severity.",
"cfg-security-mode": "Policy behavior used when a command violates security rules.",
"cfg-security-binaries": "Comma-separated command binaries permitted by policy.",
"cfg-security-timeout": "Maximum command execution time before forced timeout.",
"cfg-security-inherit": "Environment variable names to pass through to subprocesses.",
"cfg-security-scrub": "Environment variable names to strip before command execution.",
"cfg-limit-concurrent": "Maximum number of agents that can run concurrently across sessions.",
"cfg-limit-session": "Maximum number of agents that can run concurrently within a single session.",
"cfg-limit-depth": "Maximum recursive spawn depth allowed for agent tasks.",
"cfg-topology-depth": "Maximum orchestration graph depth permitted by topology rules.",
"cfg-topology-retries": "Maximum retry expansions allowed by topology orchestration.",
"cfg-relationship-children": "Maximum children each persona relationship can spawn.",
"cfg-merge-conflict-attempts": "Maximum merge-conflict resolution attempts before emitting unresolved conflict events.",
"cfg-port-base": "Starting port number for provisioning port allocations.",
"cfg-port-block-size": "Number of ports reserved per allocated block.",
"cfg-port-block-count": "Number of port blocks available for allocation.",
"cfg-port-primary-offset": "Offset within each block used for the primary service port.",
"manifest-path": "Workspace-relative manifest file path to load, validate, or save.",
"helper-topology-sequential": "Allow sequential execution topology in this manifest.",
"helper-topology-parallel": "Allow parallel execution topology in this manifest.",
"helper-topology-hierarchical": "Allow hierarchical parent-child execution topology.",
"helper-topology-retry-unrolled": "Allow retry-unrolled topology for explicit retry paths.",
"helper-topology-max-depth": "Top-level cap on orchestration depth in this manifest.",
"helper-topology-max-retries": "Top-level cap on retry attempts in this manifest.",
"helper-entry-node-id": "Node ID used as the pipeline entry point.",
"helper-persona-id": "Stable persona identifier referenced by nodes and relationships.",
"helper-persona-display-name": "Human-readable persona name shown in summaries and tooling.",
"helper-persona-model-constraint": "Optional model restriction for this persona only.",
"helper-persona-system-prompt": "Base prompt template that defines persona behavior.",
"helper-persona-allowlist": "Comma-separated tool names this persona may use.",
"helper-persona-banlist": "Comma-separated tool names this persona must not use.",
"helper-relationship-parent": "Parent persona ID that can spawn or delegate to the child.",
"helper-relationship-child": "Child persona ID allowed under the selected parent.",
"helper-relationship-max-depth": "Optional override limiting recursion depth for this relationship.",
"helper-relationship-max-children": "Optional override limiting child fan-out for this relationship.",
"helper-node-id": "Unique pipeline node identifier used by entry node and edges.",
"helper-node-actor-id": "Runtime actor identifier assigned to this node.",
"helper-node-persona-id": "Persona applied when this node executes.",
"helper-node-topology-kind": "Optional node-level topology override.",
"helper-node-block-id": "Optional topology block identifier for grouped scheduling logic.",
"helper-node-max-retries": "Optional node-level retry limit override.",
"helper-edge-from": "Source node where this edge starts.",
"helper-edge-to": "Target node activated when this edge condition matches.",
"helper-edge-trigger-kind": "Choose whether edge activation is status-based or event-based.",
"helper-edge-on": "Node status value that triggers this edge when using status mode.",
"helper-edge-event": "Domain event name that triggers this edge when using event mode.",
"helper-edge-when": "Optional JSON array of additional conditions required to follow the edge.",
});
function extractLabelText(label) {
const clone = label.cloneNode(true);
for (const field of clone.querySelectorAll("input, select, textarea")) {
field.remove();
}
return clone.textContent?.replace(/\s+/g, " ").trim() || "this field";
}
function applyLabelTooltips(root = document) {
for (const label of root.querySelectorAll("label")) {
const control = label.querySelector("input,select,textarea");
if (!control) {
continue;
}
let help = LABEL_HELP_BY_CONTROL[control.id] || "";
if (!help) {
for (const className of control.classList) {
help = LABEL_HELP_BY_CONTROL[className] || "";
if (help) {
break;
}
}
}
if (!help) {
const labelText = extractLabelText(label).toLowerCase();
help = `Set ${labelText} for this configuration.`;
}
label.title = help;
control.title = help;
}
}
function fmtMoney(value) {
return `$${Number(value || 0).toFixed(4)}`;
}
function fmtMs(value) {
const ms = Number(value || 0);
if (ms < 1000) {
return `${ms.toFixed(0)}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function showRunStatus(message, isError = false) {
dom.runStatus.textContent = message;
dom.runStatus.style.color = isError ? "#ff9d87" : "#9bb8cf";
}
function setManifestStatus(message, isError = false) {
dom.manifestStatus.textContent = message;
dom.manifestStatus.style.color = isError ? "#ff9d87" : "#9bb8cf";
}
function setManifestHelperStatus(message, isError = false) {
dom.manifestHelperStatus.textContent = message;
dom.manifestHelperStatus.style.color = isError ? "#ff9d87" : "#9bb8cf";
}
function cloneJson(value) {
return JSON.parse(JSON.stringify(value));
}
function toIntegerOrFallback(value, fallback, min = 0) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min) {
return fallback;
}
return parsed;
}
function readOptionalIntegerField(value, min = 0) {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
const parsed = Number(trimmed);
if (!Number.isInteger(parsed) || parsed < min) {
return undefined;
}
return parsed;
}
function buildMinimalManifestTemplate() {
return {
schemaVersion: "1",
topologies: ["sequential"],
personas: [
{
id: "builder",
displayName: "Builder",
systemPromptTemplate: "Build and validate for {{repo}}",
toolClearance: {
allowlist: ["read_file", "write_file"],
banlist: [],
},
},
],
relationships: [],
topologyConstraints: {
maxDepth: 4,
maxRetries: 1,
},
pipeline: {
entryNodeId: "build-node",
nodes: [
{
id: "build-node",
actorId: "builder_actor",
personaId: "builder",
},
],
edges: [],
},
};
}
function buildTwoStageManifestTemplate() {
return {
schemaVersion: "1",
topologies: ["sequential", "retry-unrolled"],
personas: [
{
id: "planner",
displayName: "Planner",
systemPromptTemplate: "Plan delivery for {{repo}}",
toolClearance: {
allowlist: ["read_file"],
banlist: [],
},
},
{
id: "coder",
displayName: "Coder",
systemPromptTemplate: "Implement ticket {{ticket}}",
toolClearance: {
allowlist: ["read_file", "write_file"],
banlist: [],
},
},
],
relationships: [
{
parentPersonaId: "planner",
childPersonaId: "coder",
constraints: {
maxDepth: 2,
maxChildren: 2,
},
},
],
topologyConstraints: {
maxDepth: 5,
maxRetries: 2,
},
pipeline: {
entryNodeId: "plan",
nodes: [
{
id: "plan",
actorId: "planner_actor",
personaId: "planner",
},
{
id: "implement",
actorId: "coder_actor",
personaId: "coder",
constraints: {
maxRetries: 1,
},
topology: {
kind: "retry-unrolled",
},
},
],
edges: [
{
from: "plan",
to: "implement",
on: "success",
},
],
},
};
}
function normalizeManifestDraft(manifest) {
const fallback = buildMinimalManifestTemplate();
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
return fallback;
}
const input = manifest;
const topologies = Array.isArray(input.topologies)
? input.topologies.filter((value) => MANIFEST_TOPOLOGY_ORDER.includes(value))
: [];
const personas = Array.isArray(input.personas) ? input.personas : [];
const relationships = Array.isArray(input.relationships) ? input.relationships : [];
const pipeline = input.pipeline && typeof input.pipeline === "object" ? input.pipeline : {};
const nodes = Array.isArray(pipeline.nodes) ? pipeline.nodes : [];
const edges = Array.isArray(pipeline.edges) ? pipeline.edges : [];
const constraints =
input.topologyConstraints && typeof input.topologyConstraints === "object"
? input.topologyConstraints
: {};
return {
schemaVersion: "1",
topologies: topologies.length > 0 ? topologies : ["sequential"],
personas: personas.map((persona, index) => {
const toolClearance =
persona && typeof persona.toolClearance === "object" && persona.toolClearance
? persona.toolClearance
: {};
return {
id: String(persona?.id ?? `persona_${index + 1}`),
displayName: String(persona?.displayName ?? `Persona ${index + 1}`),
systemPromptTemplate: String(persona?.systemPromptTemplate ?? "Prompt for {{repo}}"),
...(typeof persona?.modelConstraint === "string" && persona.modelConstraint.trim()
? { modelConstraint: persona.modelConstraint.trim() }
: {}),
toolClearance: {
allowlist: Array.isArray(toolClearance.allowlist)
? toolClearance.allowlist.map((value) => String(value))
: [],
banlist: Array.isArray(toolClearance.banlist)
? toolClearance.banlist.map((value) => String(value))
: [],
},
};
}),
relationships: relationships.map((relationship) => ({
parentPersonaId: String(relationship?.parentPersonaId ?? ""),
childPersonaId: String(relationship?.childPersonaId ?? ""),
...(relationship?.constraints && typeof relationship.constraints === "object"
? {
constraints: {
...(readOptionalIntegerField(relationship.constraints.maxDepth, 1) !== undefined
? { maxDepth: readOptionalIntegerField(relationship.constraints.maxDepth, 1) }
: {}),
...(readOptionalIntegerField(relationship.constraints.maxChildren, 1) !== undefined
? { maxChildren: readOptionalIntegerField(relationship.constraints.maxChildren, 1) }
: {}),
},
}
: {}),
})),
topologyConstraints: {
maxDepth: toIntegerOrFallback(constraints.maxDepth, 4, 1),
maxRetries: toIntegerOrFallback(constraints.maxRetries, 2, 0),
},
pipeline: {
entryNodeId: String(pipeline.entryNodeId ?? (nodes[0]?.id || "")),
nodes: nodes.map((node, index) => ({
id: String(node?.id ?? `node_${index + 1}`),
actorId: String(node?.actorId ?? `actor_${index + 1}`),
personaId: String(node?.personaId ?? ""),
...(node?.constraints && typeof node.constraints === "object"
? {
constraints: {
...(readOptionalIntegerField(node.constraints.maxRetries, 0) !== undefined
? { maxRetries: readOptionalIntegerField(node.constraints.maxRetries, 0) }
: {}),
},
}
: {}),
...(node?.topology && typeof node.topology === "object"
? {
topology: {
kind: String(node.topology.kind ?? ""),
...(typeof node.topology.blockId === "string" && node.topology.blockId.trim()
? { blockId: node.topology.blockId.trim() }
: {}),
},
}
: {}),
})),
edges: edges.map((edge) => ({
from: String(edge?.from ?? ""),
to: String(edge?.to ?? ""),
...(typeof edge?.on === "string" ? { on: edge.on } : {}),
...(typeof edge?.event === "string" ? { event: edge.event } : {}),
...(Array.isArray(edge?.when) ? { when: edge.when } : {}),
})),
},
};
}
function renderSelectOptions(values, selectedValue, input = {}) {
const includeBlank = input.includeBlank === true;
const blankLabel = input.blankLabel || "(none)";
const options = [];
if (includeBlank) {
options.push(``);
}
for (const value of values) {
const selected = value === selectedValue ? " selected" : "";
options.push(
``,
);
}
return options.join("");
}
function renderManifestHelper() {
if (!state.manifestDraft) {
state.manifestDraft = buildMinimalManifestTemplate();
}
const draft = state.manifestDraft;
const topologies = Array.isArray(draft.topologies) ? draft.topologies : [];
const topologySet = new Set(topologies);
dom.helperTopologySequential.checked = topologySet.has("sequential");
dom.helperTopologyParallel.checked = topologySet.has("parallel");
dom.helperTopologyHierarchical.checked = topologySet.has("hierarchical");
dom.helperTopologyRetryUnrolled.checked = topologySet.has("retry-unrolled");
dom.helperTopologyMaxDepth.value = String(
toIntegerOrFallback(draft.topologyConstraints?.maxDepth, 4, 1),
);
dom.helperTopologyMaxRetries.value = String(
toIntegerOrFallback(draft.topologyConstraints?.maxRetries, 2, 0),
);
dom.helperEntryNodeId.value = String(draft.pipeline?.entryNodeId || "");
const personas = Array.isArray(draft.personas) ? draft.personas : [];
const personaIds = personas.map((persona) => String(persona.id || "")).filter((value) => value);
dom.helperPersonas.innerHTML = personas
.map((persona, index) => {
const allowlist = Array.isArray(persona.toolClearance?.allowlist)
? persona.toolClearance.allowlist
: [];
const banlist = Array.isArray(persona.toolClearance?.banlist)
? persona.toolClearance.banlist
: [];
return `
`;
})
.join("");
const relationships = Array.isArray(draft.relationships) ? draft.relationships : [];
dom.helperRelationships.innerHTML = relationships
.map(
(relationship, index) => `
`,
)
.join("");
const nodes = Array.isArray(draft.pipeline?.nodes) ? draft.pipeline.nodes : [];
const nodeIds = nodes.map((node) => String(node.id || "")).filter((value) => value);
dom.helperNodes.innerHTML = nodes
.map(
(node, index) => `
`,
)
.join("");
const edges = Array.isArray(draft.pipeline?.edges) ? draft.pipeline.edges : [];
dom.helperEdges.innerHTML = edges
.map((edge, index) => {
const triggerKind = edge.event ? "event" : "on";
const whenText = Array.isArray(edge.when) && edge.when.length > 0 ? JSON.stringify(edge.when) : "";
return `
`;
})
.join("");
applyLabelTooltips(dom.manifestForm);
}
function readManifestDraftFromHelper() {
const topologies = [];
if (dom.helperTopologySequential.checked) topologies.push("sequential");
if (dom.helperTopologyParallel.checked) topologies.push("parallel");
if (dom.helperTopologyHierarchical.checked) topologies.push("hierarchical");
if (dom.helperTopologyRetryUnrolled.checked) topologies.push("retry-unrolled");
if (topologies.length === 0) {
topologies.push("sequential");
}
const personas = [];
for (const row of dom.helperPersonas.querySelectorAll(".helper-persona-row")) {
const id = row.querySelector(".helper-persona-id")?.value.trim() || "";
const displayName = row.querySelector(".helper-persona-display-name")?.value.trim() || "";
const systemPromptTemplate = row.querySelector(".helper-persona-system-prompt")?.value.trim() || "";
const modelConstraint = row.querySelector(".helper-persona-model-constraint")?.value.trim() || "";
const allowlistCsv = row.querySelector(".helper-persona-allowlist")?.value || "";
const banlistCsv = row.querySelector(".helper-persona-banlist")?.value || "";
personas.push({
id,
displayName,
systemPromptTemplate,
...(modelConstraint ? { modelConstraint } : {}),
toolClearance: {
allowlist: fromCsv(allowlistCsv),
banlist: fromCsv(banlistCsv),
},
});
}
const relationships = [];
for (const row of dom.helperRelationships.querySelectorAll(".helper-relationship-row")) {
const parentPersonaId = row.querySelector(".helper-relationship-parent")?.value.trim() || "";
const childPersonaId = row.querySelector(".helper-relationship-child")?.value.trim() || "";
const maxDepth = readOptionalIntegerField(
row.querySelector(".helper-relationship-max-depth")?.value,
1,
);
const maxChildren = readOptionalIntegerField(
row.querySelector(".helper-relationship-max-children")?.value,
1,
);
relationships.push({
parentPersonaId,
childPersonaId,
...((maxDepth !== undefined || maxChildren !== undefined)
? {
constraints: {
...(maxDepth !== undefined ? { maxDepth } : {}),
...(maxChildren !== undefined ? { maxChildren } : {}),
},
}
: {}),
});
}
const nodes = [];
for (const row of dom.helperNodes.querySelectorAll(".helper-node-row")) {
const id = row.querySelector(".helper-node-id")?.value.trim() || "";
const actorId = row.querySelector(".helper-node-actor-id")?.value.trim() || "";
const personaId = row.querySelector(".helper-node-persona-id")?.value.trim() || "";
const topologyKind = row.querySelector(".helper-node-topology-kind")?.value.trim() || "";
const blockId = row.querySelector(".helper-node-block-id")?.value.trim() || "";
const maxRetries = readOptionalIntegerField(
row.querySelector(".helper-node-max-retries")?.value,
0,
);
nodes.push({
id,
actorId,
personaId,
...(maxRetries !== undefined ? { constraints: { maxRetries } } : {}),
...(topologyKind
? {
topology: {
kind: topologyKind,
...(blockId ? { blockId } : {}),
},
}
: {}),
});
}
const edges = [];
for (const row of dom.helperEdges.querySelectorAll(".helper-edge-row")) {
const from = row.querySelector(".helper-edge-from")?.value.trim() || "";
const to = row.querySelector(".helper-edge-to")?.value.trim() || "";
const triggerKind = row.querySelector(".helper-edge-trigger-kind")?.value.trim() || "on";
const on = row.querySelector(".helper-edge-on")?.value.trim() || "";
const event = row.querySelector(".helper-edge-event")?.value.trim() || "";
const whenRaw = row.querySelector(".helper-edge-when")?.value.trim() || "";
const edge = {
from,
to,
...(triggerKind === "event" && event ? { event } : { on: on || "success" }),
};
if (whenRaw) {
const parsed = parseJsonSafe(whenRaw, null);
if (Array.isArray(parsed)) {
edge.when = parsed;
}
}
edges.push(edge);
}
const requestedEntryNodeId = dom.helperEntryNodeId.value.trim();
const fallbackEntryNodeId = nodes[0]?.id || "";
const entryNodeId = nodes.some((node) => node.id === requestedEntryNodeId)
? requestedEntryNodeId
: fallbackEntryNodeId;
return {
schemaVersion: "1",
topologies,
personas,
relationships,
topologyConstraints: {
maxDepth: toIntegerOrFallback(dom.helperTopologyMaxDepth.value, 4, 1),
maxRetries: toIntegerOrFallback(dom.helperTopologyMaxRetries.value, 2, 0),
},
pipeline: {
entryNodeId,
nodes,
edges,
},
};
}
function applyHelperToEditor() {
state.manifestDraft = readManifestDraftFromHelper();
dom.manifestEditor.value = JSON.stringify(state.manifestDraft, null, 2);
setManifestHelperStatus("Structured helper applied to JSON editor.");
}
function syncEditorFromHelperQuiet() {
state.manifestDraft = readManifestDraftFromHelper();
dom.manifestEditor.value = JSON.stringify(state.manifestDraft, null, 2);
}
function syncHelperFromEditor() {
const manifest = parseJsonSafe(dom.manifestEditor.value, null);
if (!manifest) {
setManifestHelperStatus("Cannot sync from JSON: editor is not valid JSON.", true);
return;
}
state.manifestDraft = normalizeManifestDraft(manifest);
renderManifestHelper();
setManifestHelperStatus("Helper synchronized from JSON editor.");
}
function setHelperTemplate(templateKind) {
state.manifestDraft =
templateKind === "two-stage" ? buildTwoStageManifestTemplate() : buildMinimalManifestTemplate();
renderManifestHelper();
dom.manifestEditor.value = JSON.stringify(state.manifestDraft, null, 2);
setManifestHelperStatus(
templateKind === "two-stage"
? "Two-stage template loaded into helper and JSON editor."
: "Minimal template loaded into helper and JSON editor.",
);
}
function addHelperRow(kind) {
const draft = readManifestDraftFromHelper();
if (kind === "persona") {
draft.personas.push({
id: `persona_${draft.personas.length + 1}`,
displayName: `Persona ${draft.personas.length + 1}`,
systemPromptTemplate: "Prompt for {{repo}}",
toolClearance: {
allowlist: [],
banlist: [],
},
});
} else if (kind === "relationship") {
draft.relationships.push({
parentPersonaId: "",
childPersonaId: "",
});
} else if (kind === "node") {
draft.pipeline.nodes.push({
id: `node_${draft.pipeline.nodes.length + 1}`,
actorId: `actor_${draft.pipeline.nodes.length + 1}`,
personaId: "",
});
if (!draft.pipeline.entryNodeId) {
draft.pipeline.entryNodeId = draft.pipeline.nodes[0].id;
}
} else if (kind === "edge") {
draft.pipeline.edges.push({
from: "",
to: "",
on: "success",
});
}
state.manifestDraft = draft;
renderManifestHelper();
}
function removeHelperRow(kind, index) {
const draft = readManifestDraftFromHelper();
if (kind === "persona") {
draft.personas.splice(index, 1);
} else if (kind === "relationship") {
draft.relationships.splice(index, 1);
} else if (kind === "node") {
draft.pipeline.nodes.splice(index, 1);
} else if (kind === "edge") {
draft.pipeline.edges.splice(index, 1);
}
state.manifestDraft = draft;
renderManifestHelper();
}
async function apiRequest(path, options = {}) {
const response = await fetch(path, {
headers: {
"Content-Type": "application/json",
},
...options,
});
const payload = await response.json().catch(() => ({ ok: false, error: "Invalid JSON response." }));
if (!response.ok || payload.ok === false) {
const message = payload.error || `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
function toCsv(items) {
return items.join(",");
}
function fromCsv(value) {
return value
.split(",")
.map((part) => part.trim())
.filter((part) => part.length > 0);
}
function parseJsonSafe(text, fallback) {
if (!text.trim()) {
return fallback;
}
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
function dedupeNonEmptyStrings(values) {
const output = [];
const seen = new Set();
for (const value of values) {
if (typeof value !== "string") {
continue;
}
const normalized = value.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
output.push(normalized);
}
return output;
}
function populateSelect(select, values, selectedValue) {
const previous = selectedValue || select.value;
select.innerHTML = "";
for (const value of values) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
if (value === previous) {
option.selected = true;
}
select.append(option);
}
if (select.options.length > 0 && !select.value) {
select.options[0].selected = true;
}
}
function populateRunManifestSelect(values, selectedValue) {
const previous = selectedValue || dom.runManifestSelect.value;
dom.runManifestSelect.innerHTML = "";
const editorOption = document.createElement("option");
editorOption.value = RUN_MANIFEST_EDITOR_VALUE;
editorOption.textContent = RUN_MANIFEST_EDITOR_LABEL;
dom.runManifestSelect.append(editorOption);
for (const value of values) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
dom.runManifestSelect.append(option);
}
if (previous && [...dom.runManifestSelect.options].some((option) => option.value === previous)) {
dom.runManifestSelect.value = previous;
return;
}
if (state.selectedManifestPath && values.includes(state.selectedManifestPath)) {
dom.runManifestSelect.value = state.selectedManifestPath;
return;
}
dom.runManifestSelect.value = RUN_MANIFEST_EDITOR_VALUE;
}
async function loadConfig() {
const payload = await apiRequest("/api/config");
state.config = payload.config;
const runtime = state.config.runtimeEvents;
dom.cfgWebhookUrl.value = runtime.webhookUrl;
dom.cfgWebhookSeverity.value = runtime.minSeverity;
dom.cfgWebhookAlways.value = toCsv(runtime.alwaysNotifyTypes);
const security = state.config.security;
dom.cfgSecurityMode.value = security.violationMode;
dom.cfgSecurityBinaries.value = toCsv(security.allowedBinaries);
dom.cfgSecurityTimeout.value = String(security.commandTimeoutMs);
dom.cfgSecurityInherit.value = toCsv(security.inheritedEnv);
dom.cfgSecurityScrub.value = toCsv(security.scrubbedEnv);
const limits = state.config.limits;
dom.cfgLimitConcurrent.value = String(limits.maxConcurrent);
dom.cfgLimitSession.value = String(limits.maxSession);
dom.cfgLimitDepth.value = String(limits.maxRecursiveDepth);
dom.cfgTopologyDepth.value = String(limits.topologyMaxDepth);
dom.cfgTopologyRetries.value = String(limits.topologyMaxRetries);
dom.cfgRelationshipChildren.value = String(limits.relationshipMaxChildren);
dom.cfgMergeConflictAttempts.value = String(limits.mergeConflictMaxAttempts);
dom.cfgPortBase.value = String(limits.portBase);
dom.cfgPortBlockSize.value = String(limits.portBlockSize);
dom.cfgPortBlockCount.value = String(limits.portBlockCount);
dom.cfgPortPrimaryOffset.value = String(limits.portPrimaryOffset);
}
async function loadManifests() {
const payload = await apiRequest("/api/manifests");
const manifests = dedupeNonEmptyStrings([
...(Array.isArray(payload.manifests) ? payload.manifests : []),
state.selectedManifestPath,
dom.manifestPath.value,
]);
state.manifests = manifests;
if (!state.selectedManifestPath && state.manifests.length > 0) {
state.selectedManifestPath = state.manifests[0];
}
populateSelect(dom.graphManifestSelect, state.manifests, state.selectedManifestPath);
populateRunManifestSelect(state.manifests, state.selectedManifestPath);
if (state.selectedManifestPath) {
dom.manifestPath.value = state.selectedManifestPath;
}
}
function statusChipClass(status) {
return `status-chip status-${status || "unknown"}`;
}
function getSessionLifecycleStatus(sessionId) {
const metadata = state.sessionMetadata.find((entry) => entry?.sessionId === sessionId);
if (!metadata) {
return undefined;
}
const status = metadata.sessionStatus;
if (status === "active" || status === "suspended" || status === "closed" || status === "closed_with_conflicts") {
return status;
}
return undefined;
}
function renderRunsAndSessionsTable() {
const rows = [];
for (const session of state.sessions) {
const lifecycleStatus = getSessionLifecycleStatus(session.sessionId);
const sessionStatus =
lifecycleStatus === "closed" || lifecycleStatus === "closed_with_conflicts"
? lifecycleStatus
: session.status || lifecycleStatus || "unknown";
rows.push(`
| ${escapeHtml(session.sessionId)} |
${escapeHtml(sessionStatus)} |
${Number(session.nodeAttemptCount || 0)} |
${fmtMs(session.durationMs)} |
${fmtMoney(session.costUsd)} |
${escapeHtml(session.endedAt || session.startedAt || "-")} |
`);
}
dom.historyBody.innerHTML = rows.join("");
for (const row of dom.historyBody.querySelectorAll("tr[data-session-id]")) {
row.addEventListener("click", () => {
const sessionId = row.getAttribute("data-session-id") || "";
state.selectedSessionId = sessionId;
dom.sessionSelect.value = sessionId;
void refreshGraph();
});
}
}
async function loadSessions() {
const payload = await apiRequest("/api/sessions");
state.sessions = payload.sessions || [];
state.sessionMetadata = payload.sessionMetadata || [];
state.runs = payload.runs || [];
if (!state.selectedSessionId && state.sessions.length > 0) {
state.selectedSessionId = state.sessions[0].sessionId;
}
populateSelect(
dom.sessionSelect,
state.sessions.map((session) => session.sessionId),
state.selectedSessionId,
);
renderRunsAndSessionsTable();
}
function createSvg(tag, attrs = {}) {
const element = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null) {
continue;
}
element.setAttribute(key, String(value));
}
return element;
}
function computeLayout(nodes, edges) {
const nodeIds = nodes.map((node) => node.nodeId);
const indegree = new Map(nodeIds.map((nodeId) => [nodeId, 0]));
const outgoing = new Map(nodeIds.map((nodeId) => [nodeId, []]));
for (const edge of edges) {
if (!indegree.has(edge.from) || !indegree.has(edge.to)) {
continue;
}
indegree.set(edge.to, (indegree.get(edge.to) || 0) + 1);
outgoing.get(edge.from).push(edge.to);
}
const queue = [];
for (const [nodeId, count] of indegree.entries()) {
if (count === 0) {
queue.push(nodeId);
}
}
const levelByNode = new Map();
for (const nodeId of queue) {
levelByNode.set(nodeId, 0);
}
while (queue.length > 0) {
const nodeId = queue.shift();
const level = levelByNode.get(nodeId) || 0;
for (const nextNodeId of outgoing.get(nodeId) || []) {
const candidate = level + 1;
const current = levelByNode.get(nextNodeId);
if (current === undefined || candidate > current) {
levelByNode.set(nextNodeId, candidate);
}
indegree.set(nextNodeId, (indegree.get(nextNodeId) || 0) - 1);
if ((indegree.get(nextNodeId) || 0) <= 0) {
queue.push(nextNodeId);
}
}
}
for (const nodeId of nodeIds) {
if (!levelByNode.has(nodeId)) {
levelByNode.set(nodeId, 0);
}
}
const maxLevel = Math.max(...levelByNode.values(), 0);
const columns = Array.from({ length: maxLevel + 1 }, () => []);
for (const node of nodes) {
const level = levelByNode.get(node.nodeId) || 0;
columns[level].push(node.nodeId);
}
const width = 1280;
const height = 640;
const nodeWidth = 190;
const nodeHeight = 76;
const xGap = (width - 160) / Math.max(columns.length, 1);
const positions = new Map();
columns.forEach((column, level) => {
const yGap = Math.min(130, Math.max(96, (height - 120) / Math.max(column.length, 1)));
const startY = Math.max(50, height / 2 - ((column.length - 1) * yGap) / 2);
column.forEach((nodeId, index) => {
positions.set(nodeId, {
x: 80 + level * xGap,
y: startY + index * yGap,
width: nodeWidth,
height: nodeHeight,
});
});
});
return positions;
}
function renderNodeInspector() {
if (!state.graph || !state.selectedNodeId) {
dom.nodeInspector.classList.add("empty");
dom.nodeInspector.innerHTML = "Select a graph node.";
return;
}
const node = state.graph.nodes.find((entry) => entry.nodeId === state.selectedNodeId);
if (!node) {
dom.nodeInspector.classList.add("empty");
dom.nodeInspector.innerHTML = "Selected node is not in the current graph.";
return;
}
dom.nodeInspector.classList.remove("empty");
const attemptsSummary = node.attempts
.map((attempt) => {
const toolCalls = attempt.usage?.toolCalls ?? 0;
const duration = attempt.usage?.durationMs ?? 0;
const cost = attempt.usage?.costUsd ?? 0;
return `- #${attempt.attempt} ${attempt.status} | ${fmtMs(duration)} | ${fmtMoney(cost)} | tools:${toolCalls} | retrySpawned:${attempt.retrySpawned}`;
})
.join("\n");
const domainEvents = (node.domainEvents || []).map((event) => `- ${event.type} @ ${event.timestamp}`).join("\n");
const sandbox = JSON.stringify(node.sandboxPayload || {}, null, 2);
dom.nodeInspector.innerHTML = `
${escapeHtml(
`Node: ${node.nodeId}
Actor: ${node.actorId}
Persona: ${node.personaId}
Topology: ${node.topology}
From: ${node.fromNodeId || "(entry)"}
Attempts: ${node.attemptCount}
Last status: ${node.lastStatus || "-"}
Cost: ${fmtMoney(node.usage?.costUsd || 0)}
Duration: ${fmtMs(node.usage?.durationMs || 0)}
Subtasks: ${node.subtaskCount}
Security violations: ${node.securityViolationCount}
Attempt Timeline:
${attemptsSummary || "-"}
Domain Events:
${domainEvents || "-"}
Sandbox Payload (ResolvedExecutionContext):
${sandbox}`,
)}
`;
}
function renderGraph() {
const svg = dom.graphSvg;
svg.innerHTML = "";
if (!state.graph) {
dom.graphBanner.textContent = "No graph loaded.";
return;
}
const graph = state.graph;
const positions = computeLayout(graph.nodes, graph.edges);
const defs = createSvg("defs");
const marker = createSvg("marker", {
id: "arrow",
viewBox: "0 0 10 10",
refX: "10",
refY: "5",
markerWidth: "7",
markerHeight: "7",
orient: "auto-start-reverse",
});
marker.appendChild(createSvg("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "#5e89ab" }));
defs.appendChild(marker);
svg.appendChild(defs);
for (const edge of graph.edges) {
const from = positions.get(edge.from);
const to = positions.get(edge.to);
if (!from || !to) {
continue;
}
const startX = from.x + from.width;
const startY = from.y + from.height / 2;
const endX = to.x;
const endY = to.y + to.height / 2;
const midX = (startX + endX) / 2;
const path = createSvg("path", {
d: `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`,
fill: "none",
stroke: edge.critical ? "#ff6e4a" : edge.visited ? "#7fbce4" : "#486987",
"stroke-width": edge.critical ? "2.7" : edge.visited ? "2" : "1.4",
"stroke-dasharray": edge.visited ? "" : "6 5",
"marker-end": "url(#arrow)",
opacity: edge.visited || edge.critical ? "1" : "0.7",
});
svg.appendChild(path);
const label = createSvg("text", {
x: String(midX),
y: String((startY + endY) / 2 - 6),
fill: edge.critical ? "#ff6e4a" : "#9dc1dc",
"font-size": "11",
"text-anchor": "middle",
"font-family": "IBM Plex Mono, monospace",
});
const condition = (edge.conditionLabels || []).join(" & ");
label.textContent = condition ? `${edge.trigger} [${condition}]` : edge.trigger;
svg.appendChild(label);
}
for (const node of graph.nodes) {
const box = positions.get(node.nodeId);
if (!box) {
continue;
}
const group = createSvg("g", {
cursor: "pointer",
});
const color = TOPOLOGY_COLORS[node.topology] || "#56c3ff";
const status = node.lastStatus || "unknown";
const statusColor = STATUS_COLORS[status] || "#9bb8cf";
const isSelected = state.selectedNodeId === node.nodeId;
const isCritical = (graph.criticalPathNodeIds || []).includes(node.nodeId);
const rect = createSvg("rect", {
x: box.x,
y: box.y,
width: box.width,
height: box.height,
rx: 12,
ry: 12,
fill: "#0d2234",
stroke: isSelected ? "#f6b73c" : isCritical ? "#ff6e4a" : color,
"stroke-width": isSelected ? "3" : "2",
});
const top = createSvg("rect", {
x: box.x,
y: box.y,
width: box.width,
height: 18,
rx: 12,
ry: 12,
fill: color,
opacity: "0.18",
stroke: "none",
});
const title = createSvg("text", {
x: box.x + 10,
y: box.y + 32,
fill: "#e8f5ff",
"font-size": "13",
"font-weight": "700",
"font-family": "Space Grotesk, sans-serif",
});
title.textContent = node.nodeId;
const meta = createSvg("text", {
x: box.x + 10,
y: box.y + 50,
fill: "#9dc1dc",
"font-size": "11",
"font-family": "IBM Plex Mono, monospace",
});
meta.textContent = `${node.topology} | ${node.personaId}`;
const metrics = createSvg("text", {
x: box.x + 10,
y: box.y + 66,
fill: statusColor,
"font-size": "10",
"font-family": "IBM Plex Mono, monospace",
});
const duration = node.usage?.durationMs || 0;
const cost = node.usage?.costUsd || 0;
metrics.textContent = `a:${node.attemptCount} ${fmtMs(duration)} ${fmtMoney(cost)}`;
const dot = createSvg("circle", {
cx: box.x + box.width - 13,
cy: box.y + 13,
r: 6,
fill: statusColor,
});
group.append(rect, top, title, meta, metrics, dot);
group.addEventListener("click", () => {
state.selectedNodeId = node.nodeId;
renderGraph();
renderNodeInspector();
});
svg.appendChild(group);
}
const statusText = graph.status || "unknown";
const abortText = graph.aborted ? ` | aborted: ${graph.abortMessage || "yes"}` : "";
dom.graphBanner.textContent = `Session ${graph.sessionId} | status: ${statusText}${abortText} | critical path: ${graph.criticalPathNodeIds.join(" -> ") || "(none)"}`;
}
async function refreshGraph() {
if (!state.selectedSessionId || !state.selectedManifestPath) {
state.graph = null;
renderGraph();
renderNodeInspector();
return;
}
const query = new URLSearchParams({
sessionId: state.selectedSessionId,
manifestPath: state.selectedManifestPath,
});
try {
const payload = await apiRequest(`/api/sessions/graph?${query.toString()}`);
state.graph = payload.graph;
if (!state.selectedNodeId && state.graph.nodes.length > 0) {
state.selectedNodeId = state.graph.nodes[0].nodeId;
}
renderGraph();
renderNodeInspector();
} catch (error) {
state.graph = null;
dom.graphSvg.innerHTML = "";
dom.graphBanner.textContent = `Graph unavailable: ${error instanceof Error ? error.message : String(error)}`;
dom.nodeInspector.classList.add("empty");
dom.nodeInspector.textContent = "Select a graph node.";
}
}
function renderEventFeed(events) {
const rows = [...events]
.reverse()
.map((event) => {
const ts = new Date(event.timestamp).toLocaleTimeString();
return `
${escapeHtml(ts)}
${escapeHtml(event.type)}
${escapeHtml(event.message)}
`;
})
.join("");
dom.eventFeed.innerHTML = rows || '';
}
function toClaudeRowSeverity(event) {
const stage = String(event?.stage || "");
const type = String(event?.sdkMessageType || "");
if (stage === "query.error") {
return "critical";
}
if (stage === "query.stderr" || (type === "result" && String(event?.sdkMessageSubtype || "").startsWith("error_"))) {
return "warning";
}
return "info";
}
function renderClaudeTraceFeed(events) {
const rows = [...events]
.reverse()
.map((event) => {
const ts = new Date(event.timestamp).toLocaleTimeString();
const stage = String(event.stage || "query.message");
const sdkMessageType = String(event.sdkMessageType || "");
const sdkMessageSubtype = String(event.sdkMessageSubtype || "");
const typeLabel = sdkMessageType
? `${stage}/${sdkMessageType}${sdkMessageSubtype ? `:${sdkMessageSubtype}` : ""}`
: stage;
const message = typeof event.message === "string" ? event.message : JSON.stringify(event.message || "");
return `
${escapeHtml(ts)}
${escapeHtml(typeLabel)}
${escapeHtml(message)}
`;
})
.join("");
dom.claudeEventFeed.innerHTML = rows || '-
-
No Claude trace events.
';
}
async function refreshEvents() {
const limit = Number(dom.eventsLimit.value || "150");
const params = new URLSearchParams({
limit: String(limit),
});
if (state.selectedSessionId) {
params.set("sessionId", state.selectedSessionId);
}
const payload = await apiRequest(`/api/runtime-events?${params.toString()}`);
renderEventFeed(payload.events || []);
}
async function refreshClaudeTrace() {
const limit = Number(dom.claudeEventsLimit.value || "150");
const params = new URLSearchParams({
limit: String(limit),
});
if (state.selectedSessionId) {
params.set("sessionId", state.selectedSessionId);
}
const payload = await apiRequest(`/api/claude-trace?${params.toString()}`);
renderClaudeTraceFeed(payload.events || []);
}
async function startRun(event) {
event.preventDefault();
const prompt = dom.runPrompt.value.trim();
if (!prompt) {
showRunStatus("Prompt is required.", true);
return;
}
const flags = parseJsonSafe(dom.runFlags.value, {});
if (typeof flags !== "object" || Array.isArray(flags) || !flags) {
showRunStatus("Initial Flags must be a JSON object.", true);
return;
}
const runtimeContext = parseJsonSafe(dom.runRuntimeContext.value, {});
if (typeof runtimeContext !== "object" || Array.isArray(runtimeContext) || !runtimeContext) {
showRunStatus("Runtime Context Overrides must be a JSON object.", true);
return;
}
const manifestSelection = dom.runManifestSelect.value.trim();
const payload = {
prompt,
executionMode: dom.runExecutionMode.value,
provider: dom.runProvider.value,
topologyHint: dom.runTopologyHint.value.trim() || undefined,
initialFlags: flags,
runtimeContextOverrides: runtimeContext,
simulateValidationNodeIds: fromCsv(dom.runValidationNodes.value),
};
const selectedSessionMetadata = state.sessionMetadata.find(
(entry) => entry?.sessionId === state.selectedSessionId,
);
if (
selectedSessionMetadata &&
(selectedSessionMetadata.sessionStatus === "active" ||
selectedSessionMetadata.sessionStatus === "suspended")
) {
payload.sessionId = selectedSessionMetadata.sessionId;
}
if (manifestSelection === RUN_MANIFEST_EDITOR_VALUE) {
const manifestFromEditor = parseJsonSafe(dom.manifestEditor.value, null);
if (!manifestFromEditor) {
showRunStatus("Manifest editor JSON is invalid. Save or fix it before running.", true);
return;
}
payload.manifest = manifestFromEditor;
} else if (manifestSelection) {
payload.manifestPath = manifestSelection;
} else {
showRunStatus("Select a manifest path or choose editor JSON.", true);
return;
}
try {
const response = await apiRequest("/api/runs", {
method: "POST",
body: JSON.stringify(payload),
});
const run = response.run;
state.activeRunId = run.runId;
state.selectedSessionId = run.sessionId;
showRunStatus(
`Run ${run.runId} started (session ${run.sessionId}, ${run.executionMode}/${run.provider}).`,
);
await loadSessions();
dom.sessionSelect.value = run.sessionId;
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
} catch (error) {
showRunStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function cancelActiveRun() {
if (!state.activeRunId) {
showRunStatus("No active run selected.", true);
return;
}
try {
const payload = await apiRequest(`/api/runs/${encodeURIComponent(state.activeRunId)}/cancel`, {
method: "POST",
});
state.activeRunId = "";
showRunStatus(`Run cancelled: ${payload.run.status}`);
await loadSessions();
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
} catch (error) {
showRunStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function createSessionFromUi() {
const projectPath = dom.sessionProjectPath.value.trim();
if (!projectPath) {
showRunStatus("Project path is required to create a session.", true);
return;
}
try {
const payload = await apiRequest("/api/sessions", {
method: "POST",
body: JSON.stringify({
projectPath,
}),
});
const created = payload.session;
if (created?.sessionId) {
state.selectedSessionId = created.sessionId;
showRunStatus(`Session ${created.sessionId} created.`);
} else {
showRunStatus("Session created.");
}
await loadSessions();
if (state.selectedSessionId) {
dom.sessionSelect.value = state.selectedSessionId;
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
}
} catch (error) {
showRunStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function closeSelectedSessionFromUi() {
const sessionId = state.selectedSessionId || dom.sessionSelect.value;
if (!sessionId) {
showRunStatus("Select a session before closing.", true);
return;
}
try {
const payload = await apiRequest(`/api/sessions/${encodeURIComponent(sessionId)}/close`, {
method: "POST",
body: JSON.stringify({
mergeToProject: dom.sessionCloseMerge.checked,
}),
});
const nextStatus = payload?.session?.sessionStatus || "closed";
showRunStatus(`Session ${sessionId} closed with status ${nextStatus}.`);
await loadSessions();
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
} catch (error) {
showRunStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function saveNotifications(event) {
event.preventDefault();
const payload = {
webhookUrl: dom.cfgWebhookUrl.value.trim(),
minSeverity: dom.cfgWebhookSeverity.value,
alwaysNotifyTypes: fromCsv(dom.cfgWebhookAlways.value),
};
await apiRequest("/api/config/runtime-events", {
method: "PUT",
body: JSON.stringify(payload),
});
showRunStatus("Notification config updated.");
await loadConfig();
}
async function saveSecurityPolicy(event) {
event.preventDefault();
const payload = {
violationMode: dom.cfgSecurityMode.value,
allowedBinaries: fromCsv(dom.cfgSecurityBinaries.value),
commandTimeoutMs: Number(dom.cfgSecurityTimeout.value || "0"),
inheritedEnv: fromCsv(dom.cfgSecurityInherit.value),
scrubbedEnv: fromCsv(dom.cfgSecurityScrub.value),
};
await apiRequest("/api/config/security", {
method: "PUT",
body: JSON.stringify(payload),
});
showRunStatus("Security policy updated.");
await loadConfig();
}
async function saveLimits(event) {
event.preventDefault();
const payload = {
maxConcurrent: Number(dom.cfgLimitConcurrent.value),
maxSession: Number(dom.cfgLimitSession.value),
maxRecursiveDepth: Number(dom.cfgLimitDepth.value),
topologyMaxDepth: Number(dom.cfgTopologyDepth.value),
topologyMaxRetries: Number(dom.cfgTopologyRetries.value),
relationshipMaxChildren: Number(dom.cfgRelationshipChildren.value),
mergeConflictMaxAttempts: Number(dom.cfgMergeConflictAttempts.value),
portBase: Number(dom.cfgPortBase.value),
portBlockSize: Number(dom.cfgPortBlockSize.value),
portBlockCount: Number(dom.cfgPortBlockCount.value),
portPrimaryOffset: Number(dom.cfgPortPrimaryOffset.value),
};
await apiRequest("/api/config/limits", {
method: "PUT",
body: JSON.stringify(payload),
});
showRunStatus("Limits updated.");
await loadConfig();
}
async function loadManifestEditor() {
const path = dom.manifestPath.value.trim() || state.selectedManifestPath;
if (!path) {
setManifestStatus("Manifest path is required.", true);
return;
}
try {
const payload = await apiRequest(`/api/manifests/read?path=${encodeURIComponent(path)}`);
dom.manifestEditor.value = JSON.stringify(payload.manifest.manifest, null, 2);
state.manifestDraft = normalizeManifestDraft(payload.manifest.manifest);
renderManifestHelper();
dom.manifestPath.value = payload.manifest.path;
state.selectedManifestPath = payload.manifest.path;
populateSelect(dom.graphManifestSelect, state.manifests, state.selectedManifestPath);
populateRunManifestSelect(state.manifests, state.selectedManifestPath);
setManifestStatus(`Loaded ${payload.manifest.path}.`);
} catch (error) {
setManifestStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function validateManifest() {
const manifest = parseJsonSafe(dom.manifestEditor.value, null);
if (!manifest) {
setManifestStatus("Manifest JSON is invalid.", true);
return;
}
try {
await apiRequest("/api/manifests/validate", {
method: "POST",
body: JSON.stringify({ manifest }),
});
setManifestStatus("Manifest is valid.");
} catch (error) {
setManifestStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function saveManifest(event) {
event.preventDefault();
const path = dom.manifestPath.value.trim();
if (!path) {
setManifestStatus("File path is required.", true);
return;
}
const manifest = parseJsonSafe(dom.manifestEditor.value, null);
if (!manifest) {
setManifestStatus("Manifest JSON is invalid.", true);
return;
}
try {
const payload = await apiRequest("/api/manifests/save", {
method: "PUT",
body: JSON.stringify({
path,
manifest,
}),
});
state.selectedManifestPath = payload.manifest.path;
state.manifestDraft = normalizeManifestDraft(payload.manifest.manifest);
renderManifestHelper();
await loadManifests();
dom.graphManifestSelect.value = state.selectedManifestPath;
if ([...dom.runManifestSelect.options].some((option) => option.value === state.selectedManifestPath)) {
dom.runManifestSelect.value = state.selectedManifestPath;
}
setManifestStatus(`Saved ${payload.manifest.path}.`);
await refreshGraph();
} catch (error) {
setManifestStatus(error instanceof Error ? error.message : String(error), true);
}
}
function bindUiEvents() {
dom.sessionSelect.addEventListener("change", async () => {
state.selectedSessionId = dom.sessionSelect.value;
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
});
dom.graphManifestSelect.addEventListener("change", async () => {
state.selectedManifestPath = dom.graphManifestSelect.value;
if ([...dom.runManifestSelect.options].some((option) => option.value === state.selectedManifestPath)) {
dom.runManifestSelect.value = state.selectedManifestPath;
}
dom.manifestPath.value = state.selectedManifestPath;
await refreshGraph();
});
dom.graphRefresh.addEventListener("click", async () => {
await refreshGraph();
});
dom.eventsRefresh.addEventListener("click", async () => {
await refreshEvents();
});
dom.claudeEventsRefresh.addEventListener("click", async () => {
await refreshClaudeTrace();
});
dom.historyRefresh.addEventListener("click", async () => {
await loadSessions();
await refreshGraph();
await refreshClaudeTrace();
});
dom.runForm.addEventListener("submit", startRun);
dom.killRun.addEventListener("click", () => {
void cancelActiveRun();
});
dom.sessionCreate.addEventListener("click", () => {
void createSessionFromUi();
});
dom.sessionClose.addEventListener("click", () => {
void closeSelectedSessionFromUi();
});
dom.notificationsForm.addEventListener("submit", (event) => {
void saveNotifications(event);
});
dom.securityForm.addEventListener("submit", (event) => {
void saveSecurityPolicy(event);
});
dom.limitsForm.addEventListener("submit", (event) => {
void saveLimits(event);
});
dom.manifestLoad.addEventListener("click", () => {
void loadManifestEditor();
});
dom.manifestValidate.addEventListener("click", () => {
void validateManifest();
});
dom.manifestForm.addEventListener("submit", (event) => {
void saveManifest(event);
});
dom.manifestTemplateMinimal.addEventListener("click", () => {
setHelperTemplate("minimal");
});
dom.manifestTemplateTwoStage.addEventListener("click", () => {
setHelperTemplate("two-stage");
});
dom.manifestSyncFromEditor.addEventListener("click", () => {
syncHelperFromEditor();
});
dom.manifestApplyToEditor.addEventListener("click", () => {
applyHelperToEditor();
});
dom.helperAddPersona.addEventListener("click", () => {
addHelperRow("persona");
});
dom.helperAddRelationship.addEventListener("click", () => {
addHelperRow("relationship");
});
dom.helperAddNode.addEventListener("click", () => {
addHelperRow("node");
});
dom.helperAddEdge.addEventListener("click", () => {
addHelperRow("edge");
});
dom.manifestForm.addEventListener("input", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const classNames = [...target.classList];
const isHelperInput =
target.id.startsWith("helper-") ||
classNames.some((name) => name.startsWith("helper-"));
if (!isHelperInput) {
return;
}
syncEditorFromHelperQuiet();
});
dom.manifestForm.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const classNames = [...target.classList];
const isHelperInput =
target.id.startsWith("helper-") ||
classNames.some((name) => name.startsWith("helper-"));
if (!isHelperInput) {
return;
}
syncEditorFromHelperQuiet();
});
dom.manifestForm.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (!target.classList.contains("helper-remove-row")) {
return;
}
const kind = target.dataset.kind;
const index = Number(target.dataset.index || "-1");
if (!kind || !Number.isInteger(index) || index < 0) {
return;
}
removeHelperRow(kind, index);
});
}
async function refreshAll() {
await loadConfig();
await loadManifests();
await loadSessions();
state.selectedManifestPath = dom.graphManifestSelect.value || state.selectedManifestPath;
state.selectedSessionId = dom.sessionSelect.value || state.selectedSessionId;
await refreshGraph();
await refreshEvents();
await refreshClaudeTrace();
}
async function initialize() {
bindUiEvents();
applyLabelTooltips();
try {
await apiRequest("/api/health");
dom.serverStatus.textContent = "Connected";
} catch (error) {
dom.serverStatus.textContent = `Offline: ${error instanceof Error ? error.message : String(error)}`;
dom.serverStatus.style.color = "#ff9d87";
return;
}
await refreshAll();
if (!dom.manifestEditor.value.trim()) {
setHelperTemplate("minimal");
} else {
syncHelperFromEditor();
}
setInterval(() => {
void loadSessions();
}, 5000);
setInterval(() => {
void refreshEvents();
}, 3000);
setInterval(() => {
void refreshClaudeTrace();
}, 3000);
setInterval(() => {
void refreshGraph();
}, 7000);
}
initialize().catch((error) => {
dom.serverStatus.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`;
dom.serverStatus.style.color = "#ff9d87";
});