diff --git a/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx b/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx
index bb811d13..f02774c6 100644
--- a/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx
+++ b/ccw/frontend/src/components/api-settings/CliSettingsModal.tsx
@@ -164,7 +164,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
setCliProvider(p);
setMode('direct');
setProviderId('');
- setModel(p === 'claude' ? 'sonnet' : '');
+ setModel('');
setSettingsFile('');
setAuthToken('');
setBaseUrl('');
@@ -291,7 +291,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
}
settings = {
env,
- model: model || 'sonnet',
+ model: model || undefined,
settingsFile: settingsFile.trim() || undefined,
availableModels,
tags,
@@ -505,7 +505,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
- setModel(e.target.value)} placeholder="sonnet" />
+ setModel(e.target.value)} placeholder="" />
@@ -545,7 +545,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
- setModel(e.target.value)} placeholder="sonnet" />
+ setModel(e.target.value)} placeholder="" />
@@ -644,7 +644,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
id="codex-model"
value={model}
onChange={(e) => setModel(e.target.value)}
- placeholder="gpt-5.2"
+ placeholder=""
/>
指定使用的模型,将自动更新到 config.toml 中
@@ -711,7 +711,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
id="codex-configtoml"
value={configToml}
onChange={(e) => setConfigToml(e.target.value)}
- placeholder={'model = "gpt-5.2"\nmodel_reasoning_effort = "xhigh"'}
+ placeholder=""
className="font-mono text-sm"
rows={6}
/>
@@ -778,7 +778,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
id="gemini-model"
value={model}
onChange={(e) => setModel(e.target.value)}
- placeholder="gemini-2.5-flash"
+ placeholder=""
/>
@@ -803,7 +803,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
id="gemini-settingsjson"
value={geminiSettingsJson}
onChange={(e) => setGeminiSettingsJson(e.target.value)}
- placeholder='{"model": "gemini-2.5-flash", ...}'
+ placeholder="{}"
className="font-mono text-sm"
rows={8}
readOnly
diff --git a/ccw/frontend/src/components/api-settings/EndpointModal.tsx b/ccw/frontend/src/components/api-settings/EndpointModal.tsx
index 1889f313..c9b96c8e 100644
--- a/ccw/frontend/src/components/api-settings/EndpointModal.tsx
+++ b/ccw/frontend/src/components/api-settings/EndpointModal.tsx
@@ -295,7 +295,7 @@ export function EndpointModal({ open, onClose, endpoint }: EndpointModalProps) {
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
- placeholder="gpt-4o"
+ placeholder=""
className="font-mono"
/>
) : (
diff --git a/ccw/frontend/src/components/layout/Header.tsx b/ccw/frontend/src/components/layout/Header.tsx
index 4e083057..2523fc19 100644
--- a/ccw/frontend/src/components/layout/Header.tsx
+++ b/ccw/frontend/src/components/layout/Header.tsx
@@ -16,7 +16,6 @@ import {
LogOut,
Terminal,
Bell,
- Clock,
Monitor,
SquareTerminal,
} from 'lucide-react';
@@ -86,19 +85,6 @@ export function Header({
{/* Right side - Actions */}
- {/* History entry */}
-
-
{/* CLI Monitor button */}
)}
diff --git a/ccw/frontend/src/components/terminal-dashboard/QueueListColumn.tsx b/ccw/frontend/src/components/terminal-dashboard/QueueListColumn.tsx
new file mode 100644
index 00000000..d0723020
--- /dev/null
+++ b/ccw/frontend/src/components/terminal-dashboard/QueueListColumn.tsx
@@ -0,0 +1,330 @@
+// ========================================
+// QueueListColumn Component
+// ========================================
+// Queue items list for embedding in the Issues dual-column panel.
+// Unified data source: queueSchedulerStore only.
+// Includes inline scheduler controls at the bottom.
+
+import { useMemo, useCallback, useState } from 'react';
+import { useIntl } from 'react-intl';
+import {
+ ListChecks,
+ Loader2,
+ CheckCircle,
+ XCircle,
+ Zap,
+ Ban,
+ Square,
+ Terminal,
+ Timer,
+ Clock,
+ Play,
+ Pause,
+ StopCircle,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/Badge';
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogAction,
+ AlertDialogCancel,
+} from '@/components/ui/AlertDialog';
+import { cn } from '@/lib/utils';
+import {
+ useIssueQueueIntegrationStore,
+ selectAssociationChain,
+} from '@/stores/issueQueueIntegrationStore';
+import {
+ useQueueExecutionStore,
+ selectByQueueItem,
+} from '@/stores/queueExecutionStore';
+import {
+ useQueueSchedulerStore,
+ selectQueueSchedulerStatus,
+ selectQueueItems,
+ selectSchedulerProgress,
+ selectCurrentConcurrency,
+ selectSchedulerConfig,
+} from '@/stores/queueSchedulerStore';
+import type { QueueItem, QueueItemStatus } from '@/types/queue-frontend-types';
+
+// ========== Status Config ==========
+
+const STATUS_CONFIG: Record = {
+ pending: { variant: 'secondary', icon: Clock, label: 'Pending' },
+ queued: { variant: 'info', icon: Timer, label: 'Queued' },
+ ready: { variant: 'info', icon: Zap, label: 'Ready' },
+ blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
+ executing: { variant: 'warning', icon: Loader2, label: 'Executing' },
+ completed: { variant: 'success', icon: CheckCircle, label: 'Completed' },
+ failed: { variant: 'destructive', icon: XCircle, label: 'Failed' },
+ cancelled: { variant: 'secondary', icon: Square, label: 'Cancelled' },
+};
+
+// ========== Scheduler Status Styles ==========
+
+const SCHEDULER_STATUS_STYLE: Record = {
+ idle: 'bg-muted text-muted-foreground',
+ running: 'bg-blue-500/15 text-blue-600',
+ paused: 'bg-yellow-500/15 text-yellow-600',
+ stopping: 'bg-orange-500/15 text-orange-600',
+ completed: 'bg-green-500/15 text-green-600',
+ failed: 'bg-red-500/15 text-red-600',
+};
+
+// ========== Item Row ==========
+
+function QueueItemRow({
+ item,
+ isHighlighted,
+ onSelect,
+}: {
+ item: QueueItem;
+ isHighlighted: boolean;
+ onSelect: () => void;
+}) {
+ const { formatMessage } = useIntl();
+ const config = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.pending;
+ const StatusIcon = config.icon;
+
+ const executions = useQueueExecutionStore(selectByQueueItem(item.item_id));
+ const activeExec = executions.find((e) => e.status === 'running') ?? executions[0];
+ const sessionKey = item.sessionKey ?? activeExec?.sessionKey;
+
+ const isExecuting = item.status === 'executing';
+ const isBlocked = item.status === 'blocked';
+
+ // Show issue_id if available (for items added from IssuePanel)
+ const displayId = item.issue_id ? `${item.issue_id}` : item.item_id;
+
+ return (
+
+
+
+
+
+ {displayId}
+
+
+
+ {formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}`, defaultMessage: config.label })}
+
+
+
+ {item.execution_group && {item.execution_group}}
+ {sessionKey && (
+ <>
+ |
+
+
+ {sessionKey}
+
+ >
+ )}
+
+ {isBlocked && item.depends_on.length > 0 && (
+
+ {formatMessage(
+ { id: 'terminalDashboard.queuePanel.blockedBy', defaultMessage: 'Blocked by: {deps}' },
+ { deps: item.depends_on.join(', ') }
+ )}
+
+ )}
+
+ );
+}
+
+// ========== Inline Scheduler Controls ==========
+
+function SchedulerBar() {
+ const { formatMessage } = useIntl();
+ const status = useQueueSchedulerStore(selectQueueSchedulerStatus);
+ const progress = useQueueSchedulerStore(selectSchedulerProgress);
+ const concurrency = useQueueSchedulerStore(selectCurrentConcurrency);
+ const config = useQueueSchedulerStore(selectSchedulerConfig);
+ const startQueue = useQueueSchedulerStore((s) => s.startQueue);
+ const pauseQueue = useQueueSchedulerStore((s) => s.pauseQueue);
+ const stopQueue = useQueueSchedulerStore((s) => s.stopQueue);
+ const items = useQueueSchedulerStore(selectQueueItems);
+
+ const canStart = status === 'idle' && items.length > 0;
+ const canPause = status === 'running';
+ const canResume = status === 'paused';
+ const canStop = status === 'running' || status === 'paused';
+ const isActive = status !== 'idle';
+
+ const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false);
+
+ const handleStart = useCallback(() => {
+ if (canResume) {
+ startQueue();
+ } else if (canStart) {
+ startQueue(items);
+ }
+ }, [canResume, canStart, startQueue, items]);
+
+ return (
+
+
+ {/* Status badge */}
+
+ {formatMessage({ id: `terminalDashboard.queuePanel.scheduler.status.${status}`, defaultMessage: status })}
+
+
+ {/* Progress + Concurrency */}
+ {isActive && (
+
+ {progress}% | {concurrency}/{config.maxConcurrentSessions}
+
+ )}
+
+ {/* Controls */}
+
+ {(canStart || canResume) && (
+
+
+
+ )}
+ {canPause && (
+
+
+
+ )}
+ {canStop && (
+
setIsStopConfirmOpen(true)}
+ title={formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
+ >
+
+
+ )}
+
+
+
+ {/* Progress bar */}
+ {isActive && (
+
+ )}
+
+ {/* Stop confirmation dialog */}
+
+
+
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmTitle', defaultMessage: 'Stop Queue?' })}
+
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmMessage', defaultMessage: 'Executing tasks will finish, but no new tasks will be started.' })}
+
+
+
+
+ {formatMessage({ id: 'common.cancel', defaultMessage: 'Cancel' })}
+
+ { stopQueue(); setIsStopConfirmOpen(false); }}
+ >
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
+
+
+
+
+
+ );
+}
+
+// ========== Main Component ==========
+
+export function QueueListColumn() {
+ const { formatMessage } = useIntl();
+
+ const items = useQueueSchedulerStore(selectQueueItems);
+ const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
+ const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain);
+
+ const sortedItems = useMemo(
+ () => [...items].sort((a, b) => a.execution_order - b.execution_order),
+ [items]
+ );
+
+ const handleSelect = useCallback(
+ (queueItemId: string) => {
+ buildAssociationChain(queueItemId, 'queue');
+ },
+ [buildAssociationChain]
+ );
+
+ return (
+
+ {/* Item list */}
+ {sortedItems.length === 0 ? (
+
+
+
+
{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc', defaultMessage: 'Select issues and click Queue to add items' })}
+
+
+
+ ) : (
+
+ {sortedItems.map((item) => (
+ handleSelect(item.item_id)}
+ />
+ ))}
+
+ )}
+
+ {/* Inline scheduler controls */}
+
+
+ );
+}
diff --git a/ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx b/ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx
index a089212a..145d5a2f 100644
--- a/ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx
+++ b/ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx
@@ -2,9 +2,10 @@
// QueuePanel Component
// ========================================
// Queue list panel for the terminal dashboard with tab switching.
-// Tab 1 (Queue): Issue queue items from useIssueQueue() hook.
+// Tab 1 (Queue): Issue queue items from queueSchedulerStore (with useIssueQueue() fallback).
// Tab 2 (Orchestrator): Active orchestration plans from orchestratorStore.
// Integrates with issueQueueIntegrationStore for association chain.
+// Note: Scheduler controls are in the standalone SchedulerPanel.
import { useState, useMemo, useCallback, memo } from 'react';
import { useIntl } from 'react-intl';
@@ -27,6 +28,7 @@ import {
Square,
RotateCcw,
AlertCircle,
+ Timer,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -40,6 +42,11 @@ import {
useQueueExecutionStore,
selectByQueueItem,
} from '@/stores/queueExecutionStore';
+import {
+ useQueueSchedulerStore,
+ selectQueueSchedulerStatus,
+ selectQueueItems,
+} from '@/stores/queueSchedulerStore';
import {
useOrchestratorStore,
selectActivePlans,
@@ -47,54 +54,77 @@ import {
type OrchestrationRunState,
} from '@/stores/orchestratorStore';
import type { StepStatus, OrchestrationStatus } from '@/types/orchestrator';
-import type { QueueItem } from '@/lib/api';
+import type { QueueItem as ApiQueueItem } from '@/lib/api';
+import type { QueueItem as SchedulerQueueItem, QueueItemStatus as SchedulerQueueItemStatus } from '@/types/queue-frontend-types';
// ========== Tab Type ==========
type QueueTab = 'queue' | 'orchestrator';
+// ========== Queue Tab: Unified Item Type ==========
+
+/**
+ * Unified queue item type that works with both the legacy API QueueItem
+ * and the new scheduler QueueItem. The scheduler type is a superset.
+ */
+type UnifiedQueueItem = ApiQueueItem | SchedulerQueueItem;
+
+/**
+ * Combined status type covering both scheduler statuses and legacy API statuses.
+ */
+type CombinedQueueItemStatus = SchedulerQueueItemStatus | ApiQueueItem['status'];
+
// ========== Queue Tab: Status Config ==========
-type QueueItemStatus = QueueItem['status'];
-
-const STATUS_CONFIG: Record = {
pending: { variant: 'secondary', icon: Clock, label: 'Pending' },
+ queued: { variant: 'info', icon: Timer, label: 'Queued' },
ready: { variant: 'info', icon: Zap, label: 'Ready' },
+ blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
executing: { variant: 'warning', icon: Loader2, label: 'Executing' },
completed: { variant: 'success', icon: CheckCircle, label: 'Completed' },
failed: { variant: 'destructive', icon: XCircle, label: 'Failed' },
- blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
+ cancelled: { variant: 'secondary', icon: Square, label: 'Cancelled' },
};
-// ========== Queue Tab: Item Row ==========
+// ========== Queue Tab: Content ==========
function QueueItemRow({
item,
isHighlighted,
onSelect,
}: {
- item: QueueItem;
+ item: UnifiedQueueItem;
isHighlighted: boolean;
onSelect: () => void;
}) {
const { formatMessage } = useIntl();
- const config = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.pending;
+ const statusKey = item.status as CombinedQueueItemStatus;
+ const config = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.pending;
const StatusIcon = config.icon;
const executions = useQueueExecutionStore(selectByQueueItem(item.item_id));
const activeExec = executions.find((e) => e.status === 'running') ?? executions[0];
+ // Session key: prefer scheduler QueueItem.sessionKey, fallback to execution store
+ const schedulerItem = item as SchedulerQueueItem;
+ const sessionKey = schedulerItem.sessionKey ?? activeExec?.sessionKey;
+
+ const isExecuting = item.status === 'executing';
+ const isBlocked = item.status === 'blocked';
+
return (
@@ -103,7 +133,7 @@ function QueueItemRow({
@@ -111,7 +141,7 @@ function QueueItemRow({
- {formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}` })}
+ {formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}`, defaultMessage: config.label })}
@@ -125,17 +155,25 @@ function QueueItemRow({
|
{item.execution_group}
- {activeExec?.sessionKey && (
+ {sessionKey && (
<>
|
- {activeExec.sessionKey}
+ {sessionKey}
>
)}
- {item.depends_on.length > 0 && (
+ {isBlocked && item.depends_on.length > 0 && (
+
+ {formatMessage(
+ { id: 'terminalDashboard.queuePanel.blockedBy', defaultMessage: 'Blocked by: {deps}' },
+ { deps: item.depends_on.join(', ') }
+ )}
+
+ )}
+ {!isBlocked && item.depends_on.length > 0 && (
{formatMessage(
{ id: 'terminalDashboard.queuePanel.dependsOn' },
@@ -151,14 +189,24 @@ function QueueItemRow({
function QueueTabContent(_props: { embedded?: boolean }) {
const { formatMessage } = useIntl();
+
+ // Scheduler store data
+ const schedulerItems = useQueueSchedulerStore(selectQueueItems);
+ const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
+
+ // Legacy API data (fallback)
const queueQuery = useIssueQueue();
+
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain);
- const allItems = useMemo(() => {
+ // Use scheduler items when scheduler has data, otherwise fall back to legacy API
+ const useSchedulerData = schedulerItems.length > 0 || schedulerStatus !== 'idle';
+
+ const legacyItems = useMemo(() => {
if (!queueQuery.data) return [];
const grouped = queueQuery.data.grouped_items ?? {};
- const items: QueueItem[] = [];
+ const items: ApiQueueItem[] = [];
for (const group of Object.values(grouped)) {
items.push(...group);
}
@@ -166,6 +214,13 @@ function QueueTabContent(_props: { embedded?: boolean }) {
return items;
}, [queueQuery.data]);
+ const allItems: UnifiedQueueItem[] = useMemo(() => {
+ if (useSchedulerData) {
+ return [...schedulerItems].sort((a, b) => a.execution_order - b.execution_order);
+ }
+ return legacyItems;
+ }, [useSchedulerData, schedulerItems, legacyItems]);
+
const handleSelect = useCallback(
(queueItemId: string) => {
buildAssociationChain(queueItemId, 'queue');
@@ -173,7 +228,8 @@ function QueueTabContent(_props: { embedded?: boolean }) {
[buildAssociationChain]
);
- if (queueQuery.isLoading) {
+ // Show loading only for legacy mode
+ if (!useSchedulerData && queueQuery.isLoading) {
return (
@@ -181,7 +237,7 @@ function QueueTabContent(_props: { embedded?: boolean }) {
);
}
- if (queueQuery.error) {
+ if (!useSchedulerData && queueQuery.error) {
return (
@@ -446,8 +502,23 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
const [activeTab, setActiveTab] = useState
('queue');
const orchestratorCount = useOrchestratorStore(selectActivePlanCount);
+ // Scheduler store data for active count
+ const schedulerItems = useQueueSchedulerStore(selectQueueItems);
+ const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
+ const useSchedulerData = schedulerItems.length > 0 || schedulerStatus !== 'idle';
+
+ // Legacy API data for active count fallback
const queueQuery = useIssueQueue();
+
const queueActiveCount = useMemo(() => {
+ if (useSchedulerData) {
+ return schedulerItems.filter(
+ (item) =>
+ item.status === 'pending' ||
+ item.status === 'queued' ||
+ item.status === 'executing'
+ ).length;
+ }
if (!queueQuery.data) return 0;
const grouped = queueQuery.data.grouped_items ?? {};
let count = 0;
@@ -457,7 +528,7 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
).length;
}
return count;
- }, [queueQuery.data]);
+ }, [useSchedulerData, schedulerItems, queueQuery.data]);
return (
diff --git a/ccw/frontend/src/components/terminal-dashboard/SchedulerPanel.tsx b/ccw/frontend/src/components/terminal-dashboard/SchedulerPanel.tsx
new file mode 100644
index 00000000..8ef5a7d1
--- /dev/null
+++ b/ccw/frontend/src/components/terminal-dashboard/SchedulerPanel.tsx
@@ -0,0 +1,262 @@
+// ========================================
+// SchedulerPanel Component
+// ========================================
+// Standalone queue scheduler control panel.
+// Shows scheduler status, start/pause/stop controls, concurrency config,
+// progress bar, and session pool overview.
+
+import { useCallback, useState } from 'react';
+import { useIntl } from 'react-intl';
+import {
+ Play,
+ Pause,
+ Square,
+ Loader2,
+} from 'lucide-react';
+import { Button } from '@/components/ui/Button';
+import { Progress } from '@/components/ui/Progress';
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogAction,
+ AlertDialogCancel,
+} from '@/components/ui/AlertDialog';
+import { cn } from '@/lib/utils';
+import {
+ useQueueSchedulerStore,
+ selectQueueSchedulerStatus,
+ selectSchedulerProgress,
+ selectSchedulerConfig,
+ selectSessionPool,
+ selectCurrentConcurrency,
+ selectSchedulerError,
+} from '@/stores/queueSchedulerStore';
+import type { QueueSchedulerStatus } from '@/types/queue-frontend-types';
+
+// ========== Status Badge Config ==========
+
+const SCHEDULER_STATUS_CLASS: Record
= {
+ idle: 'bg-muted text-muted-foreground border-border',
+ running: 'bg-primary/10 text-primary border-primary/50',
+ paused: 'bg-amber-500/10 text-amber-500 border-amber-500/50',
+ stopping: 'bg-orange-500/10 text-orange-500 border-orange-500/50',
+ completed: 'bg-green-500/10 text-green-500 border-green-500/50',
+ failed: 'bg-destructive/10 text-destructive border-destructive/50',
+};
+
+// ========== Component ==========
+
+export function SchedulerPanel() {
+ const { formatMessage } = useIntl();
+
+ const schedulerStatus = useQueueSchedulerStore(selectQueueSchedulerStatus);
+ const progress = useQueueSchedulerStore(selectSchedulerProgress);
+ const config = useQueueSchedulerStore(selectSchedulerConfig);
+ const sessionPool = useQueueSchedulerStore(selectSessionPool);
+ const concurrency = useQueueSchedulerStore(selectCurrentConcurrency);
+ const error = useQueueSchedulerStore(selectSchedulerError);
+ const startQueue = useQueueSchedulerStore((s) => s.startQueue);
+ const pauseQueue = useQueueSchedulerStore((s) => s.pauseQueue);
+ const stopQueue = useQueueSchedulerStore((s) => s.stopQueue);
+ const updateConfig = useQueueSchedulerStore((s) => s.updateConfig);
+
+ const isIdle = schedulerStatus === 'idle';
+ const isRunning = schedulerStatus === 'running';
+ const isPaused = schedulerStatus === 'paused';
+ const isStopping = schedulerStatus === 'stopping';
+ const isTerminal = schedulerStatus === 'completed' || schedulerStatus === 'failed';
+
+ const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false);
+
+ const handleConcurrencyChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value, 10);
+ if (!isNaN(value) && value >= 1 && value <= 10) {
+ updateConfig({ maxConcurrentSessions: value });
+ }
+ },
+ [updateConfig]
+ );
+
+ const sessionEntries = Object.entries(sessionPool);
+
+ return (
+
+ {/* Status + Controls */}
+
+ {/* Status badge */}
+
+
+ {formatMessage({
+ id: `terminalDashboard.queuePanel.scheduler.status.${schedulerStatus}`,
+ defaultMessage: schedulerStatus,
+ })}
+
+
+ {concurrency}/{config.maxConcurrentSessions}
+
+
+
+ {/* Control buttons */}
+
+ {(isIdle || isTerminal) && (
+
startQueue()}
+ >
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.start', defaultMessage: 'Start' })}
+
+ )}
+ {isPaused && (
+
startQueue()}
+ >
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.start', defaultMessage: 'Resume' })}
+
+ )}
+ {isRunning && (
+
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.pause', defaultMessage: 'Pause' })}
+
+ )}
+ {(isRunning || isPaused) && (
+
setIsStopConfirmOpen(true)}
+ >
+ {isStopping ? : }
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
+
+ )}
+
+
+ {/* Progress bar (visible when not idle) */}
+ {!isIdle && (
+
+
+
+ {formatMessage(
+ { id: 'terminalDashboard.queuePanel.scheduler.progress', defaultMessage: '{percent}%' },
+ { percent: progress }
+ )}
+
+
+ )}
+
+
+ {/* Concurrency Config */}
+
+
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.concurrency', defaultMessage: 'Concurrency' })}
+
+
+
+
+
+ {/* Session Pool */}
+
+
+
+ {formatMessage({ id: 'terminalDashboard.schedulerPanel.sessionPool', defaultMessage: 'Session Pool' })}
+
+ {sessionEntries.length === 0 ? (
+
+ {formatMessage({ id: 'terminalDashboard.schedulerPanel.noSessions', defaultMessage: 'No active sessions' })}
+
+ ) : (
+
+ {sessionEntries.map(([resumeKey, binding]) => (
+
+
+ {binding.sessionKey}
+ {resumeKey}
+
+
+ {new Date(binding.lastUsed).toLocaleTimeString()}
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Error display */}
+ {error && (
+
+ )}
+
+ {/* Stop confirmation dialog */}
+
+
+
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmTitle', defaultMessage: 'Stop Queue?' })}
+
+
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stopConfirmMessage', defaultMessage: 'Executing tasks will finish, but no new tasks will be started. Pending items will remain in the queue.' })}
+
+
+
+
+ {formatMessage({ id: 'common.cancel', defaultMessage: 'Cancel' })}
+
+ { stopQueue(); setIsStopConfirmOpen(false); }}
+ >
+ {formatMessage({ id: 'terminalDashboard.queuePanel.scheduler.stop', defaultMessage: 'Stop' })}
+
+
+
+
+
+ );
+}
diff --git a/ccw/frontend/src/hooks/useHistory.ts b/ccw/frontend/src/hooks/useHistory.ts
index 21fc27fc..cc9ede99 100644
--- a/ccw/frontend/src/hooks/useHistory.ts
+++ b/ccw/frontend/src/hooks/useHistory.ts
@@ -85,9 +85,14 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
executions = executions.filter(
- (exec) =>
- exec.prompt_preview.toLowerCase().includes(searchLower) ||
- exec.tool.toLowerCase().includes(searchLower)
+ (exec) => {
+ // Guard against prompt_preview being an object instead of string
+ const preview = typeof exec.prompt_preview === 'string'
+ ? exec.prompt_preview
+ : JSON.stringify(exec.prompt_preview);
+ return preview.toLowerCase().includes(searchLower) ||
+ exec.tool.toLowerCase().includes(searchLower);
+ }
);
}
diff --git a/ccw/frontend/src/hooks/useNativeSessions.ts b/ccw/frontend/src/hooks/useNativeSessions.ts
new file mode 100644
index 00000000..28664f8a
--- /dev/null
+++ b/ccw/frontend/src/hooks/useNativeSessions.ts
@@ -0,0 +1,127 @@
+// ========================================
+// useNativeSessions Hook
+// ========================================
+// TanStack Query hook for native CLI sessions list
+
+import React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+ fetchNativeSessions,
+ type NativeSessionListItem,
+ type NativeSessionsListResponse,
+} from '../lib/api';
+import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
+import { workspaceQueryKeys } from '@/lib/queryKeys';
+
+// ========== Constants ==========
+
+const STALE_TIME = 30 * 1000;
+const GC_TIME = 5 * 60 * 1000;
+
+// ========== Types ==========
+
+export type NativeTool = 'gemini' | 'qwen' | 'codex' | 'claude' | 'opencode';
+
+export interface UseNativeSessionsOptions {
+ /** Filter by tool type */
+ tool?: NativeTool;
+ /** Override default stale time (ms) */
+ staleTime?: number;
+ /** Override default gc time (ms) */
+ gcTime?: number;
+ /** Enable/disable the query */
+ enabled?: boolean;
+}
+
+export interface ByToolRecord {
+ [tool: string]: NativeSessionListItem[];
+}
+
+export interface UseNativeSessionsReturn {
+ /** All sessions data */
+ sessions: NativeSessionListItem[];
+ /** Sessions grouped by tool */
+ byTool: ByToolRecord;
+ /** Total count from API */
+ count: number;
+ /** Loading state for initial fetch */
+ isLoading: boolean;
+ /** Fetching state (initial or refetch) */
+ isFetching: boolean;
+ /** Error object if query failed */
+ error: Error | null;
+ /** Manually refetch data */
+ refetch: () => Promise;
+}
+
+// ========== Helper Functions ==========
+
+/**
+ * Group sessions by tool type
+ */
+function groupByTool(sessions: NativeSessionListItem[]): ByToolRecord {
+ return sessions.reduce((acc, session) => {
+ const tool = session.tool;
+ if (!acc[tool]) {
+ acc[tool] = [];
+ }
+ acc[tool].push(session);
+ return acc;
+ }, {});
+}
+
+// ========== Hook ==========
+
+/**
+ * Hook for fetching native CLI sessions list
+ *
+ * @example
+ * ```tsx
+ * const { sessions, byTool, isLoading } = useNativeSessions();
+ * const geminiSessions = byTool['gemini'] ?? [];
+ * ```
+ *
+ * @example
+ * ```tsx
+ * // Filter by tool
+ * const { sessions } = useNativeSessions({ tool: 'gemini' });
+ * ```
+ */
+export function useNativeSessions(
+ options: UseNativeSessionsOptions = {}
+): UseNativeSessionsReturn {
+ const { tool, staleTime = STALE_TIME, gcTime = GC_TIME, enabled = true } = options;
+ const projectPath = useWorkflowStore(selectProjectPath);
+
+ const query = useQuery({
+ queryKey: workspaceQueryKeys.nativeSessionsList(projectPath, tool),
+ queryFn: () => fetchNativeSessions(tool, projectPath),
+ staleTime,
+ gcTime,
+ enabled,
+ refetchOnWindowFocus: false,
+ retry: 2,
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
+ });
+
+ // Memoize sessions and byTool calculations
+ const { sessions, byTool } = React.useMemo(() => {
+ const sessions = query.data?.sessions ?? [];
+ const byTool = groupByTool(sessions);
+ return { sessions, byTool };
+ }, [query.data]);
+
+ const refetch = async () => {
+ await query.refetch();
+ };
+
+ return {
+ sessions,
+ byTool,
+ count: query.data?.count ?? 0,
+ isLoading: query.isLoading,
+ isFetching: query.isFetching,
+ error: query.error,
+ refetch,
+ };
+}
diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts
index 1a20912a..7f353aa1 100644
--- a/ccw/frontend/src/lib/api.ts
+++ b/ccw/frontend/src/lib/api.ts
@@ -2100,7 +2100,8 @@ export interface CliExecution {
tool: 'gemini' | 'qwen' | 'codex' | string;
mode?: string;
status: 'success' | 'error' | 'timeout';
- prompt_preview: string;
+ // Backend may return string or object {text: string} for legacy data
+ prompt_preview: string | { text: string } | Record;
timestamp: string;
duration_ms: number;
sourceDir?: string;
@@ -2233,7 +2234,8 @@ export interface ConversationRecord {
*/
export interface ConversationTurn {
turn: number;
- prompt: string;
+ // Backend may return string or object {text: string} for legacy data
+ prompt: string | { text: string } | Record;
output: {
stdout: string;
stderr?: string;
@@ -2270,7 +2272,8 @@ export interface NativeSessionTurn {
turnNumber: number;
timestamp: string;
role: 'user' | 'assistant';
- content: string;
+ // Backend may return string or object {text: string} for legacy data
+ content: string | { text: string } | Record;
thoughts?: string[];
toolCalls?: NativeToolCall[];
tokens?: NativeTokenInfo;
diff --git a/ccw/frontend/src/lib/cli-tool-theme.ts b/ccw/frontend/src/lib/cli-tool-theme.ts
new file mode 100644
index 00000000..5382688b
--- /dev/null
+++ b/ccw/frontend/src/lib/cli-tool-theme.ts
@@ -0,0 +1,171 @@
+// ========================================
+// CLI Tool Theme Configuration
+// ========================================
+// Centralized theme configuration for CLI tools (gemini, codex, qwen, opencode)
+// Used for Badge variants, icons, and color theming across components
+
+import type { LucideIcon } from 'lucide-react';
+import { Sparkles, Code, Brain, Terminal, Cpu } from 'lucide-react';
+
+// ========== Types ==========
+
+export type BadgeVariant = 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' | 'destructive';
+
+export interface CliToolTheme {
+ /** Badge variant for UI components */
+ variant: BadgeVariant;
+ /** Lucide icon name */
+ icon: LucideIcon;
+ /** Color theme (used for CSS classes) */
+ color: 'blue' | 'green' | 'amber' | 'gray' | 'purple';
+ /** Human-readable display name */
+ displayName: string;
+ /** Short label for compact display */
+ shortLabel: string;
+}
+
+// ========== Tool Theme Configuration ==========
+
+/**
+ * Theme configuration for each supported CLI tool
+ * Maps tool ID to visual theme properties
+ */
+export const CLI_TOOL_THEMES: Record = {
+ gemini: {
+ variant: 'info',
+ icon: Sparkles,
+ color: 'blue',
+ displayName: 'Gemini',
+ shortLabel: 'GEM',
+ },
+ codex: {
+ variant: 'success',
+ icon: Code,
+ color: 'green',
+ displayName: 'Codex',
+ shortLabel: 'CDX',
+ },
+ qwen: {
+ variant: 'warning',
+ icon: Brain,
+ color: 'amber',
+ displayName: 'Qwen',
+ shortLabel: 'QWN',
+ },
+ opencode: {
+ variant: 'secondary',
+ icon: Terminal,
+ color: 'gray',
+ displayName: 'OpenCode',
+ shortLabel: 'OPC',
+ },
+ claude: {
+ variant: 'default',
+ icon: Cpu,
+ color: 'purple',
+ displayName: 'Claude',
+ shortLabel: 'CLD',
+ },
+};
+
+/**
+ * Default theme for unknown tools
+ */
+export const DEFAULT_TOOL_THEME: CliToolTheme = {
+ variant: 'secondary',
+ icon: Terminal,
+ color: 'gray',
+ displayName: 'CLI',
+ shortLabel: 'CLI',
+};
+
+// ========== Helper Functions ==========
+
+/**
+ * Get theme configuration for a CLI tool
+ * Falls back to default theme for unknown tools
+ *
+ * @param tool - Tool identifier (e.g., 'gemini', 'codex', 'qwen')
+ * @returns Theme configuration for the tool
+ */
+export function getToolTheme(tool: string): CliToolTheme {
+ const normalizedTool = tool.toLowerCase().trim();
+ return CLI_TOOL_THEMES[normalizedTool] || DEFAULT_TOOL_THEME;
+}
+
+/**
+ * Get Badge variant for a CLI tool
+ * Used for tool badges in UI components
+ *
+ * @param tool - Tool identifier
+ * @returns Badge variant
+ */
+export function getToolVariant(tool: string): BadgeVariant {
+ return getToolTheme(tool).variant;
+}
+
+/**
+ * Get icon component for a CLI tool
+ *
+ * @param tool - Tool identifier
+ * @returns Lucide icon component
+ */
+export function getToolIcon(tool: string): LucideIcon {
+ return getToolTheme(tool).icon;
+}
+
+/**
+ * Get color class for a CLI tool
+ * Returns a Tailwind CSS color class prefix
+ *
+ * @param tool - Tool identifier
+ * @returns Color class prefix (e.g., 'text-blue-500')
+ */
+export function getToolColorClass(tool: string, shade: number = 500): string {
+ const color = getToolTheme(tool).color;
+ const colorMap: Record = {
+ blue: 'blue',
+ green: 'green',
+ amber: 'amber',
+ gray: 'gray',
+ purple: 'purple',
+ };
+ return `text-${colorMap[color] || 'gray'}-${shade}`;
+}
+
+/**
+ * Get background color class for a CLI tool
+ *
+ * @param tool - Tool identifier
+ * @returns Background color class (e.g., 'bg-blue-100')
+ */
+export function getToolBgClass(tool: string, shade: number = 100): string {
+ const color = getToolTheme(tool).color;
+ const colorMap: Record = {
+ blue: 'blue',
+ green: 'green',
+ amber: 'amber',
+ gray: 'gray',
+ purple: 'purple',
+ };
+ return `bg-${colorMap[color] || 'gray'}-${shade}`;
+}
+
+/**
+ * Check if a tool is a known CLI tool
+ *
+ * @param tool - Tool identifier
+ * @returns Whether the tool is known
+ */
+export function isKnownTool(tool: string): boolean {
+ return tool.toLowerCase().trim() in CLI_TOOL_THEMES;
+}
+
+/**
+ * Get all known tool identifiers
+ *
+ * @returns Array of known tool IDs
+ */
+export function getKnownTools(): string[] {
+ return Object.keys(CLI_TOOL_THEMES);
+}
diff --git a/ccw/frontend/src/lib/queryKeys.ts b/ccw/frontend/src/lib/queryKeys.ts
index a5f3cbbb..61f522bb 100644
--- a/ccw/frontend/src/lib/queryKeys.ts
+++ b/ccw/frontend/src/lib/queryKeys.ts
@@ -118,6 +118,11 @@ export const workspaceQueryKeys = {
cliExecutionDetail: (projectPath: string, executionId: string) =>
[...workspaceQueryKeys.cliHistory(projectPath), 'detail', executionId] as const,
+ // ========== Native Sessions ==========
+ nativeSessions: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'nativeSessions'] as const,
+ nativeSessionsList: (projectPath: string, tool?: string) =>
+ [...workspaceQueryKeys.nativeSessions(projectPath), 'list', tool] as const,
+
// ========== Audit ==========
audit: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'audit'] as const,
cliSessionAudit: (
diff --git a/ccw/frontend/src/locales/en/history.json b/ccw/frontend/src/locales/en/history.json
index 0747d350..6d13a810 100644
--- a/ccw/frontend/src/locales/en/history.json
+++ b/ccw/frontend/src/locales/en/history.json
@@ -3,7 +3,8 @@
"description": "View and manage your CLI execution history",
"tabs": {
"executions": "Executions",
- "observability": "Session Audit"
+ "observability": "Session Audit",
+ "nativeSessions": "Native Sessions"
},
"searchPlaceholder": "Search executions...",
"filterAllTools": "All Tools",
@@ -33,5 +34,13 @@
"message": "CLI execution history will appear here when you run CLI commands.",
"filtered": "No Matching Results",
"filteredMessage": "No executions match your current filter. Try adjusting your search or filter."
+ },
+ "nativeSessions": {
+ "count": "{count} native sessions",
+ "sessions": "sessions",
+ "empty": {
+ "title": "No Native Sessions",
+ "message": "Native CLI sessions from Gemini, Codex, Qwen, etc. will appear here."
+ }
}
}
diff --git a/ccw/frontend/src/locales/en/terminal-dashboard.json b/ccw/frontend/src/locales/en/terminal-dashboard.json
index 6c1e214a..3fa7cff5 100644
--- a/ccw/frontend/src/locales/en/terminal-dashboard.json
+++ b/ccw/frontend/src/locales/en/terminal-dashboard.json
@@ -48,6 +48,11 @@
"issuePanel": {
"title": "Issues",
"sendToQueue": "Send to Queue",
+ "addToQueue": "Queue",
+ "addToQueueHint": "Add selected issues to the execution queue",
+ "addedToQueue": "Queued!",
+ "addToQueueFailed": "Failed to add to queue",
+ "queueModeHint": "Execution mode for queued items",
"noIssues": "No issues found",
"noIssuesDesc": "Issues will appear here when discovered",
"error": "Failed to load issues"
@@ -59,13 +64,35 @@
"error": "Failed to load queue",
"order": "#{order}",
"dependsOn": "Depends on: {deps}",
+ "blockedBy": "Blocked by: {deps}",
"status": {
"pending": "Pending",
+ "queued": "Queued",
"ready": "Ready",
+ "blocked": "Blocked",
"executing": "Executing",
"completed": "Completed",
"failed": "Failed",
- "blocked": "Blocked"
+ "cancelled": "Cancelled",
+ "skipped": "Skipped"
+ },
+ "scheduler": {
+ "start": "Start",
+ "pause": "Pause",
+ "stop": "Stop",
+ "status": {
+ "idle": "Idle",
+ "running": "Running",
+ "paused": "Paused",
+ "stopping": "Stopping",
+ "completed": "Completed",
+ "failed": "Failed"
+ },
+ "progress": "{percent}%",
+ "concurrency": "Concurrency",
+ "concurrencyLabel": "Max",
+ "stopConfirmTitle": "Stop Queue?",
+ "stopConfirmMessage": "Executing tasks will finish, but no new tasks will be started. Pending items will remain in the queue."
}
},
"toolbar": {
@@ -81,7 +108,13 @@
"launchCli": "New Session",
"launchCliHint": "Click to configure and create a new CLI session",
"fullscreen": "Fullscreen",
- "orchestrator": "Orchestrator"
+ "orchestrator": "Orchestrator",
+ "scheduler": "Scheduler",
+ "executionMonitor": "Execution Monitor"
+ },
+ "schedulerPanel": {
+ "sessionPool": "Session Pool",
+ "noSessions": "No active sessions"
},
"orchestratorPanel": {
"noPlans": "No active orchestrations",
diff --git a/ccw/frontend/src/locales/zh/history.json b/ccw/frontend/src/locales/zh/history.json
index e17d7a29..5da35235 100644
--- a/ccw/frontend/src/locales/zh/history.json
+++ b/ccw/frontend/src/locales/zh/history.json
@@ -3,7 +3,8 @@
"description": "查看和管理 CLI 执行历史",
"tabs": {
"executions": "执行历史",
- "observability": "会话审计"
+ "observability": "会话审计",
+ "nativeSessions": "原生会话"
},
"searchPlaceholder": "搜索执行记录...",
"filterAllTools": "全部工具",
@@ -33,5 +34,13 @@
"message": "运行 CLI 命令后,执行历史将显示在这里。",
"filtered": "没有匹配结果",
"filteredMessage": "没有匹配当前筛选条件的执行记录。请尝试调整搜索或筛选条件。"
+ },
+ "nativeSessions": {
+ "count": "{count} 个原生会话",
+ "sessions": "个会话",
+ "empty": {
+ "title": "无原生会话",
+ "message": "来自 Gemini、Codex、Qwen 等的原生 CLI 会话将显示在这里。"
+ }
}
}
diff --git a/ccw/frontend/src/locales/zh/terminal-dashboard.json b/ccw/frontend/src/locales/zh/terminal-dashboard.json
index cf9356ed..b0f54013 100644
--- a/ccw/frontend/src/locales/zh/terminal-dashboard.json
+++ b/ccw/frontend/src/locales/zh/terminal-dashboard.json
@@ -48,6 +48,11 @@
"issuePanel": {
"title": "问题",
"sendToQueue": "发送到队列",
+ "addToQueue": "入队",
+ "addToQueueHint": "将选中的问题添加到执行队列",
+ "addedToQueue": "已入队!",
+ "addToQueueFailed": "添加到队列失败",
+ "queueModeHint": "队列项的执行模式",
"noIssues": "暂无问题",
"noIssuesDesc": "发现问题时将在此显示",
"error": "加载问题失败"
@@ -59,13 +64,35 @@
"error": "加载队列失败",
"order": "#{order}",
"dependsOn": "依赖: {deps}",
+ "blockedBy": "阻塞于: {deps}",
"status": {
"pending": "等待中",
+ "queued": "排队中",
"ready": "就绪",
+ "blocked": "已阻塞",
"executing": "执行中",
"completed": "已完成",
"failed": "已失败",
- "blocked": "已阻塞"
+ "cancelled": "已取消",
+ "skipped": "已跳过"
+ },
+ "scheduler": {
+ "start": "启动",
+ "pause": "暂停",
+ "stop": "停止",
+ "status": {
+ "idle": "空闲",
+ "running": "运行中",
+ "paused": "已暂停",
+ "stopping": "停止中",
+ "completed": "已完成",
+ "failed": "已失败"
+ },
+ "progress": "{percent}%",
+ "concurrency": "并发数",
+ "concurrencyLabel": "上限",
+ "stopConfirmTitle": "停止队列?",
+ "stopConfirmMessage": "执行中的任务将继续完成,但不会启动新任务。待处理项将保留在队列中。"
}
},
"toolbar": {
@@ -81,7 +108,13 @@
"launchCli": "新建会话",
"launchCliHint": "点击配置并创建新的 CLI 会话",
"fullscreen": "全屏",
- "orchestrator": "编排器"
+ "orchestrator": "编排器",
+ "scheduler": "调度器",
+ "executionMonitor": "执行监控"
+ },
+ "schedulerPanel": {
+ "sessionPool": "会话池",
+ "noSessions": "无活跃会话"
},
"orchestratorPanel": {
"noPlans": "没有活跃的编排任务",
diff --git a/ccw/frontend/src/pages/HistoryPage.tsx b/ccw/frontend/src/pages/HistoryPage.tsx
index dfaac498..103d705e 100644
--- a/ccw/frontend/src/pages/HistoryPage.tsx
+++ b/ccw/frontend/src/pages/HistoryPage.tsx
@@ -16,10 +16,16 @@ import {
X,
Maximize2,
Minimize2,
+ ChevronDown,
+ ChevronRight,
+ FileJson,
+ Clock,
+ Calendar,
} from 'lucide-react';
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
import { cn } from '@/lib/utils';
import { useHistory } from '@/hooks/useHistory';
+import { useNativeSessions } from '@/hooks/useNativeSessions';
import { ConversationCard } from '@/components/shared/ConversationCard';
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
import { NativeSessionPanel } from '@/components/shared/NativeSessionPanel';
@@ -42,9 +48,55 @@ import {
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/Dropdown';
-import type { CliExecution } from '@/lib/api';
+import { Badge } from '@/components/ui/Badge';
+import type { CliExecution, NativeSessionListItem } from '@/lib/api';
+import { getToolVariant } from '@/lib/cli-tool-theme';
-type HistoryTab = 'executions' | 'observability';
+type HistoryTab = 'executions' | 'observability' | 'native-sessions';
+
+// ========== Date Grouping Helpers ==========
+
+type DateGroup = 'today' | 'yesterday' | 'thisWeek' | 'older';
+
+function getDateGroup(date: Date): DateGroup {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+ const weekAgo = new Date(today);
+ weekAgo.setDate(weekAgo.getDate() - 7);
+
+ if (date >= today) return 'today';
+ if (date >= yesterday) return 'yesterday';
+ if (date >= weekAgo) return 'thisWeek';
+ return 'older';
+}
+
+function groupSessionsByDate(sessions: NativeSessionListItem[]): Map {
+ const groups = new Map([
+ ['today', []],
+ ['yesterday', []],
+ ['thisWeek', []],
+ ['older', []],
+ ]);
+
+ sessions.forEach((session) => {
+ const date = new Date(session.updatedAt);
+ const group = getDateGroup(date);
+ groups.get(group)?.push(session);
+ });
+
+ return groups;
+}
+
+const dateGroupOrder: DateGroup[] = ['today', 'yesterday', 'thisWeek', 'older'];
+
+const dateGroupLabels: Record = {
+ today: '今天',
+ yesterday: '昨天',
+ thisWeek: '本周',
+ older: '更早',
+};
/**
* HistoryPage component - Display CLI execution history
@@ -78,6 +130,40 @@ export function HistoryPage() {
filter: { search: searchQuery || undefined, tool: toolFilter },
});
+ // Native sessions hook
+ const {
+ sessions: nativeSessions,
+ byTool: nativeSessionsByTool,
+ isLoading: isLoadingNativeSessions,
+ isFetching: isFetchingNativeSessions,
+ error: nativeSessionsError,
+ refetch: refetchNativeSessions,
+ } = useNativeSessions();
+
+ // Track expanded tool groups in native sessions tab
+ const [expandedTools, setExpandedTools] = React.useState>(new Set());
+
+ const toggleToolExpand = (tool: string) => {
+ setExpandedTools((prev) => {
+ const next = new Set(prev);
+ if (next.has(tool)) {
+ next.delete(tool);
+ } else {
+ next.add(tool);
+ }
+ return next;
+ });
+ };
+
+ // Native session click handler - opens NativeSessionPanel
+ const handleNativeSessionClick = (session: NativeSessionListItem) => {
+ setNativeExecutionId(session.id);
+ setIsNativePanelOpen(true);
+ };
+
+ // Tool order for display
+ const toolOrder = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const;
+
const tools = React.useMemo(() => {
const toolSet = new Set(executions.map((e) => e.tool));
return Array.from(toolSet).sort();
@@ -197,6 +283,19 @@ export function HistoryPage() {
>
{formatMessage({ id: 'history.tabs.observability' })}
+ setCurrentTab('native-sessions')}
+ >
+
+ {formatMessage({ id: 'history.tabs.nativeSessions' })}
+
{/* Tab Content */}
@@ -354,6 +453,183 @@ export function HistoryPage() {
)}
+ {currentTab === 'native-sessions' && (
+
+ {/* Header with refresh */}
+
+
+ {formatMessage(
+ { id: 'history.nativeSessions.count' },
+ { count: nativeSessions.length }
+ )}
+
+
refetchNativeSessions()}
+ disabled={isFetchingNativeSessions}
+ >
+
+ {formatMessage({ id: 'common.actions.refresh' })}
+
+
+
+ {/* Error alert */}
+ {nativeSessionsError && (
+
+
+
+
{formatMessage({ id: 'common.errors.loadFailed' })}
+
{nativeSessionsError.message}
+
+
refetchNativeSessions()}>
+ {formatMessage({ id: 'common.actions.retry' })}
+
+
+ )}
+
+ {/* Loading state */}
+ {isLoadingNativeSessions ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : nativeSessions.length === 0 ? (
+ /* Empty state */
+
+
+
+ {formatMessage({ id: 'history.nativeSessions.empty.title' })}
+
+
+ {formatMessage({ id: 'history.nativeSessions.empty.message' })}
+
+
+ ) : (
+ /* Sessions grouped by tool */
+
+ {toolOrder
+ .filter((tool) => nativeSessionsByTool[tool]?.length > 0)
+ .map((tool) => {
+ const sessions = nativeSessionsByTool[tool];
+ const isExpanded = expandedTools.has(tool);
+ return (
+
+ {/* Tool header - clickable to expand/collapse */}
+
toggleToolExpand(tool)}
+ >
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {tool.toUpperCase()}
+
+
+ {sessions.length} {formatMessage({ id: 'history.nativeSessions.sessions' })}
+
+
+
+ {/* Sessions list */}
+ {isExpanded && (
+
+ {sessions.map((session) => (
+
handleNativeSessionClick(session)}
+ >
+
+
+ {session.id.length > 24 ? session.id.slice(0, 24) + '...' : session.id}
+
+ {session.title && (
+
+ {session.title}
+
+ )}
+
+
+
+
+ {new Date(session.updatedAt).toLocaleString()}
+
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+ {/* Other tools not in predefined order */}
+ {Object.keys(nativeSessionsByTool)
+ .filter((tool) => !toolOrder.includes(tool as typeof toolOrder[number]))
+ .sort()
+ .map((tool) => {
+ const sessions = nativeSessionsByTool[tool];
+ const isExpanded = expandedTools.has(tool);
+ return (
+
+
toggleToolExpand(tool)}
+ >
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {tool.toUpperCase()}
+
+
+ {sessions.length} {formatMessage({ id: 'history.nativeSessions.sessions' })}
+
+
+
+ {isExpanded && (
+
+ {sessions.map((session) => (
+
handleNativeSessionClick(session)}
+ >
+
+
+ {session.id.length > 24 ? session.id.slice(0, 24) + '...' : session.id}
+
+ {session.title && (
+
+ {session.title}
+
+ )}
+
+
+
+
+ {new Date(session.updatedAt).toLocaleString()}
+
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ )}
+
{/* CLI Stream Panel */}
-
+
+
+
+
+
+
+
{formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
+
+
+
+
{featureFlags.dashboardQueuePanelEnabled && (
@@ -145,6 +157,16 @@ export function TerminalDashboardPage() {
)}
+
+
+
+
);
diff --git a/ccw/frontend/src/stores/cliStreamStore.ts b/ccw/frontend/src/stores/cliStreamStore.ts
index fafca490..a885caa8 100644
--- a/ccw/frontend/src/stores/cliStreamStore.ts
+++ b/ccw/frontend/src/stores/cliStreamStore.ts
@@ -78,6 +78,7 @@ interface CliStreamState extends BlockCacheState {
executions: Record
;
currentExecutionId: string | null;
userClosedExecutions: Set; // Track executions closed by user
+ deduplicationWindows: Record; // Rolling hash window per execution
// Legacy methods
addOutput: (executionId: string, line: CliOutputLine) => void;
@@ -106,8 +107,26 @@ interface CliStreamState extends BlockCacheState {
*/
const MAX_OUTPUT_LINES = 5000;
+/**
+ * Size of rolling deduplication window per execution
+ * Lines with identical content within this window will be skipped
+ */
+const DEDUPLICATION_WINDOW_SIZE = 100;
+
// ========== Helper Functions ==========
+/**
+ * Simple hash function for content deduplication
+ * Uses djb2 algorithm - fast and good enough for deduplication
+ */
+function simpleHash(str: string): string {
+ let hash = 5381;
+ for (let i = 0; i < str.length; i++) {
+ hash = ((hash << 5) + hash) + str.charCodeAt(i);
+ }
+ return hash.toString(36);
+}
+
/**
* Parse tool call metadata from content
* Expected format: "[Tool] toolName(args)"
@@ -325,12 +344,25 @@ export const useCliStreamStore = create()(
executions: {},
currentExecutionId: null,
userClosedExecutions: new Set(),
+ deduplicationWindows: {},
// Block cache state
blocks: {},
lastUpdate: {},
addOutput: (executionId: string, line: CliOutputLine) => {
+ // Content-based deduplication using rolling hash window
+ const contentHash = simpleHash(line.content);
+ const currentWindow = get().deduplicationWindows[executionId] || [];
+
+ // Skip if duplicate detected (same hash in recent window)
+ if (currentWindow.includes(contentHash)) {
+ return; // Skip duplicate content
+ }
+
+ // Update deduplication window
+ const newWindow = [...currentWindow, contentHash].slice(-DEDUPLICATION_WINDOW_SIZE);
+
set((state) => {
const current = state.outputs[executionId] || [];
const updated = [...current, line];
@@ -342,6 +374,10 @@ export const useCliStreamStore = create()(
...state.outputs,
[executionId]: updated.slice(-MAX_OUTPUT_LINES),
},
+ deduplicationWindows: {
+ ...state.deduplicationWindows,
+ [executionId]: newWindow,
+ },
};
}
@@ -350,6 +386,10 @@ export const useCliStreamStore = create()(
...state.outputs,
[executionId]: updated,
},
+ deduplicationWindows: {
+ ...state.deduplicationWindows,
+ [executionId]: newWindow,
+ },
};
}, false, 'cliStream/addOutput');
@@ -425,13 +465,16 @@ export const useCliStreamStore = create()(
const newExecutions = { ...state.executions };
const newBlocks = { ...state.blocks };
const newLastUpdate = { ...state.lastUpdate };
+ const newDeduplicationWindows = { ...state.deduplicationWindows };
delete newExecutions[executionId];
delete newBlocks[executionId];
delete newLastUpdate[executionId];
+ delete newDeduplicationWindows[executionId];
return {
executions: newExecutions,
blocks: newBlocks,
lastUpdate: newLastUpdate,
+ deduplicationWindows: newDeduplicationWindows,
currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId,
};
}, false, 'cliStream/removeExecution');
diff --git a/ccw/frontend/src/stores/queueSchedulerStore.ts b/ccw/frontend/src/stores/queueSchedulerStore.ts
new file mode 100644
index 00000000..74a6d506
--- /dev/null
+++ b/ccw/frontend/src/stores/queueSchedulerStore.ts
@@ -0,0 +1,351 @@
+// ========================================
+// Queue Scheduler Store
+// ========================================
+// Zustand store for queue scheduler state management.
+// Handles WebSocket message dispatch, API actions, and provides
+// granular selectors for efficient React re-renders.
+
+import { create } from 'zustand';
+import { devtools } from 'zustand/middleware';
+import type {
+ QueueSchedulerStatus,
+ QueueSchedulerConfig,
+ QueueItem,
+ QueueSchedulerState,
+ QueueWSMessage,
+ SessionBinding,
+} from '../types/queue-frontend-types';
+
+// ========== Default Config ==========
+
+const DEFAULT_CONFIG: QueueSchedulerConfig = {
+ maxConcurrentSessions: 2,
+ sessionIdleTimeoutMs: 60_000,
+ resumeKeySessionBindingTimeoutMs: 300_000,
+};
+
+// ========== Store State Interface ==========
+
+/**
+ * Store state extends the wire format QueueSchedulerState with
+ * nullable fields for "not yet loaded" state.
+ */
+interface QueueSchedulerStoreState {
+ status: QueueSchedulerStatus;
+ items: QueueItem[];
+ sessionPool: Record;
+ config: QueueSchedulerConfig;
+ currentConcurrency: number;
+ lastActivityAt: string | null;
+ error: string | null;
+}
+
+// ========== Actions Interface ==========
+
+interface QueueSchedulerActions {
+ /** Dispatch a WebSocket message to update store state */
+ handleSchedulerMessage: (msg: QueueWSMessage) => void;
+ /** Fetch initial state from GET /api/queue/scheduler/state */
+ loadInitialState: () => Promise;
+ /** Submit items to the queue via POST /api/queue/execute (auto-starts if idle) */
+ submitItems: (items: QueueItem[]) => Promise;
+ /** Start the queue scheduler via POST /api/queue/scheduler/start */
+ startQueue: (items?: QueueItem[]) => Promise;
+ /** Pause the queue scheduler via POST /api/queue/scheduler/pause */
+ pauseQueue: () => Promise;
+ /** Stop the queue scheduler via POST /api/queue/scheduler/stop */
+ stopQueue: () => Promise;
+ /** Update scheduler config via POST /api/queue/scheduler/config */
+ updateConfig: (config: Partial) => Promise;
+}
+
+export type QueueSchedulerStore = QueueSchedulerStoreState & QueueSchedulerActions;
+
+// ========== Initial State ==========
+
+const initialState: QueueSchedulerStoreState = {
+ status: 'idle',
+ items: [],
+ sessionPool: {},
+ config: DEFAULT_CONFIG,
+ currentConcurrency: 0,
+ lastActivityAt: null,
+ error: null,
+};
+
+// ========== Store ==========
+
+export const useQueueSchedulerStore = create()(
+ devtools(
+ (set) => ({
+ ...initialState,
+
+ // ========== WebSocket Message Handler ==========
+
+ handleSchedulerMessage: (msg: QueueWSMessage) => {
+ switch (msg.type) {
+ case 'QUEUE_SCHEDULER_STATE_UPDATE':
+ // Backend sends full state snapshot
+ set(
+ {
+ status: msg.state.status,
+ items: msg.state.items,
+ sessionPool: msg.state.sessionPool,
+ config: msg.state.config,
+ currentConcurrency: msg.state.currentConcurrency,
+ lastActivityAt: msg.state.lastActivityAt,
+ error: msg.state.error ?? null,
+ },
+ false,
+ 'handleSchedulerMessage/QUEUE_SCHEDULER_STATE_UPDATE'
+ );
+ break;
+
+ case 'QUEUE_ITEM_ADDED':
+ set(
+ (state) => ({
+ items: [...state.items, msg.item],
+ }),
+ false,
+ 'handleSchedulerMessage/QUEUE_ITEM_ADDED'
+ );
+ break;
+
+ case 'QUEUE_ITEM_UPDATED':
+ set(
+ (state) => ({
+ items: state.items.map((item) =>
+ item.item_id === msg.item.item_id ? msg.item : item
+ ),
+ }),
+ false,
+ 'handleSchedulerMessage/QUEUE_ITEM_UPDATED'
+ );
+ break;
+
+ case 'QUEUE_ITEM_REMOVED':
+ set(
+ (state) => ({
+ items: state.items.filter((item) => item.item_id !== msg.item_id),
+ }),
+ false,
+ 'handleSchedulerMessage/QUEUE_ITEM_REMOVED'
+ );
+ break;
+
+ case 'QUEUE_SCHEDULER_CONFIG_UPDATED':
+ set(
+ {
+ config: msg.config,
+ },
+ false,
+ 'handleSchedulerMessage/QUEUE_SCHEDULER_CONFIG_UPDATED'
+ );
+ break;
+
+ // No default - all 5 message types are handled exhaustively
+ }
+ },
+
+ // ========== API Actions ==========
+
+ submitItems: async (items: QueueItem[]) => {
+ try {
+ const response = await fetch('/api/queue/execute', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ items }),
+ });
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.error || body.message || response.statusText);
+ }
+ // State will be updated via WebSocket broadcast from backend
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error('[QueueScheduler] submitItems error:', message);
+ set({ error: message }, false, 'submitItems/error');
+ throw error;
+ }
+ },
+
+ loadInitialState: async () => {
+ try {
+ const response = await fetch('/api/queue/scheduler/state', {
+ credentials: 'same-origin',
+ });
+ if (!response.ok) {
+ throw new Error(`Failed to load scheduler state: ${response.statusText}`);
+ }
+ const data: QueueSchedulerState = await response.json();
+ set(
+ {
+ status: data.status,
+ items: data.items,
+ sessionPool: data.sessionPool,
+ config: data.config,
+ currentConcurrency: data.currentConcurrency,
+ lastActivityAt: data.lastActivityAt,
+ error: data.error ?? null,
+ },
+ false,
+ 'loadInitialState'
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error('[QueueScheduler] loadInitialState error:', message);
+ set({ error: message }, false, 'loadInitialState/error');
+ }
+ },
+
+ startQueue: async (items?: QueueItem[]) => {
+ try {
+ const body = items ? { items } : {};
+ const response = await fetch('/api/queue/scheduler/start', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.error || body.message || response.statusText);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error('[QueueScheduler] startQueue error:', message);
+ set({ error: message }, false, 'startQueue/error');
+ }
+ },
+
+ pauseQueue: async () => {
+ try {
+ const response = await fetch('/api/queue/scheduler/pause', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.error || body.message || response.statusText);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error('[QueueScheduler] pauseQueue error:', message);
+ set({ error: message }, false, 'pauseQueue/error');
+ }
+ },
+
+ stopQueue: async () => {
+ try {
+ const response = await fetch('/api/queue/scheduler/stop', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.error || body.message || response.statusText);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error('[QueueScheduler] stopQueue error:', message);
+ set({ error: message }, false, 'stopQueue/error');
+ }
+ },
+
+ updateConfig: async (config: Partial) => {
+ try {
+ const response = await fetch('/api/queue/scheduler/config', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(config),
+ });
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.error || body.message || response.statusText);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error('[QueueScheduler] updateConfig error:', message);
+ set({ error: message }, false, 'updateConfig/error');
+ }
+ },
+ }),
+ { name: 'QueueSchedulerStore' }
+ )
+);
+
+// ========== Selectors ==========
+
+/** Stable empty array to avoid new references on each call */
+const EMPTY_ITEMS: QueueItem[] = [];
+
+/** Select current scheduler status */
+export const selectQueueSchedulerStatus = (state: QueueSchedulerStore): QueueSchedulerStatus =>
+ state.status;
+
+/** Select all queue items */
+export const selectQueueItems = (state: QueueSchedulerStore): QueueItem[] =>
+ state.items;
+
+/**
+ * Select items that are ready to execute (status 'queued' or 'pending').
+ * WARNING: Returns new array each call - use with useMemo in components.
+ */
+export const selectReadyItems = (state: QueueSchedulerStore): QueueItem[] => {
+ const ready = state.items.filter(
+ (item) => item.status === 'queued' || item.status === 'pending'
+ );
+ return ready.length === 0 ? EMPTY_ITEMS : ready;
+};
+
+/**
+ * Select items that are blocked (status 'blocked').
+ * WARNING: Returns new array each call - use with useMemo in components.
+ */
+export const selectBlockedItems = (state: QueueSchedulerStore): QueueItem[] => {
+ const blocked = state.items.filter((item) => item.status === 'blocked');
+ return blocked.length === 0 ? EMPTY_ITEMS : blocked;
+};
+
+/**
+ * Select items currently executing (status 'executing').
+ * WARNING: Returns new array each call - use with useMemo in components.
+ */
+export const selectExecutingItems = (state: QueueSchedulerStore): QueueItem[] => {
+ const executing = state.items.filter((item) => item.status === 'executing');
+ return executing.length === 0 ? EMPTY_ITEMS : executing;
+};
+
+/**
+ * Calculate overall scheduler progress as a percentage (0-100).
+ * Progress = (completed + failed) / total * 100.
+ * Returns 0 when there are no items.
+ */
+export const selectSchedulerProgress = (state: QueueSchedulerStore): number => {
+ const total = state.items.length;
+ if (total === 0) return 0;
+ const terminal = state.items.filter(
+ (item) => item.status === 'completed' || item.status === 'failed'
+ ).length;
+ return Math.round((terminal / total) * 100);
+};
+
+/** Select scheduler config */
+export const selectSchedulerConfig = (state: QueueSchedulerStore): QueueSchedulerConfig =>
+ state.config;
+
+/** Select session pool */
+export const selectSessionPool = (state: QueueSchedulerStore): Record =>
+ state.sessionPool;
+
+/** Select current concurrency */
+export const selectCurrentConcurrency = (state: QueueSchedulerStore): number =>
+ state.currentConcurrency;
+
+/** Select scheduler error */
+export const selectSchedulerError = (state: QueueSchedulerStore): string | null =>
+ state.error;
diff --git a/ccw/frontend/src/test/i18n.tsx b/ccw/frontend/src/test/i18n.tsx
index 3025ee99..bef3b073 100644
--- a/ccw/frontend/src/test/i18n.tsx
+++ b/ccw/frontend/src/test/i18n.tsx
@@ -283,6 +283,54 @@ const mockMessages: Record> = {
'codexlens.reranker.selectBackend': 'Select backend...',
'codexlens.reranker.selectModel': 'Select model...',
'codexlens.reranker.selectProvider': 'Select provider...',
+ // MCP - CCW Tools
+ 'mcp.ccw.title': 'CCW MCP Server',
+ 'mcp.ccw.description': 'Configure CCW MCP tools and paths',
+ 'mcp.ccw.status.installed': 'Installed',
+ 'mcp.ccw.status.notInstalled': 'Not installed',
+ 'mcp.ccw.status.special': 'Special',
+ 'mcp.ccw.actions.enableAll': 'Enable All',
+ 'mcp.ccw.actions.disableAll': 'Disable All',
+ 'mcp.ccw.actions.saveConfig': 'Save Configuration',
+ 'mcp.ccw.actions.saving': 'Saving...',
+ 'mcp.ccw.actions.installing': 'Installing...',
+ 'mcp.ccw.actions.uninstall': 'Uninstall',
+ 'mcp.ccw.actions.uninstalling': 'Uninstalling...',
+ 'mcp.ccw.actions.uninstallConfirm': 'Are you sure you want to uninstall?',
+ 'mcp.ccw.actions.uninstallScopeConfirm': 'Are you sure you want to uninstall from this scope?',
+ 'mcp.ccw.codexNote': 'Codex only supports global installation',
+ 'mcp.ccw.tools.label': 'Tools',
+ 'mcp.ccw.tools.hint': 'Install to edit tools',
+ 'mcp.ccw.tools.core': 'Core',
+ 'mcp.ccw.tools.write_file.name': 'Write File',
+ 'mcp.ccw.tools.write_file.desc': 'Write/create files',
+ 'mcp.ccw.tools.edit_file.name': 'Edit File',
+ 'mcp.ccw.tools.edit_file.desc': 'Edit/replace content',
+ 'mcp.ccw.tools.read_file.name': 'Read File',
+ 'mcp.ccw.tools.read_file.desc': 'Read single file',
+ 'mcp.ccw.tools.read_many_files.name': 'Read Many Files',
+ 'mcp.ccw.tools.read_many_files.desc': 'Read multiple files/dirs',
+ 'mcp.ccw.tools.core_memory.name': 'Core Memory',
+ 'mcp.ccw.tools.core_memory.desc': 'Core memory management',
+ 'mcp.ccw.tools.ask_question.name': 'Ask Question',
+ 'mcp.ccw.tools.ask_question.desc': 'Interactive questions (A2UI)',
+ 'mcp.ccw.tools.smart_search.name': 'Smart Search',
+ 'mcp.ccw.tools.smart_search.desc': 'Intelligent code search',
+ 'mcp.ccw.tools.team_msg.name': 'Team Message',
+ 'mcp.ccw.tools.team_msg.desc': 'Agent team message bus',
+ 'mcp.ccw.paths.label': 'Paths',
+ 'mcp.ccw.paths.projectRoot': 'Project Root',
+ 'mcp.ccw.paths.projectRootPlaceholder': 'e.g. D:\\path\\to\\project',
+ 'mcp.ccw.paths.allowedDirs': 'Allowed Directories',
+ 'mcp.ccw.paths.allowedDirsPlaceholder': 'Comma-separated directories',
+ 'mcp.ccw.paths.allowedDirsHint': 'Separate multiple directories with commas',
+ 'mcp.ccw.paths.enableSandbox': 'Enable Sandbox',
+ 'mcp.ccw.scope.installToGlobal': 'Install to Global',
+ 'mcp.ccw.scope.installToProject': 'Install to Project',
+ 'mcp.ccw.scope.uninstallGlobal': 'Uninstall Global',
+ 'mcp.ccw.scope.uninstallProject': 'Uninstall Project',
+ 'mcp.ccw.feedback.saveSuccess': 'Configuration saved',
+ 'mcp.ccw.feedback.saveError': 'Failed to save configuration',
'navigation.codexlens': 'CodexLens',
},
zh: {
@@ -555,6 +603,54 @@ const mockMessages: Record> = {
'codexlens.reranker.selectBackend': '选择后端...',
'codexlens.reranker.selectModel': '选择模型...',
'codexlens.reranker.selectProvider': '选择提供商...',
+ // MCP - CCW Tools
+ 'mcp.ccw.title': 'CCW MCP 服务器',
+ 'mcp.ccw.description': '配置 CCW MCP 工具与路径',
+ 'mcp.ccw.status.installed': '已安装',
+ 'mcp.ccw.status.notInstalled': '未安装',
+ 'mcp.ccw.status.special': '特殊',
+ 'mcp.ccw.actions.enableAll': '全选',
+ 'mcp.ccw.actions.disableAll': '全不选',
+ 'mcp.ccw.actions.saveConfig': '保存配置',
+ 'mcp.ccw.actions.saving': '保存中...',
+ 'mcp.ccw.actions.installing': '安装中...',
+ 'mcp.ccw.actions.uninstall': '卸载',
+ 'mcp.ccw.actions.uninstalling': '卸载中...',
+ 'mcp.ccw.actions.uninstallConfirm': '确定要卸载吗?',
+ 'mcp.ccw.actions.uninstallScopeConfirm': '确定要从该作用域卸载吗?',
+ 'mcp.ccw.codexNote': 'Codex 仅支持全局安装',
+ 'mcp.ccw.tools.label': '工具',
+ 'mcp.ccw.tools.hint': '安装后可编辑工具',
+ 'mcp.ccw.tools.core': '核心',
+ 'mcp.ccw.tools.write_file.name': '写入文件',
+ 'mcp.ccw.tools.write_file.desc': '写入/创建文件',
+ 'mcp.ccw.tools.edit_file.name': '编辑文件',
+ 'mcp.ccw.tools.edit_file.desc': '编辑/替换内容',
+ 'mcp.ccw.tools.read_file.name': '读取文件',
+ 'mcp.ccw.tools.read_file.desc': '读取单个文件',
+ 'mcp.ccw.tools.read_many_files.name': '读取多个文件',
+ 'mcp.ccw.tools.read_many_files.desc': '读取多个文件/目录',
+ 'mcp.ccw.tools.core_memory.name': '核心记忆',
+ 'mcp.ccw.tools.core_memory.desc': '核心记忆管理',
+ 'mcp.ccw.tools.ask_question.name': '提问',
+ 'mcp.ccw.tools.ask_question.desc': '交互式问题(A2UI)',
+ 'mcp.ccw.tools.smart_search.name': '智能搜索',
+ 'mcp.ccw.tools.smart_search.desc': '智能代码搜索',
+ 'mcp.ccw.tools.team_msg.name': '团队消息',
+ 'mcp.ccw.tools.team_msg.desc': '代理团队消息总线',
+ 'mcp.ccw.paths.label': '路径',
+ 'mcp.ccw.paths.projectRoot': '项目根目录',
+ 'mcp.ccw.paths.projectRootPlaceholder': '例如:D:\\path\\to\\project',
+ 'mcp.ccw.paths.allowedDirs': '允许目录',
+ 'mcp.ccw.paths.allowedDirsPlaceholder': '用逗号分隔的目录',
+ 'mcp.ccw.paths.allowedDirsHint': '使用逗号分隔多个目录',
+ 'mcp.ccw.paths.enableSandbox': '启用沙箱',
+ 'mcp.ccw.scope.installToGlobal': '安装到全局',
+ 'mcp.ccw.scope.installToProject': '安装到项目',
+ 'mcp.ccw.scope.uninstallGlobal': '卸载全局',
+ 'mcp.ccw.scope.uninstallProject': '卸载项目',
+ 'mcp.ccw.feedback.saveSuccess': '配置已保存',
+ 'mcp.ccw.feedback.saveError': '保存配置失败',
'navigation.codexlens': 'CodexLens',
},
};
diff --git a/ccw/frontend/src/types/queue-frontend-types.ts b/ccw/frontend/src/types/queue-frontend-types.ts
new file mode 100644
index 00000000..0646fa22
--- /dev/null
+++ b/ccw/frontend/src/types/queue-frontend-types.ts
@@ -0,0 +1,201 @@
+// ========================================
+// Queue Scheduler Frontend Types
+// ========================================
+// Frontend type definitions for the queue scheduling system.
+// Mirrors backend queue-types.ts for wire format compatibility.
+
+// ========== Item Status ==========
+
+/**
+ * Status of a single queue item through its lifecycle.
+ * Must match backend QueueItemStatus exactly.
+ */
+export type QueueItemStatus =
+ | 'pending' // Submitted, awaiting dependency check
+ | 'queued' // Dependencies met, waiting for session
+ | 'ready' // Ready for execution
+ | 'executing' // Running in an allocated CLI session
+ | 'completed' // Finished successfully
+ | 'failed' // Finished with error
+ | 'blocked' // Waiting on depends_on items to complete
+ | 'cancelled'; // Cancelled by user
+
+// ========== Scheduler Status ==========
+
+/**
+ * Status of the scheduler service itself.
+ * Must match backend QueueSchedulerStatus exactly.
+ */
+export type QueueSchedulerStatus =
+ | 'idle' // No active queue, waiting for items
+ | 'running' // Actively processing queue items
+ | 'paused' // User-paused, no new items dispatched
+ | 'stopping' // Graceful stop in progress, waiting for executing items
+ | 'completed' // All items processed successfully
+ | 'failed'; // Queue terminated due to critical error
+
+// ========== Scheduler Config ==========
+
+/**
+ * Configuration for the queue scheduler.
+ * Must match backend QueueSchedulerConfig exactly.
+ */
+export interface QueueSchedulerConfig {
+ /** Maximum number of concurrent CLI sessions executing tasks */
+ maxConcurrentSessions: number;
+ /** Idle timeout (ms) before releasing a session from the pool */
+ sessionIdleTimeoutMs: number;
+ /** Timeout (ms) for resumeKey-to-session binding affinity */
+ resumeKeySessionBindingTimeoutMs: number;
+}
+
+// ========== Queue Item ==========
+
+/**
+ * A single task item in the execution queue.
+ * Must match backend QueueItem exactly.
+ */
+export interface QueueItem {
+ /** Unique identifier for this queue item */
+ item_id: string;
+ /** Reference to the parent issue (if applicable) */
+ issue_id?: string;
+ /** Current status of the item */
+ status: QueueItemStatus;
+ /** CLI tool to use for execution (e.g., 'gemini', 'claude') */
+ tool: string;
+ /** Prompt/instruction to send to the CLI tool */
+ prompt: string;
+ /** Execution mode */
+ mode?: 'analysis' | 'write' | 'auto';
+ /** Resume key for session affinity and conversation continuity */
+ resumeKey?: string;
+ /** Strategy for resuming a previous CLI session */
+ resumeStrategy?: string;
+ /** Item IDs that must complete before this item can execute */
+ depends_on: string[];
+ /** Numeric order for scheduling priority within a group. Lower = earlier */
+ execution_order: number;
+ /** Logical grouping for related items (e.g., same issue) */
+ execution_group?: string;
+ /** Session key assigned when executing */
+ sessionKey?: string;
+ /** Timestamp when item was added to the queue */
+ createdAt: string;
+ /** Timestamp when execution started */
+ startedAt?: string;
+ /** Timestamp when execution completed (success or failure) */
+ completedAt?: string;
+ /** Error message if status is 'failed' */
+ error?: string;
+ /** Output from CLI execution */
+ output?: string;
+ /** Arbitrary metadata for extensibility */
+ metadata?: Record;
+}
+
+// ========== Session Binding ==========
+
+/**
+ * Tracks a session bound to a resumeKey for affinity-based allocation.
+ * Must match backend SessionBinding exactly.
+ */
+export interface SessionBinding {
+ /** The CLI session key from CliSessionManager */
+ sessionKey: string;
+ /** Timestamp of last activity on this binding */
+ lastUsed: string;
+}
+
+// ========== Scheduler State ==========
+
+/**
+ * Complete snapshot of the scheduler state.
+ * Must match backend QueueSchedulerState exactly.
+ */
+export interface QueueSchedulerState {
+ /** Current scheduler status */
+ status: QueueSchedulerStatus;
+ /** All items in the queue */
+ items: QueueItem[];
+ /** Session pool: resumeKey -> SessionBinding */
+ sessionPool: Record;
+ /** Active scheduler configuration */
+ config: QueueSchedulerConfig;
+ /** Number of currently executing tasks */
+ currentConcurrency: number;
+ /** Timestamp of last scheduler activity */
+ lastActivityAt: string;
+ /** Error message if scheduler status is 'failed' */
+ error?: string;
+}
+
+// ========== WebSocket Message Types ==========
+
+/**
+ * Discriminator values for queue-related WebSocket messages.
+ */
+export type QueueWSMessageType =
+ | 'QUEUE_SCHEDULER_STATE_UPDATE'
+ | 'QUEUE_ITEM_ADDED'
+ | 'QUEUE_ITEM_UPDATED'
+ | 'QUEUE_ITEM_REMOVED'
+ | 'QUEUE_SCHEDULER_CONFIG_UPDATED';
+
+// ========== WebSocket Messages (Discriminated Union) ==========
+
+/**
+ * Full scheduler state broadcast (sent on start/pause/stop/complete).
+ */
+export interface QueueSchedulerStateUpdateMessage {
+ type: 'QUEUE_SCHEDULER_STATE_UPDATE';
+ state: QueueSchedulerState;
+ timestamp: string;
+}
+
+/**
+ * Broadcast when a new item is added to the queue.
+ */
+export interface QueueItemAddedMessage {
+ type: 'QUEUE_ITEM_ADDED';
+ item: QueueItem;
+ timestamp: string;
+}
+
+/**
+ * Broadcast when an item's status or data changes.
+ */
+export interface QueueItemUpdatedMessage {
+ type: 'QUEUE_ITEM_UPDATED';
+ item: QueueItem;
+ timestamp: string;
+}
+
+/**
+ * Broadcast when an item is removed from the queue.
+ */
+export interface QueueItemRemovedMessage {
+ type: 'QUEUE_ITEM_REMOVED';
+ item_id: string;
+ timestamp: string;
+}
+
+/**
+ * Broadcast when scheduler configuration is updated.
+ */
+export interface QueueSchedulerConfigUpdatedMessage {
+ type: 'QUEUE_SCHEDULER_CONFIG_UPDATED';
+ config: QueueSchedulerConfig;
+ timestamp: string;
+}
+
+/**
+ * Discriminated union of all queue WebSocket messages.
+ * Use `msg.type` as the discriminator in switch statements.
+ */
+export type QueueWSMessage =
+ | QueueSchedulerStateUpdateMessage
+ | QueueItemAddedMessage
+ | QueueItemUpdatedMessage
+ | QueueItemRemovedMessage
+ | QueueSchedulerConfigUpdatedMessage;
diff --git a/ccw/src/config/litellm-static-models.ts b/ccw/src/config/litellm-static-models.ts
new file mode 100644
index 00000000..6ca75dc6
--- /dev/null
+++ b/ccw/src/config/litellm-static-models.ts
@@ -0,0 +1,93 @@
+/**
+ * LiteLLM Static Model Lists (Fallback)
+ *
+ * Sourced from LiteLLM's internal model lists.
+ * Used as fallback when user config has no availableModels defined.
+ *
+ * Last updated: 2026-02-27
+ * Source: Python litellm module static lists
+ */
+
+export interface ModelInfo {
+ id: string;
+ name: string;
+}
+
+/**
+ * Mapping from CLI tool names to LiteLLM provider model lists
+ */
+export const LITELLM_STATIC_MODELS: Record = {
+ // Gemini models (from litellm.gemini_models)
+ gemini: [
+ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
+ { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
+ { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' },
+ { id: 'gemini-2.0-pro-exp-02-05', name: 'Gemini 2.0 Pro Exp' },
+ { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' },
+ { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash' },
+ { id: 'gemini-1.5-pro-latest', name: 'Gemini 1.5 Pro Latest' },
+ { id: 'gemini-embedding-001', name: 'Gemini Embedding 001' }
+ ],
+
+ // OpenAI models (from litellm.open_ai_chat_completion_models)
+ codex: [
+ { id: 'gpt-5.2', name: 'GPT-5.2' },
+ { id: 'gpt-5.1-chat-latest', name: 'GPT-5.1 Chat Latest' },
+ { id: 'gpt-4o', name: 'GPT-4o' },
+ { id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
+ { id: 'o4-mini-2025-04-16', name: 'O4 Mini' },
+ { id: 'o3', name: 'O3' },
+ { id: 'o1-mini', name: 'O1 Mini' },
+ { id: 'gpt-4-turbo', name: 'GPT-4 Turbo' }
+ ],
+
+ // Anthropic models (from litellm.anthropic_models)
+ claude: [
+ { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
+ { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
+ { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
+ { id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
+ { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
+ { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
+ { id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
+ { id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' }
+ ],
+
+ // OpenAI models for opencode (via LiteLLM proxy)
+ opencode: [
+ { id: 'opencode/glm-4.7-free', name: 'GLM-4.7 Free' },
+ { id: 'opencode/gpt-5-nano', name: 'GPT-5 Nano' },
+ { id: 'opencode/grok-code', name: 'Grok Code' },
+ { id: 'opencode/minimax-m2.1-free', name: 'MiniMax M2.1 Free' }
+ ],
+
+ // Qwen models
+ qwen: [
+ { id: 'qwen2.5-coder-32b', name: 'Qwen 2.5 Coder 32B' },
+ { id: 'qwen2.5-coder', name: 'Qwen 2.5 Coder' },
+ { id: 'qwen2.5-72b', name: 'Qwen 2.5 72B' },
+ { id: 'qwen2-72b', name: 'Qwen 2 72B' },
+ { id: 'coder-model', name: 'Qwen Coder' },
+ { id: 'vision-model', name: 'Qwen Vision' }
+ ]
+};
+
+/**
+ * Get fallback models for a tool
+ * @param toolId - Tool identifier (e.g., 'gemini', 'claude', 'codex')
+ * @returns Array of model info, or empty array if not found
+ */
+export function getFallbackModels(toolId: string): ModelInfo[] {
+ return LITELLM_STATIC_MODELS[toolId] ?? [];
+}
+
+/**
+ * Check if a tool has fallback models defined
+ * @param toolId - Tool identifier
+ * @returns true if fallback models exist
+ */
+export function hasFallbackModels(toolId: string): boolean {
+ return toolId in LITELLM_STATIC_MODELS;
+}
diff --git a/ccw/src/config/provider-models.ts b/ccw/src/config/provider-models.ts
index ca20c6fa..87247f06 100644
--- a/ccw/src/config/provider-models.ts
+++ b/ccw/src/config/provider-models.ts
@@ -1,9 +1,9 @@
/**
- * CLI Tool Model Reference Library
+ * CLI Tool Model Type Definitions
*
- * System reference for available models per CLI tool provider.
- * This is a read-only reference, NOT user configuration.
- * User configuration is managed via tools.{tool}.primaryModel/secondaryModel in cli-tools.json
+ * Type definitions for CLI tool models.
+ * Model lists are now read from user configuration (cli-tools.json).
+ * Each tool can define availableModels in its configuration.
*/
export interface ProviderModelInfo {
@@ -19,105 +19,5 @@ export interface ProviderInfo {
models: ProviderModelInfo[];
}
-/**
- * System reference for CLI tool models
- * Maps provider names to their available models
- */
-export const PROVIDER_MODELS: Record = {
- google: {
- name: 'Google AI',
- models: [
- { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', capabilities: ['text', 'vision', 'code'], contextWindow: 1000000 },
- { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', capabilities: ['text', 'code'], contextWindow: 1000000 },
- { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', capabilities: ['text'], contextWindow: 1000000 },
- { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', capabilities: ['text', 'vision'], contextWindow: 2000000 },
- { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', capabilities: ['text'], contextWindow: 1000000 }
- ]
- },
- qwen: {
- name: 'Qwen',
- models: [
- { id: 'coder-model', name: 'Qwen Coder', capabilities: ['code'] },
- { id: 'vision-model', name: 'Qwen Vision', capabilities: ['vision'] },
- { id: 'qwen2.5-coder-32b', name: 'Qwen 2.5 Coder 32B', capabilities: ['code'] }
- ]
- },
- openai: {
- name: 'OpenAI',
- models: [
- { id: 'gpt-5.2', name: 'GPT-5.2', capabilities: ['text', 'code'] },
- { id: 'gpt-4.1', name: 'GPT-4.1', capabilities: ['text', 'code'] },
- { id: 'o4-mini', name: 'O4 Mini', capabilities: ['text'] },
- { id: 'o3', name: 'O3', capabilities: ['text'] }
- ]
- },
- anthropic: {
- name: 'Anthropic',
- models: [
- { id: 'sonnet', name: 'Claude Sonnet', capabilities: ['text', 'code'] },
- { id: 'opus', name: 'Claude Opus', capabilities: ['text', 'code', 'vision'] },
- { id: 'haiku', name: 'Claude Haiku', capabilities: ['text'] },
- { id: 'claude-sonnet-4-5-20250929', name: 'Claude 4.5 Sonnet (2025-09-29)', capabilities: ['text', 'code'] },
- { id: 'claude-opus-4-5-20251101', name: 'Claude 4.5 Opus (2025-11-01)', capabilities: ['text', 'code', 'vision'] }
- ]
- },
- litellm: {
- name: 'LiteLLM Aggregator',
- models: [
- { id: 'opencode/glm-4.7-free', name: 'GLM-4.7 Free', capabilities: ['text'] },
- { id: 'opencode/gpt-5-nano', name: 'GPT-5 Nano', capabilities: ['text'] },
- { id: 'opencode/grok-code', name: 'Grok Code', capabilities: ['code'] },
- { id: 'opencode/minimax-m2.1-free', name: 'MiniMax M2.1 Free', capabilities: ['text'] },
- { id: 'anthropic/claude-sonnet-4-20250514', name: 'Claude Sonnet 4 (via LiteLLM)', capabilities: ['text'] },
- { id: 'anthropic/claude-opus-4-20250514', name: 'Claude Opus 4 (via LiteLLM)', capabilities: ['text'] },
- { id: 'openai/gpt-4.1', name: 'GPT-4.1 (via LiteLLM)', capabilities: ['text'] },
- { id: 'openai/o3', name: 'O3 (via LiteLLM)', capabilities: ['text'] },
- { id: 'google/gemini-2.5-pro', name: 'Gemini 2.5 Pro (via LiteLLM)', capabilities: ['text'] },
- { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash (via LiteLLM)', capabilities: ['text'] }
- ]
- }
-} as const;
-
-/**
- * Get models for a specific provider
- * @param provider - Provider name (e.g., 'google', 'qwen', 'openai', 'anthropic', 'litellm')
- * @returns Array of model information
- */
-export function getProviderModels(provider: string): ProviderModelInfo[] {
- return PROVIDER_MODELS[provider]?.models || [];
-}
-
-/**
- * Get all provider names
- * @returns Array of provider names
- */
-export function getAllProviders(): string[] {
- return Object.keys(PROVIDER_MODELS);
-}
-
-/**
- * Find model information across all providers
- * @param modelId - Model identifier to search for
- * @returns Model information or undefined if not found
- */
-export function findModelInfo(modelId: string): ProviderModelInfo | undefined {
- for (const provider of Object.values(PROVIDER_MODELS)) {
- const model = provider.models.find(m => m.id === modelId);
- if (model) return model;
- }
- return undefined;
-}
-
-/**
- * Get provider name for a model ID
- * @param modelId - Model identifier
- * @returns Provider name or undefined if not found
- */
-export function getProviderForModel(modelId: string): string | undefined {
- for (const [providerId, provider] of Object.entries(PROVIDER_MODELS)) {
- if (provider.models.some(m => m.id === modelId)) {
- return providerId;
- }
- }
- return undefined;
-}
+// Re-export from claude-cli-tools for convenience
+export type { ClaudeCliTool, ClaudeCliToolsConfig, CliToolName } from '../tools/claude-cli-tools.js';
diff --git a/ccw/src/core/routes/cli-routes.ts b/ccw/src/core/routes/cli-routes.ts
index cce4f413..faf9b1e1 100644
--- a/ccw/src/core/routes/cli-routes.ts
+++ b/ccw/src/core/routes/cli-routes.ts
@@ -23,6 +23,7 @@ import {
getEnrichedConversation,
getHistoryWithNativeInfo
} from '../../tools/cli-executor.js';
+import { listAllNativeSessions } from '../../tools/native-session-discovery.js';
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
import { generateSmartContext, formatSmartContext } from '../../tools/smart-context.js';
import {
@@ -851,6 +852,35 @@ export async function handleCliRoutes(ctx: RouteContext): Promise {
return true;
}
+ // API: List Native CLI Sessions
+ if (pathname === '/api/cli/native-sessions' && req.method === 'GET') {
+ const projectPath = url.searchParams.get('path') || null;
+ const limit = parseInt(url.searchParams.get('limit') || '100', 10);
+
+ try {
+ const sessions = listAllNativeSessions({
+ workingDir: projectPath || undefined,
+ limit
+ });
+
+ // Group sessions by tool
+ const byTool: Record = {};
+ for (const session of sessions) {
+ if (!byTool[session.tool]) {
+ byTool[session.tool] = [];
+ }
+ byTool[session.tool].push(session);
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ sessions, byTool }));
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: (err as Error).message }));
+ }
+ return true;
+ }
+
// API: Execute CLI Tool
if (pathname === '/api/cli/execute' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
diff --git a/ccw/src/core/routes/provider-routes.ts b/ccw/src/core/routes/provider-routes.ts
index 5725d12d..81522ff0 100644
--- a/ccw/src/core/routes/provider-routes.ts
+++ b/ccw/src/core/routes/provider-routes.ts
@@ -1,31 +1,56 @@
/**
* Provider Reference Routes Module
* Handles read-only provider model reference API endpoints
+ *
+ * Model source priority:
+ * 1. User configuration (cli-tools.json availableModels)
+ * 2. LiteLLM static model lists (fallback)
*/
import type { RouteContext } from './types.js';
-import {
- PROVIDER_MODELS,
- getAllProviders,
- getProviderModels
-} from '../../config/provider-models.js';
+import { loadClaudeCliTools, type ClaudeCliToolsConfig } from '../../tools/claude-cli-tools.js';
+import { getFallbackModels, hasFallbackModels, type ModelInfo } from '../../config/litellm-static-models.js';
+
+/**
+ * Get models for a tool, using config or fallback
+ */
+function getToolModels(toolId: string, configModels?: string[]): ModelInfo[] {
+ // Priority 1: User config
+ if (configModels && configModels.length > 0) {
+ return configModels.map(id => ({ id, name: id }));
+ }
+
+ // Priority 2: LiteLLM static fallback
+ return getFallbackModels(toolId);
+}
/**
* Handle Provider Reference routes
* @returns true if route was handled, false otherwise
*/
export async function handleProviderRoutes(ctx: RouteContext): Promise {
- const { pathname, req, res } = ctx;
+ const { pathname, req, res, initialPath } = ctx;
// ========== GET ALL PROVIDERS ==========
// GET /api/providers
if (pathname === '/api/providers' && req.method === 'GET') {
try {
- const providers = getAllProviders().map(id => ({
- id,
- name: PROVIDER_MODELS[id].name,
- modelCount: PROVIDER_MODELS[id].models.length
- }));
+ const config = loadClaudeCliTools(initialPath);
+ const providers = Object.entries(config.tools)
+ .filter(([, tool]) => tool.enabled)
+ .map(([id, tool]) => {
+ // Use config models or fallback count
+ const models = getToolModels(id, tool.availableModels);
+ return {
+ id,
+ name: id.charAt(0).toUpperCase() + id.slice(1),
+ modelCount: models.length,
+ primaryModel: tool.primaryModel ?? '',
+ secondaryModel: tool.secondaryModel ?? '',
+ type: tool.type ?? 'builtin',
+ hasCustomModels: !!(tool.availableModels && tool.availableModels.length > 0)
+ };
+ });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, providers }));
@@ -46,9 +71,10 @@ export async function handleProviderRoutes(ctx: RouteContext): Promise
const provider = decodeURIComponent(providerMatch[1]);
try {
- const models = getProviderModels(provider);
+ const config = loadClaudeCliTools(initialPath);
+ const tool = config.tools[provider];
- if (models.length === 0) {
+ if (!tool || !tool.enabled) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
@@ -57,12 +83,19 @@ export async function handleProviderRoutes(ctx: RouteContext): Promise
return true;
}
+ // Get models from config or fallback
+ const models = getToolModels(provider, tool.availableModels);
+ const usingFallback = !tool.availableModels || tool.availableModels.length === 0;
+
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
provider,
- providerName: PROVIDER_MODELS[provider].name,
- models
+ providerName: provider.charAt(0).toUpperCase() + provider.slice(1),
+ models,
+ primaryModel: tool.primaryModel ?? '',
+ secondaryModel: tool.secondaryModel ?? '',
+ source: usingFallback ? 'fallback' : 'config'
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
diff --git a/ccw/src/core/routes/queue-routes.ts b/ccw/src/core/routes/queue-routes.ts
new file mode 100644
index 00000000..6cd09437
--- /dev/null
+++ b/ccw/src/core/routes/queue-routes.ts
@@ -0,0 +1,157 @@
+/**
+ * Queue Scheduler Routes Module
+ *
+ * HTTP API endpoints for the Queue Scheduler Service.
+ * Delegates all business logic to QueueSchedulerService.
+ *
+ * API Endpoints:
+ * - POST /api/queue/execute - Submit items to the scheduler and start
+ * - GET /api/queue/scheduler/state - Get full scheduler state
+ * - POST /api/queue/scheduler/start - Start scheduling loop with items
+ * - POST /api/queue/scheduler/pause - Pause scheduling
+ * - POST /api/queue/scheduler/stop - Graceful stop
+ * - POST /api/queue/scheduler/config - Update scheduler configuration
+ */
+
+import type { RouteContext } from './types.js';
+import type { QueueSchedulerService } from '../services/queue-scheduler-service.js';
+import type { QueueItem, QueueSchedulerConfig } from '../../types/queue-types.js';
+
+/**
+ * Handle queue scheduler routes
+ * @returns true if route was handled, false otherwise
+ */
+export async function handleQueueSchedulerRoutes(
+ ctx: RouteContext,
+ schedulerService: QueueSchedulerService,
+): Promise {
+ const { pathname, req, res, handlePostRequest } = ctx;
+
+ // POST /api/queue/execute - Submit items and start the scheduler
+ if (pathname === '/api/queue/execute' && req.method === 'POST') {
+ handlePostRequest(req, res, async (body) => {
+ const { items } = body as { items?: QueueItem[] };
+
+ if (!items || !Array.isArray(items) || items.length === 0) {
+ return { error: 'items array is required and must not be empty', status: 400 };
+ }
+
+ try {
+ const state = schedulerService.getState();
+
+ // If idle, start with items; otherwise add items to running scheduler
+ if (state.status === 'idle') {
+ schedulerService.start(items);
+ } else if (state.status === 'running' || state.status === 'paused') {
+ for (const item of items) {
+ schedulerService.addItem(item);
+ }
+ } else {
+ return {
+ error: `Cannot add items when scheduler is in '${state.status}' state`,
+ status: 409,
+ };
+ }
+
+ return {
+ success: true,
+ state: schedulerService.getState(),
+ };
+ } catch (err) {
+ return { error: (err as Error).message, status: 500 };
+ }
+ });
+ return true;
+ }
+
+ // GET /api/queue/scheduler/state - Return full scheduler state
+ if (pathname === '/api/queue/scheduler/state' && req.method === 'GET') {
+ try {
+ const state = schedulerService.getState();
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: true, state }));
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: (err as Error).message }));
+ }
+ return true;
+ }
+
+ // POST /api/queue/scheduler/start - Start scheduling loop with items
+ if (pathname === '/api/queue/scheduler/start' && req.method === 'POST') {
+ handlePostRequest(req, res, async (body) => {
+ const { items } = body as { items?: QueueItem[] };
+
+ if (!items || !Array.isArray(items) || items.length === 0) {
+ return { error: 'items array is required and must not be empty', status: 400 };
+ }
+
+ try {
+ schedulerService.start(items);
+ return {
+ success: true,
+ state: schedulerService.getState(),
+ };
+ } catch (err) {
+ return { error: (err as Error).message, status: 409 };
+ }
+ });
+ return true;
+ }
+
+ // POST /api/queue/scheduler/pause - Pause scheduling
+ if (pathname === '/api/queue/scheduler/pause' && req.method === 'POST') {
+ handlePostRequest(req, res, async () => {
+ try {
+ schedulerService.pause();
+ return {
+ success: true,
+ state: schedulerService.getState(),
+ };
+ } catch (err) {
+ return { error: (err as Error).message, status: 409 };
+ }
+ });
+ return true;
+ }
+
+ // POST /api/queue/scheduler/stop - Graceful stop
+ if (pathname === '/api/queue/scheduler/stop' && req.method === 'POST') {
+ handlePostRequest(req, res, async () => {
+ try {
+ await schedulerService.stop();
+ return {
+ success: true,
+ state: schedulerService.getState(),
+ };
+ } catch (err) {
+ return { error: (err as Error).message, status: 409 };
+ }
+ });
+ return true;
+ }
+
+ // POST /api/queue/scheduler/config - Update scheduler configuration
+ if (pathname === '/api/queue/scheduler/config' && req.method === 'POST') {
+ handlePostRequest(req, res, async (body) => {
+ const config = body as Partial;
+
+ if (!config || typeof config !== 'object') {
+ return { error: 'Configuration object is required', status: 400 };
+ }
+
+ try {
+ schedulerService.updateConfig(config);
+ return {
+ success: true,
+ config: schedulerService.getState().config,
+ };
+ } catch (err) {
+ return { error: (err as Error).message, status: 500 };
+ }
+ });
+ return true;
+ }
+
+ return false;
+}
diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts
index b1561d1d..4468dd84 100644
--- a/ccw/src/core/server.ts
+++ b/ccw/src/core/server.ts
@@ -21,6 +21,7 @@ import { handleSkillsRoutes } from './routes/skills-routes.js';
import { handleSkillHubRoutes } from './routes/skill-hub-routes.js';
import { handleCommandsRoutes } from './routes/commands-routes.js';
import { handleIssueRoutes } from './routes/issue-routes.js';
+import { handleQueueSchedulerRoutes } from './routes/queue-routes.js';
import { handleDiscoveryRoutes } from './routes/discovery-routes.js';
import { handleRulesRoutes } from './routes/rules-routes.js';
import { handleSessionRoutes } from './routes/session-routes.js';
@@ -56,6 +57,8 @@ import { randomBytes } from 'crypto';
// Import health check service
import { getHealthCheckService } from './services/health-check-service.js';
import { getCliSessionShareManager } from './services/cli-session-share.js';
+import { getCliSessionManager } from './services/cli-session-manager.js';
+import { QueueSchedulerService } from './services/queue-scheduler-service.js';
// Import status check functions for warmup
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
@@ -294,6 +297,10 @@ export async function startServer(options: ServerOptions = {}): Promise(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
const cliSessionShareManager = getCliSessionShareManager();
+ // Initialize Queue Scheduler Service (needs broadcastToClients and cliSessionManager)
+ const cliSessionManager = getCliSessionManager(initialPath);
+ const queueSchedulerService = new QueueSchedulerService(broadcastToClients, cliSessionManager);
+
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
const pathname = url.pathname;
@@ -589,7 +596,12 @@ export async function startServer(options: ServerOptions = {}): Promise idle reuse -> new creation.
+ */
+
+import type { CliSessionManager } from './cli-session-manager.js';
+import type {
+ QueueItem,
+ QueueItemStatus,
+ QueueSchedulerConfig,
+ QueueSchedulerState,
+ QueueSchedulerStatus,
+ QueueWSMessage,
+ SessionBinding,
+} from '../../types/queue-types.js';
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const DEFAULT_CONFIG: QueueSchedulerConfig = {
+ maxConcurrentSessions: 2,
+ sessionIdleTimeoutMs: 5 * 60 * 1000, // 5 minutes
+ resumeKeySessionBindingTimeoutMs: 30 * 60 * 1000, // 30 minutes
+};
+
+/**
+ * Valid state machine transitions.
+ * Key = current status, Value = set of allowed target statuses.
+ */
+const VALID_TRANSITIONS: Record> = {
+ idle: new Set(['running']),
+ running: new Set(['paused', 'stopping']),
+ paused: new Set(['running', 'stopping']),
+ stopping: new Set(['completed', 'failed']),
+ completed: new Set(['idle']),
+ failed: new Set(['idle']),
+};
+
+// ============================================================================
+// QueueSchedulerService
+// ============================================================================
+
+export class QueueSchedulerService {
+ private state: QueueSchedulerState;
+ private broadcastFn: (data: unknown) => void;
+ private cliSessionManager: CliSessionManager;
+
+ /** Tracks in-flight execution promises by item_id. */
+ private executingTasks = new Map>();
+
+ /** Interval handle for session idle cleanup. */
+ private cleanupTimer: ReturnType | null = null;
+
+ /** Guard to prevent re-entrant processQueue calls. */
+ private processingLock = false;
+
+ constructor(
+ broadcastToClients: (data: unknown) => void,
+ cliSessionManager: CliSessionManager,
+ config?: Partial,
+ ) {
+ this.broadcastFn = broadcastToClients;
+ this.cliSessionManager = cliSessionManager;
+
+ const mergedConfig: QueueSchedulerConfig = { ...DEFAULT_CONFIG, ...config };
+
+ this.state = {
+ status: 'idle',
+ items: [],
+ sessionPool: {},
+ config: mergedConfig,
+ currentConcurrency: 0,
+ lastActivityAt: new Date().toISOString(),
+ };
+ }
+
+ // ==========================================================================
+ // Public API
+ // ==========================================================================
+
+ /**
+ * Start the scheduler with an initial set of items.
+ * Transitions: idle -> running.
+ */
+ start(items: QueueItem[]): void {
+ this.validateTransition('running');
+ this.state.status = 'running';
+ this.state.error = undefined;
+ this.touchActivity();
+
+ // Resolve initial statuses based on dependency graph
+ for (const item of items) {
+ const resolved = this.resolveInitialStatus(item, items);
+ this.state.items.push({ ...item, status: resolved });
+ }
+
+ this.startCleanupInterval();
+ this.broadcastStateUpdate();
+
+ // Kick off the scheduling loop (non-blocking)
+ void this.processQueue();
+ }
+
+ /**
+ * Pause the scheduler. Running tasks continue to completion but no new tasks start.
+ * Transitions: running -> paused.
+ */
+ pause(): void {
+ this.validateTransition('paused');
+ this.state.status = 'paused';
+ this.touchActivity();
+ this.broadcastStateUpdate();
+ }
+
+ /**
+ * Resume from paused state.
+ * Transitions: paused -> running.
+ */
+ resume(): void {
+ this.validateTransition('running');
+ this.state.status = 'running';
+ this.touchActivity();
+ this.broadcastStateUpdate();
+ void this.processQueue();
+ }
+
+ /**
+ * Request graceful stop. Waits for executing tasks to finish.
+ * Transitions: running|paused -> stopping -> completed|failed.
+ */
+ async stop(): Promise {
+ this.validateTransition('stopping');
+ this.state.status = 'stopping';
+ this.touchActivity();
+ this.broadcastStateUpdate();
+
+ // Wait for all in-flight executions
+ if (this.executingTasks.size > 0) {
+ await Promise.allSettled(Array.from(this.executingTasks.values()));
+ }
+
+ // Determine final status
+ const hasFailures = this.state.items.some(i => i.status === 'failed');
+ const finalStatus: QueueSchedulerStatus = hasFailures ? 'failed' : 'completed';
+ this.state.status = finalStatus;
+
+ // Cancel any remaining pending/queued/blocked items
+ for (const item of this.state.items) {
+ if (item.status === 'pending' || item.status === 'queued' || item.status === 'ready' || item.status === 'blocked') {
+ item.status = 'cancelled';
+ item.completedAt = new Date().toISOString();
+ }
+ }
+
+ this.stopCleanupInterval();
+ this.touchActivity();
+ this.broadcastStateUpdate();
+ }
+
+ /**
+ * Reset the scheduler back to idle, clearing all items and session pool.
+ * Transitions: completed|failed -> idle.
+ */
+ reset(): void {
+ this.validateTransition('idle');
+ this.state.status = 'idle';
+ this.state.items = [];
+ this.state.sessionPool = {};
+ this.state.currentConcurrency = 0;
+ this.state.error = undefined;
+ this.executingTasks.clear();
+ this.stopCleanupInterval();
+ this.touchActivity();
+ this.broadcastStateUpdate();
+ }
+
+ /**
+ * Add a single item to the queue while the scheduler is running.
+ */
+ addItem(item: QueueItem): void {
+ const resolved = this.resolveInitialStatus(item, this.state.items);
+ const newItem = { ...item, status: resolved };
+ this.state.items.push(newItem);
+ this.touchActivity();
+
+ this.broadcast({
+ type: 'QUEUE_ITEM_ADDED',
+ item: newItem,
+ timestamp: new Date().toISOString(),
+ });
+
+ // Trigger scheduling if running
+ if (this.state.status === 'running') {
+ void this.processQueue();
+ }
+ }
+
+ /**
+ * Remove an item from the queue. Only non-executing items can be removed.
+ */
+ removeItem(itemId: string): boolean {
+ const idx = this.state.items.findIndex(i => i.item_id === itemId);
+ if (idx === -1) return false;
+
+ const item = this.state.items[idx];
+ if (item.status === 'executing') return false;
+
+ this.state.items.splice(idx, 1);
+ this.touchActivity();
+
+ this.broadcast({
+ type: 'QUEUE_ITEM_REMOVED',
+ item_id: itemId,
+ timestamp: new Date().toISOString(),
+ });
+
+ return true;
+ }
+
+ /**
+ * Update scheduler configuration at runtime.
+ */
+ updateConfig(partial: Partial): void {
+ Object.assign(this.state.config, partial);
+ this.touchActivity();
+
+ this.broadcast({
+ type: 'QUEUE_SCHEDULER_CONFIG_UPDATED',
+ config: { ...this.state.config },
+ timestamp: new Date().toISOString(),
+ });
+
+ // If maxConcurrentSessions increased, try to schedule more
+ if (partial.maxConcurrentSessions !== undefined && this.state.status === 'running') {
+ void this.processQueue();
+ }
+ }
+
+ /**
+ * Get a snapshot of the current scheduler state.
+ */
+ getState(): QueueSchedulerState {
+ return {
+ ...this.state,
+ items: this.state.items.map(i => ({ ...i })),
+ sessionPool: { ...this.state.sessionPool },
+ config: { ...this.state.config },
+ };
+ }
+
+ /**
+ * Get a specific item by ID.
+ */
+ getItem(itemId: string): QueueItem | undefined {
+ return this.state.items.find(i => i.item_id === itemId);
+ }
+
+ // ==========================================================================
+ // Core Scheduling Loop
+ // ==========================================================================
+
+ /**
+ * Main scheduling loop. Resolves dependencies, selects ready tasks,
+ * allocates sessions, and triggers execution.
+ *
+ * The selection phase is synchronous (guarded by processingLock) to prevent
+ * race conditions in session allocation. Only execution is async.
+ */
+ private async processQueue(): Promise {
+ // Guard: prevent re-entrant calls
+ if (this.processingLock) return;
+ this.processingLock = true;
+
+ try {
+ while (this.state.status === 'running') {
+ // Step 1: Check preconditions
+ if (this.state.currentConcurrency >= this.state.config.maxConcurrentSessions) {
+ break;
+ }
+
+ // Step 2: Resolve blocked items whose dependencies are now completed
+ this.resolveDependencies();
+
+ // Step 3: Select next task to execute
+ const candidate = this.selectNextTask();
+ if (!candidate) {
+ // Check if everything is done
+ this.checkCompletion();
+ break;
+ }
+
+ // Step 4: Allocate a session
+ const sessionKey = this.allocateSession(candidate);
+ if (!sessionKey) {
+ // Could not allocate a session (all slots busy)
+ break;
+ }
+
+ // Step 5: Mark as executing and launch
+ candidate.status = 'executing';
+ candidate.sessionKey = sessionKey;
+ candidate.startedAt = new Date().toISOString();
+ this.state.currentConcurrency++;
+ this.touchActivity();
+
+ this.broadcastItemUpdate(candidate);
+
+ // Step 6: Execute asynchronously
+ const execPromise = this.executeTask(candidate, sessionKey);
+ this.executingTasks.set(candidate.item_id, execPromise);
+
+ // Chain cleanup and re-trigger
+ void execPromise.then(() => {
+ this.executingTasks.delete(candidate.item_id);
+ // Re-trigger scheduling on completion
+ if (this.state.status === 'running') {
+ void this.processQueue();
+ }
+ });
+ }
+ } finally {
+ this.processingLock = false;
+ }
+ }
+
+ /**
+ * Resolve blocked items whose depends_on are all completed.
+ */
+ private resolveDependencies(): void {
+ const completedIds = new Set(
+ this.state.items
+ .filter(i => i.status === 'completed')
+ .map(i => i.item_id),
+ );
+
+ for (const item of this.state.items) {
+ if (item.status !== 'blocked' && item.status !== 'pending') continue;
+
+ if (item.depends_on.length === 0) {
+ if (item.status === 'pending') {
+ item.status = 'queued';
+ this.broadcastItemUpdate(item);
+ }
+ continue;
+ }
+
+ // Check if any dependency failed
+ const anyDepFailed = item.depends_on.some(depId => {
+ const dep = this.state.items.find(i => i.item_id === depId);
+ return dep && (dep.status === 'failed' || dep.status === 'cancelled');
+ });
+ if (anyDepFailed) {
+ item.status = 'cancelled';
+ item.completedAt = new Date().toISOString();
+ item.error = 'Dependency failed or was cancelled';
+ this.broadcastItemUpdate(item);
+ continue;
+ }
+
+ const allDepsComplete = item.depends_on.every(depId => completedIds.has(depId));
+ if (allDepsComplete) {
+ item.status = 'queued';
+ this.broadcastItemUpdate(item);
+ } else if (item.status === 'pending') {
+ item.status = 'blocked';
+ this.broadcastItemUpdate(item);
+ }
+ }
+ }
+
+ /**
+ * Select the next queued task by execution_order, then createdAt.
+ */
+ private selectNextTask(): QueueItem | undefined {
+ const queued = this.state.items.filter(i => i.status === 'queued');
+ if (queued.length === 0) return undefined;
+
+ queued.sort((a, b) => {
+ if (a.execution_order !== b.execution_order) {
+ return a.execution_order - b.execution_order;
+ }
+ return a.createdAt.localeCompare(b.createdAt);
+ });
+
+ return queued[0];
+ }
+
+ // ==========================================================================
+ // Session Pool Management
+ // ==========================================================================
+
+ /**
+ * 3-tier session allocation strategy:
+ * 1. ResumeKey affinity: if the item has a resumeKey and we have a bound session, reuse it.
+ * 2. Idle session reuse: find any session in the pool not currently executing.
+ * 3. New session creation: create a new session via CliSessionManager if under the limit.
+ *
+ * Returns sessionKey or null if no session available.
+ */
+ private allocateSession(item: QueueItem): string | null {
+ const now = new Date();
+
+ // Tier 1: ResumeKey affinity
+ if (item.resumeKey) {
+ const binding = this.state.sessionPool[item.resumeKey];
+ if (binding) {
+ const bindingAge = now.getTime() - new Date(binding.lastUsed).getTime();
+ if (bindingAge < this.state.config.resumeKeySessionBindingTimeoutMs) {
+ // Verify the session still exists in CliSessionManager
+ if (this.cliSessionManager.hasSession(binding.sessionKey)) {
+ binding.lastUsed = now.toISOString();
+ return binding.sessionKey;
+ }
+ // Session gone, remove stale binding
+ delete this.state.sessionPool[item.resumeKey];
+ } else {
+ // Binding expired
+ delete this.state.sessionPool[item.resumeKey];
+ }
+ }
+ }
+
+ // Tier 2: Idle session reuse
+ const executingSessionKeys = new Set(
+ this.state.items
+ .filter(i => i.status === 'executing' && i.sessionKey)
+ .map(i => i.sessionKey!),
+ );
+
+ for (const [resumeKey, binding] of Object.entries(this.state.sessionPool)) {
+ if (!executingSessionKeys.has(binding.sessionKey)) {
+ // This session is idle in the pool
+ if (this.cliSessionManager.hasSession(binding.sessionKey)) {
+ binding.lastUsed = now.toISOString();
+ // Rebind to new resumeKey if different
+ if (item.resumeKey && item.resumeKey !== resumeKey) {
+ this.state.sessionPool[item.resumeKey] = binding;
+ }
+ return binding.sessionKey;
+ }
+ // Stale session, clean up
+ delete this.state.sessionPool[resumeKey];
+ }
+ }
+
+ // Tier 3: New session creation
+ const activeSessions = this.cliSessionManager.listSessions();
+ // Count sessions managed by our pool (not all sessions globally)
+ const poolSessionKeys = new Set(
+ Object.values(this.state.sessionPool).map(b => b.sessionKey),
+ );
+ const ourActiveCount = activeSessions.filter(s => poolSessionKeys.has(s.sessionKey)).length;
+
+ if (ourActiveCount < this.state.config.maxConcurrentSessions) {
+ try {
+ const newSession = this.cliSessionManager.createSession({
+ workingDir: this.cliSessionManager.getProjectRoot(),
+ tool: item.tool,
+ resumeKey: item.resumeKey,
+ });
+
+ const binding: SessionBinding = {
+ sessionKey: newSession.sessionKey,
+ lastUsed: now.toISOString(),
+ };
+
+ // Bind to resumeKey if available, otherwise use item_id as key
+ const poolKey = item.resumeKey || item.item_id;
+ this.state.sessionPool[poolKey] = binding;
+
+ return newSession.sessionKey;
+ } catch (err) {
+ console.error('[QueueScheduler] Failed to create session:', (err as Error).message);
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Release a session back to the pool after task completion.
+ */
+ private releaseSession(item: QueueItem): void {
+ if (!item.sessionKey) return;
+
+ // Update the binding's lastUsed timestamp
+ const poolKey = item.resumeKey || item.item_id;
+ const binding = this.state.sessionPool[poolKey];
+ if (binding && binding.sessionKey === item.sessionKey) {
+ binding.lastUsed = new Date().toISOString();
+ }
+ }
+
+ // ==========================================================================
+ // Task Execution
+ // ==========================================================================
+
+ /**
+ * Execute a single queue item via CliSessionManager.
+ */
+ private async executeTask(item: QueueItem, sessionKey: string): Promise {
+ try {
+ this.cliSessionManager.execute(sessionKey, {
+ tool: item.tool,
+ prompt: item.prompt,
+ mode: item.mode,
+ resumeKey: item.resumeKey,
+ resumeStrategy: item.resumeStrategy,
+ });
+
+ // Mark as completed (fire-and-forget execution model for PTY sessions)
+ // The actual CLI execution is async in the PTY; we mark completion
+ // after the command is sent. Real completion tracking requires
+ // hook callbacks or output parsing (future enhancement).
+ item.status = 'completed';
+ item.completedAt = new Date().toISOString();
+ } catch (err) {
+ item.status = 'failed';
+ item.completedAt = new Date().toISOString();
+ item.error = (err as Error).message;
+ }
+
+ // Update concurrency and release session
+ this.state.currentConcurrency = Math.max(0, this.state.currentConcurrency - 1);
+ this.releaseSession(item);
+ this.touchActivity();
+ this.broadcastItemUpdate(item);
+ }
+
+ // ==========================================================================
+ // State Machine
+ // ==========================================================================
+
+ /**
+ * Validate that the requested transition is allowed.
+ * Throws if the transition is invalid.
+ */
+ private validateTransition(target: QueueSchedulerStatus): void {
+ const allowed = VALID_TRANSITIONS[this.state.status];
+ if (!allowed || !allowed.has(target)) {
+ throw new Error(
+ `Invalid state transition: ${this.state.status} -> ${target}. ` +
+ `Allowed transitions from '${this.state.status}': [${Array.from(allowed || []).join(', ')}]`,
+ );
+ }
+ }
+
+ /**
+ * Determine initial status for an item based on its dependencies.
+ */
+ private resolveInitialStatus(item: QueueItem, allItems: QueueItem[]): QueueItemStatus {
+ if (item.depends_on.length === 0) {
+ return 'queued';
+ }
+ // Check if all dependencies are already completed
+ const completedIds = new Set(
+ allItems.filter(i => i.status === 'completed').map(i => i.item_id),
+ );
+ const allResolved = item.depends_on.every(id => completedIds.has(id));
+ return allResolved ? 'queued' : 'blocked';
+ }
+
+ /**
+ * Check if all items are in a terminal state, and transition scheduler accordingly.
+ */
+ private checkCompletion(): void {
+ if (this.state.status !== 'running') return;
+ if (this.executingTasks.size > 0) return;
+
+ const allTerminal = this.state.items.every(
+ i => i.status === 'completed' || i.status === 'failed' || i.status === 'cancelled',
+ );
+
+ if (!allTerminal) return;
+
+ const hasFailures = this.state.items.some(i => i.status === 'failed');
+ // Transition through stopping to final state
+ this.state.status = 'stopping';
+ this.state.status = hasFailures ? 'failed' : 'completed';
+ this.stopCleanupInterval();
+ this.touchActivity();
+ this.broadcastStateUpdate();
+ }
+
+ // ==========================================================================
+ // Session Cleanup
+ // ==========================================================================
+
+ /**
+ * Start periodic cleanup of idle sessions from the pool.
+ */
+ private startCleanupInterval(): void {
+ this.stopCleanupInterval();
+ this.cleanupTimer = setInterval(() => {
+ this.cleanupIdleSessions();
+ }, 60_000);
+
+ // Prevent the timer from keeping the process alive
+ if (this.cleanupTimer && typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {
+ this.cleanupTimer.unref();
+ }
+ }
+
+ private stopCleanupInterval(): void {
+ if (this.cleanupTimer !== null) {
+ clearInterval(this.cleanupTimer);
+ this.cleanupTimer = null;
+ }
+ }
+
+ /**
+ * Remove sessions from the pool that have been idle beyond the timeout.
+ */
+ private cleanupIdleSessions(): void {
+ const now = Date.now();
+ const timeoutMs = this.state.config.sessionIdleTimeoutMs;
+
+ const executingSessionKeys = new Set(
+ this.state.items
+ .filter(i => i.status === 'executing' && i.sessionKey)
+ .map(i => i.sessionKey!),
+ );
+
+ for (const [key, binding] of Object.entries(this.state.sessionPool)) {
+ // Skip sessions currently in use
+ if (executingSessionKeys.has(binding.sessionKey)) continue;
+
+ const idleMs = now - new Date(binding.lastUsed).getTime();
+ if (idleMs >= timeoutMs) {
+ // Close the session in CliSessionManager
+ try {
+ this.cliSessionManager.close(binding.sessionKey);
+ } catch {
+ // Session may already be gone
+ }
+ delete this.state.sessionPool[key];
+ }
+ }
+ }
+
+ // ==========================================================================
+ // Broadcasting
+ // ==========================================================================
+
+ private broadcast(message: QueueWSMessage): void {
+ try {
+ this.broadcastFn(message);
+ } catch {
+ // Ignore broadcast errors
+ }
+ }
+
+ private broadcastStateUpdate(): void {
+ this.broadcast({
+ type: 'QUEUE_SCHEDULER_STATE_UPDATE',
+ state: this.getState(),
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ private broadcastItemUpdate(item: QueueItem): void {
+ this.broadcast({
+ type: 'QUEUE_ITEM_UPDATED',
+ item: { ...item },
+ timestamp: new Date().toISOString(),
+ });
+ }
+
+ // ==========================================================================
+ // Utilities
+ // ==========================================================================
+
+ private touchActivity(): void {
+ this.state.lastActivityAt = new Date().toISOString();
+ }
+}
diff --git a/ccw/src/core/websocket.ts b/ccw/src/core/websocket.ts
index 0216d38b..f695f7e4 100644
--- a/ccw/src/core/websocket.ts
+++ b/ccw/src/core/websocket.ts
@@ -3,6 +3,15 @@ import type { IncomingMessage } from 'http';
import type { Duplex } from 'stream';
import { a2uiWebSocketHandler, handleA2UIMessage } from './a2ui/A2UIWebSocketHandler.js';
import { handleAnswer } from '../tools/ask-question.js';
+import type {
+ QueueWSMessageType,
+ QueueWSMessage,
+ QueueSchedulerStateUpdateMessage,
+ QueueItemAddedMessage,
+ QueueItemUpdatedMessage,
+ QueueItemRemovedMessage,
+ QueueSchedulerConfigUpdatedMessage,
+} from '../types/queue-types.js';
// WebSocket clients for real-time notifications
export const wsClients = new Set();
@@ -622,3 +631,53 @@ export function broadcastCoordinatorLog(
timestamp: new Date().toISOString()
});
}
+
+// Re-export Queue WebSocket types from queue-types.ts
+export type {
+ QueueWSMessageType as QueueMessageType,
+ QueueSchedulerStateUpdateMessage,
+ QueueItemAddedMessage,
+ QueueItemUpdatedMessage,
+ QueueItemRemovedMessage,
+ QueueSchedulerConfigUpdatedMessage,
+};
+
+/**
+ * Union type for Queue messages (without timestamp - added automatically)
+ */
+export type QueueMessage =
+ | Omit
+ | Omit
+ | Omit
+ | Omit
+ | Omit;
+
+/**
+ * Queue-specific broadcast with throttling
+ * Throttles QUEUE_SCHEDULER_STATE_UPDATE messages to avoid flooding clients
+ */
+let lastQueueBroadcast = 0;
+const QUEUE_BROADCAST_THROTTLE = 1000; // 1 second
+
+/**
+ * Broadcast queue update with throttling
+ * STATE_UPDATE messages are throttled to 1 per second
+ * Other message types are sent immediately
+ */
+export function broadcastQueueUpdate(message: QueueMessage): void {
+ const now = Date.now();
+
+ // Throttle QUEUE_SCHEDULER_STATE_UPDATE to reduce WebSocket traffic
+ if (message.type === 'QUEUE_SCHEDULER_STATE_UPDATE' && now - lastQueueBroadcast < QUEUE_BROADCAST_THROTTLE) {
+ return;
+ }
+
+ if (message.type === 'QUEUE_SCHEDULER_STATE_UPDATE') {
+ lastQueueBroadcast = now;
+ }
+
+ broadcastToClients({
+ ...message,
+ timestamp: new Date().toISOString()
+ });
+}
diff --git a/ccw/src/tools/cli-executor-core.ts b/ccw/src/tools/cli-executor-core.ts
index 070304b8..75e089a7 100644
--- a/ccw/src/tools/cli-executor-core.ts
+++ b/ccw/src/tools/cli-executor-core.ts
@@ -390,9 +390,13 @@ export function generateTransactionId(conversationId: string): TransactionId {
* Inject transaction ID into user prompt
* @param prompt - Original user prompt
* @param txId - Transaction ID to inject
- * @returns Prompt with transaction ID injected at the start
+ * @returns Prompt with transaction ID injected at the start, or empty string if prompt is empty
*/
export function injectTransactionId(prompt: string, txId: TransactionId): string {
+ // Don't inject TX ID for empty prompts (e.g., review mode with target flags)
+ if (!prompt || !prompt.trim()) {
+ return '';
+ }
return `[CCW-TX-ID: ${txId}]\n\n${prompt}`;
}
@@ -844,8 +848,15 @@ async function executeCliTool(
// Inject transaction ID at the start of the final prompt for session tracking
// This enables exact session matching during parallel execution scenarios
- finalPrompt = injectTransactionId(finalPrompt, transactionId);
- debugLog('TX_ID', `Injected transaction ID into prompt`, { transactionId, promptLength: finalPrompt.length });
+ // Skip injection for review mode with target flags (uncommitted/base/commit) as these
+ // modes don't accept prompt arguments in codex CLI
+ const isReviewWithTarget = mode === 'review' && (uncommitted || base || commit);
+ if (!isReviewWithTarget) {
+ finalPrompt = injectTransactionId(finalPrompt, transactionId);
+ debugLog('TX_ID', `Injected transaction ID into prompt`, { transactionId, promptLength: finalPrompt.length });
+ } else {
+ debugLog('TX_ID', `Skipped transaction ID injection for review mode with target flag`);
+ }
// Check tool availability
const toolStatus = await checkToolAvailability(tool);
diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts
index d206c8ab..30c47d3c 100644
--- a/ccw/src/tools/cli-history-store.ts
+++ b/ccw/src/tools/cli-history-store.ts
@@ -502,9 +502,11 @@ export class CliHistoryStore {
* Save or update a conversation
*/
saveConversation(conversation: ConversationRecord): void {
- const promptPreview = conversation.turns.length > 0
- ? conversation.turns[conversation.turns.length - 1].prompt.substring(0, 100)
- : '';
+ // Ensure prompt is a string before calling substring
+ const lastTurn = conversation.turns.length > 0 ? conversation.turns[conversation.turns.length - 1] : null;
+ const rawPrompt = lastTurn?.prompt ?? '';
+ const promptStr = typeof rawPrompt === 'string' ? rawPrompt : JSON.stringify(rawPrompt);
+ const promptPreview = promptStr.substring(0, 100);
const upsertConversation = this.db.prepare(`
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview, parent_execution_id, project_root, relative_path)
@@ -609,7 +611,8 @@ export class CliHistoryStore {
turns: turns.map(t => ({
turn: t.turn_number,
timestamp: t.timestamp,
- prompt: t.prompt,
+ // Ensure prompt is always a string (handle legacy object data)
+ prompt: typeof t.prompt === 'string' ? t.prompt : JSON.stringify(t.prompt),
duration_ms: t.duration_ms,
status: t.status,
exit_code: t.exit_code,
@@ -840,7 +843,10 @@ export class CliHistoryStore {
category: r.category || 'user',
duration_ms: r.total_duration_ms,
turn_count: r.turn_count,
- prompt_preview: r.prompt_preview || ''
+ // Ensure prompt_preview is always a string (handle legacy object data)
+ prompt_preview: typeof r.prompt_preview === 'string'
+ ? r.prompt_preview
+ : (r.prompt_preview ? JSON.stringify(r.prompt_preview) : '')
}))
};
}
diff --git a/ccw/src/tools/native-session-discovery.ts b/ccw/src/tools/native-session-discovery.ts
index 61edd1ff..77d7a323 100644
--- a/ccw/src/tools/native-session-discovery.ts
+++ b/ccw/src/tools/native-session-discovery.ts
@@ -1197,3 +1197,30 @@ export function getToolSessionPath(tool: string): string | null {
const discoverer = discoverers[tool];
return discoverer?.basePath || null;
}
+
+/**
+ * List all native sessions from all supported CLI tools
+ * Aggregates sessions from Gemini, Qwen, Codex, Claude, and OpenCode
+ * @param options - Optional filtering (workingDir, limit, afterTimestamp)
+ * @returns Combined sessions sorted by updatedAt descending
+ */
+export function listAllNativeSessions(options?: SessionDiscoveryOptions): NativeSession[] {
+ const allSessions: NativeSession[] = [];
+
+ // Collect sessions from all discoverers
+ for (const tool of Object.keys(discoverers)) {
+ const discoverer = discoverers[tool];
+ const sessions = discoverer.getSessions(options);
+ allSessions.push(...sessions);
+ }
+
+ // Sort by updatedAt descending
+ allSessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
+
+ // Apply limit if provided
+ if (options?.limit) {
+ return allSessions.slice(0, options.limit);
+ }
+
+ return allSessions;
+}
diff --git a/ccw/src/tools/session-content-parser.ts b/ccw/src/tools/session-content-parser.ts
index f942d1a5..4a2e550c 100644
--- a/ccw/src/tools/session-content-parser.ts
+++ b/ccw/src/tools/session-content-parser.ts
@@ -236,13 +236,18 @@ function parseGeminiQwenSession(content: string, tool: string): ParsedSession {
let model: string | undefined;
for (const msg of session.messages) {
+ // Ensure content is always a string (handle legacy object data like {text: "..."})
+ const contentStr = typeof msg.content === 'string'
+ ? msg.content
+ : JSON.stringify(msg.content);
+
if (msg.type === 'user') {
turnNumber++;
turns.push({
turnNumber,
timestamp: msg.timestamp,
role: 'user',
- content: msg.content
+ content: contentStr
});
} else if (msg.type === 'gemini' || msg.type === 'qwen') {
// Find the corresponding user turn
@@ -255,7 +260,7 @@ function parseGeminiQwenSession(content: string, tool: string): ParsedSession {
turnNumber,
timestamp: msg.timestamp,
role: 'assistant',
- content: msg.content,
+ content: contentStr,
thoughts: thoughts.length > 0 ? thoughts : undefined,
tokens: msg.tokens ? {
input: msg.tokens.input,
@@ -428,7 +433,11 @@ function parseCodexSession(content: string): ParsedSession {
currentTurn++;
const textContent = item.payload.content
?.filter(c => c.type === 'input_text')
- .map(c => c.text)
+ .map(c => {
+ // Ensure text is a string (handle legacy object data like {text: "..."})
+ const txt = c.text;
+ return typeof txt === 'string' ? txt : JSON.stringify(txt);
+ })
.join('\n') || '';
turns.push({
@@ -461,7 +470,11 @@ function parseCodexSession(content: string): ParsedSession {
// Assistant message (final response)
const textContent = item.payload.content
?.filter(c => c.type === 'output_text' || c.type === 'text')
- .map(c => c.text)
+ .map(c => {
+ // Ensure text is a string (handle legacy object data like {text: "..."})
+ const txt = c.text;
+ return typeof txt === 'string' ? txt : JSON.stringify(txt);
+ })
.join('\n') || '';
if (textContent) {
diff --git a/ccw/src/types/cli-settings.ts b/ccw/src/types/cli-settings.ts
index 792e9296..fbcce2eb 100644
--- a/ccw/src/types/cli-settings.ts
+++ b/ccw/src/types/cli-settings.ts
@@ -187,7 +187,7 @@ export function createDefaultSettings(provider: CliProvider = 'claude'): CliSett
env: {
DISABLE_AUTOUPDATER: '1'
},
- model: 'sonnet',
+ model: '',
tags: [],
availableModels: []
} satisfies ClaudeCliSettings;
diff --git a/ccw/src/types/queue-types.ts b/ccw/src/types/queue-types.ts
new file mode 100644
index 00000000..b8b55d54
--- /dev/null
+++ b/ccw/src/types/queue-types.ts
@@ -0,0 +1,209 @@
+/**
+ * Queue Scheduler Type Definitions
+ * TypeScript types for queue scheduling, dependency resolution, and session management.
+ */
+
+import type { CliSessionResumeStrategy } from '../core/services/cli-session-command-builder.js';
+
+// ============================================================================
+// Status Enums
+// ============================================================================
+
+/**
+ * Status of a single queue item through its lifecycle.
+ *
+ * Transitions:
+ * pending -> queued -> ready -> executing -> completed | failed
+ * pending -> blocked (has unresolved depends_on)
+ * blocked -> queued (all depends_on completed)
+ * any -> cancelled (user cancellation)
+ */
+export type QueueItemStatus =
+ | 'pending'
+ | 'queued'
+ | 'ready'
+ | 'executing'
+ | 'completed'
+ | 'failed'
+ | 'blocked'
+ | 'cancelled';
+
+/**
+ * Status of the scheduler state machine.
+ *
+ * Transitions:
+ * idle -> running (start)
+ * running -> paused (pause)
+ * running -> stopping (stop requested, waiting for executing tasks)
+ * paused -> running (resume)
+ * stopping -> completed (all executing tasks finished successfully)
+ * stopping -> failed (executing task failure during stop)
+ */
+export type QueueSchedulerStatus =
+ | 'idle'
+ | 'running'
+ | 'paused'
+ | 'stopping'
+ | 'completed'
+ | 'failed';
+
+// ============================================================================
+// Configuration
+// ============================================================================
+
+/**
+ * Configuration for the queue scheduler.
+ */
+export interface QueueSchedulerConfig {
+ /** Maximum number of concurrent CLI sessions executing tasks. */
+ maxConcurrentSessions: number;
+ /** Idle timeout (ms) before releasing a session from the pool. */
+ sessionIdleTimeoutMs: number;
+ /** Timeout (ms) for resumeKey-to-session binding affinity. */
+ resumeKeySessionBindingTimeoutMs: number;
+}
+
+// ============================================================================
+// Core Entities
+// ============================================================================
+
+/**
+ * A single task item in the execution queue.
+ */
+export interface QueueItem {
+ /** Unique identifier for this queue item. */
+ item_id: string;
+ /** Reference to the parent issue (if applicable). */
+ issue_id?: string;
+ /** Current status of the item. */
+ status: QueueItemStatus;
+ /** CLI tool to use for execution (e.g., 'gemini', 'claude'). */
+ tool: string;
+ /** Prompt/instruction to send to the CLI tool. */
+ prompt: string;
+ /** Execution mode. */
+ mode?: 'analysis' | 'write' | 'auto';
+ /** Resume key for session affinity and conversation continuity. */
+ resumeKey?: string;
+ /** Strategy for resuming a previous CLI session. */
+ resumeStrategy?: CliSessionResumeStrategy;
+ /** Item IDs that must complete before this item can execute. */
+ depends_on: string[];
+ /** Numeric order for scheduling priority within a group. Lower = earlier. */
+ execution_order: number;
+ /** Logical grouping for related items (e.g., same issue). */
+ execution_group?: string;
+ /** Session key assigned when executing. */
+ sessionKey?: string;
+ /** Timestamp when item was added to the queue. */
+ createdAt: string;
+ /** Timestamp when execution started. */
+ startedAt?: string;
+ /** Timestamp when execution completed (success or failure). */
+ completedAt?: string;
+ /** Error message if status is 'failed'. */
+ error?: string;
+ /** Output from CLI execution. */
+ output?: string;
+ /** Arbitrary metadata for extensibility. */
+ metadata?: Record;
+}
+
+/**
+ * Tracks a session bound to a resumeKey for affinity-based allocation.
+ */
+export interface SessionBinding {
+ /** The CLI session key from CliSessionManager. */
+ sessionKey: string;
+ /** Timestamp of last activity on this binding. */
+ lastUsed: string;
+}
+
+/**
+ * Complete snapshot of the scheduler state, used for WS broadcast and API responses.
+ */
+export interface QueueSchedulerState {
+ /** Current scheduler status. */
+ status: QueueSchedulerStatus;
+ /** All items in the queue (pending, executing, completed, etc.). */
+ items: QueueItem[];
+ /** Session pool: resumeKey -> SessionBinding. */
+ sessionPool: Record;
+ /** Active scheduler configuration. */
+ config: QueueSchedulerConfig;
+ /** Number of currently executing tasks. */
+ currentConcurrency: number;
+ /** Timestamp of last scheduler activity. */
+ lastActivityAt: string;
+ /** Error message if scheduler status is 'failed'. */
+ error?: string;
+}
+
+// ============================================================================
+// WebSocket Message Types
+// ============================================================================
+
+/**
+ * Discriminator values for queue-related WebSocket messages.
+ */
+export type QueueWSMessageType =
+ | 'QUEUE_SCHEDULER_STATE_UPDATE'
+ | 'QUEUE_ITEM_ADDED'
+ | 'QUEUE_ITEM_UPDATED'
+ | 'QUEUE_ITEM_REMOVED'
+ | 'QUEUE_SCHEDULER_CONFIG_UPDATED';
+
+/**
+ * Full scheduler state broadcast (sent on start/pause/stop/complete).
+ */
+export interface QueueSchedulerStateUpdateMessage {
+ type: 'QUEUE_SCHEDULER_STATE_UPDATE';
+ state: QueueSchedulerState;
+ timestamp: string;
+}
+
+/**
+ * Broadcast when a new item is added to the queue.
+ */
+export interface QueueItemAddedMessage {
+ type: 'QUEUE_ITEM_ADDED';
+ item: QueueItem;
+ timestamp: string;
+}
+
+/**
+ * Broadcast when an item's status or data changes.
+ */
+export interface QueueItemUpdatedMessage {
+ type: 'QUEUE_ITEM_UPDATED';
+ item: QueueItem;
+ timestamp: string;
+}
+
+/**
+ * Broadcast when an item is removed from the queue.
+ */
+export interface QueueItemRemovedMessage {
+ type: 'QUEUE_ITEM_REMOVED';
+ item_id: string;
+ timestamp: string;
+}
+
+/**
+ * Broadcast when scheduler configuration is updated.
+ */
+export interface QueueSchedulerConfigUpdatedMessage {
+ type: 'QUEUE_SCHEDULER_CONFIG_UPDATED';
+ config: QueueSchedulerConfig;
+ timestamp: string;
+}
+
+/**
+ * Discriminated union of all queue WebSocket messages.
+ */
+export type QueueWSMessage =
+ | QueueSchedulerStateUpdateMessage
+ | QueueItemAddedMessage
+ | QueueItemUpdatedMessage
+ | QueueItemRemovedMessage
+ | QueueSchedulerConfigUpdatedMessage;