@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.
Files changed (119) hide show
  1. package/README.md +85 -139
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/thread/route.ts +1 -2
  4. package/src/app/api/agents/route.ts +1 -1
  5. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  6. package/src/app/api/{sessions → chats}/[id]/main-loop/route.ts +2 -2
  7. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  8. package/src/app/api/{sessions → chats}/[id]/route.ts +4 -52
  9. package/src/app/api/{sessions → chats}/route.ts +5 -7
  10. package/src/app/api/plugins/route.ts +3 -0
  11. package/src/app/api/plugins/settings/route.ts +35 -0
  12. package/src/app/api/usage/route.ts +30 -0
  13. package/src/cli/index.js +35 -33
  14. package/src/cli/index.ts +40 -39
  15. package/src/cli/spec.js +29 -27
  16. package/src/components/agents/agent-card.tsx +1 -1
  17. package/src/components/agents/agent-chat-list.tsx +3 -3
  18. package/src/components/agents/agent-list.tsx +8 -13
  19. package/src/components/agents/agent-sheet.tsx +2 -2
  20. package/src/components/agents/cron-job-form.tsx +3 -3
  21. package/src/components/agents/inspector-panel.tsx +2 -2
  22. package/src/components/auth/setup-wizard.tsx +5 -38
  23. package/src/components/chat/chat-area.tsx +10 -14
  24. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +3 -3
  25. package/src/components/chat/chat-header.tsx +156 -73
  26. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +4 -5
  27. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  28. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  29. package/src/components/chat/message-bubble.tsx +4 -1
  30. package/src/components/chat/message-list.tsx +2 -2
  31. package/src/components/{sessions/new-session-sheet.tsx → chat/new-chat-sheet.tsx} +6 -6
  32. package/src/components/chat/session-debug-panel.tsx +1 -1
  33. package/src/components/chat/tool-request-banner.tsx +3 -3
  34. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  35. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  36. package/src/components/connectors/connector-sheet.tsx +1 -1
  37. package/src/components/home/home-view.tsx +1 -1
  38. package/src/components/layout/app-layout.tsx +23 -2
  39. package/src/components/plugins/plugin-list.tsx +475 -254
  40. package/src/components/plugins/plugin-sheet.tsx +124 -10
  41. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  42. package/src/components/shared/command-palette.tsx +0 -1
  43. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  44. package/src/components/shared/settings/section-providers.tsx +1 -1
  45. package/src/components/shared/settings/settings-page.tsx +1 -12
  46. package/src/components/usage/metrics-dashboard.tsx +73 -0
  47. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  48. package/src/lib/chat.ts +1 -1
  49. package/src/lib/{sessions.ts → chats.ts} +28 -18
  50. package/src/lib/providers/claude-cli.ts +1 -1
  51. package/src/lib/server/approvals.ts +4 -4
  52. package/src/lib/server/capability-router.ts +10 -8
  53. package/src/lib/server/chat-execution.ts +36 -105
  54. package/src/lib/server/chatroom-helpers.ts +3 -3
  55. package/src/lib/server/connectors/manager.ts +4 -4
  56. package/src/lib/server/cost.ts +34 -1
  57. package/src/lib/server/daemon-state.ts +2 -2
  58. package/src/lib/server/heartbeat-service.ts +1 -1
  59. package/src/lib/server/main-agent-loop.ts +25 -160
  60. package/src/lib/server/main-session.ts +6 -13
  61. package/src/lib/server/orchestrator-lg.ts +3 -3
  62. package/src/lib/server/orchestrator.ts +5 -5
  63. package/src/lib/server/plugins.ts +112 -4
  64. package/src/lib/server/provider-health.ts +5 -3
  65. package/src/lib/server/queue.ts +12 -10
  66. package/src/lib/server/session-run-manager.test.ts +9 -6
  67. package/src/lib/server/session-run-manager.ts +1 -3
  68. package/src/lib/server/session-tools/calendar.ts +376 -0
  69. package/src/lib/server/session-tools/canvas.ts +1 -1
  70. package/src/lib/server/session-tools/chatroom.ts +4 -2
  71. package/src/lib/server/session-tools/connector.ts +5 -2
  72. package/src/lib/server/session-tools/context.ts +7 -3
  73. package/src/lib/server/session-tools/crud.ts +14 -6
  74. package/src/lib/server/session-tools/delegate.ts +95 -8
  75. package/src/lib/server/session-tools/discovery.ts +2 -2
  76. package/src/lib/server/session-tools/edit_file.ts +4 -2
  77. package/src/lib/server/session-tools/email.ts +322 -0
  78. package/src/lib/server/session-tools/file.ts +5 -2
  79. package/src/lib/server/session-tools/git.ts +1 -1
  80. package/src/lib/server/session-tools/http.ts +1 -1
  81. package/src/lib/server/session-tools/image-gen.ts +382 -0
  82. package/src/lib/server/session-tools/index.ts +74 -49
  83. package/src/lib/server/session-tools/memory.ts +139 -2
  84. package/src/lib/server/session-tools/monitor.ts +1 -1
  85. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  86. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  87. package/src/lib/server/session-tools/platform.ts +6 -3
  88. package/src/lib/server/session-tools/plugin-creator.ts +3 -3
  89. package/src/lib/server/session-tools/replicate.ts +303 -0
  90. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  91. package/src/lib/server/session-tools/sandbox.ts +4 -2
  92. package/src/lib/server/session-tools/schedule.ts +4 -2
  93. package/src/lib/server/session-tools/session-info.ts +7 -4
  94. package/src/lib/server/session-tools/shell.ts +5 -2
  95. package/src/lib/server/session-tools/subagent.ts +2 -2
  96. package/src/lib/server/session-tools/wallet.ts +29 -2
  97. package/src/lib/server/session-tools/web.ts +44 -5
  98. package/src/lib/server/storage.ts +29 -9
  99. package/src/lib/server/stream-agent-chat.ts +72 -249
  100. package/src/lib/server/tool-aliases.ts +26 -15
  101. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  102. package/src/lib/server/tool-capability-policy.ts +32 -27
  103. package/src/lib/tool-definitions.ts +4 -0
  104. package/src/lib/validation/schemas.ts +3 -1
  105. package/src/stores/use-app-store.ts +5 -5
  106. package/src/stores/use-chat-store.ts +7 -7
  107. package/src/types/index.ts +65 -3
  108. /package/src/app/api/{sessions → chats}/[id]/browser/route.ts +0 -0
  109. /package/src/app/api/{sessions → chats}/[id]/chat/route.ts +0 -0
  110. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  111. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  112. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  113. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  114. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  115. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  116. /package/src/app/api/{sessions → chats}/[id]/messages/route.ts +0 -0
  117. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  118. /package/src/app/api/{sessions → chats}/[id]/stop/route.ts +0 -0
  119. /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: {} as PluginHooks,
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 { hasTool } = bctx
452
+ const { hasPlugin } = bctx
366
453
 
367
- if (hasTool('delegate')) {
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 { toolIdMatches } from '../tool-aliases'
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 && toolIdMatches(currentSession.tools, pluginId)) {
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: {} as PluginHooks,
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.hasTool('edit_file')) return []
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: {} as PluginHooks,
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.hasTool('files')) return []
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.hasTool('git')) return []
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.hasTool('http_request')) return []
100
+ if (!bctx.hasPlugin('http_request')) return []
101
101
 
102
102
  return [
103
103
  tool(