Skip to content

Commit 5215ce8

Browse files
committed
Agent-centric UI, orchestrator capability, tool access requests, and CLI resume tracking
- Sidebar now shows agents as chat threads with persistent thread sessions - Agent awareness: agents see each other's capabilities and status in system prompt - Orchestrator capability toggle gates delegate_to_agent behind explicit permission - Tool access request UI: agents request disabled tools, user grants via chat banner - Store all CLI resume IDs (claude/codex/opencode) on tasks separately - CLI resume IDs shown in task sheet and agent thread notifications - Task notifications include working directory and resume IDs - File path preview: inline code file paths render as interactive chips with preview/open - File serve API endpoint (/api/files/serve) for local file preview - Code block preview/open/save buttons for HTML and SVG - Atomic storage upserts to prevent task race conditions - Queue orphan recovery for tasks stuck in queued status - Agent delegation name-to-ID fallback resolution
1 parent 68e262c commit 5215ce8

30 files changed

+1665
-285
lines changed

src/app/api/agents/[id]/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from 'next/server'
2-
import { loadAgents, saveAgents } from '@/lib/server/storage'
2+
import { loadAgents, saveAgents, deleteAgent } from '@/lib/server/storage'
33
import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
44

55
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -25,7 +25,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
2525
const { id } = await params
2626
const agents = loadAgents()
2727
if (!agents[id]) return new NextResponse(null, { status: 404 })
28-
delete agents[id]
29-
saveAgents(agents)
28+
deleteAgent(id)
3029
return NextResponse.json('ok')
3130
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { NextResponse } from 'next/server'
2+
import crypto from 'crypto'
3+
import { loadAgents, saveAgents, loadSessions, saveSessions } from '@/lib/server/storage'
4+
5+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
6+
const { id: agentId } = await params
7+
const agents = loadAgents()
8+
const agent = agents[agentId]
9+
if (!agent) {
10+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
11+
}
12+
13+
const body = await req.json().catch(() => ({}))
14+
const user = body.user || 'default'
15+
const sessions = loadSessions()
16+
17+
// If agent already has a thread session that exists, return it
18+
if (agent.threadSessionId && sessions[agent.threadSessionId]) {
19+
return NextResponse.json(sessions[agent.threadSessionId])
20+
}
21+
22+
// Check if an existing session is already linked to this agent as a thread
23+
const existing = Object.values(sessions).find(
24+
(s: any) => s.name === `agent-thread:${agentId}` && s.user === user
25+
)
26+
if (existing) {
27+
agent.threadSessionId = (existing as any).id
28+
agent.updatedAt = Date.now()
29+
saveAgents(agents)
30+
return NextResponse.json(existing)
31+
}
32+
33+
// Create a new thread session
34+
const sessionId = `agent-thread-${agentId}-${crypto.randomBytes(4).toString('hex')}`
35+
const now = Date.now()
36+
const session = {
37+
id: sessionId,
38+
name: `agent-thread:${agentId}`,
39+
cwd: process.cwd(),
40+
user: user,
41+
provider: agent.provider,
42+
model: agent.model,
43+
credentialId: agent.credentialId || null,
44+
fallbackCredentialIds: agent.fallbackCredentialIds || [],
45+
apiEndpoint: agent.apiEndpoint || null,
46+
claudeSessionId: null,
47+
messages: [],
48+
createdAt: now,
49+
lastActiveAt: now,
50+
active: false,
51+
sessionType: 'human' as const,
52+
agentId,
53+
tools: agent.tools || [],
54+
heartbeatEnabled: agent.heartbeatEnabled || false,
55+
heartbeatIntervalSec: agent.heartbeatIntervalSec || null,
56+
}
57+
58+
sessions[sessionId] = session as any
59+
saveSessions(sessions)
60+
61+
agent.threadSessionId = sessionId
62+
agent.updatedAt = Date.now()
63+
saveAgents(agents)
64+
65+
return NextResponse.json(session)
66+
}

src/app/api/agents/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export async function POST(req: Request) {
2424
isOrchestrator: body.isOrchestrator || false,
2525
subAgentIds: body.subAgentIds || [],
2626
tools: body.tools || [],
27+
capabilities: body.capabilities || [],
2728
createdAt: now,
2829
updatedAt: now,
2930
}

