nyk commited on
Commit
608b8db
·
unverified ·
1 Parent(s): 1f1a802

fix: migrate clawdbot CLI calls to gateway RPC and improve session labels (#407)

Browse files

Replace all `runClawdbot(['-c', ...])` invocations with
`callOpenClawGateway()` RPC calls in session control, spawn, and delete
routes. This eliminates the deprecated CLI dependency and uses the
structured gateway protocol instead.

Also add `formatSessionLabel()` helper and `useAgentSessions()` hook to
task-board-panel for better session dropdown display labels.

Closes #401, closes #406

src/app/api/sessions/[id]/control/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
- import { runClawdbot } from '@/lib/command'
4
  import { db_helpers } from '@/lib/db'
5
  import { mutationLimiter } from '@/lib/rate-limit'
6
  import { logger } from '@/lib/logger'
@@ -36,20 +36,14 @@ export async function POST(
36
  )
37
  }
38
 
39
- let result
40
  if (action === 'terminate') {
41
- result = await runClawdbot(
42
- ['-c', `sessions_kill("${id}")`],
43
- { timeoutMs: 10000 }
44
- )
45
  } else {
46
  const message = action === 'monitor'
47
- ? JSON.stringify({ type: 'control', action: 'monitor' })
48
- : JSON.stringify({ type: 'control', action: 'pause' })
49
- result = await runClawdbot(
50
- ['-c', `sessions_send("${id}", ${JSON.stringify(message)})`],
51
- { timeoutMs: 10000 }
52
- )
53
  }
54
 
55
  db_helpers.logActivity(
@@ -65,7 +59,7 @@ export async function POST(
65
  success: true,
66
  action,
67
  session: id,
68
- stdout: result.stdout.trim(),
69
  })
70
  } catch (error: any) {
71
  logger.error({ err: error }, 'Session control error')
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
+ import { callOpenClawGateway } from '@/lib/openclaw-gateway'
4
  import { db_helpers } from '@/lib/db'
5
  import { mutationLimiter } from '@/lib/rate-limit'
6
  import { logger } from '@/lib/logger'
 
36
  )
37
  }
38
 
39
+ let result: unknown
40
  if (action === 'terminate') {
41
+ result = await callOpenClawGateway('sessions_kill', { sessionKey: id }, 10_000)
 
 
 
42
  } else {
43
  const message = action === 'monitor'
44
+ ? { type: 'control', action: 'monitor' }
45
+ : { type: 'control', action: 'pause' }
46
+ result = await callOpenClawGateway('sessions_send', { sessionKey: id, message }, 10_000)
 
 
 
47
  }
48
 
49
  db_helpers.logActivity(
 
59
  success: true,
60
  action,
61
  session: id,
62
+ result,
63
  })
