Fix conductor mission tracking and portable fallback
(cherry picked from commit 2ce6c799b17e5bd69ecae41fcf7b85e67ebb2a8c)
This commit is contained in:
committed by
Aurora release bot
parent
3ef7a60324
commit
e2425f698e
13
docs/conductor-bug-log.md
Normal file
13
docs/conductor-bug-log.md
Normal file
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
67
src/lib/jobs-api.test.ts
Normal file
67
src/lib/jobs-api.test.ts
Normal file
@@ -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<JobOutput> = [
|
||||
{ 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()
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
@@ -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<ClaudeJob> {
|
||||
if (Array.isArray(data)) return data as Array<ClaudeJob>
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'jobs' in data &&
|
||||
Array.isArray((data as { jobs?: unknown }).jobs)
|
||||
) {
|
||||
return (data as { jobs: Array<ClaudeJob> }).jobs
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function findJobById(
|
||||
jobs: Array<ClaudeJob>,
|
||||
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<JobOutput>): 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<Array<ClaudeJob>> {
|
||||
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: {
|
||||
|
||||
@@ -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-<slug>' : `${outputBase}/dispatch-<slug>`
|
||||
|
||||
const outputPrefix = outputBase === '/tmp' ? '/tmp/dispatch-<slug>' : `${outputBase}/dispatch-<slug>`
|
||||
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<string, string> {
|
||||
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',
|
||||
})
|
||||
const capabilities = await ensureGatewayProbed()
|
||||
const res = capabilities.dashboard.available
|
||||
? await dashboardFetch('/api/cron/jobs', {
|
||||
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,
|
||||
})
|
||||
: await fetch(`${CLAUDE_API}/api/jobs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body,
|
||||
body: JSON.stringify({ name: payload.name, prompt: payload.prompt }),
|
||||
})
|
||||
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<string, unknown> = {}
|
||||
try {
|
||||
mission = JSON.parse(text) as Record<string, unknown>
|
||||
} 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 },
|
||||
)
|
||||
}
|
||||
|
||||
// Claude runs cron jobs in sessions keyed `cron_<jobId>_<timestamp>`.
|
||||
// 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
|
||||
if (!capabilities.dashboard.available) {
|
||||
return json({
|
||||
ok: true,
|
||||
sessionKey: `cron_${jobId}_pending`,
|
||||
sessionKeyPrefix: `cron_${jobId}_`,
|
||||
jobId,
|
||||
jobName: result.name ?? jobName,
|
||||
mode: 'portable',
|
||||
prompt,
|
||||
missionId: null,
|
||||
sessionKey: missionName,
|
||||
sessionKeyPrefix: null,
|
||||
jobId: null,
|
||||
jobName: missionName,
|
||||
runId: null,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
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 })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -91,7 +91,9 @@ export function useStreamingMessage(options: UseStreamingMessageOptions = {}) {
|
||||
const finishedRef = useRef(false)
|
||||
const thinkingRef = useRef<string>('')
|
||||
const activeRunIdRef = useRef<string | null>(null)
|
||||
const delayedUnregisterTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const delayedUnregisterTimerRef = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
const activeSessionKeyRef = useRef<string>('main')
|
||||
const lifecyclePhaseRef = useRef<StreamLifecyclePhase>('idle')
|
||||
const acceptedAtRef = useRef<number | null>(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<string, unknown>
|
||||
| undefined,
|
||||
message: payload.message as Record<string, unknown> | 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,
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-start justify-between gap-4 text-left"
|
||||
>
|
||||
<button type="button" onClick={onToggle} aria-expanded={expanded} className="flex w-full items-start justify-between gap-4 text-left">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Mission Cost</p>
|
||||
<p className="mt-1 text-sm text-[var(--theme-muted-2)]">Approximate at $5 / 1M tokens blended from input/output pricing.</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-xl border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-2 text-xs font-medium text-[var(--theme-text)]">
|
||||
{expanded ? 'Hide' : 'Show'}
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
size={16}
|
||||
strokeWidth={1.7}
|
||||
className={cn('transition-transform duration-200', expanded ? 'rotate-180' : 'rotate-0')}
|
||||
/>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} strokeWidth={1.7} className={cn('transition-transform duration-200', expanded ? 'rotate-180' : 'rotate-0')} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -180,7 +173,9 @@ function MissionCostSection({
|
||||
<div className="divide-y divide-[var(--theme-border)]">
|
||||
{workers.map((worker) => (
|
||||
<div key={worker.id} className="flex items-center gap-3 px-4 py-3 text-sm">
|
||||
<span className="font-medium text-[var(--theme-text)]">{worker.personaEmoji} {worker.personaName}</span>
|
||||
<span className="font-medium text-[var(--theme-text)]">
|
||||
{worker.personaEmoji} {worker.personaName}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--theme-muted)]">{worker.label}</span>
|
||||
<span className="text-xs text-[var(--theme-muted)]">{worker.totalTokens.toLocaleString()} tok</span>
|
||||
<span className="min-w-[4.5rem] text-right font-medium text-[var(--theme-text)]">{formatUsd(estimateTokenCost(worker.totalTokens))}</span>
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<div className="flex size-3.5 items-center justify-center rounded-full border border-amber-400/60 bg-amber-500/10 text-[9px] text-amber-300">
|
||||
||
|
||||
</div>
|
||||
<div className="flex size-3.5 items-center justify-center rounded-full border border-amber-400/60 bg-amber-500/10 text-[9px] text-amber-300">||</div>
|
||||
<p className="text-sm text-[var(--theme-muted)]">Paused</p>
|
||||
</div>
|
||||
)
|
||||
@@ -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 (
|
||||
<div
|
||||
key={worker.key}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-2xl border border-[var(--theme-border)] border-l-4 bg-[var(--theme-card)] px-4 py-3',
|
||||
getWorkerBorderClass(worker.status),
|
||||
)}
|
||||
>
|
||||
<div key={worker.key} className={cn('overflow-hidden rounded-2xl border border-[var(--theme-border)] border-l-4 bg-[var(--theme-card)] px-4 py-3', getWorkerBorderClass(worker.status))}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -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({
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-1 text-xs font-medium text-[var(--theme-text)]">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
value ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]',
|
||||
)}
|
||||
/>
|
||||
<span className={cn('size-2 rounded-full', value ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]')} />
|
||||
<span className="truncate">{getModelDisplayName(selectedModel, value)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
size={16}
|
||||
strokeWidth={1.8}
|
||||
className={cn('shrink-0 text-[var(--theme-muted)] transition-transform', open && 'rotate-180')}
|
||||
/>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} size={16} strokeWidth={1.8} className={cn('shrink-0 text-[var(--theme-muted)] transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{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}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
!value ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]',
|
||||
)}
|
||||
/>
|
||||
<span className={cn('size-2 rounded-full', !value ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]')} />
|
||||
<span className="min-w-0 flex-1 truncate">Default (auto)</span>
|
||||
<span className="rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[var(--theme-muted)]">
|
||||
Auto
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[var(--theme-muted)]">Auto</span>
|
||||
</button>
|
||||
|
||||
{groupedModels.map((group) => (
|
||||
<div key={group.provider} className="mt-2 first:mt-3">
|
||||
<div className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">
|
||||
{group.provider}
|
||||
</div>
|
||||
<div className="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">{group.provider}</div>
|
||||
<div className="space-y-1">
|
||||
{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}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
active ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]',
|
||||
)}
|
||||
/>
|
||||
<span className={cn('size-2 rounded-full', active ? 'bg-[var(--theme-accent)]' : 'bg-[var(--theme-border2)]')} />
|
||||
<span className="min-w-0 flex-1 truncate">{getModelDisplayName(model, modelId)}</span>
|
||||
<span className="rounded-full border border-[var(--theme-border)] bg-[var(--theme-card2)] px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[var(--theme-muted)]">
|
||||
{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<MissionHistoryEntry | GatewaySession> = 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 (
|
||||
<div className="flex min-h-dvh flex-col overflow-y-auto bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
|
||||
@@ -1223,7 +1149,8 @@ export function Conductor() {
|
||||
</p>
|
||||
<h1 className="mt-2 text-xl font-semibold tracking-tight text-[var(--theme-text)] sm:text-2xl">{selectedHistoryEntry.goal}</h1>
|
||||
<p className="mt-2 text-xs text-[var(--theme-muted-2)]">
|
||||
{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
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -1258,12 +1185,7 @@ export function Conductor() {
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-4 overflow-auto rounded-2xl border border-[var(--theme-border)] bg-white">
|
||||
<iframe
|
||||
src={selectedHistoryPreviewUrl!}
|
||||
className="h-[clamp(280px,55vh,520px)] w-full"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
title="Mission history output preview"
|
||||
/>
|
||||
<iframe src={selectedHistoryPreviewUrl!} className="h-[clamp(280px,55vh,520px)] w-full" sandbox="allow-scripts allow-same-origin" title="Mission history output preview" />
|
||||
</div>
|
||||
</section>
|
||||
) : selectedHistoryOutputPath && selectedHistoryPreview.loading ? (
|
||||
@@ -1274,7 +1196,9 @@ export function Conductor() {
|
||||
</div>
|
||||
</section>
|
||||
) : selectedHistoryOutputPath && selectedHistoryPreview.unavailable ? (
|
||||
showHistoryOutputFallback ? null : <p className="px-1 text-sm text-[var(--theme-muted)]">No preview available.</p>
|
||||
showHistoryOutputFallback ? null : (
|
||||
<p className="px-1 text-sm text-[var(--theme-muted)]">No preview available.</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<section className="overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
||||
@@ -1282,9 +1206,7 @@ export function Conductor() {
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Agent Summary</p>
|
||||
</div>
|
||||
<span className={cn('rounded-full px-3 py-1 text-xs font-medium', historyStatusClasses)}>
|
||||
{historyStatusLabel}
|
||||
</span>
|
||||
<span className={cn('rounded-full px-3 py-1 text-xs font-medium', historyStatusClasses)}>{historyStatusLabel}</span>
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
||||
{historySummary ? (
|
||||
@@ -1298,9 +1220,13 @@ export function Conductor() {
|
||||
{historyWorkerDetails.map((worker: MissionHistoryWorkerDetail, index) => (
|
||||
<div key={`${selectedHistoryEntry.id}-worker-${index}`} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm">
|
||||
<span className={cn('size-2 rounded-full', selectedHistoryEntry.status === 'completed' ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||
<span className="font-medium text-[var(--theme-text)]">{worker.personaEmoji} {worker.personaName}</span>
|
||||
<span className="font-medium text-[var(--theme-text)]">
|
||||
{worker.personaEmoji} {worker.personaName}
|
||||
</span>
|
||||
<span className="text-[var(--theme-muted)]">{worker.label}</span>
|
||||
<span className="ml-auto text-xs text-[var(--theme-muted)]">{getShortModelName(worker.model)} · {worker.totalTokens.toLocaleString()} tok</span>
|
||||
<span className="ml-auto text-xs text-[var(--theme-muted)]">
|
||||
{getShortModelName(worker.model)} · {worker.totalTokens.toLocaleString()} tok
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1331,7 +1257,8 @@ export function Conductor() {
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output</p>
|
||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">
|
||||
Preview unavailable{selectedHistoryOutputPath ? ` for ${selectedHistoryOutputLabel}` : ''}.
|
||||
Preview unavailable
|
||||
{selectedHistoryOutputPath ? ` for ${selectedHistoryOutputLabel}` : ''}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1407,13 +1334,15 @@ export function Conductor() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
{(hasMissionHistory || conductor.recentSessions.length > 0) ? (
|
||||
{hasMissionHistory || conductor.recentSessions.length > 0 ? (
|
||||
<section className="mt-6 w-full space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--theme-muted)]">Recent Missions</h2>
|
||||
{activityTotalPages > 1 && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-[var(--theme-muted-2)]">{safeActivityPage + 1}/{activityTotalPages}</span>
|
||||
<span className="text-[10px] text-[var(--theme-muted-2)]">
|
||||
{safeActivityPage + 1}/{activityTotalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={safeActivityPage === 0}
|
||||
@@ -1469,9 +1398,7 @@ export function Conductor() {
|
||||
<span
|
||||
className={cn(
|
||||
'w-[76px] shrink-0 rounded-full border px-2 py-0.5 text-center text-[10px] font-medium uppercase tracking-[0.12em]',
|
||||
entry.status === 'completed'
|
||||
? 'border-emerald-400/35 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-red-400/35 bg-red-500/10 text-red-300',
|
||||
entry.status === 'completed' ? 'border-emerald-400/35 bg-emerald-500/10 text-emerald-300' : 'border-red-400/35 bg-red-500/10 text-red-300',
|
||||
)}
|
||||
>
|
||||
{entry.status === 'completed' ? 'Complete' : 'Failed'}
|
||||
@@ -1496,18 +1423,10 @@ export function Conductor() {
|
||||
? recentSession.createdAt
|
||||
: null
|
||||
const sessionStatus = deriveSessionStatus(recentSession)
|
||||
const dotClass =
|
||||
sessionStatus === 'completed'
|
||||
? 'bg-emerald-400'
|
||||
: sessionStatus === 'failed'
|
||||
? 'bg-red-400'
|
||||
: 'bg-sky-400 animate-pulse'
|
||||
const dotClass = sessionStatus === 'completed' ? 'bg-emerald-400' : sessionStatus === 'failed' ? 'bg-red-400' : 'bg-sky-400 animate-pulse'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={recentSession.key}
|
||||
className="flex items-center gap-3 rounded-xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-3 py-2 text-sm"
|
||||
>
|
||||
<div key={recentSession.key} className="flex items-center gap-3 rounded-xl border border-[var(--theme-border)] bg-[var(--theme-card)] px-3 py-2 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate font-medium capitalize text-[var(--theme-text)]">{displayName}</span>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -1531,7 +1450,8 @@ export function Conductor() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-[var(--theme-border)] px-4 py-6 text-center text-sm text-[var(--theme-muted)]">
|
||||
No {activityFilter === 'all' ? '' : `${activityFilter} `}{hasMissionHistory ? 'missions' : 'sessions'} found
|
||||
No {activityFilter === 'all' ? '' : `${activityFilter} `}
|
||||
{hasMissionHistory ? 'missions' : 'sessions'} found
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -1605,11 +1525,7 @@ export function Conductor() {
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!goalDraft.trim() || conductor.isSending}
|
||||
className="rounded-full bg-[var(--theme-accent)] px-5 text-white hover:bg-[var(--theme-accent-strong)]"
|
||||
>
|
||||
<Button type="submit" disabled={!goalDraft.trim() || conductor.isSending} className="rounded-full bg-[var(--theme-accent)] px-5 text-white hover:bg-[var(--theme-accent-strong)]">
|
||||
{conductor.isSending ? 'Launching...' : 'Launch Mission'}
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} size={16} strokeWidth={1.7} />
|
||||
</Button>
|
||||
@@ -1736,10 +1652,7 @@ export function Conductor() {
|
||||
)}
|
||||
|
||||
{directoryBrowserOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-[color-mix(in_srgb,var(--theme-bg)_55%,transparent)] px-4 py-6 backdrop-blur-md"
|
||||
onClick={closeDirectoryBrowser}
|
||||
>
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-[color-mix(in_srgb,var(--theme-bg)_55%,transparent)] px-4 py-6 backdrop-blur-md" onClick={closeDirectoryBrowser}>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-3xl border border-[var(--theme-border2)] bg-[var(--theme-card)] p-5 shadow-[0_24px_80px_var(--theme-shadow)] sm:p-6"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -1785,9 +1698,7 @@ export function Conductor() {
|
||||
onClick={() => setDirectoryBrowserPath(crumb.path)}
|
||||
className={cn(
|
||||
'rounded-md px-1.5 py-0.5 transition-colors',
|
||||
crumb.path === directoryBrowserPath
|
||||
? 'bg-[var(--theme-accent-soft)] text-[var(--theme-accent-strong)]'
|
||||
: 'text-[var(--theme-text)] hover:bg-[var(--theme-card2)]',
|
||||
crumb.path === directoryBrowserPath ? 'bg-[var(--theme-accent-soft)] text-[var(--theme-accent-strong)]' : 'text-[var(--theme-text)] hover:bg-[var(--theme-card2)]',
|
||||
)}
|
||||
>
|
||||
{crumb.label}
|
||||
@@ -1806,9 +1717,7 @@ export function Conductor() {
|
||||
</div>
|
||||
|
||||
{directoryBrowserError ? (
|
||||
<div className="rounded-2xl border border-[var(--theme-warning-border)] bg-[var(--theme-warning-soft)] px-4 py-3 text-sm text-[var(--theme-warning)]">
|
||||
{directoryBrowserError}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--theme-warning-border)] bg-[var(--theme-warning-soft)] px-4 py-3 text-sm text-[var(--theme-warning)]">{directoryBrowserError}</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)]">
|
||||
@@ -1842,9 +1751,7 @@ export function Conductor() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-10 text-center text-sm text-[var(--theme-muted)]">
|
||||
No folders found in this location.
|
||||
</div>
|
||||
<div className="px-4 py-10 text-center text-sm text-[var(--theme-muted)]">No folders found in this location.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1901,9 +1808,7 @@ export function Conductor() {
|
||||
<div className="space-y-2 text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--theme-accent)]">Mission Decomposition</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{conductor.goal}</h1>
|
||||
<p className="text-sm text-[var(--theme-muted-2)]">
|
||||
The agent is breaking the mission into workers. Once they spawn, this view flips into the active board.
|
||||
</p>
|
||||
<p className="text-sm text-[var(--theme-muted-2)]">The agent is breaking the mission into workers. Once they spawn, this view flips into the active board.</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] p-6 shadow-[0_24px_80px_var(--theme-shadow)]">
|
||||
@@ -1912,27 +1817,19 @@ export function Conductor() {
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Mission Planning</p>
|
||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">Analyzing your request and preparing agents</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-400/30 bg-sky-500/10 px-3 py-1 text-xs font-medium text-sky-300 animate-pulse">
|
||||
Working
|
||||
</span>
|
||||
<span className="rounded-full border border-sky-400/30 bg-sky-500/10 px-3 py-1 text-xs font-medium text-sky-300 animate-pulse">Working</span>
|
||||
</div>
|
||||
<div className="mt-4 min-h-[200px] overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
||||
{conductor.planText ? (
|
||||
<div className="space-y-4">
|
||||
<Markdown className="max-h-[500px] max-w-none overflow-auto text-sm text-[var(--theme-text)]">
|
||||
{conductor.planText.replace(/(.{20,}?)\1+/g, '$1')}
|
||||
</Markdown>
|
||||
<Markdown className="max-h-[500px] max-w-none overflow-auto text-sm text-[var(--theme-text)]">{conductor.planText.replace(/(.{20,}?)\1+/g, '$1')}</Markdown>
|
||||
<PlanningIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<PlanningIndicator />
|
||||
)}
|
||||
</div>
|
||||
{conductor.streamError && (
|
||||
<div className="mt-4 rounded-2xl border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-600">
|
||||
{conductor.streamError}
|
||||
</div>
|
||||
)}
|
||||
{conductor.streamError && <div className="mt-4 rounded-2xl border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-600">{conductor.streamError}</div>}
|
||||
{conductor.timeoutWarning && (
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-2xl border border-amber-400/40 bg-amber-500/10 px-5 py-3">
|
||||
<p className="text-sm text-amber-700">⚠️ Planning is taking longer than expected...</p>
|
||||
@@ -1947,14 +1844,9 @@ export function Conductor() {
|
||||
)}
|
||||
{conductor.tasks.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">
|
||||
Identified Tasks ({conductor.tasks.length})
|
||||
</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Identified Tasks ({conductor.tasks.length})</p>
|
||||
{conductor.tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-2 text-sm"
|
||||
>
|
||||
<div key={task.id} className="flex items-center gap-2 rounded-lg border border-[var(--theme-border)] bg-[var(--theme-card2)] px-3 py-2 text-sm">
|
||||
<span className="size-2 rounded-full bg-zinc-500" />
|
||||
<span className="text-[var(--theme-text)]">{task.title}</span>
|
||||
</div>
|
||||
@@ -1997,11 +1889,7 @@ export function Conductor() {
|
||||
>
|
||||
Retry Mission
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewMission}
|
||||
className="rounded-xl bg-[var(--theme-accent)] px-4 text-white hover:bg-[var(--theme-accent-strong)]"
|
||||
>
|
||||
<Button type="button" onClick={handleNewMission} className="rounded-xl bg-[var(--theme-accent)] px-4 text-white hover:bg-[var(--theme-accent-strong)]">
|
||||
New Mission
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2016,7 +1904,8 @@ export function Conductor() {
|
||||
</p>
|
||||
<h1 className="mt-2 text-xl font-semibold tracking-tight text-[var(--theme-text)] sm:text-2xl">{conductor.goal}</h1>
|
||||
<p className="mt-2 text-xs text-[var(--theme-muted-2)]">
|
||||
{completedWorkers}/{Math.max(totalWorkers, completedWorkers)} workers finished · {formatElapsedTime(conductor.missionStartedAt, conductor.completedAt ? new Date(conductor.completedAt).getTime() : now)} total elapsed
|
||||
{completedWorkers}/{Math.max(totalWorkers, completedWorkers)} workers finished ·{' '}
|
||||
{formatElapsedTime(conductor.missionStartedAt, conductor.completedAt ? new Date(conductor.completedAt).getTime() : now)} total elapsed
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -2029,11 +1918,7 @@ export function Conductor() {
|
||||
Continue
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewMission}
|
||||
className="rounded-xl bg-[var(--theme-accent)] px-5 text-white hover:bg-[var(--theme-accent-strong)]"
|
||||
>
|
||||
<Button type="button" onClick={handleNewMission} className="rounded-xl bg-[var(--theme-accent)] px-5 text-white hover:bg-[var(--theme-accent-strong)]">
|
||||
New Mission
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2045,9 +1930,7 @@ export function Conductor() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output Preview</p>
|
||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">
|
||||
{completePhaseProjectPath.split('/').pop() || 'index.html'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">{completePhaseProjectPath.split('/').pop() || 'index.html'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
@@ -2068,12 +1951,7 @@ export function Conductor() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 overflow-auto rounded-2xl border border-[var(--theme-border)] bg-white">
|
||||
<iframe
|
||||
src={previewUrl!}
|
||||
className="h-[clamp(280px,55vh,520px)] w-full"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
title="Mission output preview"
|
||||
/>
|
||||
<iframe src={previewUrl!} className="h-[clamp(280px,55vh,520px)] w-full" sandbox="allow-scripts allow-same-origin" title="Mission output preview" />
|
||||
</div>
|
||||
</section>
|
||||
) : completePhaseProjectPath && previewState.loading && !conductor.streamError ? (
|
||||
@@ -2086,19 +1964,24 @@ export function Conductor() {
|
||||
) : null}
|
||||
|
||||
{/* Worker output fallback — show when no iframe preview is available */}
|
||||
{(!completePhaseProjectPath || previewState.unavailable) && (() => {
|
||||
{(!completePhaseProjectPath || previewState.unavailable) &&
|
||||
(() => {
|
||||
const outputSections = conductor.workers
|
||||
.map((worker, index) => {
|
||||
const output = (conductor.workerOutputs[worker.key] ?? getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)).trim()
|
||||
if (!output) return null
|
||||
const persona = getAgentPersona(index)
|
||||
return { key: worker.key, persona, label: worker.label, output }
|
||||
return {
|
||||
key: worker.key,
|
||||
persona,
|
||||
label: worker.label,
|
||||
output,
|
||||
}
|
||||
})
|
||||
.filter((section): section is NonNullable<typeof section> => section !== null)
|
||||
|
||||
const fallbackText = outputSections.length > 0
|
||||
? outputSections.map((s) => `### ${s.persona.emoji} ${s.persona.name} · ${s.label}\n\n${s.output}`).join('\n\n---\n\n')
|
||||
: conductor.streamText.trim()
|
||||
const fallbackText =
|
||||
outputSections.length > 0 ? outputSections.map((s) => `### ${s.persona.emoji} ${s.persona.name} · ${s.label}\n\n${s.output}`).join('\n\n---\n\n') : conductor.streamText.trim()
|
||||
|
||||
if (!fallbackText) return null
|
||||
|
||||
@@ -2107,9 +1990,7 @@ export function Conductor() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Output</p>
|
||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">
|
||||
{completePhaseProjectPath ? `Preview unavailable for ${completePhaseOutputLabel}` : 'Agent work output'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--theme-muted-2)]">{completePhaseProjectPath ? `Preview unavailable for ${completePhaseOutputLabel}` : 'Agent work output'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-5 py-4">
|
||||
@@ -2129,10 +2010,7 @@ export function Conductor() {
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{completedTaskOutputs.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-4 py-4"
|
||||
>
|
||||
<div key={task.id} className="rounded-2xl border border-[var(--theme-border)] bg-[var(--theme-bg)] px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -2166,12 +2044,12 @@ export function Conductor() {
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--theme-muted)]">Agent Summary</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1 text-xs font-medium',
|
||||
conductor.streamError
|
||||
? 'border border-red-400/35 bg-red-500/10 text-red-300'
|
||||
: 'border border-emerald-400/35 bg-emerald-500/10 text-emerald-300',
|
||||
)}>
|
||||
conductor.streamError ? 'border border-red-400/35 bg-red-500/10 text-red-300' : 'border border-emerald-400/35 bg-emerald-500/10 text-emerald-300',
|
||||
)}
|
||||
>
|
||||
{conductor.streamError ? 'Stopped' : 'Complete'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -2192,9 +2070,13 @@ export function Conductor() {
|
||||
return (
|
||||
<div key={worker.key} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm">
|
||||
<span className="size-2 rounded-full bg-emerald-400" />
|
||||
<span className="font-medium text-[var(--theme-text)]">{persona.emoji} {persona.name}</span>
|
||||
<span className="font-medium text-[var(--theme-text)]">
|
||||
{persona.emoji} {persona.name}
|
||||
</span>
|
||||
<span className="text-[var(--theme-muted)]">{worker.label}</span>
|
||||
<span className="ml-auto text-xs text-[var(--theme-muted)]">{shortModelName} · {worker.totalTokens.toLocaleString()} tok</span>
|
||||
<span className="ml-auto text-xs text-[var(--theme-muted)]">
|
||||
{shortModelName} · {worker.totalTokens.toLocaleString()} tok
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -2202,12 +2084,7 @@ export function Conductor() {
|
||||
)}
|
||||
{(totalTokens > 0 || completeMissionCostWorkers.length > 0) && (
|
||||
<div className="mt-4">
|
||||
<MissionCostSection
|
||||
totalTokens={totalTokens}
|
||||
workers={completeMissionCostWorkers}
|
||||
expanded={completeCostExpanded}
|
||||
onToggle={() => setCompleteCostExpanded((current) => !current)}
|
||||
/>
|
||||
<MissionCostSection totalTokens={totalTokens} workers={completeMissionCostWorkers} expanded={completeCostExpanded} onToggle={() => setCompleteCostExpanded((current) => !current)} />
|
||||
</div>
|
||||
)}
|
||||
{conductor.streamText && completeSummary && (
|
||||
@@ -2219,7 +2096,6 @@ export function Conductor() {
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{continueModalOpen ? (
|
||||
@@ -2307,7 +2183,9 @@ export function Conductor() {
|
||||
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-[var(--theme-muted)]">
|
||||
<span>{formatElapsedMilliseconds(conductor.isPaused ? conductor.pausedElapsedMs : conductor.missionElapsedMs)}</span>
|
||||
<span className="text-[var(--theme-border)]">·</span>
|
||||
<span>{completedWorkers}/{Math.max(totalWorkers, 1)} complete</span>
|
||||
<span>
|
||||
{completedWorkers}/{Math.max(totalWorkers, 1)} complete
|
||||
</span>
|
||||
<span className="text-[var(--theme-border)]">·</span>
|
||||
<span>{activeWorkerCount} active</span>
|
||||
</div>
|
||||
@@ -2381,15 +2259,7 @@ export function Conductor() {
|
||||
</section>
|
||||
)}
|
||||
<section className="h-[360px] overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] shadow-[0_24px_80px_var(--theme-shadow)]">
|
||||
<OfficeView
|
||||
agentRows={officeAgentRows}
|
||||
missionRunning
|
||||
onViewOutput={() => {}}
|
||||
processType="parallel"
|
||||
companyName="Conductor Office"
|
||||
containerHeight={360}
|
||||
hideHeader
|
||||
/>
|
||||
<OfficeView agentRows={officeAgentRows} missionRunning onViewOutput={() => {}} processType="parallel" companyName="Conductor Office" containerHeight={360} hideHeader />
|
||||
</section>
|
||||
|
||||
{conductor.tasks.length > 0 ? (
|
||||
@@ -2400,14 +2270,7 @@ export function Conductor() {
|
||||
</h2>
|
||||
{conductor.tasks.map((task) => {
|
||||
const isSelected = selectedTaskId === task.id
|
||||
const statusDot =
|
||||
task.status === 'complete'
|
||||
? 'bg-emerald-400'
|
||||
: task.status === 'running'
|
||||
? 'bg-sky-400 animate-pulse'
|
||||
: task.status === 'failed'
|
||||
? 'bg-red-400'
|
||||
: 'bg-zinc-500'
|
||||
const statusDot = task.status === 'complete' ? 'bg-emerald-400' : task.status === 'running' ? 'bg-sky-400 animate-pulse' : task.status === 'failed' ? 'bg-red-400' : 'bg-zinc-500'
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
@@ -2415,9 +2278,7 @@ export function Conductor() {
|
||||
onClick={() => setSelectedTaskId(isSelected ? null : task.id)}
|
||||
className={cn(
|
||||
'w-full rounded-xl border px-3 py-2.5 text-left text-sm transition-colors',
|
||||
isSelected
|
||||
? 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)]'
|
||||
: 'border-[var(--theme-border)] bg-[var(--theme-card)] hover:border-[var(--theme-accent)]',
|
||||
isSelected ? 'border-[var(--theme-accent)] bg-[var(--theme-accent-soft)]' : 'border-[var(--theme-border)] bg-[var(--theme-card)] hover:border-[var(--theme-accent)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -2437,27 +2298,20 @@ export function Conductor() {
|
||||
) : null}
|
||||
{(() => {
|
||||
const selectedTask = selectedTaskId ? conductor.tasks.find((task) => task.id === selectedTaskId) : null
|
||||
const displayWorkers = selectedTask?.workerKey
|
||||
? conductor.workers.filter((worker) => worker.key === selectedTask.workerKey)
|
||||
: conductor.workers
|
||||
const displayWorkers = selectedTask?.workerKey ? conductor.workers.filter((worker) => worker.key === selectedTask.workerKey) : conductor.workers
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{displayWorkers.map((worker, index) => {
|
||||
return (
|
||||
<WorkerCard
|
||||
key={worker.key}
|
||||
worker={worker}
|
||||
index={index}
|
||||
conductor={conductor}
|
||||
now={now}
|
||||
/>
|
||||
)
|
||||
return <WorkerCard key={worker.key} worker={worker} index={index} conductor={conductor} now={now} />
|
||||
})}
|
||||
{displayWorkers.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-8 text-center text-sm text-[var(--theme-muted)] md:col-span-2">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
||||
<span>Spawning workers…</span>
|
||||
<span>Spawning workers...</span>
|
||||
</div>
|
||||
{conductor.planText ? <p className="max-w-xl text-xs text-[var(--theme-muted-2)]">{conductor.planText}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2470,28 +2324,22 @@ export function Conductor() {
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{conductor.workers.map((worker, index) => {
|
||||
return (
|
||||
<WorkerCard
|
||||
key={worker.key}
|
||||
worker={worker}
|
||||
index={index}
|
||||
conductor={conductor}
|
||||
now={now}
|
||||
/>
|
||||
)
|
||||
return <WorkerCard key={worker.key} worker={worker} index={index} conductor={conductor} now={now} />
|
||||
})}
|
||||
{conductor.workers.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-8 text-center text-sm text-[var(--theme-muted)] md:col-span-2">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-sky-400 border-t-transparent" />
|
||||
<span>Spawning workers…</span>
|
||||
<span>Spawning workers...</span>
|
||||
</div>
|
||||
{conductor.planText ? <p className="max-w-xl text-xs text-[var(--theme-muted-2)]">{conductor.planText}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { fetchSessions, type GatewaySession } from '@/lib/gateway-api'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { GatewaySession } from '@/lib/gateway-api'
|
||||
import { fetchSessions } from '@/lib/gateway-api'
|
||||
|
||||
type HistoryMessagePart = {
|
||||
type?: string
|
||||
@@ -17,6 +19,22 @@ type HistoryResponse = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
type ConductorMissionRecord = {
|
||||
id?: string
|
||||
name?: string
|
||||
status?: string
|
||||
error?: string
|
||||
session_id?: string | null
|
||||
lines?: unknown
|
||||
exit_code?: number | null
|
||||
}
|
||||
|
||||
type ConductorMissionResponse = {
|
||||
ok?: boolean
|
||||
mission?: ConductorMissionRecord
|
||||
error?: string
|
||||
}
|
||||
|
||||
type MissionPhase = 'idle' | 'decomposing' | 'running' | 'complete'
|
||||
|
||||
export type ConductorSettings = {
|
||||
@@ -38,6 +56,8 @@ const DEFAULT_CONDUCTOR_SETTINGS: ConductorSettings = {
|
||||
}
|
||||
|
||||
type PersistedMission = {
|
||||
missionId: string | null
|
||||
missionJobId: string | null
|
||||
goal: string
|
||||
phase: MissionPhase
|
||||
missionStartedAt: string | null
|
||||
@@ -57,11 +77,35 @@ type PersistedMission = {
|
||||
type StreamEvent =
|
||||
| { type: 'assistant'; text: string }
|
||||
| { type: 'thinking'; text: string }
|
||||
| { type: 'tool'; name?: string; phase?: string; data?: Record<string, unknown> }
|
||||
| {
|
||||
type: 'tool'
|
||||
name?: string
|
||||
phase?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
| { type: 'done'; state?: string; message?: string }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'started'; runId?: string; sessionKey?: string }
|
||||
|
||||
type ConductorSpawnResponse = {
|
||||
ok?: boolean
|
||||
mode?: 'dashboard' | 'portable'
|
||||
prompt?: string | null
|
||||
missionId?: string | null
|
||||
sessionKey?: string | null
|
||||
sessionKeyPrefix?: string | null
|
||||
jobId?: string | null
|
||||
jobName?: string | null
|
||||
runId?: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
type PortableStreamResult = {
|
||||
runId: string | null
|
||||
sessionKey: string | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export type ConductorWorker = {
|
||||
key: string
|
||||
label: string
|
||||
@@ -122,14 +166,9 @@ function getAgentPersona(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function extractTasksFromPlan(planText: string): ConductorTask[] {
|
||||
const tasks: ConductorTask[] = []
|
||||
const patterns = [
|
||||
/^\s*(\d+)\.\s+(.+)$/gm,
|
||||
/^\s*#{1,3}\s+(?:Step\s+)?(\d+)[.:]\s*(.+)$/gm,
|
||||
/^\s*-\s+\*\*(?:Task\s+)?(\d+)[.:]\s*\*\*\s*(.+)$/gm,
|
||||
]
|
||||
const patterns = [/^\s*(\d+)\.\s+(.+)$/gm, /^\s*#{1,3}\s+(?:Step\s+)?(\d+)[.:]\s*(.+)$/gm, /^\s*-\s+\*\*(?:Task\s+)?(\d+)[.:]\s*\*\*\s*(.+)$/gm]
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const pattern of patterns) {
|
||||
@@ -140,7 +179,13 @@ function extractTasksFromPlan(planText: string): ConductorTask[] {
|
||||
const id = `task-${num}`
|
||||
if (!seen.has(id) && title.length > 3 && title.length < 200) {
|
||||
seen.add(id)
|
||||
tasks.push({ id, title, status: 'pending', workerKey: null, output: null })
|
||||
tasks.push({
|
||||
id,
|
||||
title,
|
||||
status: 'pending',
|
||||
workerKey: null,
|
||||
output: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,12 +223,61 @@ function toIso(value: unknown): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeMatchText(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function getSessionSearchText(session: GatewaySession): string {
|
||||
return [
|
||||
readString(session.label),
|
||||
readString(session.title),
|
||||
readString(session.derivedTitle),
|
||||
readString(session.preview),
|
||||
readString(session.kind),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function buildMissionNeedles(goal: string): string[] {
|
||||
const words = normalizeMatchText(goal).split(' ').filter(Boolean)
|
||||
const prefixes = [5, 8, 12]
|
||||
.map((count) => words.slice(0, count).join(' ').trim())
|
||||
.filter(Boolean)
|
||||
return [...new Set(prefixes)]
|
||||
}
|
||||
|
||||
function sessionMatchesMissionContext(
|
||||
session: GatewaySession,
|
||||
missionStartMs: number,
|
||||
missionNeedles: string[],
|
||||
): boolean {
|
||||
const createdAt = toIso(session.createdAt ?? session.startedAt ?? session.updatedAt)
|
||||
if (!createdAt) return false
|
||||
|
||||
const createdMs = new Date(createdAt).getTime()
|
||||
if (!Number.isFinite(createdMs) || createdMs < missionStartMs) return false
|
||||
|
||||
const totalTokens = readNumber(session.totalTokens) ?? readNumber(session.tokenCount) ?? 0
|
||||
if (totalTokens <= 0) return false
|
||||
|
||||
const text = normalizeMatchText(getSessionSearchText(session))
|
||||
if (!text) return false
|
||||
if (text.includes('mission orchestrator')) return true
|
||||
if (text.includes('dashboard-backed conductor')) return true
|
||||
if (text.includes('conductor mission')) return true
|
||||
|
||||
return missionNeedles.some((needle) => text.includes(needle))
|
||||
}
|
||||
|
||||
function loadPersistedMission(): PersistedMission | null {
|
||||
try {
|
||||
const raw = globalThis.localStorage?.getItem(ACTIVE_MISSION_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
const missionId = readString(parsed.missionId)
|
||||
const missionJobId = readString(parsed.missionJobId)
|
||||
const goal = typeof parsed.goal === 'string' ? parsed.goal : null
|
||||
const phase = parsed.phase
|
||||
const streamText = typeof parsed.streamText === 'string' ? parsed.streamText : null
|
||||
@@ -192,22 +286,13 @@ function loadPersistedMission(): PersistedMission | null {
|
||||
const workerLabels = Array.isArray(parsed.workerLabels) ? parsed.workerLabels.filter((value): value is string => typeof value === 'string') : null
|
||||
const workerOutputs =
|
||||
parsed.workerOutputs && typeof parsed.workerOutputs === 'object' && !Array.isArray(parsed.workerOutputs)
|
||||
? Object.fromEntries(
|
||||
Object.entries(parsed.workerOutputs as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
? Object.fromEntries(Object.entries(parsed.workerOutputs as Record<string, unknown>).filter((entry): entry is [string, string] => typeof entry[0] === 'string' && typeof entry[1] === 'string'))
|
||||
: {}
|
||||
const missionStartedAt =
|
||||
parsed.missionStartedAt === null || parsed.missionStartedAt === undefined ? null : toIso(parsed.missionStartedAt)
|
||||
const missionStartedAt = parsed.missionStartedAt === null || parsed.missionStartedAt === undefined ? null : toIso(parsed.missionStartedAt)
|
||||
const isPaused = parsed.isPaused === true
|
||||
const pausedElapsedMs = typeof parsed.pausedElapsedMs === 'number' && Number.isFinite(parsed.pausedElapsedMs) ? Math.max(0, parsed.pausedElapsedMs) : 0
|
||||
const accumulatedPausedMs =
|
||||
typeof parsed.accumulatedPausedMs === 'number' && Number.isFinite(parsed.accumulatedPausedMs)
|
||||
? Math.max(0, parsed.accumulatedPausedMs)
|
||||
: 0
|
||||
const pauseStartedAt =
|
||||
parsed.pauseStartedAt === null || parsed.pauseStartedAt === undefined ? null : toIso(parsed.pauseStartedAt)
|
||||
const accumulatedPausedMs = typeof parsed.accumulatedPausedMs === 'number' && Number.isFinite(parsed.accumulatedPausedMs) ? Math.max(0, parsed.accumulatedPausedMs) : 0
|
||||
const pauseStartedAt = parsed.pauseStartedAt === null || parsed.pauseStartedAt === undefined ? null : toIso(parsed.pauseStartedAt)
|
||||
const completedAt = parsed.completedAt === null || parsed.completedAt === undefined ? null : toIso(parsed.completedAt)
|
||||
const tasks = Array.isArray(parsed.tasks)
|
||||
? parsed.tasks
|
||||
@@ -217,11 +302,7 @@ function loadPersistedMission(): PersistedMission | null {
|
||||
const id = readString(record.id)
|
||||
const title = readString(record.title)
|
||||
const status = record.status
|
||||
if (
|
||||
!id ||
|
||||
!title ||
|
||||
(status !== 'pending' && status !== 'running' && status !== 'complete' && status !== 'failed')
|
||||
) {
|
||||
if (!id || !title || (status !== 'pending' && status !== 'running' && status !== 'complete' && status !== 'failed')) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -236,30 +317,21 @@ function loadPersistedMission(): PersistedMission | null {
|
||||
.filter((task): task is ConductorTask => task !== null)
|
||||
: []
|
||||
|
||||
if (
|
||||
!goal ||
|
||||
(phase !== 'idle' && phase !== 'decomposing' && phase !== 'running' && phase !== 'complete') ||
|
||||
streamText === null ||
|
||||
planText === null ||
|
||||
!workerKeys ||
|
||||
!workerLabels
|
||||
) {
|
||||
if (!goal || (phase !== 'idle' && phase !== 'decomposing' && phase !== 'running' && phase !== 'complete') || streamText === null || planText === null || !workerKeys || !workerLabels) {
|
||||
return null
|
||||
}
|
||||
// Never restore running/decomposing — if the browser closed mid-mission, it's dead.
|
||||
// Only restore 'complete' (reviewable) or 'idle'.
|
||||
const isStale = phase === 'running' || phase === 'decomposing'
|
||||
|
||||
return {
|
||||
goal: isStale ? '' : goal,
|
||||
phase: isStale ? 'idle' : phase,
|
||||
missionStartedAt: isStale ? null : missionStartedAt,
|
||||
isPaused: isStale ? false : isPaused,
|
||||
pausedElapsedMs: isStale ? 0 : pausedElapsedMs,
|
||||
accumulatedPausedMs: isStale ? 0 : accumulatedPausedMs,
|
||||
pauseStartedAt: isStale ? null : pauseStartedAt,
|
||||
workerKeys: isStale ? [] : workerKeys,
|
||||
workerLabels: isStale ? [] : workerLabels,
|
||||
missionId,
|
||||
missionJobId,
|
||||
goal,
|
||||
phase,
|
||||
missionStartedAt,
|
||||
isPaused,
|
||||
pausedElapsedMs,
|
||||
accumulatedPausedMs,
|
||||
pauseStartedAt,
|
||||
workerKeys,
|
||||
workerLabels,
|
||||
workerOutputs,
|
||||
streamText,
|
||||
planText,
|
||||
@@ -313,10 +385,7 @@ function loadMissionHistory(): MissionHistoryEntry[] {
|
||||
return true
|
||||
})
|
||||
.map((entry) => {
|
||||
const projectPath =
|
||||
(typeof entry.projectPath === 'string' && entry.projectPath.trim()) ||
|
||||
extractProjectPath(typeof entry.projectPath === 'string' ? entry.projectPath : '') ||
|
||||
null
|
||||
const projectPath = (typeof entry.projectPath === 'string' && entry.projectPath.trim()) || extractProjectPath(typeof entry.projectPath === 'string' ? entry.projectPath : '') || null
|
||||
const outputText = typeof entry.outputText === 'string' ? entry.outputText : undefined
|
||||
const streamText = typeof entry.streamText === 'string' ? entry.streamText : undefined
|
||||
const outputPath =
|
||||
@@ -471,8 +540,6 @@ function toWorker(session: GatewaySession): ConductorWorker | null {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function extractHistoryMessageText(message: HistoryMessage | undefined): string {
|
||||
if (!message) return ''
|
||||
if (typeof message.content === 'string') return message.content
|
||||
@@ -498,6 +565,43 @@ function getLastAssistantMessage(messages: HistoryMessage[] | undefined): string
|
||||
return best
|
||||
}
|
||||
|
||||
function readMissionLines(mission: ConductorMissionRecord | null | undefined): string[] {
|
||||
if (!Array.isArray(mission?.lines)) return []
|
||||
return mission.lines.filter((line): line is string => typeof line === 'string')
|
||||
}
|
||||
|
||||
function extractSessionIdFromMission(mission: ConductorMissionRecord | null | undefined): string | null {
|
||||
const direct = readString(mission?.session_id)
|
||||
if (direct) return direct
|
||||
|
||||
const lines = readMissionLines(mission)
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const match = lines[index].match(/\bsession_id:\s*([A-Za-z0-9_.:-]+)/)
|
||||
if (match?.[1]) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatMissionLog(lines: string[]): string {
|
||||
return lines
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.join('\n')
|
||||
.slice(-10_000)
|
||||
}
|
||||
|
||||
function isFailedMissionStatus(status: string | null): boolean {
|
||||
return status === 'failed' || status === 'error' || status === 'errored' || status === 'cancelled' || status === 'canceled'
|
||||
}
|
||||
|
||||
async function fetchConductorMission(missionId: string): Promise<ConductorMissionRecord> {
|
||||
const response = await fetch(`/api/conductor-spawn?missionId=${encodeURIComponent(missionId)}&lines=400`)
|
||||
const payload = (await response.json().catch(() => ({}))) as ConductorMissionResponse
|
||||
if (!response.ok || !payload.ok || !payload.mission) {
|
||||
throw new Error(payload.error || `Failed to load conductor mission ${missionId}`)
|
||||
}
|
||||
return payload.mission
|
||||
}
|
||||
|
||||
function extractProjectPath(text: string): string | null {
|
||||
const structuredPatterns = [
|
||||
@@ -533,16 +637,8 @@ function extractProjectPath(text: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function buildMissionOutputPath(
|
||||
workers: ConductorWorker[],
|
||||
workerOutputs: Record<string, string>,
|
||||
tasks: ConductorTask[],
|
||||
streamText: string,
|
||||
): string | null {
|
||||
const workerOutputTexts = [
|
||||
...Object.values(workerOutputs),
|
||||
...workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)),
|
||||
].filter(Boolean)
|
||||
function buildMissionOutputPath(workers: ConductorWorker[], workerOutputs: Record<string, string>, tasks: ConductorTask[], streamText: string): string | null {
|
||||
const workerOutputTexts = [...Object.values(workerOutputs), ...workers.map((worker) => getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined))].filter(Boolean)
|
||||
|
||||
for (const text of workerOutputTexts) {
|
||||
const extractedPath = extractProjectPath(text)
|
||||
@@ -564,7 +660,10 @@ function buildMissionOutputPath(
|
||||
function summarizeWorkers(workers: ConductorWorker[]): string[] {
|
||||
return workers.map((worker) => {
|
||||
const output = getLastAssistantMessage(worker.raw.messages as HistoryMessage[] | undefined)
|
||||
const firstLine = output.split(/\n+/).map((line) => line.trim()).find(Boolean)
|
||||
const firstLine = output
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean)
|
||||
const statusLabel = worker.status === 'stale' ? 'failed' : worker.status
|
||||
return `${worker.displayName}: ${firstLine ?? `${statusLabel} · ${worker.totalTokens.toLocaleString()} tok`}`
|
||||
})
|
||||
@@ -587,12 +686,7 @@ function buildCompleteSummary(params: {
|
||||
const seconds = totalSeconds % 60
|
||||
const duration = hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
|
||||
|
||||
const lines = [
|
||||
streamError ? `❌ ${streamError}` : '✅ Mission completed successfully',
|
||||
'',
|
||||
`**Goal:** ${goal}`,
|
||||
`**Duration:** ${duration}`,
|
||||
]
|
||||
const lines = [streamError ? `❌ ${streamError}` : '✅ Mission completed successfully', '', `**Goal:** ${goal}`, `**Duration:** ${duration}`]
|
||||
|
||||
if (totalWorkers > 0) {
|
||||
lines.push(`**Workers:** ${totalWorkers} ran · ${totalTokens.toLocaleString()} tokens`)
|
||||
@@ -630,9 +724,167 @@ async function fetchWorkerOutput(sessionKey: string, limit = 5): Promise<string>
|
||||
return getLastAssistantMessage(payload.messages)
|
||||
}
|
||||
|
||||
function appendStreamEvent(
|
||||
update: Dispatch<SetStateAction<Array<StreamEvent>>>,
|
||||
event: StreamEvent,
|
||||
): void {
|
||||
update((current) => [...current.slice(-99), event])
|
||||
}
|
||||
|
||||
function readStreamText(event: string, payload: Record<string, unknown>, currentText: string): string | null {
|
||||
if (event !== 'chunk' && event !== 'assistant') return null
|
||||
const text =
|
||||
readString(payload.delta) ??
|
||||
readString(payload.text) ??
|
||||
readString(payload.content) ??
|
||||
readString(payload.chunk)
|
||||
if (!text) return null
|
||||
return payload.fullReplace === true || event === 'assistant' ? text : currentText + text
|
||||
}
|
||||
|
||||
function readDoneMessageText(payload: Record<string, unknown>): string {
|
||||
const message = readRecord(payload.message)
|
||||
return extractHistoryMessageText(message as HistoryMessage | undefined).trim()
|
||||
}
|
||||
|
||||
async function streamPortableConductorMission(params: {
|
||||
sessionKey: string
|
||||
friendlyId: string
|
||||
prompt: string
|
||||
model?: string
|
||||
signal: AbortSignal
|
||||
onSessionResolved: (sessionKey: string, runId: string | null) => void
|
||||
onText: (text: string) => void
|
||||
onStreamEvent: (event: StreamEvent) => void
|
||||
}): Promise<PortableStreamResult> {
|
||||
const response = await fetch('/api/send-stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionKey: params.sessionKey,
|
||||
friendlyId: params.friendlyId,
|
||||
message: params.prompt,
|
||||
history: [],
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
model: params.model || undefined,
|
||||
locale: typeof window !== 'undefined' ? localStorage.getItem('hermes-workspace-locale') || 'en' : 'en',
|
||||
}),
|
||||
signal: params.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
throw new Error(text || `Conductor stream failed (${response.status})`)
|
||||
}
|
||||
|
||||
let sessionKey = response.headers.get('x-hermes-session-key')?.trim() || params.sessionKey
|
||||
let runId: string | null = null
|
||||
let accumulated = ''
|
||||
let sawDone = false
|
||||
|
||||
params.onSessionResolved(sessionKey, runId)
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('Conductor stream did not include a response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- reader.read() exits when done is true
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const blocks = buffer.split('\n\n')
|
||||
buffer = blocks.pop() ?? ''
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.trim()) continue
|
||||
const lines = block.split('\n')
|
||||
let event = ''
|
||||
let data = ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
event = line.slice(7).trim()
|
||||
} else if (line.startsWith('data: ')) {
|
||||
data += line.slice(6)
|
||||
} else if (line.startsWith('data:')) {
|
||||
data += line.slice(5)
|
||||
}
|
||||
}
|
||||
|
||||
if (!event || !data) continue
|
||||
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = readRecord(JSON.parse(data)) ?? {}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (event === 'started') {
|
||||
runId = readString(payload.runId) ?? runId
|
||||
sessionKey = readString(payload.sessionKey) ?? sessionKey
|
||||
params.onSessionResolved(sessionKey, runId)
|
||||
params.onStreamEvent({ type: 'started', runId: runId ?? undefined, sessionKey })
|
||||
continue
|
||||
}
|
||||
|
||||
const nextText = readStreamText(event, payload, accumulated)
|
||||
if (nextText !== null) {
|
||||
accumulated = nextText
|
||||
params.onText(accumulated)
|
||||
continue
|
||||
}
|
||||
|
||||
if (event === 'thinking') {
|
||||
const text = readString(payload.text) ?? readString(payload.thinking)
|
||||
if (text) params.onStreamEvent({ type: 'thinking', text })
|
||||
continue
|
||||
}
|
||||
|
||||
if (event === 'tool') {
|
||||
const name = readString(payload.name) ?? undefined
|
||||
const phase = readString(payload.phase) ?? undefined
|
||||
params.onStreamEvent({ type: 'tool', name, phase, data: payload })
|
||||
continue
|
||||
}
|
||||
|
||||
if (event === 'done' || event === 'complete') {
|
||||
sawDone = true
|
||||
const state = readString(payload.state) ?? undefined
|
||||
const message = readString(payload.errorMessage) ?? readString(payload.message) ?? undefined
|
||||
const finalText = readDoneMessageText(payload)
|
||||
if (!accumulated && finalText) {
|
||||
accumulated = finalText
|
||||
params.onText(accumulated)
|
||||
}
|
||||
params.onStreamEvent({ type: 'done', state, message })
|
||||
if (state === 'error' && message) throw new Error(message)
|
||||
continue
|
||||
}
|
||||
|
||||
if (event === 'error') {
|
||||
const message = readString(payload.message) ?? 'Conductor stream error'
|
||||
params.onStreamEvent({ type: 'error', message })
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawDone && !accumulated) {
|
||||
throw new Error('Conductor stream closed without output')
|
||||
}
|
||||
|
||||
return { runId, sessionKey, text: accumulated }
|
||||
}
|
||||
|
||||
export function useConductorGateway() {
|
||||
const [initialMission] = useState<PersistedMission | null>(() => loadPersistedMission())
|
||||
const [missionId, setMissionId] = useState<string | null>(() => initialMission?.missionId ?? null)
|
||||
const [missionJobId, setMissionJobId] = useState<string | null>(() => initialMission?.missionJobId ?? null)
|
||||
const [phase, setPhase] = useState<MissionPhase>(() => initialMission?.phase ?? 'idle')
|
||||
const [goal, setGoal] = useState(() => initialMission?.goal ?? '')
|
||||
const [orchestratorSessionKey, setOrchestratorSessionKey] = useState<string | null>(() => initialMission?.workerKeys[0] ?? null)
|
||||
@@ -659,6 +911,7 @@ export function useConductorGateway() {
|
||||
const historySavedRef = useRef(false)
|
||||
const lastActivityAtRef = useRef<number>(Date.now())
|
||||
const lastWorkerSnapshotRef = useRef('')
|
||||
const portableStreamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['conductor', 'gateway', 'sessions'],
|
||||
@@ -666,6 +919,7 @@ export function useConductorGateway() {
|
||||
const payload = await fetchSessions()
|
||||
const sessions = Array.isArray(payload.sessions) ? payload.sessions : []
|
||||
const missionStartMs = missionStartedAt ? new Date(missionStartedAt).getTime() : 0
|
||||
const missionNeedles = buildMissionNeedles(goal)
|
||||
return sessions
|
||||
.filter((session) => {
|
||||
const label = readString(session.label) ?? ''
|
||||
@@ -696,6 +950,10 @@ export function useConductorGateway() {
|
||||
}
|
||||
}
|
||||
|
||||
if (missionStartMs > 0 && sessionMatchesMissionContext(session, missionStartMs, missionNeedles)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
.map(toWorker)
|
||||
@@ -736,28 +994,70 @@ export function useConductorGateway() {
|
||||
refetchInterval: false,
|
||||
})
|
||||
|
||||
const missionStatusQuery = useQuery({
|
||||
queryKey: ['conductor', 'mission-status', missionId],
|
||||
queryFn: async () => {
|
||||
if (!missionId) return null
|
||||
return fetchConductorMission(missionId)
|
||||
},
|
||||
enabled: Boolean(missionId) && phase !== 'idle',
|
||||
refetchInterval: phase === 'decomposing' || phase === 'running' ? 2_500 : false,
|
||||
})
|
||||
|
||||
const workers = sessionsQuery.data ?? []
|
||||
const activeWorkers = useMemo(
|
||||
() => workers.filter((worker) => worker.status === 'running' || worker.status === 'idle'),
|
||||
[workers],
|
||||
)
|
||||
const activeWorkers = useMemo(() => workers.filter((worker) => worker.status === 'running' || worker.status === 'idle'), [workers])
|
||||
const hasPersistedMission = initialMission !== null
|
||||
|
||||
useEffect(() => {
|
||||
const mission = missionStatusQuery.data
|
||||
if (!mission) return
|
||||
|
||||
const status = readString(mission.status)?.toLowerCase() ?? null
|
||||
const realSessionKey = extractSessionIdFromMission(mission)
|
||||
const lines = readMissionLines(mission)
|
||||
const missionLog = formatMissionLog(lines)
|
||||
|
||||
if (realSessionKey) {
|
||||
setOrchestratorSessionKey(realSessionKey)
|
||||
setMissionWorkerKeys((current) => {
|
||||
if (current.has(realSessionKey)) return current
|
||||
const next = new Set(current)
|
||||
next.add(realSessionKey)
|
||||
return next
|
||||
})
|
||||
setPlanText((current) => (current && !current.startsWith('Conductor mission') ? current : 'Orchestrator session attached. Tracking worker activity...'))
|
||||
lastActivityAtRef.current = Date.now()
|
||||
setTimeoutWarning(false)
|
||||
} else if (phase === 'decomposing' || phase === 'running') {
|
||||
setPlanText((current) => current || `Conductor mission ${status ?? 'running'}. Waiting for Hermes to report the session...`)
|
||||
}
|
||||
|
||||
if (missionLog) {
|
||||
setStreamText((current) => (current === missionLog ? current : missionLog))
|
||||
lastActivityAtRef.current = Date.now()
|
||||
setTimeoutWarning(false)
|
||||
}
|
||||
|
||||
if (isFailedMissionStatus(status)) {
|
||||
doneRef.current = true
|
||||
setStreamError(mission.error || 'Conductor mission failed')
|
||||
setCompletedAt((value) => value ?? new Date().toISOString())
|
||||
setPhase('complete')
|
||||
}
|
||||
}, [missionStatusQuery.data, phase])
|
||||
|
||||
const getMissionElapsedMs = (referenceTime = Date.now()) => {
|
||||
if (!missionStartedAt) return 0
|
||||
const startedMs = new Date(missionStartedAt).getTime()
|
||||
if (!Number.isFinite(startedMs)) return 0
|
||||
const pauseStartedMs = pauseStartedAt ? new Date(pauseStartedAt).getTime() : NaN
|
||||
const inFlightPausedMs =
|
||||
isPaused && Number.isFinite(pauseStartedMs) ? Math.max(0, referenceTime - pauseStartedMs) : 0
|
||||
const inFlightPausedMs = isPaused && Number.isFinite(pauseStartedMs) ? Math.max(0, referenceTime - pauseStartedMs) : 0
|
||||
return Math.max(0, referenceTime - startedMs - accumulatedPausedMs - inFlightPausedMs)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (missionWorkerLabels.size === 0 || workers.length === 0) return
|
||||
const matchedKeys = workers
|
||||
.filter((worker) => missionWorkerLabels.has(worker.label))
|
||||
.map((worker) => worker.key)
|
||||
const matchedKeys = workers.filter((worker) => missionWorkerLabels.has(worker.label)).map((worker) => worker.key)
|
||||
|
||||
if (matchedKeys.length === 0) return
|
||||
|
||||
@@ -806,9 +1106,7 @@ export function useConductorGateway() {
|
||||
useEffect(() => {
|
||||
if (phase !== 'running' && phase !== 'decomposing') return
|
||||
|
||||
const workerSnapshot = workers
|
||||
.map((worker) => `${worker.key}:${worker.updatedAt ?? ''}:${worker.totalTokens}:${worker.status}`)
|
||||
.join('|')
|
||||
const workerSnapshot = workers.map((worker) => `${worker.key}:${worker.updatedAt ?? ''}:${worker.totalTokens}:${worker.status}`).join('|')
|
||||
|
||||
if (workerSnapshot && workerSnapshot !== lastWorkerSnapshotRef.current) {
|
||||
lastWorkerSnapshotRef.current = workerSnapshot
|
||||
@@ -886,9 +1184,12 @@ export function useConductorGateway() {
|
||||
}
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const timer = window.setInterval(
|
||||
() => {
|
||||
void fetchAll()
|
||||
}, hasRunningWorkers ? 5_000 : 2_000)
|
||||
},
|
||||
hasRunningWorkers ? 5_000 : 2_000,
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -916,16 +1217,14 @@ export function useConductorGateway() {
|
||||
const worker = workers[index]
|
||||
if (!worker) return task
|
||||
const workerOutput = workerOutputs[worker.key] ?? null
|
||||
const newStatus: ConductorTask['status'] =
|
||||
worker.status === 'complete'
|
||||
? 'complete'
|
||||
: worker.status === 'stale'
|
||||
? 'failed'
|
||||
: worker.status === 'running'
|
||||
? 'running'
|
||||
: task.status
|
||||
const newStatus: ConductorTask['status'] = worker.status === 'complete' ? 'complete' : worker.status === 'stale' ? 'failed' : worker.status === 'running' ? 'running' : task.status
|
||||
if (task.workerKey === worker.key && task.status === newStatus && task.output === workerOutput) return task
|
||||
return { ...task, workerKey: worker.key, status: newStatus, output: workerOutput }
|
||||
return {
|
||||
...task,
|
||||
workerKey: worker.key,
|
||||
status: newStatus,
|
||||
output: workerOutput,
|
||||
}
|
||||
})
|
||||
const changed = updated.some((task, index) => task !== current[index])
|
||||
return changed ? updated : current
|
||||
@@ -938,7 +1237,7 @@ export function useConductorGateway() {
|
||||
useEffect(() => {
|
||||
if (phase !== 'complete' || !goal || !completedAt || !missionStartedAt) return
|
||||
|
||||
const missionId = `mission-${new Date(missionStartedAt).getTime()}`
|
||||
const missionHistoryId = `mission-${new Date(missionStartedAt).getTime()}`
|
||||
const outputPath = buildMissionOutputPath(workers, workerOutputs, tasks, streamText)
|
||||
const workerSummary = summarizeWorkers(workers)
|
||||
const outputText = buildMissionOutputText(workers, workerOutputs, streamText)
|
||||
@@ -963,7 +1262,7 @@ export function useConductorGateway() {
|
||||
}
|
||||
})
|
||||
const entry: MissionHistoryEntry = {
|
||||
id: missionId,
|
||||
id: missionHistoryId,
|
||||
goal,
|
||||
startedAt: missionStartedAt,
|
||||
completedAt,
|
||||
@@ -987,13 +1286,11 @@ export function useConductorGateway() {
|
||||
if (historySaveCountRef.current === 0) {
|
||||
historySavedRef.current = true
|
||||
setMissionHistory((current) => {
|
||||
if (current.some((e) => e.id === missionId)) return current
|
||||
if (current.some((e) => e.id === missionHistoryId)) return current
|
||||
return [entry, ...current].slice(0, MAX_HISTORY_ENTRIES)
|
||||
})
|
||||
} else {
|
||||
setMissionHistory((current) =>
|
||||
current.map((e) => (e.id === missionId ? entry : e)),
|
||||
)
|
||||
setMissionHistory((current) => current.map((e) => (e.id === missionHistoryId ? entry : e)))
|
||||
}
|
||||
historySaveCountRef.current += 1
|
||||
}, [phase, goal, completedAt, missionStartedAt, workers, streamError, workerOutputs, tasks, streamText])
|
||||
@@ -1011,6 +1308,8 @@ export function useConductorGateway() {
|
||||
}
|
||||
|
||||
persistMission({
|
||||
missionId,
|
||||
missionJobId,
|
||||
goal,
|
||||
phase,
|
||||
missionStartedAt,
|
||||
@@ -1026,7 +1325,24 @@ export function useConductorGateway() {
|
||||
completedAt,
|
||||
tasks,
|
||||
})
|
||||
}, [phase, goal, missionStartedAt, isPaused, pausedElapsedMs, accumulatedPausedMs, pauseStartedAt, completedAt, missionWorkerKeys, missionWorkerLabels, workerOutputs, streamText, planText, tasks])
|
||||
}, [
|
||||
missionId,
|
||||
missionJobId,
|
||||
phase,
|
||||
goal,
|
||||
missionStartedAt,
|
||||
isPaused,
|
||||
pausedElapsedMs,
|
||||
accumulatedPausedMs,
|
||||
pauseStartedAt,
|
||||
completedAt,
|
||||
missionWorkerKeys,
|
||||
missionWorkerLabels,
|
||||
workerOutputs,
|
||||
streamText,
|
||||
planText,
|
||||
tasks,
|
||||
])
|
||||
|
||||
const dismissTimeoutWarning = () => {
|
||||
lastActivityAtRef.current = Date.now()
|
||||
@@ -1035,7 +1351,11 @@ export function useConductorGateway() {
|
||||
|
||||
const clearMissionState = () => {
|
||||
doneRef.current = false
|
||||
portableStreamAbortRef.current?.abort()
|
||||
portableStreamAbortRef.current = null
|
||||
clearPersistedMission()
|
||||
setMissionId(null)
|
||||
setMissionJobId(null)
|
||||
setPhase('idle')
|
||||
setGoal('')
|
||||
setOrchestratorSessionKey(null)
|
||||
@@ -1070,6 +1390,8 @@ export function useConductorGateway() {
|
||||
lastWorkerSnapshotRef.current = ''
|
||||
setTimeoutWarning(false)
|
||||
setGoal(trimmed)
|
||||
setMissionId(null)
|
||||
setMissionJobId(null)
|
||||
setOrchestratorSessionKey(null)
|
||||
setStreamText('')
|
||||
setPlanText('')
|
||||
@@ -1087,10 +1409,29 @@ export function useConductorGateway() {
|
||||
setSelectedHistoryEntry(null)
|
||||
seenToolCallRef.current = false
|
||||
historySavedRef.current = false
|
||||
setMissionStartedAt(new Date().toISOString())
|
||||
const startedAt = new Date().toISOString()
|
||||
setMissionStartedAt(startedAt)
|
||||
setPhase('decomposing')
|
||||
persistMission({
|
||||
missionId: null,
|
||||
missionJobId: null,
|
||||
goal: trimmed,
|
||||
phase: 'decomposing',
|
||||
missionStartedAt: startedAt,
|
||||
isPaused: false,
|
||||
pausedElapsedMs: 0,
|
||||
accumulatedPausedMs: 0,
|
||||
pauseStartedAt: null,
|
||||
workerKeys: [],
|
||||
workerLabels: [],
|
||||
workerOutputs: {},
|
||||
streamText: '',
|
||||
planText: '',
|
||||
completedAt: null,
|
||||
tasks: [],
|
||||
})
|
||||
|
||||
// Spawn a dedicated orchestrator session via the server
|
||||
// Spawn a Conductor mission via the server.
|
||||
const response = await fetch('/api/conductor-spawn', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -1102,23 +1443,92 @@ export function useConductorGateway() {
|
||||
throw new Error(text || `Spawn failed (${response.status})`)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as {
|
||||
ok?: boolean
|
||||
sessionKey?: string
|
||||
sessionKeyPrefix?: string
|
||||
jobId?: string
|
||||
error?: string
|
||||
}
|
||||
if (!result.ok || !result.sessionKey) {
|
||||
const result = (await response.json()) as ConductorSpawnResponse
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error ?? 'Failed to spawn orchestrator')
|
||||
}
|
||||
|
||||
// Claude runs cron jobs in sessions keyed `cron_<jobId>_<timestamp>`.
|
||||
// The session doesn't exist yet at spawn time — the cron loop creates
|
||||
// it within ~5s. Poll /api/sessions until we find one matching the
|
||||
// prefix, then track it as the orchestrator session.
|
||||
const orchestratorKey = result.sessionKey
|
||||
if (result.mode === 'portable' || result.prompt) {
|
||||
const prompt = typeof result.prompt === 'string' ? result.prompt : ''
|
||||
if (!prompt.trim()) throw new Error('Portable conductor response did not include a prompt')
|
||||
|
||||
const portableSessionKey = result.sessionKey?.trim() || result.jobName?.trim() || `conductor-${Date.now()}`
|
||||
const portableFriendlyId = result.jobName?.trim() || portableSessionKey
|
||||
setMissionId(null)
|
||||
setMissionJobId(null)
|
||||
setOrchestratorSessionKey(portableSessionKey)
|
||||
setMissionWorkerKeys((current) => {
|
||||
if (current.has(portableSessionKey)) return current
|
||||
const next = new Set(current)
|
||||
next.add(portableSessionKey)
|
||||
return next
|
||||
})
|
||||
setPlanText('Conductor portable mission launched. Streaming orchestrator output...')
|
||||
setPhase('running')
|
||||
|
||||
const abortController = new AbortController()
|
||||
portableStreamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const streamResult = await streamPortableConductorMission({
|
||||
sessionKey: portableSessionKey,
|
||||
friendlyId: portableFriendlyId,
|
||||
prompt,
|
||||
model: settings.orchestratorModel || undefined,
|
||||
signal: abortController.signal,
|
||||
onSessionResolved: (resolvedSessionKey) => {
|
||||
setOrchestratorSessionKey(resolvedSessionKey)
|
||||
setMissionWorkerKeys((current) => {
|
||||
if (current.has(resolvedSessionKey)) return current
|
||||
const next = new Set(current)
|
||||
next.add(resolvedSessionKey)
|
||||
return next
|
||||
})
|
||||
lastActivityAtRef.current = Date.now()
|
||||
setTimeoutWarning(false)
|
||||
},
|
||||
onText: (text) => {
|
||||
setStreamText(text)
|
||||
setPlanText(text)
|
||||
lastActivityAtRef.current = Date.now()
|
||||
setTimeoutWarning(false)
|
||||
},
|
||||
onStreamEvent: (event) => {
|
||||
appendStreamEvent(setStreamEvents, event)
|
||||
lastActivityAtRef.current = Date.now()
|
||||
setTimeoutWarning(false)
|
||||
},
|
||||
})
|
||||
|
||||
if (streamResult.text.trim()) {
|
||||
setStreamText(streamResult.text)
|
||||
setPlanText(streamResult.text)
|
||||
}
|
||||
doneRef.current = true
|
||||
setCompletedAt((value) => value ?? new Date().toISOString())
|
||||
setPhase('complete')
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
throw error
|
||||
} finally {
|
||||
if (portableStreamAbortRef.current === abortController) {
|
||||
portableStreamAbortRef.current = null
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.sessionKey && !result.sessionKeyPrefix && !result.missionId && !result.jobId) {
|
||||
throw new Error(result.error ?? 'Failed to spawn orchestrator')
|
||||
}
|
||||
|
||||
const nextMissionId = result.missionId ?? null
|
||||
setMissionId(nextMissionId)
|
||||
setMissionJobId(result.jobId ?? null)
|
||||
|
||||
const orchestratorKey = result.sessionKey ?? null
|
||||
const prefix = result.sessionKeyPrefix
|
||||
if (orchestratorKey) {
|
||||
setOrchestratorSessionKey(orchestratorKey)
|
||||
setMissionWorkerKeys((current) => {
|
||||
if (current.has(orchestratorKey)) return current
|
||||
@@ -1126,17 +1536,16 @@ export function useConductorGateway() {
|
||||
next.add(orchestratorKey)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (prefix) {
|
||||
if (prefix && orchestratorKey) {
|
||||
// Async: resolve the placeholder to the real session key once it exists.
|
||||
const resolveOrchestrator = async () => {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
try {
|
||||
const sessionPayload = await fetchSessions()
|
||||
const sessions = Array.isArray(sessionPayload.sessions)
|
||||
? sessionPayload.sessions
|
||||
: []
|
||||
const sessions = Array.isArray(sessionPayload.sessions) ? sessionPayload.sessions : []
|
||||
const match = sessions.find((session) => {
|
||||
const key = typeof session.key === 'string' ? session.key : ''
|
||||
return key.startsWith(prefix)
|
||||
@@ -1160,7 +1569,11 @@ export function useConductorGateway() {
|
||||
}
|
||||
|
||||
// Transition to running — the orchestrator is alive, workers will appear via polling
|
||||
setPlanText(`Orchestrator spawned. Decomposing mission and spawning workers...`)
|
||||
setPlanText(
|
||||
nextMissionId
|
||||
? 'Conductor mission launched. Waiting for Hermes session and worker activity...'
|
||||
: 'Orchestrator spawned. Decomposing mission and spawning workers...',
|
||||
)
|
||||
setPhase('running')
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -1203,8 +1616,7 @@ export function useConductorGateway() {
|
||||
}
|
||||
|
||||
const pauseStartedMs = pauseStartedAt ? new Date(pauseStartedAt).getTime() : NaN
|
||||
const additionalPausedMs =
|
||||
Number.isFinite(pauseStartedMs) ? Math.max(0, now - pauseStartedMs) : 0
|
||||
const additionalPausedMs = Number.isFinite(pauseStartedMs) ? Math.max(0, now - pauseStartedMs) : 0
|
||||
setAccumulatedPausedMs((current) => current + additionalPausedMs)
|
||||
setPauseStartedAt(null)
|
||||
setIsPaused(false)
|
||||
@@ -1213,13 +1625,16 @@ export function useConductorGateway() {
|
||||
})
|
||||
|
||||
const stopMission = async () => {
|
||||
portableStreamAbortRef.current?.abort()
|
||||
portableStreamAbortRef.current = null
|
||||
const sessionKeys = [...new Set([...missionWorkerKeys, ...workers.map((worker) => worker.key)])]
|
||||
const missionIds = missionId ? [missionId] : []
|
||||
|
||||
try {
|
||||
await fetch('/api/conductor-stop', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionKeys }),
|
||||
body: JSON.stringify({ sessionKeys, missionIds }),
|
||||
})
|
||||
} catch {
|
||||
// Best effort cleanup.
|
||||
@@ -1238,7 +1653,10 @@ export function useConductorGateway() {
|
||||
const currentGoal = goal
|
||||
resetMission()
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await sendMission.mutateAsync({ nextGoal: currentGoal, settings: conductorSettings })
|
||||
await sendMission.mutateAsync({
|
||||
nextGoal: currentGoal,
|
||||
settings: conductorSettings,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user