@swarmclawai/swarmclaw 0.7.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -139
- package/package.json +1 -1
- package/src/app/api/agents/[id]/thread/route.ts +1 -2
- package/src/app/api/agents/route.ts +1 -1
- package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
- package/src/app/api/{sessions → chats}/[id]/main-loop/route.ts +2 -2
- package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
- package/src/app/api/{sessions → chats}/[id]/route.ts +4 -52
- package/src/app/api/{sessions → chats}/route.ts +5 -7
- package/src/app/api/plugins/route.ts +3 -0
- package/src/app/api/plugins/settings/route.ts +35 -0
- package/src/app/api/usage/route.ts +30 -0
- package/src/cli/index.js +35 -33
- package/src/cli/index.ts +40 -39
- package/src/cli/spec.js +29 -27
- package/src/components/agents/agent-card.tsx +1 -1
- package/src/components/agents/agent-chat-list.tsx +3 -3
- package/src/components/agents/agent-list.tsx +8 -13
- package/src/components/agents/agent-sheet.tsx +2 -2
- package/src/components/agents/cron-job-form.tsx +3 -3
- package/src/components/agents/inspector-panel.tsx +2 -2
- package/src/components/auth/setup-wizard.tsx +5 -38
- package/src/components/chat/chat-area.tsx +10 -14
- package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +3 -3
- package/src/components/chat/chat-header.tsx +156 -73
- package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +4 -5
- package/src/components/chat/chat-tool-toggles.tsx +26 -17
- package/src/components/chat/checkpoint-timeline.tsx +4 -4
- package/src/components/chat/message-bubble.tsx +4 -1
- package/src/components/chat/message-list.tsx +2 -2
- package/src/components/{sessions/new-session-sheet.tsx → chat/new-chat-sheet.tsx} +6 -6
- package/src/components/chat/session-debug-panel.tsx +1 -1
- package/src/components/chat/tool-request-banner.tsx +3 -3
- package/src/components/chatrooms/agent-hover-card.tsx +3 -3
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
- package/src/components/connectors/connector-sheet.tsx +1 -1
- package/src/components/home/home-view.tsx +1 -1
- package/src/components/layout/app-layout.tsx +23 -2
- package/src/components/plugins/plugin-list.tsx +475 -254
- package/src/components/plugins/plugin-sheet.tsx +124 -10
- package/src/components/settings/gateway-connection-panel.tsx +1 -1
- package/src/components/shared/command-palette.tsx +0 -1
- package/src/components/shared/settings/section-heartbeat.tsx +1 -1
- package/src/components/shared/settings/section-providers.tsx +1 -1
- package/src/components/shared/settings/settings-page.tsx +1 -12
- package/src/components/usage/metrics-dashboard.tsx +73 -0
- package/src/components/webhooks/webhook-sheet.tsx +1 -1
- package/src/lib/chat.ts +1 -1
- package/src/lib/{sessions.ts → chats.ts} +28 -18
- package/src/lib/providers/claude-cli.ts +1 -1
- package/src/lib/server/approvals.ts +4 -4
- package/src/lib/server/capability-router.ts +10 -8
- package/src/lib/server/chat-execution.ts +36 -105
- package/src/lib/server/chatroom-helpers.ts +3 -3
- package/src/lib/server/connectors/manager.ts +4 -4
- package/src/lib/server/cost.ts +34 -1
- package/src/lib/server/daemon-state.ts +2 -2
- package/src/lib/server/heartbeat-service.ts +1 -1
- package/src/lib/server/main-agent-loop.ts +25 -160
- package/src/lib/server/main-session.ts +6 -13
- package/src/lib/server/orchestrator-lg.ts +3 -3
- package/src/lib/server/orchestrator.ts +5 -5
- package/src/lib/server/plugins.ts +112 -4
- package/src/lib/server/provider-health.ts +5 -3
- package/src/lib/server/queue.ts +12 -10
- package/src/lib/server/session-run-manager.test.ts +9 -6
- package/src/lib/server/session-run-manager.ts +1 -3
- package/src/lib/server/session-tools/calendar.ts +376 -0
- package/src/lib/server/session-tools/canvas.ts +1 -1
- package/src/lib/server/session-tools/chatroom.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +5 -2
- package/src/lib/server/session-tools/context.ts +7 -3
- package/src/lib/server/session-tools/crud.ts +14 -6
- package/src/lib/server/session-tools/delegate.ts +95 -8
- package/src/lib/server/session-tools/discovery.ts +2 -2
- package/src/lib/server/session-tools/edit_file.ts +4 -2
- package/src/lib/server/session-tools/email.ts +322 -0
- package/src/lib/server/session-tools/file.ts +5 -2
- package/src/lib/server/session-tools/git.ts +1 -1
- package/src/lib/server/session-tools/http.ts +1 -1
- package/src/lib/server/session-tools/image-gen.ts +382 -0
- package/src/lib/server/session-tools/index.ts +74 -49
- package/src/lib/server/session-tools/memory.ts +139 -2
- package/src/lib/server/session-tools/monitor.ts +1 -1
- package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
- package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
- package/src/lib/server/session-tools/platform.ts +6 -3
- package/src/lib/server/session-tools/plugin-creator.ts +3 -3
- package/src/lib/server/session-tools/replicate.ts +303 -0
- package/src/lib/server/session-tools/sample-ui.ts +1 -1
- package/src/lib/server/session-tools/sandbox.ts +4 -2
- package/src/lib/server/session-tools/schedule.ts +4 -2
- package/src/lib/server/session-tools/session-info.ts +7 -4
- package/src/lib/server/session-tools/shell.ts +5 -2
- package/src/lib/server/session-tools/subagent.ts +2 -2
- package/src/lib/server/session-tools/wallet.ts +29 -2
- package/src/lib/server/session-tools/web.ts +44 -5
- package/src/lib/server/storage.ts +29 -9
- package/src/lib/server/stream-agent-chat.ts +72 -249
- package/src/lib/server/tool-aliases.ts +26 -15
- package/src/lib/server/tool-capability-policy.test.ts +9 -9
- package/src/lib/server/tool-capability-policy.ts +32 -27
- package/src/lib/tool-definitions.ts +4 -0
- package/src/lib/validation/schemas.ts +3 -1
- package/src/stores/use-app-store.ts +5 -5
- package/src/stores/use-chat-store.ts +7 -7
- package/src/types/index.ts +65 -3
- /package/src/app/api/{sessions → chats}/[id]/browser/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/chat/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/messages/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/stop/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
|
@@ -12,13 +12,15 @@ const MAX_DELEGATION_CHAIN_HOPS = 128
|
|
|
12
12
|
interface DelegateContext {
|
|
13
13
|
cwd?: string
|
|
14
14
|
claudeTimeoutMs?: number
|
|
15
|
-
readStoredDelegateResumeId?: (key: 'claudeCode' | 'codex' | 'opencode') => string | null
|
|
16
|
-
persistDelegateResumeId?: (key: 'claudeCode' | 'codex' | 'opencode', id: string) => void
|
|
15
|
+
readStoredDelegateResumeId?: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini') => string | null
|
|
16
|
+
persistDelegateResumeId?: (key: 'claudeCode' | 'codex' | 'opencode' | 'gemini', id: string) => void
|
|
17
17
|
ctx?: { platformAssignScope?: string; agentId?: string | null }
|
|
18
|
+
hasPlugin?: (name: string) => boolean
|
|
19
|
+
/** @deprecated Use hasPlugin */
|
|
18
20
|
hasTool?: (name: string) => boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
type DelegateBackend = 'claude' | 'codex' | 'opencode'
|
|
23
|
+
type DelegateBackend = 'claude' | 'codex' | 'opencode' | 'gemini'
|
|
22
24
|
|
|
23
25
|
function asTaskRecord(value: unknown): Record<string, unknown> | null {
|
|
24
26
|
return value && typeof value === 'object' ? value as Record<string, unknown> : null
|
|
@@ -74,6 +76,7 @@ async function executeDelegateAction(args: Record<string, unknown>, bctx: Delega
|
|
|
74
76
|
claude: findBinaryOnPath('claude'),
|
|
75
77
|
codex: findBinaryOnPath('codex'),
|
|
76
78
|
opencode: findBinaryOnPath('opencode'),
|
|
79
|
+
gemini: findBinaryOnPath('gemini'),
|
|
77
80
|
}
|
|
78
81
|
const binary = backends[backend as keyof typeof backends]
|
|
79
82
|
if (!binary) return `Error: Backend "${backend}" unavailable.`
|
|
@@ -81,6 +84,7 @@ async function executeDelegateAction(args: Record<string, unknown>, bctx: Delega
|
|
|
81
84
|
if (backend === 'claude') return runClaudeDelegate(binary, task, resume, resumeId, bctx)
|
|
82
85
|
if (backend === 'codex') return runCodexDelegate(binary, task, resume, resumeId, bctx)
|
|
83
86
|
if (backend === 'opencode') return runOpenCodeDelegate(binary, task, resume, resumeId, bctx)
|
|
87
|
+
if (backend === 'gemini') return runGeminiDelegate(binary, task, resume, resumeId, bctx)
|
|
84
88
|
return `Error: Unsupported backend "${backend}".`
|
|
85
89
|
}
|
|
86
90
|
|
|
@@ -273,6 +277,86 @@ async function runOpenCodeDelegate(binary: string, task: string, resume: boolean
|
|
|
273
277
|
}
|
|
274
278
|
}
|
|
275
279
|
|
|
280
|
+
async function runGeminiDelegate(binary: string, task: string, resume: boolean, resumeId: string, bctx: DelegateContext): Promise<string> {
|
|
281
|
+
try {
|
|
282
|
+
const env = { ...process.env, TERM: 'dumb', NO_COLOR: '1' } as NodeJS.ProcessEnv
|
|
283
|
+
const storedResumeId = bctx.readStoredDelegateResumeId?.('gemini')
|
|
284
|
+
const resumeIdToUse = resumeId?.trim() || (resume ? storedResumeId : null)
|
|
285
|
+
|
|
286
|
+
return await new Promise<string>((resolve) => {
|
|
287
|
+
const args = ['--prompt', task, '--output-format', 'stream-json', '--yolo']
|
|
288
|
+
if (resumeIdToUse) args.push('--resume', resumeIdToUse)
|
|
289
|
+
|
|
290
|
+
const child = spawn(binary, args, { cwd: bctx.cwd, env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
291
|
+
let stdoutBuf = ''
|
|
292
|
+
let stderrBuf = ''
|
|
293
|
+
let responseText = ''
|
|
294
|
+
let discoveredId: string | null = null
|
|
295
|
+
let settled = false
|
|
296
|
+
|
|
297
|
+
const finish = (text: string) => {
|
|
298
|
+
if (settled) return
|
|
299
|
+
settled = true
|
|
300
|
+
resolve(truncate(text, MAX_OUTPUT))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const timeoutHandle = setTimeout(() => {
|
|
304
|
+
try { child.kill('SIGTERM') } catch { /* ignore */ }
|
|
305
|
+
}, bctx.claudeTimeoutMs || 300000)
|
|
306
|
+
|
|
307
|
+
child.stdout?.on('data', (chunk) => {
|
|
308
|
+
stdoutBuf += chunk.toString()
|
|
309
|
+
const lines = stdoutBuf.split('\n')
|
|
310
|
+
stdoutBuf = lines.pop() || ''
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
const trimmed = line.trim()
|
|
313
|
+
if (!trimmed) continue
|
|
314
|
+
try {
|
|
315
|
+
const ev = JSON.parse(trimmed) as Record<string, unknown>
|
|
316
|
+
// Capture session ID from init event
|
|
317
|
+
if (ev.type === 'init' && typeof ev.session_id === 'string') {
|
|
318
|
+
discoveredId = ev.session_id
|
|
319
|
+
}
|
|
320
|
+
// Capture assistant text from message events
|
|
321
|
+
if (ev.type === 'message' && ev.role === 'assistant' && typeof ev.content === 'string') {
|
|
322
|
+
responseText += ev.content
|
|
323
|
+
}
|
|
324
|
+
// Capture final result
|
|
325
|
+
if (ev.type === 'result' && ev.status === 'error') {
|
|
326
|
+
const errMsg = typeof ev.error === 'string' ? ev.error : 'Gemini error'
|
|
327
|
+
stderrBuf += `${errMsg}\n`
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
responseText += `${line}\n`
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
child.stderr?.on('data', (chunk) => {
|
|
336
|
+
stderrBuf += chunk.toString()
|
|
337
|
+
if (stderrBuf.length > 16_000) stderrBuf = stderrBuf.slice(-16_000)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
child.on('close', (code, signal) => {
|
|
341
|
+
clearTimeout(timeoutHandle)
|
|
342
|
+
if (discoveredId) bctx.persistDelegateResumeId?.('gemini', discoveredId)
|
|
343
|
+
const output = responseText.trim()
|
|
344
|
+
if (output) return finish(output)
|
|
345
|
+
const stderr = stderrBuf.trim()
|
|
346
|
+
if (stderr) return finish(`Error: ${stderr}`)
|
|
347
|
+
return finish(`Error: Gemini exited with code ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}.`)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
child.on('error', (err) => {
|
|
351
|
+
clearTimeout(timeoutHandle)
|
|
352
|
+
finish(`Error: ${err.message}`)
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
} catch (err: unknown) {
|
|
356
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
276
360
|
async function runClaudeDelegate(binary: string, task: string, resume: boolean, resumeId: string, bctx: DelegateContext): Promise<string> {
|
|
277
361
|
try {
|
|
278
362
|
const env: NodeJS.ProcessEnv = stripEnvPrefixes({ ...process.env }, ['CLAUDE'])
|
|
@@ -335,16 +419,19 @@ async function runClaudeDelegate(binary: string, task: string, resume: boolean,
|
|
|
335
419
|
const DelegatePlugin: Plugin = {
|
|
336
420
|
name: 'Core Delegate',
|
|
337
421
|
description: 'Delegate complex multi-file tasks to specialized CLI backends or other agents.',
|
|
338
|
-
hooks: {
|
|
422
|
+
hooks: {
|
|
423
|
+
getCapabilityDescription: () => 'I can hand off deep coding work to Claude Code, Codex, or Gemini CLI (`delegate`) for complex multi-file refactors and code generation. Resume IDs may come back via `[delegate_meta]`.',
|
|
424
|
+
getOperatingGuidance: () => ['CRITICAL: `execute_command` (not delegation) for running servers, installs, scripts. Delegation sessions end and kill processes.', 'Delegate only for deep multi-file code work: refactors, debugging, generation, test suites.'],
|
|
425
|
+
} as PluginHooks,
|
|
339
426
|
tools: [
|
|
340
427
|
{
|
|
341
428
|
name: 'delegate',
|
|
342
|
-
description: 'Delegate to a specialized backend (Claude, Codex, OpenCode).',
|
|
429
|
+
description: 'Delegate to a specialized backend (Claude, Codex, OpenCode, Gemini).',
|
|
343
430
|
parameters: {
|
|
344
431
|
type: 'object',
|
|
345
432
|
properties: {
|
|
346
433
|
task: { type: 'string' },
|
|
347
|
-
backend: { type: 'string', enum: ['claude', 'codex', 'opencode'] },
|
|
434
|
+
backend: { type: 'string', enum: ['claude', 'codex', 'opencode', 'gemini'] },
|
|
348
435
|
resume: { type: 'boolean' },
|
|
349
436
|
resumeId: { type: 'string', description: 'Optional explicit session/thread ID to resume' }
|
|
350
437
|
},
|
|
@@ -362,9 +449,9 @@ getPluginManager().registerBuiltin('delegate', DelegatePlugin)
|
|
|
362
449
|
*/
|
|
363
450
|
export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
364
451
|
const tools: StructuredToolInterface[] = []
|
|
365
|
-
const {
|
|
452
|
+
const { hasPlugin } = bctx
|
|
366
453
|
|
|
367
|
-
if (
|
|
454
|
+
if (hasPlugin('delegate')) {
|
|
368
455
|
tools.push(
|
|
369
456
|
tool(
|
|
370
457
|
async (args) => executeDelegateAction(args, bctx),
|
|
@@ -5,7 +5,7 @@ import { getPluginManager } from '../plugins'
|
|
|
5
5
|
import type { Plugin, PluginHooks, ClawHubSkill } from '@/types'
|
|
6
6
|
import { searchClawHub } from '../clawhub-client'
|
|
7
7
|
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
8
|
-
import {
|
|
8
|
+
import { pluginIdMatches } from '../tool-aliases'
|
|
9
9
|
import { loadSessions } from '../storage'
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -88,7 +88,7 @@ async function executeDiscoveryAction(args: Record<string, unknown>, bctx?: Tool
|
|
|
88
88
|
if (bctx?.ctx?.sessionId) {
|
|
89
89
|
const allSessions = loadSessions()
|
|
90
90
|
const currentSession = allSessions[bctx.ctx.sessionId]
|
|
91
|
-
if (currentSession &&
|
|
91
|
+
if (currentSession && pluginIdMatches(currentSession.tools, pluginId)) {
|
|
92
92
|
return JSON.stringify({
|
|
93
93
|
alreadyGranted: true,
|
|
94
94
|
pluginId,
|
|
@@ -43,7 +43,9 @@ async function executeEditFile(args: { filePath: string; oldString: string; newS
|
|
|
43
43
|
const EditFilePlugin: Plugin = {
|
|
44
44
|
name: 'Core Edit File',
|
|
45
45
|
description: 'Surgical search-and-replace within existing files.',
|
|
46
|
-
hooks: {
|
|
46
|
+
hooks: {
|
|
47
|
+
getCapabilityDescription: () => 'I can make precise edits to files (`edit_file`) — surgical find-and-replace without rewriting the whole file.',
|
|
48
|
+
} as PluginHooks,
|
|
47
49
|
tools: [
|
|
48
50
|
{
|
|
49
51
|
name: 'edit_file',
|
|
@@ -68,7 +70,7 @@ getPluginManager().registerBuiltin('edit_file', EditFilePlugin)
|
|
|
68
70
|
* Legacy Bridge
|
|
69
71
|
*/
|
|
70
72
|
export function buildEditFileTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
71
|
-
if (!bctx.
|
|
73
|
+
if (!bctx.hasPlugin('edit_file')) return []
|
|
72
74
|
return [
|
|
73
75
|
tool(
|
|
74
76
|
async (args) => executeEditFile(args as any, { cwd: bctx.cwd }),
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
3
|
+
import type { Plugin, PluginHooks } from '@/types'
|
|
4
|
+
import { getPluginManager } from '../plugins'
|
|
5
|
+
import { normalizeToolInputArgs } from './normalize-tool-args'
|
|
6
|
+
import { loadSettings } from '../storage'
|
|
7
|
+
import type { ToolBuildContext } from './context'
|
|
8
|
+
|
|
9
|
+
interface SmtpConfig {
|
|
10
|
+
host: string
|
|
11
|
+
port: number
|
|
12
|
+
secure: boolean
|
|
13
|
+
username: string
|
|
14
|
+
password: string
|
|
15
|
+
fromAddress: string
|
|
16
|
+
fromName: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSmtpConfig(): SmtpConfig {
|
|
20
|
+
const settings = loadSettings()
|
|
21
|
+
const ps = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined)?.email ?? {}
|
|
22
|
+
return {
|
|
23
|
+
host: (ps.host as string) || '',
|
|
24
|
+
port: Number(ps.port) || 587,
|
|
25
|
+
secure: ps.secure === true || ps.secure === 'true',
|
|
26
|
+
username: (ps.username as string) || '',
|
|
27
|
+
password: (ps.password as string) || '',
|
|
28
|
+
fromAddress: (ps.fromAddress as string) || '',
|
|
29
|
+
fromName: (ps.fromName as string) || 'SwarmClaw Agent',
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Minimal SMTP client using raw sockets.
|
|
35
|
+
* Avoids nodemailer dependency — uses Node's built-in net/tls.
|
|
36
|
+
*/
|
|
37
|
+
async function sendSmtpEmail(cfg: SmtpConfig, to: string[], subject: string, body: string, html?: string): Promise<string> {
|
|
38
|
+
const net = await import('net')
|
|
39
|
+
const tls = await import('tls')
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const timeout = setTimeout(() => reject(new Error('SMTP timeout (30s)')), 30_000)
|
|
43
|
+
let socket: import('net').Socket
|
|
44
|
+
const lines: string[] = []
|
|
45
|
+
let phase = 'connect'
|
|
46
|
+
|
|
47
|
+
const cleanup = () => { clearTimeout(timeout); try { socket.destroy() } catch { /* ok */ } }
|
|
48
|
+
|
|
49
|
+
const readLine = (data: Buffer) => {
|
|
50
|
+
const text = data.toString()
|
|
51
|
+
lines.push(text)
|
|
52
|
+
const code = parseInt(text.slice(0, 3), 10)
|
|
53
|
+
return { text, code }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const send = (cmd: string) => { socket.write(cmd + '\r\n') }
|
|
57
|
+
|
|
58
|
+
// Build MIME message
|
|
59
|
+
const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).slice(2)}`
|
|
60
|
+
const date = new Date().toUTCString()
|
|
61
|
+
const msgId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${cfg.host}>`
|
|
62
|
+
const toHeader = to.join(', ')
|
|
63
|
+
|
|
64
|
+
let message = `From: ${cfg.fromName ? `"${cfg.fromName}" ` : ''}<${cfg.fromAddress}>\r\n`
|
|
65
|
+
message += `To: ${toHeader}\r\n`
|
|
66
|
+
message += `Subject: ${subject}\r\n`
|
|
67
|
+
message += `Date: ${date}\r\n`
|
|
68
|
+
message += `Message-ID: ${msgId}\r\n`
|
|
69
|
+
message += `MIME-Version: 1.0\r\n`
|
|
70
|
+
|
|
71
|
+
if (html) {
|
|
72
|
+
message += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`
|
|
73
|
+
message += `--${boundary}\r\n`
|
|
74
|
+
message += `Content-Type: text/plain; charset=utf-8\r\n\r\n`
|
|
75
|
+
message += body + '\r\n'
|
|
76
|
+
message += `--${boundary}\r\n`
|
|
77
|
+
message += `Content-Type: text/html; charset=utf-8\r\n\r\n`
|
|
78
|
+
message += html + '\r\n'
|
|
79
|
+
message += `--${boundary}--\r\n`
|
|
80
|
+
} else {
|
|
81
|
+
message += `Content-Type: text/plain; charset=utf-8\r\n\r\n`
|
|
82
|
+
message += body + '\r\n'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const connectOpts = { host: cfg.host, port: cfg.port }
|
|
86
|
+
|
|
87
|
+
const handleData = (data: Buffer) => {
|
|
88
|
+
const { code } = readLine(data)
|
|
89
|
+
|
|
90
|
+
switch (phase) {
|
|
91
|
+
case 'connect':
|
|
92
|
+
if (code === 220) { phase = 'ehlo'; send(`EHLO ${cfg.host}`) }
|
|
93
|
+
else { cleanup(); reject(new Error(`SMTP connect failed: ${data.toString().trim()}`)) }
|
|
94
|
+
break
|
|
95
|
+
case 'ehlo':
|
|
96
|
+
if (code === 250) {
|
|
97
|
+
if (cfg.secure && !('encrypted' in socket)) {
|
|
98
|
+
phase = 'starttls'; send('STARTTLS')
|
|
99
|
+
} else if (cfg.username) {
|
|
100
|
+
phase = 'auth'; send('AUTH LOGIN')
|
|
101
|
+
} else {
|
|
102
|
+
phase = 'mail_from'; send(`MAIL FROM:<${cfg.fromAddress}>`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
break
|
|
106
|
+
case 'starttls':
|
|
107
|
+
if (code === 220) {
|
|
108
|
+
const tlsSocket = tls.connect({ socket, host: cfg.host, rejectUnauthorized: false }, () => {
|
|
109
|
+
socket = tlsSocket as unknown as import('net').Socket
|
|
110
|
+
socket.on('data', handleData)
|
|
111
|
+
phase = 'ehlo2'; send(`EHLO ${cfg.host}`)
|
|
112
|
+
})
|
|
113
|
+
tlsSocket.on('error', (err: Error) => { cleanup(); reject(err) })
|
|
114
|
+
}
|
|
115
|
+
break
|
|
116
|
+
case 'ehlo2':
|
|
117
|
+
if (code === 250) {
|
|
118
|
+
if (cfg.username) { phase = 'auth'; send('AUTH LOGIN') }
|
|
119
|
+
else { phase = 'mail_from'; send(`MAIL FROM:<${cfg.fromAddress}>`) }
|
|
120
|
+
}
|
|
121
|
+
break
|
|
122
|
+
case 'auth':
|
|
123
|
+
if (code === 334) { phase = 'auth_user'; send(Buffer.from(cfg.username).toString('base64')) }
|
|
124
|
+
else { cleanup(); reject(new Error(`SMTP AUTH failed: ${data.toString().trim()}`)) }
|
|
125
|
+
break
|
|
126
|
+
case 'auth_user':
|
|
127
|
+
if (code === 334) { phase = 'auth_pass'; send(Buffer.from(cfg.password).toString('base64')) }
|
|
128
|
+
break
|
|
129
|
+
case 'auth_pass':
|
|
130
|
+
if (code === 235) { phase = 'mail_from'; send(`MAIL FROM:<${cfg.fromAddress}>`) }
|
|
131
|
+
else { cleanup(); reject(new Error(`SMTP auth failed: ${data.toString().trim()}`)) }
|
|
132
|
+
break
|
|
133
|
+
case 'mail_from':
|
|
134
|
+
if (code === 250) { phase = 'rcpt_to'; send(`RCPT TO:<${to[0]}>`) }
|
|
135
|
+
break
|
|
136
|
+
case 'rcpt_to':
|
|
137
|
+
if (code === 250) { phase = 'data'; send('DATA') }
|
|
138
|
+
else { cleanup(); reject(new Error(`SMTP RCPT rejected: ${data.toString().trim()}`)) }
|
|
139
|
+
break
|
|
140
|
+
case 'data':
|
|
141
|
+
if (code === 354) { phase = 'message'; send(message + '\r\n.') }
|
|
142
|
+
break
|
|
143
|
+
case 'message':
|
|
144
|
+
if (code === 250) { phase = 'quit'; send('QUIT'); cleanup(); resolve('Email sent successfully.') }
|
|
145
|
+
else { cleanup(); reject(new Error(`SMTP send failed: ${data.toString().trim()}`)) }
|
|
146
|
+
break
|
|
147
|
+
case 'quit':
|
|
148
|
+
cleanup()
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (cfg.secure && cfg.port === 465) {
|
|
154
|
+
socket = tls.connect({ ...connectOpts, rejectUnauthorized: false }, () => {
|
|
155
|
+
(socket as unknown as Record<string, boolean>).encrypted = true
|
|
156
|
+
}) as unknown as import('net').Socket
|
|
157
|
+
} else {
|
|
158
|
+
socket = net.createConnection(connectOpts)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
socket.on('data', handleData)
|
|
162
|
+
socket.on('error', (err: Error) => { cleanup(); reject(err) })
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function executeEmail(args: Record<string, unknown>): Promise<string> {
|
|
167
|
+
const normalized = normalizeToolInputArgs(args)
|
|
168
|
+
const action = String(normalized.action || 'send')
|
|
169
|
+
|
|
170
|
+
if (action === 'send') {
|
|
171
|
+
const to = normalized.to
|
|
172
|
+
const recipients: string[] = Array.isArray(to) ? to.map(String) : typeof to === 'string' ? to.split(/[,;\s]+/).filter(Boolean) : []
|
|
173
|
+
if (recipients.length === 0) return 'Error: "to" (recipient email addresses) is required.'
|
|
174
|
+
|
|
175
|
+
const subject = String(normalized.subject || '').trim()
|
|
176
|
+
if (!subject) return 'Error: "subject" is required.'
|
|
177
|
+
|
|
178
|
+
const body = String(normalized.body || '').trim()
|
|
179
|
+
if (!body) return 'Error: "body" (plain text content) is required.'
|
|
180
|
+
|
|
181
|
+
const html = typeof normalized.html === 'string' ? normalized.html : undefined
|
|
182
|
+
|
|
183
|
+
const cfg = getSmtpConfig()
|
|
184
|
+
if (!cfg.host) return 'Error: SMTP host not configured. Ask the user to configure email in Plugin Settings > Email.'
|
|
185
|
+
if (!cfg.fromAddress) return 'Error: From address not configured in email plugin settings.'
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const result = await sendSmtpEmail(cfg, recipients, subject, body, html)
|
|
189
|
+
return `${result}\nTo: ${recipients.join(', ')}\nSubject: ${subject}`
|
|
190
|
+
} catch (err: unknown) {
|
|
191
|
+
return `Error sending email: ${err instanceof Error ? err.message : String(err)}`
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (action === 'status') {
|
|
196
|
+
const cfg = getSmtpConfig()
|
|
197
|
+
if (!cfg.host) return 'Email plugin not configured. No SMTP host set.'
|
|
198
|
+
return JSON.stringify({
|
|
199
|
+
configured: true,
|
|
200
|
+
host: cfg.host,
|
|
201
|
+
port: cfg.port,
|
|
202
|
+
secure: cfg.secure,
|
|
203
|
+
from: cfg.fromAddress,
|
|
204
|
+
fromName: cfg.fromName,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return `Error: Unknown action "${action}". Use "send" or "status".`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const EmailPlugin: Plugin = {
|
|
212
|
+
name: 'Email',
|
|
213
|
+
enabledByDefault: false,
|
|
214
|
+
description: 'Send emails via SMTP. Supports plain text and HTML, multiple recipients.',
|
|
215
|
+
hooks: {
|
|
216
|
+
getCapabilityDescription: () =>
|
|
217
|
+
'I can send emails using `email`. Supports plain text and HTML bodies, multiple recipients.',
|
|
218
|
+
} as PluginHooks,
|
|
219
|
+
tools: [
|
|
220
|
+
{
|
|
221
|
+
name: 'email',
|
|
222
|
+
description: 'Send an email or check email configuration status. For sending: provide to, subject, and body. Optionally include html for rich formatting.',
|
|
223
|
+
parameters: {
|
|
224
|
+
type: 'object',
|
|
225
|
+
properties: {
|
|
226
|
+
action: { type: 'string', enum: ['send', 'status'], description: 'Action to perform (default: send)' },
|
|
227
|
+
to: {
|
|
228
|
+
anyOf: [
|
|
229
|
+
{ type: 'string', description: 'Recipient email address(es), comma-separated' },
|
|
230
|
+
{ type: 'array', items: { type: 'string' }, description: 'Array of recipient email addresses' },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
subject: { type: 'string', description: 'Email subject line' },
|
|
234
|
+
body: { type: 'string', description: 'Plain text email body' },
|
|
235
|
+
html: { type: 'string', description: 'Optional HTML email body (sent as multipart/alternative alongside plain text)' },
|
|
236
|
+
},
|
|
237
|
+
required: ['action'],
|
|
238
|
+
},
|
|
239
|
+
execute: async (args) => executeEmail(args),
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
ui: {
|
|
243
|
+
settingsFields: [
|
|
244
|
+
{
|
|
245
|
+
key: 'host',
|
|
246
|
+
label: 'SMTP Host',
|
|
247
|
+
type: 'text',
|
|
248
|
+
required: true,
|
|
249
|
+
placeholder: 'smtp.gmail.com',
|
|
250
|
+
help: 'SMTP server hostname.',
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
key: 'port',
|
|
254
|
+
label: 'SMTP Port',
|
|
255
|
+
type: 'number',
|
|
256
|
+
defaultValue: 587,
|
|
257
|
+
help: '587 for STARTTLS, 465 for SSL, 25 for unencrypted.',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
key: 'secure',
|
|
261
|
+
label: 'Use SSL/TLS (port 465)',
|
|
262
|
+
type: 'boolean',
|
|
263
|
+
defaultValue: false,
|
|
264
|
+
help: 'Enable for direct TLS connections (port 465). Leave off for STARTTLS (port 587).',
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
key: 'username',
|
|
268
|
+
label: 'Username',
|
|
269
|
+
type: 'text',
|
|
270
|
+
placeholder: 'you@gmail.com',
|
|
271
|
+
help: 'SMTP authentication username (usually your email address).',
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
key: 'password',
|
|
275
|
+
label: 'Password',
|
|
276
|
+
type: 'secret',
|
|
277
|
+
required: true,
|
|
278
|
+
placeholder: 'App password or SMTP password',
|
|
279
|
+
help: 'SMTP password. For Gmail, use an App Password.',
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
key: 'fromAddress',
|
|
283
|
+
label: 'From Address',
|
|
284
|
+
type: 'text',
|
|
285
|
+
required: true,
|
|
286
|
+
placeholder: 'agent@example.com',
|
|
287
|
+
help: 'The sender email address.',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
key: 'fromName',
|
|
291
|
+
label: 'From Name',
|
|
292
|
+
type: 'text',
|
|
293
|
+
defaultValue: 'SwarmClaw Agent',
|
|
294
|
+
placeholder: 'SwarmClaw Agent',
|
|
295
|
+
help: 'Display name shown to recipients.',
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
getPluginManager().registerBuiltin('email', EmailPlugin)
|
|
302
|
+
|
|
303
|
+
export function buildEmailTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
304
|
+
if (!bctx.hasPlugin('email')) return []
|
|
305
|
+
|
|
306
|
+
return [
|
|
307
|
+
tool(
|
|
308
|
+
async (args) => executeEmail(args),
|
|
309
|
+
{
|
|
310
|
+
name: 'email',
|
|
311
|
+
description: EmailPlugin.tools![0].description,
|
|
312
|
+
schema: z.object({
|
|
313
|
+
action: z.enum(['send', 'status']).optional().describe('Action (default: send)'),
|
|
314
|
+
to: z.union([z.string(), z.array(z.string())]).optional().describe('Recipient email address(es)'),
|
|
315
|
+
subject: z.string().optional().describe('Email subject line'),
|
|
316
|
+
body: z.string().optional().describe('Plain text email body'),
|
|
317
|
+
html: z.string().optional().describe('Optional HTML body'),
|
|
318
|
+
}),
|
|
319
|
+
},
|
|
320
|
+
),
|
|
321
|
+
]
|
|
322
|
+
}
|
|
@@ -144,6 +144,7 @@ export function normalizeSendFilePaths(args: Record<string, unknown>): string[]
|
|
|
144
144
|
const candidates: string[] = []
|
|
145
145
|
collectSendFilePaths(args.filePath, candidates)
|
|
146
146
|
collectSendFilePaths(args.path, candidates)
|
|
147
|
+
collectSendFilePaths(args.file, candidates)
|
|
147
148
|
collectSendFilePaths(args.files, candidates)
|
|
148
149
|
|
|
149
150
|
const nestedInput = args.input
|
|
@@ -202,7 +203,9 @@ async function executeSendFile(args: Record<string, unknown>, bctx: { cwd: strin
|
|
|
202
203
|
const FilePlugin: Plugin = {
|
|
203
204
|
name: 'Core Files',
|
|
204
205
|
description: 'Complete file management: read, write, list, move, copy, delete, and send.',
|
|
205
|
-
hooks: {
|
|
206
|
+
hooks: {
|
|
207
|
+
getCapabilityDescription: () => 'I can read, write, copy, move, and send files (`read_file`, `write_file`, `list_files`, `copy_file`, `move_file`, `send_file`). Deleting files is destructive, so that may need explicit permission.',
|
|
208
|
+
} as PluginHooks,
|
|
206
209
|
tools: [
|
|
207
210
|
{
|
|
208
211
|
name: 'files',
|
|
@@ -265,7 +268,7 @@ getPluginManager().registerBuiltin('files', FilePlugin)
|
|
|
265
268
|
* Legacy Bridge
|
|
266
269
|
*/
|
|
267
270
|
export function buildFileTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
268
|
-
if (!bctx.
|
|
271
|
+
if (!bctx.hasPlugin('files')) return []
|
|
269
272
|
|
|
270
273
|
return [
|
|
271
274
|
tool(
|
|
@@ -97,7 +97,7 @@ getPluginManager().registerBuiltin('git', GitPlugin)
|
|
|
97
97
|
* Legacy Bridge
|
|
98
98
|
*/
|
|
99
99
|
export function buildGitTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
100
|
-
if (!bctx.
|
|
100
|
+
if (!bctx.hasPlugin('git')) return []
|
|
101
101
|
return [
|
|
102
102
|
tool(
|
|
103
103
|
async (args) => executeGitAction(args, { cwd: bctx.cwd }),
|
|
@@ -97,7 +97,7 @@ getPluginManager().registerBuiltin('http', HttpPlugin)
|
|
|
97
97
|
* Legacy Bridge
|
|
98
98
|
*/
|
|
99
99
|
export function buildHttpTools(bctx: ToolBuildContext): StructuredToolInterface[] {
|
|
100
|
-
if (!bctx.
|
|
100
|
+
if (!bctx.hasPlugin('http_request')) return []
|
|
101
101
|
|
|
102
102
|
return [
|
|
103
103
|
tool(
|