64
  } catch (error: any) {
65
  logger.error({ err: error }, 'Session control error')
src/app/api/sessions/route.ts CHANGED
@@ -5,7 +5,7 @@ import { scanCodexSessions } from '@/lib/codex-sessions'
5
  import { scanHermesSessions } from '@/lib/hermes-sessions'
6
  import { getDatabase, db_helpers } from '@/lib/db'
7
  import { requireRole } from '@/lib/auth'
8
- import { runClawdbot } from '@/lib/command'
9
  import { mutationLimiter } from '@/lib/rate-limit'
10
  import { logger } from '@/lib/logger'
11
 
@@ -60,7 +60,8 @@ export async function POST(request: NextRequest) {
60
  return NextResponse.json({ error: 'Invalid session key' }, { status: 400 })
61
  }
62
 
63
- let rpcFn: string
 
64
  let logDetail: string
65
 
66
  switch (action) {
@@ -69,7 +70,8 @@ export async function POST(request: NextRequest) {
69
  if (!VALID_THINKING_LEVELS.includes(level)) {
70
  return NextResponse.json({ error: `Invalid thinking level. Must be: ${VALID_THINKING_LEVELS.join(', ')}` }, { status: 400 })
71
  }
72
- rpcFn = `session_setThinking("${sessionKey}", "${level}")`
 
73
  logDetail = `Set thinking=${level} on ${sessionKey}`
74
  break
75
  }
@@ -78,7 +80,8 @@ export async function POST(request: NextRequest) {
78
  if (!VALID_VERBOSE_LEVELS.includes(level)) {
79
  return NextResponse.json({ error: `Invalid verbose level. Must be: ${VALID_VERBOSE_LEVELS.join(', ')}` }, { status: 400 })
80
  }
81
- rpcFn = `session_setVerbose("${sessionKey}", "${level}")`
 
82
  logDetail = `Set verbose=${level} on ${sessionKey}`
83
  break
84
  }
@@ -87,7 +90,8 @@ export async function POST(request: NextRequest) {
87
  if (!VALID_REASONING_LEVELS.includes(level)) {
88
  return NextResponse.json({ error: `Invalid reasoning level. Must be: ${VALID_REASONING_LEVELS.join(', ')}` }, { status: 400 })
89
  }
90
- rpcFn = `session_setReasoning("${sessionKey}", "${level}")`
 
91
  logDetail = `Set reasoning=${level} on ${sessionKey}`
92
  break
93
  }
@@ -96,7 +100,8 @@ export async function POST(request: NextRequest) {
96
  if (typeof label !== 'string' || label.length > 100) {
97
  return NextResponse.json({ error: 'Label must be a string up to 100 characters' }, { status: 400 })
98
  }
99
- rpcFn = `session_setLabel("${sessionKey}", ${JSON.stringify(label)})`
 
100
  logDetail = `Set label="${label}" on ${sessionKey}`
101
  break
102
  }
@@ -104,7 +109,7 @@ export async function POST(request: NextRequest) {
104
  return NextResponse.json({ error: 'Invalid action. Must be: set-thinking, set-verbose, set-reasoning, set-label' }, { status: 400 })
105
  }
106
 
107
- const result = await runClawdbot(['-c', rpcFn], { timeoutMs: 10000 })
108
 
109
  db_helpers.logActivity(
110
  'session_control',
@@ -115,7 +120,7 @@ export async function POST(request: NextRequest) {
115
  { session_key: sessionKey, action }
116
  )
117
 
118
- return NextResponse.json({ success: true, action, sessionKey, stdout: result.stdout.trim() })
119
  } catch (error: any) {
120
  logger.error({ err: error }, 'Session POST error')
121
  return NextResponse.json({ error: error.message || 'Session action failed' }, { status: 500 })
@@ -137,10 +142,7 @@ export async function DELETE(request: NextRequest) {
137
  return NextResponse.json({ error: 'Invalid session key' }, { status: 400 })
138
  }
139
 
140
- const result = await runClawdbot(
141
- ['-c', `session_delete("${sessionKey}")`],
142
- { timeoutMs: 10000 }
143
- )
144
 
145
  db_helpers.logActivity(
146
  'session_control',
@@ -151,7 +153,7 @@ export async function DELETE(request: NextRequest) {
151
  { session_key: sessionKey, action: 'delete' }
152
  )
153
 
154
- return NextResponse.json({ success: true, sessionKey, stdout: result.stdout.trim() })
155
  } catch (error: any) {
156
  logger.error({ err: error }, 'Session DELETE error')
157
  return NextResponse.json({ error: error.message || 'Session deletion failed' }, { status: 500 })
 
5
  import { scanHermesSessions } from '@/lib/hermes-sessions'
6
  import { getDatabase, db_helpers } from '@/lib/db'
7
  import { requireRole } from '@/lib/auth'
8
+ import { callOpenClawGateway } from '@/lib/openclaw-gateway'
9
  import { mutationLimiter } from '@/lib/rate-limit'
10
  import { logger } from '@/lib/logger'
11
 
 
60
  return NextResponse.json({ error: 'Invalid session key' }, { status: 400 })
61
  }
62
 
63
+ let rpcMethod: string
64
+ let rpcParams: Record<string, unknown>
65
  let logDetail: string
66
 
67
  switch (action) {
 
70
  if (!VALID_THINKING_LEVELS.includes(level)) {
71
  return NextResponse.json({ error: `Invalid thinking level. Must be: ${VALID_THINKING_LEVELS.join(', ')}` }, { status: 400 })
72
  }
73
+ rpcMethod = 'session_setThinking'
74
+ rpcParams = { sessionKey, level }
75
  logDetail = `Set thinking=${level} on ${sessionKey}`
76
  break
77
  }
 
80
  if (!VALID_VERBOSE_LEVELS.includes(level)) {
81
  return NextResponse.json({ error: `Invalid verbose level. Must be: ${VALID_VERBOSE_LEVELS.join(', ')}` }, { status: 400 })
82
  }
83
+ rpcMethod = 'session_setVerbose'
84
+ rpcParams = { sessionKey, level }
85
  logDetail = `Set verbose=${level} on ${sessionKey}`
86
  break
87
  }
 
90
  if (!VALID_REASONING_LEVELS.includes(level)) {
91
  return NextResponse.json({ error: `Invalid reasoning level. Must be: ${VALID_REASONING_LEVELS.join(', ')}` }, { status: 400 })
92
  }
93
+ rpcMethod = 'session_setReasoning'
94
+ rpcParams = { sessionKey, level }
95
  logDetail = `Set reasoning=${level} on ${sessionKey}`
96
  break
97
  }
 
100
  if (typeof label !== 'string' || label.length > 100) {
101
  return NextResponse.json({ error: 'Label must be a string up to 100 characters' }, { status: 400 })
102
  }
103
+ rpcMethod = 'session_setLabel'
104
+ rpcParams = { sessionKey, label }
105
  logDetail = `Set label="${label}" on ${sessionKey}`
106
  break
107
  }
 
