Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# DevLog Control Plane Stdout Protocol

DevLog agents can report workflow progress and human approval gates by writing one JSON marker per stdout line. The markers are engine-neutral and do not require the agent to call DevLog APIs.

## Stage Marker

```text
[DEVLOG_STAGE] {"stage":"3/7","desc":"running tests"}
```

Fields:

- `stage`: required string. Short stage label such as `3/7`, `Phase 2`, or `Review`.
- `desc`: optional string. Human-readable current activity.
- `current_stage`: optional string alternative when the agent already has the full display text.

DevLog stores the latest stage as `current_stage`. When both `stage` and `desc` are present, the display value is `stage · desc`.

## Gate Marker

```text
[DEVLOG_GATE] {"question":"Approve the migration plan?","options":["Approve","Revise"],"stage":"2/4","desc":"plan review"}
```

Fields:

- `question`: required string. The human decision prompt.
- `options`: optional string array. Suggested responses for button rendering.
- `stage` / `desc` / `current_stage`: optional stage context for the gate.

DevLog stores the gate as JSON in `gate_status` with an internal `id`, `question`, `options`, `created_at`, and `stage`.

## Compatibility

Agents that do not emit these markers continue to work with the existing lifecycle status display. Malformed markers are left as ordinary visible output instead of failing the session.
17 changes: 15 additions & 2 deletions src/app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ export async function PATCH(
) {
const { id } = await params;
const body = await req.json();
const { action, message, approved, reason } = body as {
action?: "send" | "kill" | "pause" | "end" | "respond_permission";
const { action, message, response, approved, reason } = body as {
action?: "send" | "kill" | "pause" | "end" | "respond_permission" | "resolve_gate";
message?: string;
response?: string;
approved?: boolean;
reason?: string;
};
Expand Down Expand Up @@ -60,6 +61,18 @@ export async function PATCH(
processManager.respondToPermission(id, approved, reason);
break;

case "resolve_gate": {
const gateResponse = response ?? message;
if (!gateResponse?.trim()) {
return NextResponse.json({ error: "response is required" }, { status: 400 });
}
const result = processManager.resolveGate(id, gateResponse.trim());
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 409 });
}
break;
}