src/app/api/files/serve/route.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { NextResponse } from 'next/server'
2+
import fs from 'fs'
3+
import path from 'path'
4+
5+
const MIME_MAP: Record<string, string> = {
6+
'.html': 'text/html',
7+
'.htm': 'text/html',
8+
'.css': 'text/css',
9+
'.js': 'application/javascript',
10+
'.json': 'application/json',
11+
'.svg': 'image/svg+xml',
12+
'.png': 'image/png',
13+
'.jpg': 'image/jpeg',
14+
'.jpeg': 'image/jpeg',
15+
'.gif': 'image/gif',
16+
'.webp': 'image/webp',
17+
'.txt': 'text/plain',
18+
'.md': 'text/markdown',
19+
'.ts': 'text/plain',
20+
'.tsx': 'text/plain',
21+
'.jsx': 'text/plain',
22+
'.py': 'text/plain',
23+
'.sh': 'text/plain',
24+
}
25+
26+
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
27+
28+
export async function GET(req: Request) {
29+
const url = new URL(req.url)
30+
const filePath = url.searchParams.get('path')
31+
32+
if (!filePath) {
33+
return NextResponse.json({ error: 'Missing path parameter' }, { status: 400 })
34+
}
35+
36+
// Resolve and normalize the path
37+
const resolved = path.resolve(filePath)
38+
39+
// Block access to sensitive paths
40+
const blocked = ['.env', 'credentials', '.ssh', '.gnupg', '.aws']
41+
if (blocked.some((b) => resolved.includes(b))) {
42+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
43+
}
44+
45+
if (!fs.existsSync(resolved)) {
46+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
47+
}
48+
49+
const stat = fs.statSync(resolved)
50+
if (!stat.isFile()) {
51+
return NextResponse.json({ error: 'Not a file' }, { status: 400 })
52+
}
53+
if (stat.size > MAX_SIZE) {
54+
return NextResponse.json({ error: 'File too large' }, { status: 413 })
55+
}
56+
57+
const ext = path.extname(resolved).toLowerCase()
58+
const contentType = MIME_MAP[ext] || 'application/octet-stream'
59+
const content = fs.readFileSync(resolved)
60+
61+
return new NextResponse(content, {
62+
headers: {
63+
'Content-Type': contentType,
64+
'Content-Disposition': contentType.startsWith('text/') || contentType.startsWith('image/')
65+
? 'inline'
66+
: `attachment; filename="${path.basename(resolved)}"`,
67+
},
68+
})
69+
}

src/app/api/schedules/[id]/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from 'next/server'
2-
import { loadSchedules, saveSchedules } from '@/lib/server/storage'
2+
import { loadSchedules, saveSchedules, deleteSchedule } from '@/lib/server/storage'
33
import { resolveScheduleName } from '@/lib/schedule-name'
44

55
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -23,7 +23,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
2323
const { id } = await params
2424
const schedules = loadSchedules()
2525
if (!schedules[id]) return new NextResponse(null, { status: 404 })
26-
delete schedules[id]
27-
saveSchedules(schedules)
26+
deleteSchedule(id)
2827
return NextResponse.json('ok')
2928
}

src/app/api/schedules/[id]/run/route.ts

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,37 +34,71 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
3434
}
3535

3636
const now = Date.now()
37-
const taskId = crypto.randomBytes(4).toString('hex')
38-
tasks[taskId] = {
39-
id: taskId,
40-
title: `[Sched] ${schedule.name}: ${String(schedule.taskPrompt || '').slice(0, 40)}`,
41-
description: schedule.taskPrompt || '',
42-
status: 'backlog',
43-
agentId: schedule.agentId,
44-
sessionId: null,
45-
result: null,
46-
error: null,
47-
createdAt: now,
48-
updatedAt: now,
49-
queuedAt: null,
50-
startedAt: null,
51-
completedAt: null,
52-
sourceType: 'schedule',
53-
sourceScheduleId: schedule.id,
54-
sourceScheduleName: schedule.name,
55-
sourceScheduleKey: scheduleSignature || null,
56-
createdInSessionId: schedule.createdInSessionId || null,
57-
createdByAgentId: schedule.createdByAgentId || null,
37+
schedule.runNumber = (schedule.runNumber || 0) + 1
38+
39+
// Reuse linked task if it exists and is not in-flight
40+
let taskId = ''
41+
const existingTaskId = typeof schedule.linkedTaskId === 'string' ? schedule.linkedTaskId : ''
42+
const existingTask = existingTaskId ? tasks[existingTaskId] : null
43+
44+
if (existingTask && existingTask.status !== 'queued' && existingTask.status !== 'running') {
45+
taskId = existingTaskId
46+
const prev = existingTask as any
47+
prev.totalRuns = (prev.totalRuns || 0) + 1
48+
if (existingTask.status === 'completed') prev.totalCompleted = (prev.totalCompleted || 0) + 1
49+
if (existingTask.status === 'failed') prev.totalFailed = (prev.totalFailed || 0) + 1
50+
51+
existingTask.status = 'backlog'
52+
existingTask.title = `[Sched] ${schedule.name} (run #${schedule.runNumber})`
53+
existingTask.result = null
54+
existingTask.error = null
55+
existingTask.sessionId = null
56+
existingTask.updatedAt = now
57+
existingTask.queuedAt = null
58+
existingTask.startedAt = null
59+
existingTask.completedAt = null
60+
existingTask.archivedAt = null
61+
existingTask.attempts = 0
62+
existingTask.retryScheduledAt = null
63+
existingTask.deadLetteredAt = null
64+
existingTask.validation = null
65+
prev.runNumber = schedule.runNumber
66+
} else {
67+
taskId = crypto.randomBytes(4).toString('hex')
68+
tasks[taskId] = {
69+
id: taskId,
70+
title: `[Sched] ${schedule.name} (run #${schedule.runNumber})`,
71+
description: schedule.taskPrompt || '',
72+
status: 'backlog',
73+
agentId: schedule.agentId,
74+
sessionId: null,
75+
result: null,
76+
error: null,
77+
createdAt: now,
78+
updatedAt: now,
79+
queuedAt: null,
80+
startedAt: null,
81+
completedAt: null,
82+
sourceType: 'schedule',
83+
sourceScheduleId: schedule.id,
84+
sourceScheduleName: schedule.name,
85+
sourceScheduleKey: scheduleSignature || null,
86+
createdInSessionId: schedule.createdInSessionId || null,
87+
createdByAgentId: schedule.createdByAgentId || null,
88+
runNumber: schedule.runNumber,
89+
}
90+
schedule.linkedTaskId = taskId
5891
}
92+
5993
saveTasks(tasks)
6094
enqueueTask(taskId)
6195
pushMainLoopEventToMainSessions({
6296
type: 'schedule_fired',
63-
text: `Schedule fired manually: "${schedule.name}" (${schedule.id}) queued task "${tasks[taskId].title}" (${taskId}).`,
97+
text: `Schedule fired manually: "${schedule.name}" (${schedule.id}) run #${schedule.runNumber} — task ${taskId}`,
6498
})
6599

66100
schedule.lastRunAt = now
67101
saveSchedules(schedules)
68102

69-
return NextResponse.json({ ok: true, queued: true, taskId })
103+
return NextResponse.json({ ok: true, queued: true, taskId, runNumber: schedule.runNumber })
70104
}

