Accept story 34: Per-Project Agent Configuration and Role Definitions

Replace single [agent] config with multi-agent [[agent]] roster system.
Each agent has name, role, model, allowed_tools, max_turns, max_budget_usd,
and system_prompt fields that map to Claude CLI flags at spawn time.

- AgentConfig expanded with structured fields, validated at startup (panics
  on duplicate names, empty names, non-positive budgets/turns)
- Backwards-compatible: legacy [agent] format auto-wraps with deprecation warning
- AgentPool uses composite "story_id:agent_name" keys for concurrent agents
- agent_name added to AgentEvent variants, AgentInfo, start/stop/subscribe APIs
- GET /agents/config returns roster, POST /agents/config/reload hot-reloads
- POST /agents/start accepts optional agent_name, /agents/stop requires it
- SSE route updated to /agents/:story_id/:agent_name/stream
- Frontend: roster badges, agent selector dropdown, composite-key state
- Project root initialized to cwd at startup so config endpoints work immediately

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 18:46:14 +00:00
parent f9fc2472fd
commit 6d57b06636
12 changed files with 1013 additions and 214 deletions

View File

@@ -1,5 +1,10 @@
import * as React from "react";
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
import type {
AgentConfigInfo,
AgentEvent,
AgentInfo,
AgentStatusValue,
} from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import type { UpcomingStory } from "../api/workflow";
@@ -10,6 +15,7 @@ interface AgentPanelProps {
}
interface AgentState {
agentName: string;
status: AgentStatusValue;
log: string[];
sessionId: string | null;
@@ -71,30 +77,65 @@ function StatusBadge({ status }: { status: AgentStatusValue }) {
);
}
function RosterBadge({ agent }: { agent: AgentConfigInfo }) {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "6px",
fontSize: "0.7em",
background: "#ffffff08",
color: "#888",
border: "1px solid #333",
}}
title={agent.role || agent.name}
>
<span style={{ fontWeight: 600, color: "#aaa" }}>{agent.name}</span>
{agent.model && <span style={{ color: "#666" }}>{agent.model}</span>}
</span>
);
}
/** Build a composite key for tracking agent state. */
function agentKey(storyId: string, agentName: string): string {
return `${storyId}:${agentName}`;
}
export function AgentPanel({ stories }: AgentPanelProps) {
const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [expandedStory, setExpandedStory] = useState<string | null>(null);
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [selectorStory, setSelectorStory] = useState<string | null>(null);
const cleanupRefs = useRef<Record<string, () => void>>({});
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Load existing agents on mount
// Load roster and existing agents on mount
useEffect(() => {
agentsApi
.getAgentConfig()
.then(setRoster)
.catch((err) => console.error("Failed to load agent config:", err));
agentsApi
.listAgents()
.then((agentList) => {
const agentMap: Record<string, AgentState> = {};
for (const a of agentList) {
agentMap[a.story_id] = {
const key = agentKey(a.story_id, a.agent_name);
agentMap[key] = {
agentName: a.agent_name,
status: a.status,
log: [],
sessionId: a.session_id,
worktreePath: a.worktree_path,
};
// Re-subscribe to running agents
if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id);
subscribeToAgent(a.story_id, a.agent_name);
}
}
setAgents(agentMap);
@@ -110,15 +151,17 @@ export function AgentPanel({ stories }: AgentPanelProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const subscribeToAgent = useCallback((storyId: string) => {
// Clean up existing subscription
cleanupRefs.current[storyId]?.();
const subscribeToAgent = useCallback((storyId: string, agentName: string) => {
const key = agentKey(storyId, agentName);
cleanupRefs.current[key]?.();
const cleanup = subscribeAgentStream(
storyId,
agentName,
(event: AgentEvent) => {
setAgents((prev) => {
const current = prev[storyId] ?? {
const current = prev[key] ?? {
agentName,
status: "pending" as AgentStatusValue,
log: [],
sessionId: null,
@@ -129,7 +172,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
case "status":
return {
...prev,
[storyId]: {
[key]: {
...current,
status: (event.status as AgentStatusValue) ?? current.status,
},
@@ -137,7 +180,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
case "output":
return {
...prev,
[storyId]: {
[key]: {
...current,
log: [...current.log, event.text ?? ""],
},
@@ -145,7 +188,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
case "done":
return {
...prev,
[storyId]: {
[key]: {
...current,
status: "completed",
sessionId: event.session_id ?? current.sessionId,
@@ -154,7 +197,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
case "error":
return {
...prev,
[storyId]: {
[key]: {
...current,
status: "failed",
log: [
@@ -173,47 +216,51 @@ export function AgentPanel({ stories }: AgentPanelProps) {
},
);
cleanupRefs.current[storyId] = cleanup;
cleanupRefs.current[key] = cleanup;
}, []);
// Auto-scroll log when expanded
useEffect(() => {
if (expandedStory) {
const el = logEndRefs.current[expandedStory];
if (expandedKey) {
const el = logEndRefs.current[expandedKey];
el?.scrollIntoView({ behavior: "smooth" });
}
}, [expandedStory, agents]);
}, [expandedKey, agents]);
const handleStart = async (storyId: string) => {
const handleStart = async (storyId: string, agentName?: string) => {
setActionError(null);
setSelectorStory(null);
try {
const info: AgentInfo = await agentsApi.startAgent(storyId);
const info: AgentInfo = await agentsApi.startAgent(storyId, agentName);
const key = agentKey(info.story_id, info.agent_name);
setAgents((prev) => ({
...prev,
[storyId]: {
[key]: {
agentName: info.agent_name,
status: info.status,
log: [],
sessionId: info.session_id,
worktreePath: info.worktree_path,
},
}));
setExpandedStory(storyId);
subscribeToAgent(storyId);
setExpandedKey(key);
subscribeToAgent(info.story_id, info.agent_name);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to start agent for ${storyId}: ${message}`);
}
};
const handleStop = async (storyId: string) => {
const handleStop = async (storyId: string, agentName: string) => {
setActionError(null);
const key = agentKey(storyId, agentName);
try {
await agentsApi.stopAgent(storyId);
cleanupRefs.current[storyId]?.();
delete cleanupRefs.current[storyId];
await agentsApi.stopAgent(storyId, agentName);
cleanupRefs.current[key]?.();
delete cleanupRefs.current[key];
setAgents((prev) => {
const next = { ...prev };
delete next[storyId];
delete next[key];
return next;
});
} catch (err) {
@@ -222,9 +269,23 @@ export function AgentPanel({ stories }: AgentPanelProps) {
}
};
const isAgentActive = (storyId: string): boolean => {
const agent = agents[storyId];
return agent?.status === "running" || agent?.status === "pending";
const handleRunClick = (storyId: string) => {
if (roster.length <= 1) {
handleStart(storyId);
} else {
setSelectorStory(selectorStory === storyId ? null : storyId);
}
};
/** Get all active agent keys for a story. */
const getActiveKeysForStory = (storyId: string): string[] => {
return Object.keys(agents).filter((key) => {
const a = agents[key];
return (
key.startsWith(`${storyId}:`) &&
(a.status === "running" || a.status === "pending")
);
});
};
return (
@@ -273,6 +334,21 @@ export function AgentPanel({ stories }: AgentPanelProps) {
)}
</div>
{/* Roster badges */}
{roster.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px",
}}
>
{roster.map((a) => (
<RosterBadge key={`roster-${a.name}`} agent={a} />
))}
</div>
)}
{actionError && (
<div
style={{
@@ -300,8 +376,13 @@ export function AgentPanel({ stories }: AgentPanelProps) {
}}
>
{stories.map((story) => {
const agent = agents[story.story_id];
const isExpanded = expandedStory === story.story_id;
const activeKeys = getActiveKeysForStory(story.story_id);
const hasActive = activeKeys.length > 0;
// Gather all agent states for this story
const storyAgentEntries = Object.entries(agents).filter(([key]) =>
key.startsWith(`${story.story_id}:`),
);
return (
<div
@@ -324,11 +405,19 @@ export function AgentPanel({ stories }: AgentPanelProps) {
<button
type="button"
onClick={() =>
setExpandedStory(isExpanded ? null : story.story_id)
setExpandedKey(
expandedKey?.startsWith(`${story.story_id}:`)
? null
: (storyAgentEntries[0]?.[0] ?? story.story_id),
)
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setExpandedStory(isExpanded ? null : story.story_id);
setExpandedKey(
expandedKey?.startsWith(`${story.story_id}:`)
? null
: (storyAgentEntries[0]?.[0] ?? story.story_id),
);
}
}}
style={{
@@ -338,7 +427,9 @@ export function AgentPanel({ stories }: AgentPanelProps) {
cursor: "pointer",
fontSize: "0.8em",
padding: "0 4px",
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
transform: expandedKey?.startsWith(`${story.story_id}:`)
? "rotate(90deg)"
: "rotate(0deg)",
transition: "transform 0.15s",
}}
>
@@ -358,12 +449,38 @@ export function AgentPanel({ stories }: AgentPanelProps) {
{story.name ?? story.story_id}
</div>
{agent && <StatusBadge status={agent.status} />}
{storyAgentEntries.map(([key, a]) => (
<span
key={`badge-${key}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
}}
>
<span
style={{
fontSize: "0.7em",
color: "#666",
}}
>
{a.agentName}
</span>
<StatusBadge status={a.status} />
</span>
))}
{isAgentActive(story.story_id) ? (
{hasActive ? (
<button
type="button"
onClick={() => handleStop(story.story_id)}
onClick={() => {
for (const key of activeKeys) {
const a = agents[key];
if (a) {
handleStop(story.story_id, a.agentName);
}
}
}}
style={{
padding: "4px 10px",
borderRadius: "999px",
@@ -378,88 +495,164 @@ export function AgentPanel({ stories }: AgentPanelProps) {
Stop
</button>
) : (
<button
type="button"
onClick={() => handleStart(story.story_id)}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #7ee78744",
background: "#7ee78711",
color: "#7ee787",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Run
</button>
<div style={{ position: "relative" }}>
<button
type="button"
onClick={() => handleRunClick(story.story_id)}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #7ee78744",
background: "#7ee78711",
color: "#7ee787",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Run
</button>
{selectorStory === story.story_id &&
roster.length > 1 && (
<div
style={{
position: "absolute",
top: "100%",
right: 0,
marginTop: "4px",
background: "#222",
border: "1px solid #444",
borderRadius: "6px",
padding: "4px 0",
zIndex: 10,
minWidth: "160px",
}}
>
{roster.map((r) => (
<button
key={`sel-${r.name}`}
type="button"
onClick={() =>
handleStart(story.story_id, r.name)
}
style={{
display: "block",
width: "100%",
padding: "6px 12px",
background: "none",
border: "none",
color: "#ccc",
cursor: "pointer",
textAlign: "left",
fontSize: "0.8em",
}}
onMouseEnter={(e) => {
(
e.target as HTMLButtonElement
).style.background = "#333";
}}
onMouseLeave={(e) => {
(
e.target as HTMLButtonElement
).style.background = "none";
}}
>
<div style={{ fontWeight: 600 }}>{r.name}</div>
{r.role && (
<div
style={{
fontSize: "0.85em",
color: "#888",
}}
>
{r.role}
</div>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
{isExpanded && agent && (
<div
style={{
borderTop: "1px solid #2a2a2a",
padding: "8px 12px",
}}
>
{agent.worktreePath && (
{/* Expanded detail per agent */}
{storyAgentEntries.map(([key, a]) => {
if (expandedKey !== key) return null;
return (
<div
key={`detail-${key}`}
style={{
borderTop: "1px solid #2a2a2a",
padding: "8px 12px",
}}
>
<div
style={{
fontSize: "0.75em",
color: "#666",
fontFamily: "monospace",
marginBottom: "6px",
color: "#888",
marginBottom: "4px",
fontWeight: 600,
}}
>
Worktree: {agent.worktreePath}
{a.agentName}
</div>
)}
<div
style={{
maxHeight: "300px",
overflowY: "auto",
background: "#111",
borderRadius: "6px",
padding: "8px",
fontFamily: "monospace",
fontSize: "0.8em",
lineHeight: "1.5",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{agent.log.length === 0 ? (
<span style={{ color: "#555" }}>
{agent.status === "pending" ||
agent.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
agent.log.map((line, i) => (
<div
key={`log-${story.story_id}-${i}`}
style={{
color: line.startsWith("[ERROR]")
? "#ff7b72"
: "#ccc",
}}
>
{line}
</div>
))
{a.worktreePath && (
<div
style={{
fontSize: "0.75em",
color: "#666",
fontFamily: "monospace",
marginBottom: "6px",
}}
>
Worktree: {a.worktreePath}
</div>
)}
<div
ref={(el) => {
logEndRefs.current[story.story_id] = el;
style={{
maxHeight: "300px",
overflowY: "auto",
background: "#111",
borderRadius: "6px",
padding: "8px",
fontFamily: "monospace",
fontSize: "0.8em",
lineHeight: "1.5",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
/>
>
{a.log.length === 0 ? (
<span style={{ color: "#555" }}>
{a.status === "pending" || a.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
a.log.map((line, i) => (
<div
key={`log-${key}-${i.toString()}`}
style={{
color: line.startsWith("[ERROR]")
? "#ff7b72"
: "#ccc",
}}
>
{line}
</div>
))
)}
<div
ref={(el) => {
logEndRefs.current[key] = el;
}}
/>
</div>
</div>
</div>
)}
);
})}
</div>
);
})}