@@ -645,21 +741,27 @@ export function SuperAdminPanel() {
)}
-
- {openActionMenu === menuKey && (
-
+ {isLocal && tenant.id < 0 ? (
+ Local read-only
+ ) : (
+ <>
-
+ {openActionMenu === menuKey && (
+
+
+
+ )}
+ >
)}
|
@@ -758,42 +860,53 @@ export function SuperAdminPanel() {
Appr: {job.approved_by || '-'}
-
- {openActionMenu === menuKey && (
-
+ {isLocal && job.id < 0 ? (
+
+ ) : (
+ <>
-
-
-
-
+ {openActionMenu === menuKey && (
+
+
+
+
+
+
+ )}
+ >
)}
|
diff --git a/src/components/panels/webhook-panel.tsx b/src/components/panels/webhook-panel.tsx
index 3d5f5f1..3e84b07 100644
--- a/src/components/panels/webhook-panel.tsx
+++ b/src/components/panels/webhook-panel.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useSmartPoll } from '@/lib/use-smart-poll'
+import { useMissionControl } from '@/store'
interface Webhook {
id: number
@@ -33,6 +34,16 @@ interface Delivery {
created_at: number
}
+interface SchedulerTask {
+ id: string
+ name: string
+ enabled: boolean
+ lastRun: number | null
+ nextRun: number | null
+ running: boolean
+ lastResult?: { ok: boolean; message: string; timestamp: number }
+}
+
const AVAILABLE_EVENTS = [
{ value: '*', label: 'All events', description: 'Receive all event types' },
{ value: 'agent.error', label: 'Agent error', description: 'Agent enters error state' },
@@ -48,7 +59,10 @@ const AVAILABLE_EVENTS = [
]
export function WebhookPanel() {
+ const { dashboardMode } = useMissionControl()
+ const isLocalMode = dashboardMode === 'local'
const [webhooks, setWebhooks] = useState
([])
+ const [webhookAutomations, setWebhookAutomations] = useState([])
const [deliveries, setDeliveries] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -57,6 +71,7 @@ export function WebhookPanel() {
const [testingId, setTestingId] = useState(null)
const [testResult, setTestResult] = useState(null)
const [newSecret, setNewSecret] = useState(null)
+ const [runningAutomationId, setRunningAutomationId] = useState(null)
const fetchWebhooks = useCallback(async () => {
try {
@@ -88,9 +103,30 @@ export function WebhookPanel() {
} catch { /* silent */ }
}, [selectedWebhook])
+ const fetchWebhookAutomations = useCallback(async () => {
+ if (!isLocalMode) {
+ setWebhookAutomations([])
+ return
+ }
+ try {
+ const res = await fetch('/api/scheduler')
+ if (!res.ok) return
+ const data = await res.json()
+ const tasks = Array.isArray(data.tasks) ? data.tasks : []
+ const webhookTasks = tasks.filter((task: SchedulerTask) =>
+ typeof task.id === 'string' && task.id.includes('webhook')
+ )
+ setWebhookAutomations(webhookTasks)
+ } catch {
+ // Keep UI usable if scheduler endpoint is unavailable.
+ }
+ }, [isLocalMode])
+
useEffect(() => { fetchWebhooks() }, [fetchWebhooks])
useEffect(() => { fetchDeliveries() }, [fetchDeliveries])
+ useEffect(() => { fetchWebhookAutomations() }, [fetchWebhookAutomations])
useSmartPoll(fetchWebhooks, 60000, { pauseWhenDisconnected: true })
+ useSmartPoll(fetchWebhookAutomations, 60000, { pauseWhenDisconnected: true })
async function handleCreate(form: { name: string; url: string; events: string[] }) {
try {
@@ -142,6 +178,29 @@ export function WebhookPanel() {
}
}
+ async function handleRunAutomation(taskId: string) {
+ setRunningAutomationId(taskId)
+ try {
+ const res = await fetch('/api/scheduler', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ task_id: taskId }),
+ })
+ const data = await res.json()
+ setTestResult({
+ success: !!data.ok && res.ok,
+ error: data.error || (!data.ok ? data.message : null),
+ duration_ms: undefined,
+ status_code: res.status,
+ })
+ await fetchWebhookAutomations()
+ } catch {
+ setTestResult({ success: false, error: 'Failed to run local automation' })
+ } finally {
+ setRunningAutomationId(null)
+ }
+ }
+
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
@@ -223,6 +282,41 @@ export function WebhookPanel() {
{/* Webhook list */}
+ {isLocalMode && webhookAutomations.length > 0 && (
+
+
Local Webhook Automations
+
+ Local scheduler tasks that support webhook delivery and retries
+
+
+ {webhookAutomations.map((task) => (
+
+
+
+
+
+ {task.name}
+ {task.id}
+
+
+ {task.nextRun ? `Next run ${formatTime(task.nextRun / 1000)}` : 'No next run scheduled'}
+ {task.lastResult?.message ? ` ยท ${task.lastResult.message}` : ''}
+
+
+
+
+
+ ))}
+
+
+ )}
+
{loading && webhooks.length === 0 ? (
{[...Array(3)].map((_, i) =>
)}
diff --git a/src/lib/codex-sessions.ts b/src/lib/codex-sessions.ts
new file mode 100644
index 0000000..5bcbf00
--- /dev/null
+++ b/src/lib/codex-sessions.ts
@@ -0,0 +1,219 @@
+import { readdirSync, readFileSync, statSync } from 'fs'
+import { basename, join } from 'path'
+import { config } from './config'
+import { logger } from './logger'
+
+const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000
+const DEFAULT_FILE_SCAN_LIMIT = 120
+
+export interface CodexSessionStats {
+ sessionId: string
+ projectSlug: string
+ projectPath: string | null
+ model: string | null
+ userMessages: number
+ assistantMessages: number
+ inputTokens: number
+ outputTokens: number
+ totalTokens: number
+ firstMessageAt: string | null
+ lastMessageAt: string | null
+ isActive: boolean
+}
+
+interface ParsedFile {
+ path: string
+ mtimeMs: number
+}
+
+function asObject(value: unknown): Record
| null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
+ return value as Record
+}
+
+function asString(value: unknown): string | null {
+ return typeof value === 'string' && value.trim().length > 0 ? value : null
+}
+
+function asNumber(value: unknown): number | null {
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
+}
+
+function deriveSessionId(filePath: string): string {
+ const name = basename(filePath, '.jsonl')
+ const match = name.match(/([0-9a-f]{8,}-[0-9a-f-]{8,})$/i)
+ return match?.[1] || name
+}
+
+function listRecentCodexSessionFiles(limit: number): ParsedFile[] {
+ const root = join(config.homeDir, '.codex', 'sessions')
+ const files: ParsedFile[] = []
+ const stack = [root]
+
+ while (stack.length > 0) {
+ const dir = stack.pop()
+ if (!dir) continue
+
+ let entries: string[]
+ try {
+ entries = readdirSync(dir)
+ } catch {
+ continue
+ }
+
+ for (const entry of entries) {
+ const fullPath = join(dir, entry)
+ let stat
+ try {
+ stat = statSync(fullPath)
+ } catch {
+ continue
+ }
+
+ if (stat.isDirectory()) {
+ stack.push(fullPath)
+ continue
+ }
+
+ if (!stat.isFile() || !fullPath.endsWith('.jsonl')) continue
+ files.push({ path: fullPath, mtimeMs: stat.mtimeMs })
+ }
+ }
+
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs)
+ return files.slice(0, Math.max(1, limit))
+}
+
+function parseCodexSessionFile(filePath: string): CodexSessionStats | null {
+ let content: string
+ try {
+ content = readFileSync(filePath, 'utf-8')
+ } catch {
+ return null
+ }
+
+ const lines = content.split('\n').filter(Boolean)
+ if (lines.length === 0) return null
+
+ let sessionId = deriveSessionId(filePath)
+ let projectPath: string | null = null
+ let model: string | null = null
+ let userMessages = 0
+ let assistantMessages = 0
+ let inputTokens = 0
+ let outputTokens = 0
+ let totalTokens = 0
+ let firstMessageAt: string | null = null
+ let lastMessageAt: string | null = null
+
+ for (const line of lines) {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(line)
+ } catch {
+ continue
+ }
+
+ const entry = asObject(parsed)
+ if (!entry) continue
+
+ const timestamp = asString(entry.timestamp)
+ if (timestamp) {
+ if (!firstMessageAt) firstMessageAt = timestamp
+ lastMessageAt = timestamp
+ }
+
+ const entryType = asString(entry.type)
+ const payload = asObject(entry.payload)
+
+ if (entryType === 'session_meta' && payload) {
+ const metaId = asString(payload.id)
+ if (metaId) sessionId = metaId
+
+ const cwd = asString(payload.cwd)
+ if (cwd) projectPath = cwd
+
+ const metaModel = asString(payload.model)
+ if (metaModel) model = metaModel
+
+ const startedAt = asString(payload.timestamp)
+ if (startedAt && !firstMessageAt) firstMessageAt = startedAt
+ continue
+ }
+
+ if (entryType === 'response_item' && payload) {
+ const payloadType = asString(payload.type)
+ const role = asString(payload.role)
+ if (payloadType === 'message' && role === 'user') userMessages++
+ if (payloadType === 'message' && role === 'assistant') assistantMessages++
+ continue
+ }
+
+ if (entryType === 'event_msg' && payload) {
+ const msgType = asString(payload.type)
+ if (msgType !== 'token_count') continue
+
+ const info = asObject(payload.info)
+ const totals = info ? asObject(info.total_token_usage) : null
+ if (totals) {
+ const inTokens = asNumber(totals.input_tokens) || 0
+ const cached = asNumber(totals.cached_input_tokens) || 0
+ const outTokens = asNumber(totals.output_tokens) || 0
+ const allTokens = asNumber(totals.total_tokens) || (inTokens + cached + outTokens)
+ inputTokens = Math.max(inputTokens, inTokens + cached)
+ outputTokens = Math.max(outputTokens, outTokens)
+ totalTokens = Math.max(totalTokens, allTokens)
+ }
+
+ const limits = asObject(payload.rate_limits)
+ const limitName = limits ? asString(limits.limit_name) : null
+ if (!model && limitName) model = limitName
+ }
+ }
+
+ if (!lastMessageAt && !firstMessageAt) return null
+
+ const projectSlug = projectPath
+ ? basename(projectPath)
+ : 'codex-local'
+ const lastMessageMs = lastMessageAt ? new Date(lastMessageAt).getTime() : 0
+ const isActive = lastMessageMs > 0 && (Date.now() - lastMessageMs) < ACTIVE_THRESHOLD_MS
+
+ return {
+ sessionId,
+ projectSlug,
+ projectPath,
+ model,
+ userMessages,
+ assistantMessages,
+ inputTokens,
+ outputTokens,
+ totalTokens,
+ firstMessageAt,
+ lastMessageAt: lastMessageAt || firstMessageAt,
+ isActive,
+ }
+}
+
+export function scanCodexSessions(limit = DEFAULT_FILE_SCAN_LIMIT): CodexSessionStats[] {
+ try {
+ const files = listRecentCodexSessionFiles(limit)
+ const sessions: CodexSessionStats[] = []
+
+ for (const file of files) {
+ const parsed = parseCodexSessionFile(file.path)
+ if (parsed) sessions.push(parsed)
+ }
+
+ sessions.sort((a, b) => {
+ const aTs = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0
+ const bTs = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0
+ return bTs - aTs
+ })
+
+ return sessions
+ } catch (err) {
+ logger.warn({ err }, 'Failed to scan Codex sessions')
+ return []
+ }
+}
diff --git a/src/lib/office-layout.ts b/src/lib/office-layout.ts
new file mode 100644
index 0000000..8fa2c44
--- /dev/null
+++ b/src/lib/office-layout.ts
@@ -0,0 +1,132 @@
+import type { Agent } from '@/store'
+
+export type OfficeZoneType = 'engineering' | 'operations' | 'research' | 'product' | 'quality' | 'general'
+
+export interface OfficeZoneDefinition {
+ id: OfficeZoneType
+ label: string
+ icon: string
+ accentClass: string
+ roleKeywords: string[]
+}
+
+export interface WorkstationAnchor {
+ deskId: string
+ seatLabel: string
+ row: number
+ col: number
+ x: number
+ y: number
+}
+
+export interface ZonedAgent {
+ agent: Agent
+ anchor: WorkstationAnchor
+}
+
+export interface OfficeZoneLayout {
+ zone: OfficeZoneDefinition
+ workers: ZonedAgent[]
+}
+
+export const OFFICE_ZONES: OfficeZoneDefinition[] = [
+ {
+ id: 'engineering',
+ label: 'Engineering Bay',
+ icon: '๐งโ๐ป',
+ accentClass: 'border-cyan-500/30 bg-cyan-500/10',
+ roleKeywords: ['engineer', 'dev', 'frontend', 'backend', 'fullstack', 'software'],
+ },
+ {
+ id: 'operations',
+ label: 'Operations Pod',
+ icon: '๐ ๏ธ',
+ accentClass: 'border-amber-500/30 bg-amber-500/10',
+ roleKeywords: ['ops', 'sre', 'infra', 'platform', 'reliability'],
+ },
+ {
+ id: 'research',
+ label: 'Research Corner',
+ icon: '๐ฌ',
+ accentClass: 'border-violet-500/30 bg-violet-500/10',
+ roleKeywords: ['research', 'science', 'analyst', 'ai'],
+ },
+ {
+ id: 'product',
+ label: 'Product Studio',
+ icon: '๐',
+ accentClass: 'border-emerald-500/30 bg-emerald-500/10',
+ roleKeywords: ['product', 'pm', 'design', 'ux', 'ui'],
+ },
+ {
+ id: 'quality',
+ label: 'Quality Lab',
+ icon: '๐งช',
+ accentClass: 'border-rose-500/30 bg-rose-500/10',
+ roleKeywords: ['qa', 'test', 'quality'],
+ },
+ {
+ id: 'general',
+ label: 'General Workspace',
+ icon: '๐ข',
+ accentClass: 'border-slate-500/30 bg-slate-500/10',
+ roleKeywords: [],
+ },
+]
+
+function normalizeRole(role: string | undefined): string {
+ return String(role || '').toLowerCase()
+}
+
+export function getZoneByRole(role: string | undefined): OfficeZoneDefinition {
+ const normalized = normalizeRole(role)
+ for (const zone of OFFICE_ZONES) {
+ if (zone.id === 'general') continue
+ if (zone.roleKeywords.some((keyword) => normalized.includes(keyword))) {
+ return zone
+ }
+ }
+ return OFFICE_ZONES.find((zone) => zone.id === 'general')!
+}
+
+function buildAnchor(index: number, columnCount: number): WorkstationAnchor {
+ const row = Math.floor(index / columnCount)
+ const col = index % columnCount
+ const rowLabel = String.fromCharCode(65 + row)
+ const seatLabel = `${rowLabel}${col + 1}`
+ return {
+ deskId: `desk-${seatLabel.toLowerCase()}`,
+ seatLabel,
+ row,
+ col,
+ // Useful for future absolute-position movement/collision mechanics.
+ x: col * 220 + 110,
+ y: row * 160 + 80,
+ }
+}
+
+export function buildOfficeLayout(agents: Agent[]): OfficeZoneLayout[] {
+ const zoneMap = new Map()
+ for (const zone of OFFICE_ZONES) zoneMap.set(zone.id, [])
+
+ for (const agent of agents) {
+ const zone = getZoneByRole(agent.role)
+ zoneMap.get(zone.id)!.push(agent)
+ }
+
+ const result: OfficeZoneLayout[] = []
+ for (const zone of OFFICE_ZONES) {
+ const workers = zoneMap.get(zone.id) || []
+ if (workers.length === 0) continue
+
+ const columns = workers.length >= 8 ? 4 : workers.length >= 4 ? 3 : 2
+ const zoned = workers.map((agent, i) => ({
+ agent,
+ anchor: buildAnchor(i, columns),
+ }))
+
+ result.push({ zone, workers: zoned })
+ }
+
+ return result.sort((a, b) => b.workers.length - a.workers.length)
+}