109
  return NextResponse.json({ error: 'Invalid action. Must be: set-thinking, set-verbose, set-reasoning, set-label' }, { status: 400 })
110
  }
111
 
112
+ const result = await callOpenClawGateway(rpcMethod, rpcParams, 10_000)
113
 
114
  db_helpers.logActivity(
115
  'session_control',
 
120
  { session_key: sessionKey, action }
121
  )
122
 
123
+ return NextResponse.json({ success: true, action, sessionKey, result })
124
  } catch (error: any) {
125
  logger.error({ err: error }, 'Session POST error')
126
  return NextResponse.json({ error: error.message || 'Session action failed' }, { status: 500 })
 
142
  return NextResponse.json({ error: 'Invalid session key' }, { status: 400 })
143
  }
144
 
145
+ const result = await callOpenClawGateway('session_delete', { sessionKey }, 10_000)
 
 
 
146
 
147
  db_helpers.logActivity(
148
  'session_control',
 
153
  { session_key: sessionKey, action: 'delete' }
154
  )
155
 
156
+ return NextResponse.json({ success: true, sessionKey, result })
157
  } catch (error: any) {
158
  logger.error({ err: error }, 'Session DELETE error')
159
  return NextResponse.json({ error: error.message || 'Session deletion failed' }, { status: 500 })
src/app/api/spawn/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
- import { runClawdbot } from '@/lib/command'
3
  import { requireRole } from '@/lib/auth'
 
4
  import { config } from '@/lib/config'
5
  import { readdir, readFile, stat } from 'fs/promises'
6
  import { join } from 'path'
@@ -14,11 +14,6 @@ function getPreferredToolsProfile(): string {
14
  return String(process.env.OPENCLAW_TOOLS_PROFILE || 'coding').trim() || 'coding'
15
  }
16
 
17
- async function runSpawnWithCompatibility(spawnPayload: Record<string, unknown>) {
18
- const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})`
19
- return runClawdbot(['-c', commandArg], { timeoutMs: 10000 })
20
- }
21
-
22
  export async function POST(request: NextRequest) {
23
  const auth = requireRole(request, 'operator')
24
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
@@ -68,19 +63,14 @@ export async function POST(request: NextRequest) {
68
  }
69
 
70
  try {
71
- // Execute the spawn command (OpenClaw 2026.3.2+ defaults tools.profile to messaging).
72
- let stdout = ''
73
- let stderr = ''
74
  let compatibilityFallbackUsed = false
75
  try {
76
- const result = await runSpawnWithCompatibility(spawnPayload)
77
- stdout = result.stdout
78
- stderr = result.stderr
79
  } catch (firstError: any) {
80
- const rawErr = String(firstError?.stderr || firstError?.message || '').toLowerCase()
81
- // Only retry without tools.profile when the error specifically indicates the
82
- // gateway doesn't recognize the tools/profile fields. Other errors (auth,
83
- // network, model not found, etc.) should propagate immediately.
84
  const isToolsSchemaError =
85
  (rawErr.includes('unknown field') || rawErr.includes('unknown key') || rawErr.includes('invalid argument')) &&
86
  (rawErr.includes('tools') || rawErr.includes('profile'))
@@ -88,23 +78,11 @@ export async function POST(request: NextRequest) {
88
 
89
  const fallbackPayload = { ...spawnPayload }
90
  delete (fallbackPayload as any).tools
91
- const fallback = await runSpawnWithCompatibility(fallbackPayload)
92
- stdout = fallback.stdout
93
- stderr = fallback.stderr
94
  compatibilityFallbackUsed = true
95
  }
96
 
97
- // Parse the response to extract session info
98
- let sessionInfo = null
99
- try {
100
- // Look for session information in stdout
101
- const sessionMatch = stdout.match(/Session created: (.+)/)
102
- if (sessionMatch) {
103
- sessionInfo = sessionMatch[1]
104
- }
105
- } catch (parseError) {
106
- logger.error({ err: parseError }, 'Failed to parse session info')
107
- }
108
 
109
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
110
  logAuditEvent({
@@ -131,8 +109,7 @@ export async function POST(request: NextRequest) {
131
  label,
132
  timeoutSeconds: timeout,
133
  createdAt: Date.now(),
134
- stdout: stdout.trim(),
135
- stderr: stderr.trim(),
136
  compatibility: {
137
  toolsProfile: getPreferredToolsProfile(),
138
  fallbackUsed: compatibilityFallbackUsed,
@@ -141,7 +118,7 @@ export async function POST(request: NextRequest) {
141
 
142
  } catch (execError: any) {
143
  logger.error({ err: execError }, 'Spawn execution error')
144
-
145
  return NextResponse.json({
146
  success: false,
147
  spawnId,
 
1
  import { NextRequest, NextResponse } from 'next/server'
 
2
  import { requireRole } from '@/lib/auth'
3
+ import { callOpenClawGateway } from '@/lib/openclaw-gateway'
4
  import { config } from '@/lib/config'
5
  import { readdir, readFile, stat } from 'fs/promises'
6
  import { join } from 'path'
 
14
  return String(process.env.OPENCLAW_TOOLS_PROFILE || 'coding').trim() || 'coding'
15
  }
16
 
 
 
 
 
 
17
  export async function POST(request: NextRequest) {
18
  const auth = requireRole(request, 'operator')
19
  if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
 
63
  }
64
 
65
  try {
66
+ // Call gateway sessions_spawn directly. Try with tools.profile first,
67
+ // fall back without it for older gateways that don't support the field.
68
+ let result: any
69
  let compatibilityFallbackUsed = false
70
  try {
71
+ result = await callOpenClawGateway('sessions_spawn', spawnPayload, 15_000)
 
 
72
  } catch (firstError: any) {
73
+ const rawErr = String(firstError?.message || '').toLowerCase()
 
 
 
74
  const isToolsSchemaError =
75
  (rawErr.includes('unknown field') || rawErr.includes('unknown key') || rawErr.includes('invalid argument')) &&
76
  (rawErr.includes('tools') || rawErr.includes('profile'))
 
78
 
79
  const fallbackPayload = { ...spawnPayload }
80
  delete (fallbackPayload as any).tools
81
+ result = await callOpenClawGateway('sessions_spawn', fallbackPayload, 15_000)
 
 
82
  compatibilityFallbackUsed = true
83
  }
84
 
85
+ const sessionInfo = result?.sessionId || result?.session_id || null
 
 
 
 
 
 
 
 
 
 
86
 
87
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
88
  logAuditEvent({
 
109
  label,
110
  timeoutSeconds: timeout,
111
  createdAt: Date.now(),
112
+ result,
 
113
  compatibility: {
114
  toolsProfile: getPreferredToolsProfile(),
115
  fallbackUsed: compatibilityFallbackUsed,
 
118
 
119
  } catch (execError: any) {
120
  logger.error({ err: execError }, 'Spawn execution error')
121
+
122
  return NextResponse.json({
123
  success: false,
124
  spawnId,
src/components/panels/task-board-panel.tsx CHANGED
@@ -95,22 +95,43 @@ const STATUS_COLUMN_KEYS = [
95
  { key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' },
96
  ]
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  /** Fetch active gateway sessions for a given agent name. */
99
  function useAgentSessions(agentName: string | undefined) {
100
- const [sessions, setSessions] = useState<Array<{ key: string; id: string; channel?: string; label?: string }>>([])
101
  useEffect(() => {
102
  if (!agentName) { setSessions([]); return }
103
  let cancelled = false
104
- fetch('/api/sessions?include_local=1')
105
  .then(r => r.json())
106
  .then(data => {
107
  if (cancelled) return
108
- const all = (data.sessions || []) as Array<{ key: string; id: string; agent?: string; channel?: string; label?: string; active?: boolean }>
109
  const filtered = all.filter(s =>
110
  s.agent?.toLowerCase() === agentName.toLowerCase() ||
111
  s.key?.toLowerCase().includes(agentName.toLowerCase())
112
  )
113
- setSessions(filtered.map(s => ({ key: s.key, id: s.id, channel: s.channel, label: s.label })))
 
 
 
 
 
 
 
114
  })
115
  .catch(() => { if (!cancelled) setSessions([]) })
116
  return () => { cancelled = true }
@@ -2052,7 +2073,7 @@ function CreateTaskModal({
2052
  <option value="">New session (default)</option>
2053
  {agentSessions.map(s => (
2054
  <option key={s.key} value={s.key}>
2055
- {s.label || s.channel || s.key}
2056
  </option>
2057
  ))}
2058
  </select>
@@ -2305,7 +2326,7 @@ function EditTaskModal({
2305
  <option value="">New session (default)</option>
2306
  {agentSessions.map(s => (
2307
  <option key={s.key} value={s.key}>
2308
- {s.label || s.channel || s.key}
2309
  </option>
2310
  ))}
2311
  </select>
 
95
  { key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' },
96
  ]
97
 
98
+ /** Build a human-readable label for a session key like "agent:nefes:telegram-group-123" */
99
+ function formatSessionLabel(s: { key: string; channel?: string; kind?: string; label?: string }): string {
100
+ if (s.label) return s.label
101
+ // Extract the identifier part after the last colon: "agent:name:main" → "main"
102
+ const parts = (s.key || '').split(':')
103
+ const identifier = parts.length > 2 ? parts.slice(2).join(':') : s.key
104
+ const channel = s.channel || ''
105
+ if (channel && identifier !== 'main') {
106
+ return `${channel} (${identifier})`
107
+ }
108
+ if (channel) return `${channel} (${s.kind || 'default'})`
109
+ return identifier || s.key
110
+ }
111
+
112
  /** Fetch active gateway sessions for a given agent name. */
113
  function useAgentSessions(agentName: string | undefined) {
114
+ const [sessions, setSessions] = useState<Array<{ key: string; id: string; channel?: string; kind?: string; label?: string; displayLabel: string }>>([])
115
  useEffect(() => {
116
  if (!agentName) { setSessions([]); return }
117
  let cancelled = false
118
+ fetch('/api/sessions')
119
  .then(r => r.json())
120
  .then(data => {
121
  if (cancelled) return
122
+ const all = (data.sessions || []) as Array<{ key: string; id: string; agent?: string; channel?: string; kind?: string; label?: string; active?: boolean }>
123
  const filtered = all.filter(s =>
124
  s.agent?.toLowerCase() === agentName.toLowerCase() ||
125
  s.key?.toLowerCase().includes(agentName.toLowerCase())
126
  )
127
+ setSessions(filtered.map(s => ({
128
+ key: s.key,
129
+ id: s.id,
130
+ channel: s.channel,
131
+ kind: s.kind,
132
+ label: s.label,
133
+ displayLabel: formatSessionLabel(s),
134
+ })))
135
  })
136
  .catch(() => { if (!cancelled) setSessions([]) })
137
  return () => { cancelled = true }
 
2073
  <option value="">New session (default)</option>
2074
  {agentSessions.map(s => (
2075
  <option key={s.key} value={s.key}>
2076
+ {s.displayLabel}
2077
  </option>
2078
  ))}
2079
  </select>
 
2326
  <option value="">New session (default)</option>
2327
  {agentSessions.map(s => (
2328
  <option key={s.key} value={s.key}>
2329
+ {s.displayLabel}
2330
  </option>
2331
  ))}
2332
  </select>