case "kill":
processManager.kill(id);
break;
Expand Down
8 changes: 7 additions & 1 deletion src/app/sessions/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SessionVcc } from "@/components/sessions/session-vcc";
import { ProcessIndicator } from "@/components/sessions/process-indicator";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { parseGateStatus } from "@/core/control-plane-state";
import {
ArrowLeft,
Square,
Expand Down Expand Up @@ -66,6 +67,7 @@ export default function SessionDetailPage() {
}

const isActive = isInteractiveSessionStatus(session.status);
const gateStatus = parseGateStatus(session.gate_status);
const isEnded =
session.status === "completed" ||
session.status === "failed" ||
Expand All @@ -88,7 +90,11 @@ export default function SessionDetailPage() {
{session.prompt?.slice(0, 120) ?? session.id}
</h2>
<div className="flex items-center gap-2 mt-0.5">
<ProcessIndicator status={session.status} />
<ProcessIndicator
status={session.status}
currentStage={session.current_stage}
gateStatus={gateStatus}
/>
<span className="text-[10px] text-muted-foreground">
AI task run
</span>
Expand Down
3 changes: 2 additions & 1 deletion src/components/kanban/board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton";
const COLUMNS = getTaskBoardColumns();

export function KanbanBoard() {
const { loading, tasksByStatus, createTask, updateTask, deleteTask, reorder, executeTask } = useTasks();
const { loading, tasksByStatus, createTask, updateTask, deleteTask, reorder, executeTask, refresh } = useTasks();
const taskSessions = useTaskSessions();
const { runtimePayload, byokReady, loaded, settingsReady } = useAgentSettings();
const router = useRouter();
Expand Down Expand Up @@ -185,6 +185,7 @@ export function KanbanBoard() {
onOpenChange={setDetailOpen}
onUpdate={handleUpdateTask}
onLaunchSession={handleLaunchSession}
onRefresh={refresh}
/>
</div>
);
Expand Down
20 changes: 19 additions & 1 deletion src/components/kanban/task-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { Draggable } from "@hello-pangea/dnd";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Trash2, GitBranch, Terminal, ChevronRight, Pause, Play } from "lucide-react";
import { Trash2, GitBranch, Terminal, ChevronRight, Pause, Play, AlertCircle, Milestone } from "lucide-react";
import { canExecuteTaskFromCard, canPauseTaskFromCard } from "./task-card-actions";
import { isActiveSessionStatus } from "@/core/task-readiness";
import { parseGateStatus } from "@/core/control-plane-state";
import type { Task, Session } from "@/core/types-dashboard";
import { cn } from "@/core/dashboard-utils";
import dayjs from "dayjs";
Expand Down Expand Up @@ -47,6 +48,8 @@ export function TaskCard({ task, index, session, onDelete, onClick, onExecute, o
const isLive = isActiveSessionStatus(session?.status);
const canExecute = !!onExecute && canExecuteTaskFromCard(task, session);
const canPause = !!onPause && canPauseTaskFromCard(task, session);
const gateStatus = parseGateStatus(task.gate_status ?? session?.gate_status);
const currentStage = task.current_stage ?? session?.current_stage ?? null;

return (
<Draggable draggableId={task.id} index={index}>
Expand Down Expand Up @@ -113,7 +116,22 @@ export function TaskCard({ task, index, session, onDelete, onClick, onExecute, o
{task.description}
</p>
)}
{currentStage && (
<div className="flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
<Milestone className="h-3 w-3 shrink-0 text-primary/70" />
<span className="truncate">{currentStage}</span>
</div>
)}
<div className="flex items-center gap-1.5 flex-wrap">
{gateStatus && (
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/15 px-1.5 py-0 text-[10px] text-amber-700 dark:text-amber-300"
>
<AlertCircle className="h-2.5 w-2.5" />
needs-input
</Badge>
)}
<Badge
variant="outline"
className={cn("text-[10px] px-1.5 py-0", PRIORITY_COLORS[task.priority])}
Expand Down
94 changes: 94 additions & 0 deletions src/components/kanban/task-detail-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import {
GitBranch,
Terminal,
AlertCircle,
Send,
} from "lucide-react";
import {
DEFAULT_AGENT_TEAM_ID,
DEFAULT_CODING_AGENT_ID,
} from "@/core/agent-presets";
import { AgentSelector } from "@/components/sessions/agent-selector";
import { useAgentSettings } from "@/hooks/use-agent-settings";
import { parseGateStatus } from "@/core/control-plane-state";
import { isTaskExecutableStatus } from "@/core/task-status-flow";
import type { SessionRuntimeAuthInput } from "@/core/session-runtime-auth";
import type { Task, TaskPriority, TaskStatus, Worktree } from "@/core/types-dashboard";
Expand Down Expand Up @@ -75,6 +77,7 @@ interface TaskDetailDialogProps {
} & SessionRuntimeAuthInput,
promptOverride?: string,
) => Promise<{ id: string } | null>;
onRefresh?: () => Promise<void>;
}

export function TaskDetailDialog({
Expand All @@ -83,6 +86,7 @@ export function TaskDetailDialog({
onOpenChange,
onUpdate,
onLaunchSession,
onRefresh,
}: TaskDetailDialogProps) {
const router = useRouter();
const [editing, setEditing] = useState(false);
Expand All @@ -96,6 +100,9 @@ export function TaskDetailDialog({
const [selectedWorktree, setSelectedWorktree] = useState("");
const [codingAgentId, setCodingAgentId] = useState(DEFAULT_CODING_AGENT_ID);
const [agentTeamId, setAgentTeamId] = useState(DEFAULT_AGENT_TEAM_ID);
const [gateResponse, setGateResponse] = useState("");
const [resolvingGate, setResolvingGate] = useState(false);
const [resolvedGateId, setResolvedGateId] = useState<string | null>(null);
const { settings, runtimePayload, byokReady, loaded, settingsReady } =
useAgentSettings();

Expand All @@ -107,6 +114,9 @@ export function TaskDetailDialog({
setPriority(task.priority);
setPrompt(task.prompt ?? "");
setEditing(false);
setGateResponse("");
setResolvingGate(false);
setResolvedGateId(null);
}
}, [task]);

Expand Down Expand Up @@ -180,6 +190,30 @@ export function TaskDetailDialog({
return new Date(dateStr).toLocaleString();
};
const canLaunchSession = isTaskExecutableStatus(task.status);
const parsedGateStatus = parseGateStatus(task.gate_status);
const gateStatus =
parsedGateStatus?.id === resolvedGateId ? null : parsedGateStatus;
const currentStage = task.current_stage ?? gateStatus?.stage ?? null;

const handleResolveGate = async (response: string) => {
const trimmed = response.trim();
if (!task.session_id || !gateStatus || !trimmed) return;

setResolvingGate(true);
try {
const res = await fetch(`/api/sessions/${task.session_id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "resolve_gate", response: trimmed }),
});
if (!res.ok) return;
setResolvedGateId(gateStatus.id);
setGateResponse("");
await onRefresh?.();
} finally {
setResolvingGate(false);
}
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand Down Expand Up @@ -330,6 +364,66 @@ export function TaskDetailDialog({
</div>
)}

{currentStage && (
<div className="rounded-md border bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
{currentStage}
</div>
)}

{gateStatus && (
<div className="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1">
<Badge
variant="outline"
className="border-amber-500/40 bg-background/70 px-1.5 py-0 text-[10px] text-amber-700 dark:text-amber-300"
>
needs-input
</Badge>
<p className="text-sm font-medium leading-snug">
{gateStatus.question}
</p>
</div>
</div>
{gateStatus.options.length > 0 ? (
<div className="flex flex-wrap gap-2 pl-6">
{gateStatus.options.map((option) => (
<Button
key={option}
size="xs"
disabled={resolvingGate || !task.session_id}
onClick={() => handleResolveGate(option)}
>
{option}
</Button>
))}
</div>
) : (
<div className="flex gap-2 pl-6">
<Input
value={gateResponse}
onChange={(event) => setGateResponse(event.target.value)}
disabled={resolvingGate || !task.session_id}
className="h-8 text-xs"
/>
<Button
size="icon-sm"
disabled={resolvingGate || !task.session_id || !gateResponse.trim()}
onClick={() => handleResolveGate(gateResponse)}
aria-label="Resolve gate"
>
{resolvingGate ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
</Button>
</div>
)}
</div>
)}

{/* Metadata */}
<div className="border-t pt-3 space-y-2">
{task.worktree_name && (
Expand Down
54 changes: 40 additions & 14 deletions src/components/sessions/process-indicator.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"use client";

import { AlertCircle, Milestone } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/core/dashboard-utils";
import type { SessionStatus } from "@/core/types-dashboard";
import type { GateStatus, SessionStatus } from "@/core/types-dashboard";

const STATUS_CONFIG: Record<SessionStatus, { color: string; label: string; pulse: boolean }> = {
pending: { color: "bg-yellow-500", label: "Pending", pulse: true },
Expand All @@ -15,28 +17,52 @@ const STATUS_CONFIG: Record<SessionStatus, { color: string; label: string; pulse

interface ProcessIndicatorProps {
status: SessionStatus;
currentStage?: string | null;
gateStatus?: GateStatus | null;
className?: string;
}

export function ProcessIndicator({ status, className }: ProcessIndicatorProps) {
export function ProcessIndicator({
status,
currentStage,
gateStatus,
className,
}: ProcessIndicatorProps) {
const config = STATUS_CONFIG[status];

return (
<div className={cn("flex items-center gap-1.5", className)}>
<span className="relative flex h-2 w-2">
{config.pulse && (
<div className={cn("flex min-w-0 flex-col gap-1", className)}>
<div className="flex min-w-0 items-center gap-1.5">
<span className="relative flex h-2 w-2 shrink-0">
{config.pulse && (
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping",
config.color
)}
/>
)}
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping",
config.color
)}
className={cn("relative inline-flex h-2 w-2 rounded-full", config.color)}
/>
</span>
<span className="text-xs text-muted-foreground">{config.label}</span>
{gateStatus && (
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/15 px-1.5 py-0 text-[10px] text-amber-700 dark:text-amber-300"
>
<AlertCircle className="h-2.5 w-2.5" />
needs-input
</Badge>
)}
<span
className={cn("relative inline-flex h-2 w-2 rounded-full", config.color)}
/>
</span>
<span className="text-xs text-muted-foreground">{config.label}</span>
</div>
{currentStage && (
<div className="flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
<Milestone className="h-3 w-3 shrink-0 text-primary/70" />
<span className="truncate">{currentStage}</span>
</div>
)}
</div>
);
}
Loading
Loading