From e2425f698ea9f779d59362f060f6db4c610d223c Mon Sep 17 00:00:00 2001 From: Daniel Terenyi Date: Thu, 30 Apr 2026 20:44:36 -0500 Subject: [PATCH] Fix conductor mission tracking and portable fallback (cherry picked from commit 2ce6c799b17e5bd69ecae41fcf7b85e67ebb2a8c) --- docs/conductor-bug-log.md | 13 + skills/workspace-dispatch/SKILL.md | 2 +- src/lib/jobs-api.test.ts | 67 ++ src/lib/jobs-api.ts | 87 ++- src/routes/api/conductor-spawn.ts | 210 +++--- src/routes/api/conductor-stop.ts | 31 +- src/routes/api/send-stream.ts | 9 +- .../chat/hooks/use-streaming-message.ts | 34 +- src/screens/gateway/conductor.tsx | 508 +++++-------- .../gateway/hooks/use-conductor-gateway.ts | 688 ++++++++++++++---- 10 files changed, 1035 insertions(+), 614 deletions(-) create mode 100644 docs/conductor-bug-log.md create mode 100644 src/lib/jobs-api.test.ts diff --git a/docs/conductor-bug-log.md b/docs/conductor-bug-log.md new file mode 100644 index 00000000..20335d31 --- /dev/null +++ b/docs/conductor-bug-log.md @@ -0,0 +1,13 @@ +# Conductor Bug Log + +## 1. Portable Conductor jobs never executed + +- Symptom: portable-mode missions were being created as scheduled Hermes jobs, but the jobs stayed in `scheduled` state and never ran. +- Fix: portable Conductor now uses the existing `/api/send-stream` session-streaming path instead of the dead jobs path. +- Validation: portable API smoke test returned `started`, `chunk`, and `done` SSE events, and the build passed. + +## 2. Dashboard-backed mission was running but the UI showed `0 active` + +- Symptom: the conductor page launched a dashboard-backed mission, but the activity panel stayed at `0 active` even while the dashboard showed live mission sessions. +- Fix: the conductor session filter now matches recent mission-related sessions by exact key and by mission text/summary, not just `worker-*` / `conductor-*` labels. +- Validation: after reloading the conductor page, the mission showed `1 active` and the worker card appeared. diff --git a/skills/workspace-dispatch/SKILL.md b/skills/workspace-dispatch/SKILL.md index 3f4a2134..7f0dccfe 100644 --- a/skills/workspace-dispatch/SKILL.md +++ b/skills/workspace-dispatch/SKILL.md @@ -70,7 +70,7 @@ Working directory: {cwd} - Do NOT start servers or long-running processes - Do NOT modify files outside your working directory - Verify your own work before finishing — run the exit criteria commands yourself -- Commit when done +- Commit only if the mission explicitly allows commits; otherwise leave changes uncommitted and report them ``` On retry, append: diff --git a/src/lib/jobs-api.test.ts b/src/lib/jobs-api.test.ts new file mode 100644 index 00000000..8885bd59 --- /dev/null +++ b/src/lib/jobs-api.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + findJobById, + getJobErrorText, + getLatestJobOutputText, + isFailedJobState, + isTerminalJobState, + normalizeJobState, + normalizeJobsResponse, +} from './jobs-api' +import type { HermesJob, JobOutput } from './jobs-api' + +const job = { + id: 'job-1', + name: 'Example job', + prompt: 'Run the example job', + schedule: {}, + enabled: true, + state: 'scheduled', +} satisfies HermesJob + +describe('normalizeJobsResponse', () => { + it('accepts dashboard cron jobs returned as a top-level array', () => { + expect(normalizeJobsResponse([job])).toEqual([job]) + }) + + it('accepts gateway jobs returned in an object wrapper', () => { + expect(normalizeJobsResponse({ jobs: [job] })).toEqual([job]) + }) + + it('falls back to an empty list for unexpected payloads', () => { + expect(normalizeJobsResponse({ jobs: null })).toEqual([]) + }) +}) + +describe('job helpers', () => { + it('finds jobs by id', () => { + expect(findJobById([job], 'job-1')).toEqual(job) + expect(findJobById([job], 'missing')).toBeNull() + expect(findJobById([job], null)).toBeNull() + }) + + it('normalizes and classifies job states', () => { + expect(normalizeJobState(' Running ')).toBe('running') + expect(isFailedJobState('errored')).toBe(true) + expect(isFailedJobState('running')).toBe(false) + expect(isTerminalJobState('success')).toBe(true) + expect(isTerminalJobState('done')).toBe(true) + expect(isTerminalJobState('scheduled')).toBe(false) + }) + + it('returns the latest non-empty job output text', () => { + const outputs: Array = [ + { filename: 'a.log', timestamp: '2026-04-30T12:00:00Z', content: 'older run', size: 9 }, + { filename: 'b.log', timestamp: '2026-04-30T12:05:00Z', content: ' ', size: 3 }, + { filename: 'c.log', timestamp: '2026-04-30T12:10:00Z', content: 'newest run', size: 10 }, + ] + + expect(getLatestJobOutputText(outputs)).toBe('newest run') + }) + + it('prefers explicit job error text', () => { + expect(getJobErrorText({ ...job, last_run_error: ' boom ' })).toBe('boom') + expect(getJobErrorText({ ...job, last_run_error: null, error: 'oops' })).toBe('oops') + expect(getJobErrorText(null)).toBeNull() + }) +}) diff --git a/src/lib/jobs-api.ts b/src/lib/jobs-api.ts index 36576a69..391923a7 100644 --- a/src/lib/jobs-api.ts +++ b/src/lib/jobs-api.ts @@ -15,6 +15,8 @@ export type ClaudeJob = { next_run_at?: string | null last_run_at?: string | null last_run_success?: boolean | null + last_run_error?: string | null + error?: string | null created_at?: string updated_at?: string deliver?: Array @@ -23,6 +25,8 @@ export type ClaudeJob = { run_count?: number } +export type HermesJob = ClaudeJob + export type JobOutput = { filename: string timestamp: string @@ -30,11 +34,92 @@ export type JobOutput = { size: number } +export function normalizeJobsResponse(data: unknown): Array { + if (Array.isArray(data)) return data as Array + if ( + typeof data === 'object' && + data !== null && + 'jobs' in data && + Array.isArray((data as { jobs?: unknown }).jobs) + ) { + return (data as { jobs: Array }).jobs + } + return [] +} + +export function findJobById( + jobs: Array, + jobId: string | null | undefined, +): ClaudeJob | null { + if (!jobId) return null + return jobs.find((job) => job.id === jobId) ?? null +} + +export function normalizeJobState(state: unknown): string | null { + return typeof state === 'string' && state.trim() ? state.trim().toLowerCase() : null +} + +export function isFailedJobState(state: unknown): boolean { + const normalized = normalizeJobState(state) + return ( + normalized === 'failed' || + normalized === 'error' || + normalized === 'errored' || + normalized === 'cancelled' || + normalized === 'canceled' || + normalized === 'aborted' + ) +} + +export function isTerminalJobState(state: unknown): boolean { + const normalized = normalizeJobState(state) + return ( + normalized === 'completed' || + normalized === 'complete' || + normalized === 'succeeded' || + normalized === 'success' || + normalized === 'finished' || + normalized === 'done' || + isFailedJobState(normalized) + ) +} + +export function getLatestJobOutputText(outputs: Array): string { + let latestContent = '' + let latestTimestamp = Number.NEGATIVE_INFINITY + + for (const output of outputs) { + const content = typeof output.content === 'string' ? output.content.trim() : '' + if (!content) continue + + const timestamp = new Date(output.timestamp).getTime() + if (!Number.isFinite(timestamp) || timestamp < latestTimestamp) continue + + latestTimestamp = timestamp + latestContent = content + } + + return latestContent +} + +export function getJobErrorText(job: ClaudeJob | null | undefined): string | null { + if (!job) return null + + const candidates = [job.last_run_error, job.error] + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim() + } + } + + return null +} + export async function fetchJobs(): Promise> { const res = await fetch(`${CLAUDE_API}?include_disabled=true`) if (!res.ok) throw new Error(`Failed to fetch jobs: ${res.status}`) const data = await res.json() - return data.jobs ?? [] + return normalizeJobsResponse(data) } export async function createJob(input: { diff --git a/src/routes/api/conductor-spawn.ts b/src/routes/api/conductor-spawn.ts index 00532af4..5050bb5b 100644 --- a/src/routes/api/conductor-spawn.ts +++ b/src/routes/api/conductor-spawn.ts @@ -1,15 +1,3 @@ -/** - * Conductor mission spawn — Claude-backed. - * - * Spawns a one-shot Claude job whose prompt is the orchestrator instructions. - * The orchestrator session, when it runs, uses the create_task / delegate - * tools to spawn worker agents. The Conductor UI then polls /api/sessions - * + /api/history to track workers. - * - * Replaces the previous OCPlatform JSON-RPC implementation - * (gatewayRpc('cron.add', ...)) which only worked when the OCPlatform - * gateway was running on ws://127.0.0.1:18789. - */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' @@ -17,12 +5,7 @@ import { createFileRoute } from '@tanstack/react-router' import { json } from '@tanstack/react-start' import { isAuthenticated } from '../../server/auth-middleware' import { requireJsonContentType } from '../../server/rate-limit' -import { - CLAUDE_API, - BEARER_TOKEN, - dashboardFetch, - ensureGatewayProbed, -} from '../../server/gateway-capabilities' +import { dashboardFetch, ensureGatewayProbed } from '../../server/gateway-capabilities' let cachedSkill: string | null = null @@ -35,12 +18,9 @@ type ConductorSpawnBody = { supervised?: unknown } -// Resolve the workspace root from this module's location so we find the -// bundled skill regardless of where the server is launched from. function repoRoot(): string { try { const here = dirname(fileURLToPath(import.meta.url)) - // src/routes/api -> repo root (../..) return resolve(here, '..', '..', '..') } catch { return process.cwd() @@ -49,22 +29,18 @@ function repoRoot(): string { function loadDispatchSkill(): string { if (cachedSkill !== null) return cachedSkill + const home = process.env.HOME ?? '' const candidates = [ resolve(repoRoot(), 'skills/workspace-dispatch/SKILL.md'), resolve(process.cwd(), 'skills/workspace-dispatch/SKILL.md'), - resolve(process.env.HOME ?? '~', '.claude/skills/workspace-dispatch/SKILL.md'), - resolve( - process.env.HOME ?? '~', - '.ocplatform/workspace/skills/workspace-dispatch/SKILL.md', - ), + ...(home ? [resolve(home, '.hermes/skills/workspace-dispatch/SKILL.md')] : []), + ...(home ? [resolve(home, '.openclaw/workspace/skills/workspace-dispatch/SKILL.md')] : []), ] for (const p of candidates) { try { cachedSkill = readFileSync(p, 'utf-8') return cachedSkill - } catch { - continue - } + } catch {} } cachedSkill = '' return cachedSkill @@ -91,9 +67,7 @@ function buildOrchestratorPrompt( }, ): string { const outputBase = options.projectsDir || '/tmp' - const outputPrefix = - outputBase === '/tmp' ? '/tmp/dispatch-' : `${outputBase}/dispatch-` - + const outputPrefix = outputBase === '/tmp' ? '/tmp/dispatch-' : `${outputBase}/dispatch-` return [ 'You are a mission orchestrator. Execute this mission autonomously.', '', @@ -104,24 +78,12 @@ function buildOrchestratorPrompt( '## Mission', '', `Goal: ${goal}`, - ...(options.orchestratorModel - ? ['', `Use model: ${options.orchestratorModel} for the orchestrator`] - : []), - ...(options.workerModel - ? ['', `Use model: ${options.workerModel} for all workers`] - : []), + ...(options.orchestratorModel ? ['', `Use model: ${options.orchestratorModel} for the orchestrator`] : []), + ...(options.workerModel ? ['', `Use model: ${options.workerModel} for all workers`] : []), ...(options.maxParallel > 1 - ? [ - '', - `Run up to ${options.maxParallel} workers in parallel when tasks are independent`, - ] - : [ - '', - 'Spawn workers one at a time. Do NOT wait for workers to finish — the UI handles tracking.', - ]), - ...(options.supervised - ? ['', 'Supervised mode is enabled. Require approval before each task.'] - : []), + ? ['', `Run up to ${options.maxParallel} workers in parallel when tasks are independent`] + : ['', 'Spawn workers one at a time. Do NOT wait for workers to finish — the UI handles tracking.']), + ...(options.supervised ? ['', 'Supervised mode is enabled. Require approval before each task.'] : []), '', '## Critical Rules', '- Use create_task / delegate_task to create worker agents for each task', @@ -136,125 +98,115 @@ function buildOrchestratorPrompt( ].join('\n') } -function authHeaders(): Record { - return BEARER_TOKEN ? { Authorization: `Bearer ${BEARER_TOKEN}` } : {} -} - -function nowPlusSecondsIso(seconds: number): string { - const t = new Date(Date.now() + seconds * 1000) - // Claude accepts ISO-8601 timestamps; strip milliseconds for cleanliness - return t.toISOString().replace(/\.\d{3}Z$/, 'Z') -} - -async function createClaudeJob(payload: { - name: string - schedule: string - prompt: string - deliver?: string -}): Promise<{ id?: string; name?: string; error?: string }> { - const body = JSON.stringify({ - name: payload.name, - schedule: payload.schedule, - prompt: payload.prompt, - deliver: payload.deliver ?? 'local', +async function createDashboardConductorMission(payload: { name: string; prompt: string }): Promise<{ + id?: string + name?: string + sessionKey?: string + error?: string +}> { + const res = await dashboardFetch('/api/conductor/missions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: payload.name, prompt: payload.prompt }), }) - const capabilities = await ensureGatewayProbed() - const res = capabilities.dashboard.available - ? await dashboardFetch('/api/cron/jobs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, - }) - : await fetch(`${CLAUDE_API}/api/jobs`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, - body, - }) const text = await res.text() - let data: { job?: { id?: string; name?: string }; error?: string } = {} + let data: { id?: string; name?: string; session_id?: string; error?: string; detail?: string } = {} try { data = JSON.parse(text) } catch { return { error: text || `HTTP ${res.status}` } } - if (!res.ok || data.error) { - return { error: data.error || `HTTP ${res.status}` } + if (!res.ok || data.error || data.detail) { + return { error: data.error || data.detail || `HTTP ${res.status}` } } - return { id: data.job?.id, name: data.job?.name } + return { id: data.id, name: data.name, sessionKey: data.session_id } } export const Route = createFileRoute('/api/conductor-spawn')({ server: { handlers: { - POST: async ({ request }) => { - if (!isAuthenticated(request)) { - return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + GET: async ({ request }) => { + if (!isAuthenticated(request)) return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) + const url = new URL(request.url) + const missionId = url.searchParams.get('missionId')?.trim() + const requestedLines = Number(url.searchParams.get('lines') || '200') + const lines = Number.isFinite(requestedLines) ? Math.min(2000, Math.max(1, requestedLines)) : 200 + if (!missionId) return json({ ok: false, error: 'missionId required' }, { status: 400 }) + + const capabilities = await ensureGatewayProbed() + if (!capabilities.dashboard.available) { + return json({ ok: false, error: 'Hermes dashboard API is unavailable' }, { status: 503 }) } + + const res = await dashboardFetch(`/api/conductor/missions/${encodeURIComponent(missionId)}?lines=${lines}`) + const text = await res.text() + let mission: Record = {} + try { + mission = JSON.parse(text) as Record + } catch { + return json({ ok: false, error: text || `HTTP ${res.status}` }, { status: res.ok ? 502 : res.status }) + } + if (!res.ok) { + const error = typeof mission.detail === 'string' ? mission.detail : typeof mission.error === 'string' ? mission.error : `HTTP ${res.status}` + return json({ ok: false, error }, { status: res.status }) + } + return json({ ok: true, mission }) + }, + POST: async ({ request }) => { + if (!isAuthenticated(request)) return json({ ok: false, error: 'Unauthorized' }, { status: 401 }) const csrfCheck = requireJsonContentType(request) if (csrfCheck) return csrfCheck try { - const body = (await request - .json() - .catch(() => ({}))) as ConductorSpawnBody + const body = (await request.json().catch(() => ({}))) as ConductorSpawnBody const goal = readOptionalString(body.goal) const orchestratorModel = readOptionalString(body.orchestratorModel) const workerModel = readOptionalString(body.workerModel) const projectsDir = readOptionalString(body.projectsDir) const maxParallel = readMaxParallel(body.maxParallel) const supervised = body.supervised === true + if (!goal) return json({ ok: false, error: 'goal required' }, { status: 400 }) - if (!goal) { - return json({ ok: false, error: 'goal required' }, { status: 400 }) - } - - const skill = loadDispatchSkill() - const prompt = buildOrchestratorPrompt(goal, skill, { + const prompt = buildOrchestratorPrompt(goal, loadDispatchSkill(), { orchestratorModel, workerModel, projectsDir, maxParallel, supervised, }) + const missionName = `conductor-${Date.now()}` + const capabilities = await ensureGatewayProbed() - const jobName = `conductor-${Date.now()}` - // Schedule a one-shot job ~5s in the future so the cron loop - // picks it up promptly without racing with the create response. - const result = await createClaudeJob({ - name: jobName, - schedule: nowPlusSecondsIso(5), - prompt, - deliver: 'local', - }) - - if (result.error) { - return json( - { ok: false, error: result.error }, - { status: 502 }, - ) + if (!capabilities.dashboard.available) { + return json({ + ok: true, + mode: 'portable', + prompt, + missionId: null, + sessionKey: missionName, + sessionKeyPrefix: null, + jobId: null, + jobName: missionName, + runId: null, + }) } - // Claude runs cron jobs in sessions keyed `cron__`. - // We can't know the timestamp until the cron loop fires, so we return - // a prefix and the UI polls for any session whose key starts with it. - const jobId = result.id ?? jobName + const result = await createDashboardConductorMission({ name: missionName, prompt }) + if (result.error) return json({ ok: false, error: result.error }, { status: 502 }) + const missionId = result.id ?? missionName return json({ ok: true, - sessionKey: `cron_${jobId}_pending`, - sessionKeyPrefix: `cron_${jobId}_`, - jobId, - jobName: result.name ?? jobName, + mode: 'dashboard', + prompt: null, + missionId, + sessionKey: result.sessionKey ?? null, + sessionKeyPrefix: null, + jobId: missionId, + jobName: result.name ?? missionName, runId: null, }) } catch (error) { - return json( - { - ok: false, - error: - error instanceof Error ? error.message : String(error), - }, - { status: 500 }, - ) + return json({ ok: false, error: error instanceof Error ? error.message : String(error) }, { status: 500 }) } }, }, diff --git a/src/routes/api/conductor-stop.ts b/src/routes/api/conductor-stop.ts index 40db85eb..0a43bbb9 100644 --- a/src/routes/api/conductor-stop.ts +++ b/src/routes/api/conductor-stop.ts @@ -2,7 +2,11 @@ import { createFileRoute } from '@tanstack/react-router' import { json } from '@tanstack/react-start' import { isAuthenticated } from '../../server/auth-middleware' import { requireJsonContentType } from '../../server/rate-limit' -import { deleteSession, ensureGatewayProbed } from '../../server/claude-api' +import { deleteSession } from '../../server/claude-api' +import { + dashboardFetch, + ensureGatewayProbed, +} from '../../server/gateway-capabilities' export const Route = createFileRoute('/api/conductor-stop')({ server: { @@ -15,7 +19,6 @@ export const Route = createFileRoute('/api/conductor-stop')({ if (csrfCheck) return csrfCheck try { - await ensureGatewayProbed() const body = (await request.json().catch(() => ({}))) as Record const sessionKeys = Array.isArray(body.sessionKeys) ? body.sessionKeys.filter( @@ -23,8 +26,30 @@ export const Route = createFileRoute('/api/conductor-stop')({ typeof value === 'string' && value.trim().length > 0, ) : [] + const missionIds = Array.isArray(body.missionIds) + ? body.missionIds.filter( + (value): value is string => + typeof value === 'string' && value.trim().length > 0, + ) + : [] let deleted = 0 + let stoppedMissions = 0 + const capabilities = await ensureGatewayProbed() + if (capabilities.dashboard.available) { + for (const missionId of missionIds) { + try { + const res = await dashboardFetch( + `/api/conductor/missions/${encodeURIComponent(missionId)}`, + { method: 'DELETE' }, + ) + if (res.ok) stoppedMissions += 1 + } catch { + // Ignore per-mission stop errors so session cleanup still runs. + } + } + } + for (const sessionKey of sessionKeys) { try { await deleteSession(sessionKey) @@ -34,7 +59,7 @@ export const Route = createFileRoute('/api/conductor-stop')({ } } - return json({ ok: true, deleted }) + return json({ ok: true, deleted, stoppedMissions }) } catch (error) { return json( { diff --git a/src/routes/api/send-stream.ts b/src/routes/api/send-stream.ts index 53daa25d..74eb73ce 100644 --- a/src/routes/api/send-stream.ts +++ b/src/routes/api/send-stream.ts @@ -1073,9 +1073,12 @@ export const Route = createFileRoute('/api/send-stream')({ } }, cancel() { - // Reader cancellation happens when the user navigates away from Chat. - // Do not abort the upstream Hermes run; the UI can recover from - // session history / active-run polling when the user returns. + // Browser navigation/unmount cancels the response reader. That + // must not cancel the Hermes run itself: the chat/conductor should + // keep thinking server-side so the user can return and recover the + // answer from session history. Mark this client stream closed so we + // stop enqueueing SSE chunks, but deliberately leave the upstream + // abortController alone. streamClosed = true if (unregisterTimer) { clearTimeout(unregisterTimer) diff --git a/src/screens/chat/hooks/use-streaming-message.ts b/src/screens/chat/hooks/use-streaming-message.ts index f844a3a3..336c5e62 100644 --- a/src/screens/chat/hooks/use-streaming-message.ts +++ b/src/screens/chat/hooks/use-streaming-message.ts @@ -91,7 +91,9 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { const finishedRef = useRef(false) const thinkingRef = useRef('') const activeRunIdRef = useRef(null) - const delayedUnregisterTimerRef = useRef | null>(null) + const delayedUnregisterTimerRef = useRef | null>(null) const activeSessionKeyRef = useRef('main') const lifecyclePhaseRef = useRef('idle') const acceptedAtRef = useRef(null) @@ -268,15 +270,17 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { ]) useEffect( - function handOffAcceptedRunOnUnmount() { + function keepAcceptedRunAliveOnUnmount() { return function cleanup() { if (!eventSourceRef.current || finishedRef.current) return - // Abort only this browser reader. The server route keeps the upstream - // Hermes run alive after reader cancel, so another transport or history - // polling can be authoritative when Chat remounts. - eventSourceRef.current.abort() - eventSourceRef.current = null + // Navigating away from Chat unmounts this hook. Previously this cleanup + // aborted /api/send-stream and reset the local stream state, which made + // the UI look like Hermes stopped thinking. Leave the accepted request + // alive instead: the server-side route deliberately keeps the upstream + // Hermes run alive after the browser reader is cancelled, and the + // persisted waiting/session state lets the screen recover from history + // or active-run polling when the user comes back. lifecyclePhaseRef.current = 'handoff' clearSendStreamRun() clearHandoffTimer() @@ -607,9 +611,7 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { type: 'done', state: doneState ?? 'final', errorMessage, - message: (payload).message as - | Record - | undefined, + message: payload.message as Record | undefined, runId: activeRunIdRef.current ?? undefined, sessionKey: activeSessionKeyRef.current, transport: 'send-stream', @@ -712,7 +714,12 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { role: 'assistant', content: [ ...(thinkingRef.current - ? [{ type: 'thinking' as const, thinking: thinkingRef.current }] + ? [ + { + type: 'thinking' as const, + thinking: thinkingRef.current, + }, + ] : []), { type: 'text' as const, text: fullTextRef.current }, ], @@ -752,7 +759,10 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) { attachments: params.attachments, idempotencyKey: params.idempotencyKey ?? crypto.randomUUID(), model: params.model || undefined, - locale: typeof window !== 'undefined' ? localStorage.getItem('hermes-workspace-locale') || 'en' : 'en', + locale: + typeof window !== 'undefined' + ? localStorage.getItem('hermes-workspace-locale') || 'en' + : 'en', }), signal: abortController.signal, }) diff --git a/src/screens/gateway/conductor.tsx b/src/screens/gateway/conductor.tsx index 76166d9f..643874da 100644 --- a/src/screens/gateway/conductor.tsx +++ b/src/screens/gateway/conductor.tsx @@ -1,15 +1,7 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useQuery } from '@tanstack/react-query' import { HugeiconsIcon } from '@hugeicons/react' -import { - ArrowDown01Icon, - ArrowRight01Icon, - PlayIcon, - Rocket01Icon, - Search01Icon, - Settings01Icon, - TaskDone01Icon, -} from '@hugeicons/core-free-icons' +import { ArrowDown01Icon, ArrowRight01Icon, PlayIcon, Rocket01Icon, Search01Icon, Settings01Icon, TaskDone01Icon } from '@hugeicons/core-free-icons' import { Button } from '@/components/ui/button' import { Markdown } from '@/components/prompt-kit/markdown' import { OfficeView } from './components/office-view' @@ -106,6 +98,27 @@ const QUICK_ACTIONS: Array<{ const AGENT_NAMES = ['Nova', 'Pixel', 'Blaze', 'Echo', 'Sage', 'Drift', 'Flux', 'Volt'] const AGENT_EMOJIS = ['🤖', '⚡', '🔥', '🌊', '🌿', '💫', '🔮', '⭐'] const BLENDED_COST_PER_MILLION_TOKENS = 5 +const CONDUCTOR_GOAL_DRAFT_STORAGE_KEY = 'conductor:goal-draft' + +function loadConductorGoalDraft(): string { + try { + return globalThis.localStorage?.getItem(CONDUCTOR_GOAL_DRAFT_STORAGE_KEY) ?? '' + } catch { + return '' + } +} + +function persistConductorGoalDraft(value: string): void { + try { + if (value.trim()) { + globalThis.localStorage?.setItem(CONDUCTOR_GOAL_DRAFT_STORAGE_KEY, value) + } else { + globalThis.localStorage?.removeItem(CONDUCTOR_GOAL_DRAFT_STORAGE_KEY) + } + } catch { + // Ignore storage failures; the in-memory state still works. + } +} function getAgentPersona(index: number) { return { @@ -122,39 +135,19 @@ function formatUsd(value: number): string { return `$${value.toFixed(value >= 0.1 ? 2 : 3)}` } -function MissionCostSection({ - totalTokens, - workers, - expanded, - onToggle, -}: { - totalTokens: number - workers: MissionCostWorker[] - expanded: boolean - onToggle: () => void -}) { +function MissionCostSection({ totalTokens, workers, expanded, onToggle }: { totalTokens: number; workers: MissionCostWorker[]; expanded: boolean; onToggle: () => void }) { const estimatedCost = estimateTokenCost(totalTokens) return (
- @@ -180,7 +173,9 @@ function MissionCostSection({
{workers.map((worker) => (
- {worker.personaEmoji} {worker.personaName} + + {worker.personaEmoji} {worker.personaName} + {worker.label} {worker.totalTokens.toLocaleString()} tok {formatUsd(estimateTokenCost(worker.totalTokens))} @@ -210,15 +205,7 @@ const WORKING_STEPS = [ '🚀 Almost there…', ] -function CyclingStatus({ - steps, - intervalMs = 3000, - isPaused = false, -}: { - steps: string[] - intervalMs?: number - isPaused?: boolean -}) { +function CyclingStatus({ steps, intervalMs = 3000, isPaused = false }: { steps: string[]; intervalMs?: number; isPaused?: boolean }) { const [step, setStep] = useState(0) useEffect(() => { @@ -230,9 +217,7 @@ function CyclingStatus({ if (isPaused) { return (
-
- || -
+
||

Paused

) @@ -354,27 +339,12 @@ function WorkerCard({ const dot = getWorkerDot(worker.status) const persona = getAgentPersona(index) const workerOutput = conductor.workerOutputs[worker.key] ?? getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined) - const workerStartedAt = - typeof worker.raw.createdAt === 'string' - ? worker.raw.createdAt - : typeof worker.raw.startedAt === 'string' - ? worker.raw.startedAt - : conductor.missionStartedAt + const workerStartedAt = typeof worker.raw.createdAt === 'string' ? worker.raw.createdAt : typeof worker.raw.startedAt === 'string' ? worker.raw.startedAt : conductor.missionStartedAt const workerEndTime = - worker.status === 'complete' || worker.status === 'stale' - ? new Date(worker.updatedAt ?? new Date().toISOString()).getTime() - : conductor.isPaused - ? conductor.pausedAtMs ?? now - : now + worker.status === 'complete' || worker.status === 'stale' ? new Date(worker.updatedAt ?? new Date().toISOString()).getTime() : conductor.isPaused ? (conductor.pausedAtMs ?? now) : now return ( -
+
@@ -510,9 +480,7 @@ function groupModelsByProvider(models: AvailableModel[]) { .sort((a, b) => a[0].localeCompare(b[0])) .map(([provider, providerModels]) => ({ provider, - models: [...providerModels].sort((a, b) => - getModelDisplayName(a, a.id).localeCompare(getModelDisplayName(b, b.id)), - ), + models: [...providerModels].sort((a, b) => getModelDisplayName(a, a.id).localeCompare(getModelDisplayName(b, b.id))), })) } @@ -595,9 +563,7 @@ function ModelSelectorDropdown({ }} className={cn( 'inline-flex min-h-[3rem] w-full items-center justify-between gap-3 rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-4 py-3 text-left text-sm text-[var(--theme-text)] shadow-[0_8px_24px_color-mix(in_srgb,var(--theme-shadow)_18%,transparent)] transition-colors', - disabled - ? 'cursor-not-allowed opacity-60' - : 'hover:border-[var(--theme-accent)] focus:border-[var(--theme-accent)]', + disabled ? 'cursor-not-allowed opacity-60' : 'hover:border-[var(--theme-accent)] focus:border-[var(--theme-accent)]', )} aria-haspopup="listbox" aria-expanded={open} @@ -605,21 +571,11 @@ function ModelSelectorDropdown({ > - + {getModelDisplayName(selectedModel, value)} - + {open ? ( @@ -633,30 +589,19 @@ function ModelSelectorDropdown({ }} className={cn( 'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors', - !value - ? 'bg-[var(--theme-accent-soft)] text-[var(--theme-text)]' - : 'text-[var(--theme-text)] hover:bg-[var(--theme-bg)]', + !value ? 'bg-[var(--theme-accent-soft)] text-[var(--theme-text)]' : 'text-[var(--theme-text)] hover:bg-[var(--theme-bg)]', )} role="option" aria-selected={!value} > - + Default (auto) - - Auto - + Auto {groupedModels.map((group) => (
-
- {group.provider} -
+
{group.provider}
{group.models.map((model) => { const modelId = model.id ?? '' @@ -671,19 +616,12 @@ function ModelSelectorDropdown({ }} className={cn( 'flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors', - active - ? 'bg-[var(--theme-accent-soft)] text-[var(--theme-text)]' - : 'text-[var(--theme-text)] hover:bg-[var(--theme-bg)]', + active ? 'bg-[var(--theme-accent-soft)] text-[var(--theme-text)]' : 'text-[var(--theme-text)] hover:bg-[var(--theme-bg)]', )} role="option" aria-selected={active} > - + {getModelDisplayName(model, modelId)} {group.provider} @@ -773,7 +711,7 @@ function deriveSessionStatus(session: GatewaySession): 'running' | 'completed' | export function Conductor() { const conductor = useConductorGateway() - const [goalDraft, setGoalDraft] = useState('') + const [goalDraft, setGoalDraft] = useState(() => loadConductorGoalDraft()) const [missionModalOpen, setMissionModalOpen] = useState(false) const [continueDraft, setContinueDraft] = useState('') const [continueModalOpen, setContinueModalOpen] = useState(false) @@ -794,7 +732,10 @@ export function Conductor() { queryKey: ['conductor', 'models'], queryFn: async () => { const res = await fetch('/api/models') - const data = (await res.json()) as { ok?: boolean; models?: Array<{ id?: string; provider?: string; name?: string }> } + const data = (await res.json()) as { + ok?: boolean + models?: Array<{ id?: string; provider?: string; name?: string }> + } return data.models ?? [] }, enabled: settingsOpen, @@ -825,9 +766,7 @@ export function Conductor() { if (cancelled) return setDirectoryBrowserPath(typeof data.root === 'string' && data.root.trim() ? data.root : directoryBrowserPath) - setDirectoryBrowserEntries( - Array.isArray(data.entries) ? data.entries.filter((entry) => entry?.type === 'folder') : [], - ) + setDirectoryBrowserEntries(Array.isArray(data.entries) ? data.entries.filter((entry) => entry?.type === 'folder') : []) } catch (error) { if (cancelled) return setDirectoryBrowserEntries([]) @@ -852,17 +791,22 @@ export function Conductor() { return () => window.clearInterval(timer) }, [conductor.isPaused, conductor.phase]) + useEffect(() => { + persistConductorGoalDraft(goalDraft) + }, [goalDraft]) + useEffect(() => { if (!conductor.isPaused) return setNow(conductor.pausedAtMs ?? Date.now()) }, [conductor.isPaused, conductor.pausedAtMs]) - // Set body background to match Conductor theme so no gray shows behind keyboard/tab bar useEffect(() => { const prev = document.body.style.backgroundColor document.body.style.backgroundColor = 'var(--color-surface)' - return () => { document.body.style.backgroundColor = prev } + return () => { + document.body.style.backgroundColor = prev + } }, []) const phase: ConductorPhase = useMemo(() => { @@ -875,6 +819,7 @@ export function Conductor() { const handleNewMission = () => { conductor.resetMission() setGoalDraft('') + persistConductorGoalDraft('') setMissionModalOpen(false) setContinueDraft('') setContinueModalOpen(false) @@ -887,6 +832,8 @@ export function Conductor() { setMissionModalOpen(false) setContinueDraft('') await conductor.sendMission(trimmed) + persistConductorGoalDraft('') + setGoalDraft('') } const handleQuickActionSelect = (action: (typeof QUICK_ACTIONS)[number]) => { @@ -906,9 +853,7 @@ export function Conductor() { const continuationSummarySource = completeSummary ?? Object.values(conductor.workerOutputs).find((output) => output.trim()) ?? - conductor.workers - .map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)) - .find((output) => output.trim()) ?? + conductor.workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)).find((output) => output.trim()) ?? conductor.streamText const combinedPrompt = [ @@ -999,9 +944,7 @@ export function Conductor() { const s = session as GatewaySession const updatedAt = typeof s.updatedAt === 'string' ? new Date(s.updatedAt).getTime() : typeof s.updatedAt === 'number' ? s.updatedAt : 0 const statusText = `${s.status ?? ''} ${s.kind ?? ''}`.toLowerCase() - const status = /error|failed/.test(statusText) ? 'error' as const - : /pause/.test(statusText) ? 'paused' as const - : Date.now() - updatedAt < 120_000 ? 'active' as const : 'idle' as const + const status = /error|failed/.test(statusText) ? ('error' as const) : /pause/.test(statusText) ? ('paused' as const) : Date.now() - updatedAt < 120_000 ? ('active' as const) : ('idle' as const) return { id: s.key ?? `session-${i}`, name: OFFICE_NAMES[i % OFFICE_NAMES.length], @@ -1055,10 +998,9 @@ export function Conductor() { }, [conductor.conductorSettings.workerModel, conductor.goal, conductor.isPaused, conductor.tasks, conductor.workerOutputs, conductor.workers]) const completePhaseProjectPath = useMemo(() => { - const workerOutputTexts = [ - ...Object.values(conductor.workerOutputs), - ...conductor.workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)), - ].filter(Boolean) + const workerOutputTexts = [...Object.values(conductor.workerOutputs), ...conductor.workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined))].filter( + Boolean, + ) for (const text of workerOutputTexts) { const extractedPath = extractProjectPath(text) @@ -1077,14 +1019,9 @@ export function Conductor() { const candidates = buildProjectPathCandidates(conductor.workers, conductor.missionStartedAt) return candidates[0] ?? null }, [conductor.tasks, conductor.streamText, conductor.workerOutputs, conductor.workers, conductor.missionStartedAt]) - const completePhaseOutputLabel = useMemo( - () => getOutputDisplayName(completePhaseProjectPath), - [completePhaseProjectPath], - ) + const completePhaseOutputLabel = useMemo(() => getOutputDisplayName(completePhaseProjectPath), [completePhaseProjectPath]) - const previewUrl = completePhaseProjectPath - ? `/api/preview-file?path=${encodeURIComponent(`${completePhaseProjectPath}/index.html`)}` - : null + const previewUrl = completePhaseProjectPath ? `/api/preview-file?path=${encodeURIComponent(`${completePhaseProjectPath}/index.html`)}` : null const selectedHistoryOutputPath = useMemo(() => { const entry = conductor.selectedHistoryEntry @@ -1099,13 +1036,8 @@ export function Conductor() { ) return candidates[0] ?? null }, [conductor.selectedHistoryEntry]) - const selectedHistoryOutputLabel = useMemo( - () => getOutputDisplayName(selectedHistoryOutputPath), - [selectedHistoryOutputPath], - ) - const selectedHistoryPreviewUrl = selectedHistoryOutputPath - ? `/api/preview-file?path=${encodeURIComponent(`${selectedHistoryOutputPath}/index.html`)}` - : null + const selectedHistoryOutputLabel = useMemo(() => getOutputDisplayName(selectedHistoryOutputPath), [selectedHistoryOutputPath]) + const selectedHistoryPreviewUrl = selectedHistoryOutputPath ? `/api/preview-file?path=${encodeURIComponent(`${selectedHistoryOutputPath}/index.html`)}` : null // Skip preview probe for history entries — /tmp files are ephemeral and won't exist later. // Only probe if the mission just completed (still in complete phase with matching output path). @@ -1148,9 +1080,7 @@ export function Conductor() { const summarySource = completeSummary ?? Object.values(conductor.workerOutputs).find((output) => output.trim()) ?? - conductor.workers - .map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)) - .find((output) => output.trim()) ?? + conductor.workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)).find((output) => output.trim()) ?? conductor.streamText return truncateContinuationText(summarySource ?? '') }, [completeSummary, conductor.streamText, conductor.workerOutputs, conductor.workers]) @@ -1165,9 +1095,7 @@ export function Conductor() { const filteredSessions = (() => { const sessions = conductor.recentSessions if (activityFilter === 'all') return sessions - return sessions - .filter((session) => ((session.label as string) ?? '').startsWith('worker-')) - .filter((session) => deriveSessionStatus(session as GatewaySession) === activityFilter) + return sessions.filter((session) => ((session.label as string) ?? '').startsWith('worker-')).filter((session) => deriveSessionStatus(session as GatewaySession) === activityFilter) })() const activityItems: Array = hasMissionHistory ? filteredHistory : filteredSessions const ACTIVITY_PAGE_SIZE = 3 @@ -1199,9 +1127,7 @@ export function Conductor() { const showHistoryOutputFallback = !!historyOutputText && (!selectedHistoryOutputPath || selectedHistoryPreview.unavailable) const historyStatusLabel = selectedHistoryEntry.status === 'completed' ? 'Complete' : 'Stopped' const historyStatusClasses = - selectedHistoryEntry.status === 'completed' - ? 'border border-emerald-400/35 bg-emerald-500/10 text-emerald-300' - : 'border border-red-400/35 bg-red-500/10 text-red-300' + selectedHistoryEntry.status === 'completed' ? 'border border-emerald-400/35 bg-emerald-500/10 text-emerald-300' : 'border border-red-400/35 bg-red-500/10 text-red-300' return (
@@ -1223,7 +1149,8 @@ export function Conductor() {

{selectedHistoryEntry.goal}

- {selectedHistoryEntry.workerCount}/{Math.max(selectedHistoryEntry.workerCount, 1)} workers finished · {formatDurationRange(selectedHistoryEntry.startedAt, selectedHistoryEntry.completedAt, now)} total elapsed + {selectedHistoryEntry.workerCount}/{Math.max(selectedHistoryEntry.workerCount, 1)} workers finished ·{' '} + {formatDurationRange(selectedHistoryEntry.startedAt, selectedHistoryEntry.completedAt, now)} total elapsed

@@ -1258,12 +1185,7 @@ export function Conductor() {
-