From f129aad70364a393555a8cf5791e37cc280be69a Mon Sep 17 00:00:00 2001 From: Jan Clawdbot Stratil Date: Sun, 15 Mar 2026 18:01:17 +0100 Subject: [PATCH 1/3] feat: add task session targeting - dispatch tasks to specific agent sessions (#395) When assigning a task to an agent, users can now optionally select an existing session to dispatch the task to instead of creating a new one. Changes: - task-dispatch.ts: When target_session is set in task metadata, use gateway chat.send to dispatch to that specific session instead of creating a new one via call agent - task-board-panel.tsx: Add session selector dropdown in both Create and Edit task modals that appears when an agent is selected and has active sessions - store/index.ts: Add agent and channel fields to Session interface Closes #395 --- src/components/panels/task-board-panel.tsx | 85 ++++++++++++++++++- src/lib/task-dispatch.ts | 96 +++++++++++++++------- src/store/index.ts | 2 + 3 files changed, 151 insertions(+), 32 deletions(-) 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.

+
+ )} +
= { - 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 From 3204e34a7c3977356067abd1eccc3a942994dede Mon Sep 17 00:00:00 2001 From: Jan Clawdbot Stratil Date: Sun, 15 Mar 2026 16:55:25 +0100 Subject: [PATCH 2/3] fix(gateway): ensure gateways table exists before health probe The gateways table is created lazily by the gateways API (ensureTable). The health route was querying it directly without CREATE IF NOT EXISTS, causing SqliteError: no such table: gateways in fresh databases (E2E tests, Docker first-boot). Add ensureGatewaysTable() inline to mirror the pattern in route.ts. --- src/app/api/gateways/health/route.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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 From c952100d82d6516bd43a026c28c2c04469c018c2 Mon Sep 17 00:00:00 2001 From: Jan Clawdbot Stratil Date: Sun, 15 Mar 2026 22:41:11 +0100 Subject: [PATCH 3/3] fix: replace legacy clawdbot -c RPC with gateway agent calls (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `clawdbot` binary no longer exists and `openclaw` does not support the `-c` flag. All `runClawdbot(['-c', rpcFn])` calls now go through a new `runGatewayToolCall()` helper that invokes tools via `openclaw gateway call agent --expect-final`. Affected routes: - POST /api/spawn — sessions_spawn - POST /api/sessions — session_setThinking/setVerbose/setReasoning/setLabel - DELETE /api/sessions — session_delete - POST /api/sessions/[id]/control — sessions_kill/sessions_send - GET /api/status — removed clawdbot --version fallback Closes #401 --- src/app/api/sessions/[id]/control/route.ts | 16 ++++---- src/app/api/sessions/route.ts | 26 ++++++++----- src/app/api/spawn/route.ts | 5 +-- src/app/api/status/route.ts | 9 +---- src/lib/command.ts | 44 ++++++++++++++++++++++ 5 files changed, 73 insertions(+), 27 deletions(-) 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/lib/command.ts b/src/lib/command.ts index 70313e97..91f48da3 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -84,3 +84,47 @@ export function runClawdbot(args: string[], options: CommandOptions = {}) { cwd: options.cwd || config.openclawStateDir || process.cwd() }) } + +/** + * Execute an agent tool call via the OpenClaw gateway. + * + * Replaces the legacy `clawdbot -c ` 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 } + ) +}