src/app/api/sessions/[id]/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from 'next/server'
2-
import { loadSessions, saveSessions, active, loadAgents } from '@/lib/server/storage'
2+
import { loadSessions, saveSessions, deleteSession, active, loadAgents } from '@/lib/server/storage'
33
import { enqueueSessionRun } from '@/lib/server/session-run-manager'
44
import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
55

@@ -97,7 +97,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
9797
try { active.get(id).kill() } catch {}
9898
active.delete(id)
9999
}
100-
delete sessions[id]
101-
saveSessions(sessions)
100+
deleteSession(id)
102101
return new NextResponse('OK')
103102
}

src/app/api/sessions/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
22
import crypto from 'crypto'
33
import os from 'os'
44
import path from 'path'
5-
import { loadSessions, saveSessions, active, loadAgents } from '@/lib/server/storage'
5+
import { loadSessions, saveSessions, deleteSession, active, loadAgents } from '@/lib/server/storage'
66
import { getSessionRunState } from '@/lib/server/session-run-manager'
77
import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
88

@@ -29,9 +29,8 @@ export async function DELETE(req: Request) {
2929
try { active.get(id).kill() } catch {}
3030
active.delete(id)
3131
}
32-
delete sessions[id]
32+
deleteSession(id)
3333
}
34-
saveSessions(sessions)
3534
return NextResponse.json({ deleted: ids.length })
3635
}
3736

src/app/api/tasks/route.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,37 @@ export async function GET(req: Request) {
2828
return NextResponse.json(filtered)
2929
}
3030

31+
export async function DELETE(req: Request) {
32+
const { searchParams } = new URL(req.url)
33+
const filter = searchParams.get('filter') // 'all' | 'schedule' | null
34+
const tasks = loadTasks()
35+
const kept: Record<string, (typeof tasks)[string]> = {}
36+
let removed = 0
37+
38+
for (const [id, task] of Object.entries(tasks)) {
39+
const shouldRemove =
40+
filter === 'all' ||
41+
(filter === 'schedule' && (task as any).sourceType === 'schedule') ||
42+
(!filter && task.status === 'archived')
43+
if (shouldRemove) {
44+
removed++
45+
} else {
46+
kept[id] = task
47+
}
48+
}
49+
50+
// Delete removed tasks atomically
51+
const { deleteTask } = await import('@/lib/server/storage')
52+
for (const [id, task] of Object.entries(tasks)) {
53+
const shouldRemove =
54+
filter === 'all' ||
55+
(filter === 'schedule' && (task as any).sourceType === 'schedule') ||
56+
(!filter && task.status === 'archived')
57+
if (shouldRemove) deleteTask(id)
58+
}
59+
return NextResponse.json({ removed, remaining: Object.keys(tasks).length - removed })
60+
}
61+
3162
export async function POST(req: Request) {
3263
const body = await req.json()
3364
const id = crypto.randomBytes(4).toString('hex')

0 commit comments

Comments
 (0)