Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/app/api/gateways/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@ import { NextRequest, NextResponse } from "next/server"
import { requireRole } from "@/lib/auth"
import { getDatabase } from "@/lib/db"

function ensureGatewaysTable(db: ReturnType<typeof getDatabase>) {
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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions src/app/api/sessions/[id]/control/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 }
)
}

Expand Down
26 changes: 16 additions & 10 deletions src/app/api/sessions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string, unknown>
let logDetail: string

switch (action) {
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -104,15 +108,16 @@ 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
}
default:
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',
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 2 additions & 3 deletions src/app/api/spawn/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,8 +15,7 @@ function getPreferredToolsProfile(): string {
}

async function runSpawnWithCompatibility(spawnPayload: Record<string, unknown>) {
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) {
Expand Down
9 changes: 2 additions & 7 deletions src/app/api/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
85 changes: 82 additions & 3 deletions src/components/panels/task-board-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ key: string; id: string; channel?: string; label?: string }>>([])
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<string, string> = {
low: 'border-l-green-500',
medium: 'border-l-yellow-500',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -1960,7 +1988,7 @@ function CreateTaskModal({
<select
id="create-assignee"
value={formData.assigned_to}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value, target_session: '' }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">{t('unassigned')}</option>
Expand All @@ -1972,6 +2000,26 @@ function CreateTaskModal({
</select>
</div>

{formData.assigned_to && agentSessions.length > 0 && (
<div>
<label htmlFor="create-target-session" className="block text-sm text-muted-foreground mb-1">Target Session</label>
<select
id="create-target-session"
value={formData.target_session}
onChange={(e) => setFormData(prev => ({ ...prev, target_session: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">New session (default)</option>
{agentSessions.map(s => (
<option key={s.key} value={s.key}>
{s.label || s.channel || s.key}
</option>
))}
</select>
<p className="text-[11px] text-muted-foreground mt-1">Send task to an existing agent session instead of creating a new one.</p>
</div>
)}

<div>
<label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label>
<input
Expand Down Expand Up @@ -2061,23 +2109,34 @@ function EditTaskModal({
project_id: task.project_id ? String(task.project_id) : (projects[0]?.id ? String(projects[0].id) : ''),
assigned_to: task.assigned_to || '',
tags: task.tags ? task.tags.join(', ') : '',
target_session: task.metadata?.target_session || '',
})
const mentionTargets = useMentionTargets()
const agentSessions = useAgentSessions(formData.assigned_to || undefined)

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()

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' },
body: JSON.stringify({
...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,
})
})

Expand Down Expand Up @@ -2182,7 +2241,7 @@ function EditTaskModal({
<select
id="edit-assignee"
value={formData.assigned_to}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value, target_session: '' }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">{t('unassigned')}</option>
Expand All @@ -2194,6 +2253,26 @@ function EditTaskModal({
</select>
</div>

{formData.assigned_to && agentSessions.length > 0 && (
<div>
<label htmlFor="edit-target-session" className="block text-sm text-muted-foreground mb-1">Target Session</label>
<select
id="edit-target-session"
value={formData.target_session}
onChange={(e) => setFormData(prev => ({ ...prev, target_session: e.target.value }))}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="">New session (default)</option>
{agentSessions.map(s => (
<option key={s.key} value={s.key}>
{s.label || s.channel || s.key}
</option>
))}
</select>
<p className="text-[11px] text-muted-foreground mt-1">Send task to an existing agent session instead of creating a new one.</p>
</div>
)}

<div>
<label htmlFor="edit-tags" className="block text-sm text-muted-foreground mb-1">{t('fieldTags')}</label>
<input
Expand Down
44 changes: 44 additions & 0 deletions src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <rpcFn>` 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<string, unknown>,
options: CommandOptions = {}
): Promise<CommandResult> {
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 }
)
}
Loading
Loading