diff --git a/src/app/api/gateways/health/route.ts b/src/app/api/gateways/health/route.ts index df2cad0a..3f58d33e 100644 --- a/src/app/api/gateways/health/route.ts +++ b/src/app/api/gateways/health/route.ts @@ -2,6 +2,26 @@ import { NextRequest, NextResponse } from "next/server" import { requireRole } from "@/lib/auth" import { getDatabase } from "@/lib/db" +function ensureGatewaysTable(db: ReturnType) { + db.exec(` + CREATE TABLE IF NOT EXISTS gateways ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + host TEXT NOT NULL DEFAULT '127.0.0.1', + port INTEGER NOT NULL DEFAULT 18789, + token TEXT NOT NULL DEFAULT '', + is_primary INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'unknown', + last_seen INTEGER, + latency INTEGER, + sessions_count INTEGER NOT NULL DEFAULT 0, + agents_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `) +} + interface GatewayEntry { id: number name: string @@ -144,6 +164,7 @@ export async function POST(request: NextRequest) { if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const db = getDatabase() + ensureGatewaysTable(db) const gateways = db.prepare("SELECT * FROM gateways ORDER BY is_primary DESC, name ASC").all() as GatewayEntry[] // Build set of user-configured gateway hosts so the SSRF filter allows them diff --git a/src/app/api/sessions/[id]/control/route.ts b/src/app/api/sessions/[id]/control/route.ts index 2500fb8e..34994fbb 100644 --- a/src/app/api/sessions/[id]/control/route.ts +++ b/src/app/api/sessions/[id]/control/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { requireRole } from '@/lib/auth' -import { runClawdbot } from '@/lib/command' +import { runGatewayToolCall } from '@/lib/command' import { db_helpers } from '@/lib/db' import { mutationLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' @@ -38,17 +38,19 @@ export async function POST( let result if (action === 'terminate') { - result = await runClawdbot( - ['-c', `sessions_kill("${id}")`], - { timeoutMs: 10000 } + result = await runGatewayToolCall( + 'sessions_kill', + { sessionKey: id }, + { timeoutMs: 15000 } ) } else { const message = action === 'monitor' ? JSON.stringify({ type: 'control', action: 'monitor' }) : JSON.stringify({ type: 'control', action: 'pause' }) - result = await runClawdbot( - ['-c', `sessions_send("${id}", ${JSON.stringify(message)})`], - { timeoutMs: 10000 } + result = await runGatewayToolCall( + 'sessions_send', + { sessionKey: id, message }, + { timeoutMs: 15000 } ) } diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index d7776200..7f8e955e 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -5,7 +5,7 @@ import { scanCodexSessions } from '@/lib/codex-sessions' import { scanHermesSessions } from '@/lib/hermes-sessions' import { getDatabase, db_helpers } from '@/lib/db' import { requireRole } from '@/lib/auth' -import { runClawdbot } from '@/lib/command' +import { runGatewayToolCall } from '@/lib/command' import { mutationLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' @@ -68,7 +68,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid session key' }, { status: 400 }) } - let rpcFn: string + let toolName: string + let toolArgs: Record let logDetail: string switch (action) { @@ -77,7 +78,8 @@ export async function POST(request: NextRequest) { if (!VALID_THINKING_LEVELS.includes(level)) { return NextResponse.json({ error: `Invalid thinking level. Must be: ${VALID_THINKING_LEVELS.join(', ')}` }, { status: 400 }) } - rpcFn = `session_setThinking("${sessionKey}", "${level}")` + toolName = 'session_setThinking' + toolArgs = { sessionKey, level } logDetail = `Set thinking=${level} on ${sessionKey}` break } @@ -86,7 +88,8 @@ export async function POST(request: NextRequest) { if (!VALID_VERBOSE_LEVELS.includes(level)) { return NextResponse.json({ error: `Invalid verbose level. Must be: ${VALID_VERBOSE_LEVELS.join(', ')}` }, { status: 400 }) } - rpcFn = `session_setVerbose("${sessionKey}", "${level}")` + toolName = 'session_setVerbose' + toolArgs = { sessionKey, level } logDetail = `Set verbose=${level} on ${sessionKey}` break } @@ -95,7 +98,8 @@ export async function POST(request: NextRequest) { if (!VALID_REASONING_LEVELS.includes(level)) { return NextResponse.json({ error: `Invalid reasoning level. Must be: ${VALID_REASONING_LEVELS.join(', ')}` }, { status: 400 }) } - rpcFn = `session_setReasoning("${sessionKey}", "${level}")` + toolName = 'session_setReasoning' + toolArgs = { sessionKey, level } logDetail = `Set reasoning=${level} on ${sessionKey}` break } @@ -104,7 +108,8 @@ export async function POST(request: NextRequest) { if (typeof label !== 'string' || label.length > 100) { return NextResponse.json({ error: 'Label must be a string up to 100 characters' }, { status: 400 }) } - rpcFn = `session_setLabel("${sessionKey}", ${JSON.stringify(label)})` + toolName = 'session_setLabel' + toolArgs = { sessionKey, label } logDetail = `Set label="${label}" on ${sessionKey}` break } @@ -112,7 +117,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid action. Must be: set-thinking, set-verbose, set-reasoning, set-label' }, { status: 400 }) } - const result = await runClawdbot(['-c', rpcFn], { timeoutMs: 10000 }) + const result = await runGatewayToolCall(toolName, toolArgs, { timeoutMs: 15000 }) db_helpers.logActivity( 'session_control', @@ -145,9 +150,10 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Invalid session key' }, { status: 400 }) } - const result = await runClawdbot( - ['-c', `session_delete("${sessionKey}")`], - { timeoutMs: 10000 } + const result = await runGatewayToolCall( + 'session_delete', + { sessionKey }, + { timeoutMs: 15000 } ) db_helpers.logActivity( diff --git a/src/app/api/spawn/route.ts b/src/app/api/spawn/route.ts index 68106f99..e4e49f66 100644 --- a/src/app/api/spawn/route.ts +++ b/src/app/api/spawn/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { runClawdbot } from '@/lib/command' +import { runGatewayToolCall } from '@/lib/command' import { requireRole } from '@/lib/auth' import { config } from '@/lib/config' import { readdir, readFile, stat } from 'fs/promises' @@ -15,8 +15,7 @@ function getPreferredToolsProfile(): string { } async function runSpawnWithCompatibility(spawnPayload: Record) { - const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})` - return runClawdbot(['-c', commandArg], { timeoutMs: 10000 }) + return runGatewayToolCall('sessions_spawn', spawnPayload, { timeoutMs: 30000 }) } export async function POST(request: NextRequest) { diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 0ca1fb05..7c80f6c8 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -3,7 +3,7 @@ import net from 'node:net' import os from 'node:os' import { existsSync, statSync } from 'node:fs' import path from 'node:path' -import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command' +import { runCommand, runOpenClaw } from '@/lib/command' import { config } from '@/lib/config' import { getDatabase } from '@/lib/db' import { getAllGatewaySessions, getAgentLiveStatuses } from '@/lib/sessions' @@ -409,12 +409,7 @@ async function getGatewayStatus() { const { stdout } = await runOpenClaw(['--version'], { timeoutMs: 3000 }) gatewayStatus.version = stdout.trim() } catch (error) { - try { - const { stdout } = await runClawdbot(['--version'], { timeoutMs: 3000 }) - gatewayStatus.version = stdout.trim() - } catch (innerError) { - gatewayStatus.version = 'unknown' - } + gatewayStatus.version = 'unknown' } return gatewayStatus diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index ed601ae9..12b983db 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -95,6 +95,29 @@ const STATUS_COLUMN_KEYS = [ { key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' }, ] +/** Fetch active gateway sessions for a given agent name. */ +function useAgentSessions(agentName: string | undefined) { + const [sessions, setSessions] = useState>([]) + useEffect(() => { + if (!agentName) { setSessions([]); return } + let cancelled = false + fetch('/api/sessions?include_local=1') + .then(r => r.json()) + .then(data => { + if (cancelled) return + const all = (data.sessions || []) as Array<{ key: string; id: string; agent?: string; channel?: string; label?: string; active?: boolean }> + const filtered = all.filter(s => + s.agent?.toLowerCase() === agentName.toLowerCase() || + s.key?.toLowerCase().includes(agentName.toLowerCase()) + ) + setSessions(filtered.map(s => ({ key: s.key, id: s.id, channel: s.channel, label: s.label }))) + }) + .catch(() => { if (!cancelled) setSessions([]) }) + return () => { cancelled = true } + }, [agentName]) + return sessions +} + const priorityColors: Record = { low: 'border-l-green-500', medium: 'border-l-yellow-500', @@ -1818,8 +1841,10 @@ function CreateTaskModal({ project_id: projects[0]?.id ? String(projects[0].id) : '', assigned_to: '', tags: '', + target_session: '', }) const t = useTranslations('taskBoard') + const agentSessions = useAgentSessions(formData.assigned_to || undefined) const [isRecurring, setIsRecurring] = useState(false) const [scheduleInput, setScheduleInput] = useState('') const [parsedSchedule, setParsedSchedule] = useState<{ cronExpr: string; humanReadable: string } | null>(null) @@ -1861,6 +1886,9 @@ function CreateTaskModal({ parent_task_id: null, } } + if (formData.target_session) { + metadata.target_session = formData.target_session + } try { const response = await fetch('/api/tasks', { @@ -1960,7 +1988,7 @@ function CreateTaskModal({ + {formData.assigned_to && agentSessions.length > 0 && ( +
+ + +

Send task to an existing agent session instead of creating a new one.

+
+ )} +
{ e.preventDefault() @@ -2070,6 +2120,14 @@ function EditTaskModal({ if (!formData.title.trim()) return try { + const existingMeta = task.metadata || {} + const updatedMeta = { ...existingMeta } + if (formData.target_session) { + updatedMeta.target_session = formData.target_session + } else { + delete updatedMeta.target_session + } + const response = await fetch(`/api/tasks/${task.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -2077,7 +2135,8 @@ function EditTaskModal({ ...formData, project_id: formData.project_id ? Number(formData.project_id) : undefined, tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [], - assigned_to: formData.assigned_to || undefined + assigned_to: formData.assigned_to || undefined, + metadata: updatedMeta, }) }) @@ -2182,7 +2241,7 @@ function EditTaskModal({
+ {formData.assigned_to && agentSessions.length > 0 && ( +
+ + +

Send task to an existing agent session instead of creating a new one.

+
+ )} +
` pattern which relied on the + * now-removed `-c` flag. Instead, sends a structured message through + * `openclaw gateway call agent` instructing the agent to invoke the + * requested tool with the given arguments. + * + * @param toolName - Name of the agent tool (e.g. `sessions_spawn`). + * @param toolArgs - Plain object with tool arguments. + * @param options - Extra options (timeoutMs, cwd, etc.). + * @returns The command result with stdout/stderr from the gateway. + */ +export async function runGatewayToolCall( + toolName: string, + toolArgs: Record, + options: CommandOptions = {} +): Promise { + const idempotencyKey = `mc-${toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const message = + `You MUST call the tool "${toolName}" with exactly these arguments and return the result. ` + + `Do NOT add commentary. Arguments: ${JSON.stringify(toolArgs)}` + + const params = JSON.stringify({ + message, + sessionId: `mc-rpc-${toolName}`, + idempotencyKey, + deliver: false, + }) + + const timeoutMs = options.timeoutMs || 30000 + + return runOpenClaw( + [ + 'gateway', 'call', 'agent', + '--expect-final', + '--timeout', String(timeoutMs), + '--params', params, + '--json', + ], + { ...options, timeoutMs: timeoutMs + 5000 } + ) +} diff --git a/src/lib/task-dispatch.ts b/src/lib/task-dispatch.ts index ddf402d7..f3875be8 100644 --- a/src/lib/task-dispatch.ts +++ b/src/lib/task-dispatch.ts @@ -1,5 +1,6 @@ import { getDatabase, db_helpers } from './db' import { runOpenClaw } from './command' +import { callOpenClawGateway } from './openclaw-gateway' import { eventBus } from './event-bus' import { logger } from './logger' @@ -425,35 +426,72 @@ export async function dispatchAssignedTasks(): Promise<{ ok: boolean; message: s const prompt = buildTaskPrompt(task, rejectionFeedback) - // Step 1: Invoke via gateway - const gatewayAgentId = resolveGatewayAgentId(task) - const dispatchModel = classifyTaskModel(task) - const invokeParams: Record = { - message: prompt, - agentId: gatewayAgentId, - idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, - deliver: false, - } - // Route to appropriate model tier based on task complexity. - // null = no override, agent uses its own configured default model. - if (dispatchModel) invokeParams.model = dispatchModel - - // Use --expect-final to block until the agent completes and returns the full - // response payload (result.payloads[0].text). The two-step agent → agent.wait - // pattern only returns lifecycle metadata and never includes the agent's text. - const finalResult = await runOpenClaw( - ['gateway', 'call', 'agent', '--expect-final', '--timeout', '120000', '--params', JSON.stringify(invokeParams), '--json'], - { timeoutMs: 125_000 } - ) - const finalPayload = parseGatewayJson(finalResult.stdout) - ?? parseGatewayJson(String((finalResult as any)?.stderr || '')) - - const agentResponse = parseAgentResponse( - finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout - ) - if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) { - agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId - } + // Check if task has a target session specified in metadata + const taskMeta = (() => { + try { + const row = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(task.id) as { metadata: string } | undefined + return row?.metadata ? JSON.parse(row.metadata) : {} + } catch { return {} } + })() + const targetSession: string | null = typeof taskMeta?.target_session === 'string' && taskMeta.target_session + ? taskMeta.target_session + : null + + let agentResponse: AgentResponseParsed + + if (targetSession) { + // Dispatch to a specific existing session via chat.send + logger.info({ taskId: task.id, targetSession, agent: task.agent_name }, 'Dispatching task to targeted session') + const sendResult = await callOpenClawGateway( + 'chat.send', + { + sessionKey: targetSession, + message: prompt, + idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, + deliver: false, + }, + 125_000, + ) + const status = String(sendResult?.status || '').toLowerCase() + if (status !== 'started' && status !== 'ok' && status !== 'in_flight') { + throw new Error(`chat.send to session ${targetSession} returned status: ${status}`) + } + // chat.send is fire-and-forget; we record the session but won't get inline response text + agentResponse = { + text: `Task dispatched to existing session ${targetSession}. The agent will process it within that session context.`, + sessionId: sendResult?.runId || targetSession, + } + } else { + // Step 1: Invoke via gateway (new session) + const gatewayAgentId = resolveGatewayAgentId(task) + const dispatchModel = classifyTaskModel(task) + const invokeParams: Record = { + message: prompt, + agentId: gatewayAgentId, + idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, + deliver: false, + } + // Route to appropriate model tier based on task complexity. + // null = no override, agent uses its own configured default model. + if (dispatchModel) invokeParams.model = dispatchModel + + // Use --expect-final to block until the agent completes and returns the full + // response payload (result.payloads[0].text). The two-step agent → agent.wait + // pattern only returns lifecycle metadata and never includes the agent's text. + const finalResult = await runOpenClaw( + ['gateway', 'call', 'agent', '--expect-final', '--timeout', '120000', '--params', JSON.stringify(invokeParams), '--json'], + { timeoutMs: 125_000 } + ) + const finalPayload = parseGatewayJson(finalResult.stdout) + ?? parseGatewayJson(String((finalResult as any)?.stderr || '')) + + agentResponse = parseAgentResponse( + finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout + ) + if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) { + agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId + } + } // end else (new session dispatch) if (!agentResponse.text) { throw new Error('Agent returned empty response') diff --git a/src/store/index.ts b/src/store/index.ts index 658343ff..c2ea23e4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,6 +12,8 @@ type DashboardLayoutUpdater = string[] | null | ((current: string[] | null) => s export interface Session { id: string key: string + agent?: string + channel?: string kind: string age: string model: string