@swarmclawai/swarmclaw 0.3.0 → 0.4.0

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 (118) hide show
  1. package/README.md +20 -11
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +2 -0
  6. package/package.json +3 -1
  7. package/src/app/api/agents/[id]/route.ts +3 -0
  8. package/src/app/api/agents/[id]/thread/route.ts +2 -1
  9. package/src/app/api/agents/route.ts +5 -1
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/connectors/[id]/route.ts +4 -0
  13. package/src/app/api/connectors/route.ts +6 -1
  14. package/src/app/api/credentials/route.ts +3 -1
  15. package/src/app/api/daemon/route.ts +6 -1
  16. package/src/app/api/ip/route.ts +3 -1
  17. package/src/app/api/mcp-servers/route.ts +3 -1
  18. package/src/app/api/orchestrator/graph/route.ts +25 -0
  19. package/src/app/api/plugins/marketplace/route.ts +3 -1
  20. package/src/app/api/plugins/route.ts +3 -1
  21. package/src/app/api/providers/[id]/route.ts +3 -0
  22. package/src/app/api/providers/configs/route.ts +3 -1
  23. package/src/app/api/providers/route.ts +5 -1
  24. package/src/app/api/schedules/[id]/route.ts +3 -0
  25. package/src/app/api/schedules/route.ts +6 -1
  26. package/src/app/api/secrets/route.ts +3 -1
  27. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  28. package/src/app/api/sessions/route.ts +9 -2
  29. package/src/app/api/settings/route.ts +3 -1
  30. package/src/app/api/setup/doctor/route.ts +1 -0
  31. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  32. package/src/app/api/skills/route.ts +3 -1
  33. package/src/app/api/tasks/[id]/approve/route.ts +73 -0
  34. package/src/app/api/tasks/[id]/route.ts +3 -0
  35. package/src/app/api/tasks/route.ts +3 -0
  36. package/src/app/api/usage/route.ts +3 -1
  37. package/src/app/api/version/route.ts +3 -1
  38. package/src/app/api/webhooks/[id]/route.ts +2 -1
  39. package/src/app/api/webhooks/route.ts +3 -1
  40. package/src/app/icon.svg +58 -0
  41. package/src/app/page.tsx +8 -2
  42. package/src/cli/index.js +1 -9
  43. package/src/cli/index.ts +51 -1
  44. package/src/cli/spec.js +0 -8
  45. package/src/components/agents/agent-card.tsx +1 -1
  46. package/src/components/agents/agent-sheet.tsx +63 -80
  47. package/src/components/chat/chat-area.tsx +44 -30
  48. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  49. package/src/components/chat/message-bubble.tsx +110 -42
  50. package/src/components/chat/tool-call-bubble.tsx +41 -3
  51. package/src/components/chat/tool-request-banner.tsx +1 -9
  52. package/src/components/connectors/connector-list.tsx +3 -8
  53. package/src/components/connectors/connector-sheet.tsx +24 -29
  54. package/src/components/input/chat-input.tsx +72 -56
  55. package/src/components/knowledge/knowledge-list.tsx +27 -31
  56. package/src/components/layout/app-layout.tsx +92 -71
  57. package/src/components/layout/daemon-indicator.tsx +3 -5
  58. package/src/components/logs/log-list.tsx +5 -9
  59. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  60. package/src/components/memory/memory-detail.tsx +1 -1
  61. package/src/components/plugins/plugin-list.tsx +227 -27
  62. package/src/components/providers/provider-list.tsx +46 -13
  63. package/src/components/providers/provider-sheet.tsx +0 -45
  64. package/src/components/runs/run-list.tsx +6 -15
  65. package/src/components/schedules/schedule-card.tsx +54 -4
  66. package/src/components/schedules/schedule-list.tsx +6 -3
  67. package/src/components/schedules/schedule-sheet.tsx +0 -47
  68. package/src/components/secrets/secrets-list.tsx +20 -2
  69. package/src/components/sessions/new-session-sheet.tsx +8 -9
  70. package/src/components/shared/connector-platform-icon.tsx +22 -20
  71. package/src/components/shared/model-combobox.tsx +148 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +7 -39
  73. package/src/components/shared/settings/section-orchestrator.tsx +8 -9
  74. package/src/components/skills/skill-list.tsx +260 -34
  75. package/src/components/skills/skill-sheet.tsx +0 -45
  76. package/src/components/tasks/task-board.tsx +3 -6
  77. package/src/components/tasks/task-card.tsx +43 -1
  78. package/src/components/tasks/task-list.tsx +3 -5
  79. package/src/components/tasks/task-sheet.tsx +0 -44
  80. package/src/components/usage/usage-list.tsx +12 -4
  81. package/src/hooks/use-ws.ts +66 -0
  82. package/src/instrumentation.ts +2 -0
  83. package/src/lib/chat.ts +14 -2
  84. package/src/lib/providers/anthropic.ts +1 -1
  85. package/src/lib/providers/index.ts +2 -0
  86. package/src/lib/providers/ollama.ts +1 -1
  87. package/src/lib/providers/openai.ts +33 -12
  88. package/src/lib/server/chat-execution.ts +19 -4
  89. package/src/lib/server/connectors/manager.ts +9 -3
  90. package/src/lib/server/context-manager.ts +1 -1
  91. package/src/lib/server/daemon-state.ts +3 -0
  92. package/src/lib/server/data-dir.ts +1 -0
  93. package/src/lib/server/heartbeat-service.ts +67 -3
  94. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  95. package/src/lib/server/main-agent-loop.ts +61 -2
  96. package/src/lib/server/orchestrator-lg.ts +394 -13
  97. package/src/lib/server/orchestrator.ts +25 -5
  98. package/src/lib/server/queue.ts +17 -3
  99. package/src/lib/server/session-run-manager.ts +6 -1
  100. package/src/lib/server/session-tools/delegate.ts +2 -2
  101. package/src/lib/server/session-tools/index.ts +2 -0
  102. package/src/lib/server/session-tools/sandbox.ts +164 -0
  103. package/src/lib/server/storage-mcp.test.ts +25 -2
  104. package/src/lib/server/storage.ts +24 -7
  105. package/src/lib/server/stream-agent-chat.ts +77 -22
  106. package/src/lib/server/task-validation.test.ts +23 -0
  107. package/src/lib/server/task-validation.ts +5 -3
  108. package/src/lib/server/ws-hub.ts +85 -0
  109. package/src/lib/tool-definitions.ts +42 -0
  110. package/src/lib/upload.ts +7 -1
  111. package/src/lib/ws-client.ts +124 -0
  112. package/src/stores/use-chat-store.ts +33 -13
  113. package/src/types/index.ts +8 -1
  114. package/src/app/api/agents/generate/route.ts +0 -42
  115. package/src/app/api/generate/info/route.ts +0 -12
  116. package/src/app/api/generate/route.ts +0 -106
  117. package/src/app/favicon.ico +0 -0
  118. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -619,8 +619,8 @@ export function buildDelegateTools(bctx: ToolBuildContext): StructuredToolInterf
619
619
  }
620
620
  }
621
621
 
622
- // delegate_to_agent: requires orchestrator capability to be enabled
623
- if (bctx.activeTools.includes('orchestrator') && ctx?.agentId) {
622
+ // delegate_to_agent: requires "Assign to Other Agents" (platformAssignScope: 'all')
623
+ if (ctx?.platformAssignScope === 'all' && ctx?.agentId) {
624
624
  tools.push(
625
625
  tool(
626
626
  async ({ agentId: targetAgentId, task: taskPrompt, description: taskDesc, startImmediately }) => {
@@ -14,6 +14,7 @@ import { buildCrudTools } from './crud'
14
14
  import { buildSessionInfoTools } from './session-info'
15
15
  import { buildConnectorTools } from './connector'
16
16
  import { buildContextTools } from './context-mgmt'
17
+ import { buildSandboxTools } from './sandbox'
17
18
 
18
19
  export type { ToolContext, SessionToolsResult }
19
20
  export { sweepOrphanedBrowsers, cleanupSessionBrowser, getActiveBrowserCount, hasActiveBrowser }
@@ -93,6 +94,7 @@ export async function buildSessionTools(cwd: string, enabledTools: string[], ctx
93
94
  ...buildSessionInfoTools(bctx),
94
95
  ...buildConnectorTools(bctx),
95
96
  ...buildContextTools(bctx),
97
+ ...buildSandboxTools(bctx),
96
98
  )
97
99
 
98
100
  // ---------------------------------------------------------------------------
@@ -0,0 +1,164 @@
1
+ import { z } from 'zod'
2
+ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
+ import fs from 'fs'
4
+ import path from 'path'
5
+ import { spawnSync } from 'child_process'
6
+ import { UPLOAD_DIR } from '../storage'
7
+ import { findBinaryOnPath, truncate, MAX_OUTPUT } from './context'
8
+ import type { ToolBuildContext } from './context'
9
+
10
+ function getDenoPath(): string | null {
11
+ return findBinaryOnPath('deno')
12
+ }
13
+
14
+ function getPythonPath(): string | null {
15
+ return findBinaryOnPath('python3') ?? findBinaryOnPath('python')
16
+ }
17
+
18
+ const EXT_MAP: Record<string, string> = {
19
+ javascript: 'js',
20
+ typescript: 'ts',
21
+ python: 'py',
22
+ }
23
+
24
+ export function buildSandboxTools(bctx: ToolBuildContext): StructuredToolInterface[] {
25
+ if (!bctx.hasTool('sandbox')) return []
26
+
27
+ const tools: StructuredToolInterface[] = []
28
+
29
+ tools.push(
30
+ tool(
31
+ async ({ language, code, timeoutSec }) => {
32
+ const timeout = Math.min(Math.max(timeoutSec ?? 60, 5), 300) * 1000
33
+ const ext = EXT_MAP[language]
34
+ const sessionId = bctx.ctx?.sessionId ?? 'unknown'
35
+ const sandboxDir = path.join('/tmp', `swarmclaw-sandbox-${sessionId}-${Date.now()}`)
36
+
37
+ // Check runtime availability
38
+ if ((language === 'javascript' || language === 'typescript') && !getDenoPath()) {
39
+ return JSON.stringify({ error: 'Deno is not installed. Install it with: curl -fsSL https://deno.land/install.sh | sh' })
40
+ }
41
+ if (language === 'python' && !getPythonPath()) {
42
+ return JSON.stringify({ error: 'Python is not installed. Install python3 to use Python sandbox.' })
43
+ }
44
+
45
+ try {
46
+ fs.mkdirSync(sandboxDir, { recursive: true })
47
+ const scriptFile = `script.${ext}`
48
+ const scriptPath = path.join(sandboxDir, scriptFile)
49
+ fs.writeFileSync(scriptPath, code, 'utf-8')
50
+
51
+ let result: ReturnType<typeof spawnSync>
52
+
53
+ if (language === 'javascript' || language === 'typescript') {
54
+ const denoPath = getDenoPath()!
55
+ result = spawnSync(denoPath, [
56
+ 'run',
57
+ '--allow-read=.',
58
+ '--allow-write=.',
59
+ '--allow-net',
60
+ '--deny-env',
61
+ '--no-prompt',
62
+ scriptFile,
63
+ ], {
64
+ cwd: sandboxDir,
65
+ encoding: 'utf-8',
66
+ timeout,
67
+ maxBuffer: MAX_OUTPUT,
68
+ })
69
+ } else {
70
+ const pythonPath = getPythonPath()!
71
+ result = spawnSync(pythonPath, [scriptPath], {
72
+ cwd: sandboxDir,
73
+ encoding: 'utf-8',
74
+ timeout,
75
+ maxBuffer: MAX_OUTPUT,
76
+ env: { PATH: process.env.PATH || '/usr/bin:/bin' } as unknown as NodeJS.ProcessEnv,
77
+ })
78
+ }
79
+
80
+ const stdout = truncate((result.stdout || '').toString(), MAX_OUTPUT)
81
+ const stderr = truncate((result.stderr || '').toString(), MAX_OUTPUT)
82
+ const exitCode = result.status ?? (result.error ? 1 : 0)
83
+ const timedOut = result.error?.message?.includes('ETIMEDOUT') || result.signal === 'SIGTERM'
84
+
85
+ // Scan for created files (exclude the script itself)
86
+ const artifacts: { name: string; url: string }[] = []
87
+ try {
88
+ const files = fs.readdirSync(sandboxDir)
89
+ for (const file of files) {
90
+ if (file === scriptFile) continue
91
+ const src = path.join(sandboxDir, file)
92
+ const stat = fs.statSync(src)
93
+ if (!stat.isFile()) continue
94
+ // Copy to upload dir
95
+ fs.mkdirSync(UPLOAD_DIR, { recursive: true })
96
+ const destName = `sandbox-${Date.now()}-${file}`
97
+ const dest = path.join(UPLOAD_DIR, destName)
98
+ fs.copyFileSync(src, dest)
99
+ artifacts.push({
100
+ name: file,
101
+ url: `/api/uploads/${encodeURIComponent(destName)}`,
102
+ })
103
+ }
104
+ } catch {
105
+ // ignore scan errors
106
+ }
107
+
108
+ return JSON.stringify({
109
+ exitCode,
110
+ timedOut,
111
+ stdout,
112
+ stderr,
113
+ artifacts,
114
+ })
115
+ } catch (err: unknown) {
116
+ return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
117
+ } finally {
118
+ try { fs.rmSync(sandboxDir, { recursive: true, force: true }) } catch { /* ignore */ }
119
+ }
120
+ },
121
+ {
122
+ name: 'sandbox_exec',
123
+ description:
124
+ 'Execute code in an isolated sandbox. JS/TS runs via Deno with network access but no env vars. Python runs with a stripped environment. ' +
125
+ 'Files created in the sandbox directory are returned as downloadable artifact URLs. Use this for data processing, API calls, calculations, and file generation.',
126
+ schema: z.object({
127
+ language: z.enum(['javascript', 'typescript', 'python']).describe('Programming language to execute'),
128
+ code: z.string().describe('Source code to run'),
129
+ timeoutSec: z.number().optional().describe('Execution timeout in seconds (default 60, max 300)'),
130
+ }),
131
+ },
132
+ ),
133
+ )
134
+
135
+ tools.push(
136
+ tool(
137
+ async () => {
138
+ const denoPath = getDenoPath()
139
+ const pythonPath = getPythonPath()
140
+
141
+ const runtimes: Record<string, { available: boolean; path: string | null; version: string | null }> = {}
142
+
143
+ for (const [name, bin] of [['deno', denoPath], ['python', pythonPath]] as const) {
144
+ if (bin) {
145
+ const ver = spawnSync(bin, ['--version'], { encoding: 'utf-8', timeout: 3000 })
146
+ const version = (ver.stdout || '').split('\n')[0]?.trim() || null
147
+ runtimes[name] = { available: true, path: bin, version }
148
+ } else {
149
+ runtimes[name] = { available: false, path: null, version: null }
150
+ }
151
+ }
152
+
153
+ return JSON.stringify(runtimes)
154
+ },
155
+ {
156
+ name: 'sandbox_list_runtimes',
157
+ description: 'List available sandbox runtimes (Deno for JS/TS, Python) and their versions. Use this to check what languages are available before running code.',
158
+ schema: z.object({}),
159
+ },
160
+ ),
161
+ )
162
+
163
+ return tools
164
+ }
@@ -33,8 +33,15 @@ function loadMcpServers(): Record<string, any> {
33
33
  }
34
34
 
35
35
  function saveMcpServers(m: Record<string, any>) {
36
+ const existingRows = db.prepare(`SELECT id FROM ${TABLE}`).all() as { id: string }[]
37
+ const nextIds = new Set(Object.keys(m))
38
+ const toDelete = existingRows.map((r) => r.id).filter((id) => !nextIds.has(id))
36
39
  const upsert = db.prepare(`INSERT OR REPLACE INTO ${TABLE} (id, data) VALUES (?, ?)`)
40
+ const del = db.prepare(`DELETE FROM ${TABLE} WHERE id = ?`)
37
41
  const transaction = db.transaction(() => {
42
+ for (const id of toDelete) {
43
+ del.run(id)
44
+ }
38
45
  for (const [id, val] of Object.entries(m)) {
39
46
  upsert.run(id, JSON.stringify(val))
40
47
  }
@@ -75,8 +82,10 @@ describe('MCP server storage', () => {
75
82
  })
76
83
 
77
84
  it('loadMcpServers returns all saved configs', () => {
78
- // srv-1 already exists from previous test
79
- saveMcpServers({ 'srv-2': { id: 'srv-2', name: 'Second' } })
85
+ saveMcpServers({
86
+ 'srv-1': { id: 'srv-1', name: 'First' },
87
+ 'srv-2': { id: 'srv-2', name: 'Second' },
88
+ })
80
89
 
81
90
  const all = loadMcpServers()
82
91
  assert.ok('srv-1' in all)
@@ -102,6 +111,20 @@ describe('MCP server storage', () => {
102
111
  assert.equal(count, 1)
103
112
  })
104
113
 
114
+ it('saveMcpServers removes records omitted from the next save payload', () => {
115
+ saveMcpServers({
116
+ 'srv-a': { id: 'srv-a', name: 'A' },
117
+ 'srv-b': { id: 'srv-b', name: 'B' },
118
+ })
119
+ saveMcpServers({
120
+ 'srv-b': { id: 'srv-b', name: 'B2' },
121
+ })
122
+
123
+ const all = loadMcpServers()
124
+ assert.equal('srv-a' in all, false)
125
+ assert.equal(all['srv-b'].name, 'B2')
126
+ })
127
+
105
128
  it('deleteMcpServer removes the record', () => {
106
129
  saveMcpServers({ 'srv-d': { id: 'srv-d', name: 'ToDelete' } })
107
130
  deleteMcpServer('srv-d')
@@ -4,11 +4,11 @@ import crypto from 'crypto'
4
4
  import os from 'os'
5
5
  import Database from 'better-sqlite3'
6
6
 
7
- import { DATA_DIR } from './data-dir'
8
- export const UPLOAD_DIR = path.join(os.tmpdir(), 'swarmclaw-uploads')
7
+ import { DATA_DIR, WORKSPACE_DIR } from './data-dir'
8
+ export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
9
9
 
10
10
  // Ensure directories exist
11
- for (const dir of [DATA_DIR, UPLOAD_DIR]) {
11
+ for (const dir of [DATA_DIR, UPLOAD_DIR, WORKSPACE_DIR]) {
12
12
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
13
13
  }
14
14
 
@@ -65,8 +65,8 @@ function readCollectionRaw(table: string): Map<string, string> {
65
65
  }
66
66
 
67
67
  function getCollectionRawCache(table: string): Map<string, string> {
68
- const cached = collectionCache.get(table)
69
- if (cached) return cached
68
+ // Always reload from SQLite so concurrent Next.js workers/processes
69
+ // observe each other's writes immediately.
70
70
  const loaded = readCollectionRaw(table)
71
71
  collectionCache.set(table, loaded)
72
72
  return loaded
@@ -87,26 +87,43 @@ function loadCollection(table: string): Record<string, any> {
87
87
 
88
88
  function saveCollection(table: string, data: Record<string, any>) {
89
89
  const current = getCollectionRawCache(table)
90
+ const next = new Map<string, string>()
90
91
  const toUpsert: Array<[string, string]> = []
92
+ const toDelete: string[] = []
91
93
 
92
94
  for (const [id, val] of Object.entries(data)) {
93
95
  const serialized = JSON.stringify(val)
94
96
  if (typeof serialized !== 'string') continue
97
+ next.set(id, serialized)
95
98
  if (current.get(id) !== serialized) {
96
99
  toUpsert.push([id, serialized])
97
100
  }
98
- current.set(id, serialized)
99
101
  }
100
102
 
101
- if (!toUpsert.length) return
103
+ for (const id of current.keys()) {
104
+ if (!next.has(id)) toDelete.push(id)
105
+ }
106
+
107
+ if (!toUpsert.length && !toDelete.length) return
102
108
 
103
109
  const transaction = db.transaction(() => {
110
+ if (toDelete.length) {
111
+ const del = db.prepare(`DELETE FROM ${table} WHERE id = ?`)
112
+ for (const id of toDelete) del.run(id)
113
+ }
104
114
  const upsert = db.prepare(`INSERT OR REPLACE INTO ${table} (id, data) VALUES (?, ?)`)
105
115
  for (const [id, serialized] of toUpsert) {
106
116
  upsert.run(id, serialized)
107
117
  }
108
118
  })
109
119
  transaction()
120
+
121
+ for (const id of toDelete) {
122
+ current.delete(id)
123
+ }
124
+ for (const [id, serialized] of next.entries()) {
125
+ current.set(id, serialized)
126
+ }
110
127
  }
111
128
 
112
129
  function deleteCollectionItem(table: string, id: string) {
@@ -15,6 +15,7 @@ interface StreamAgentChatOpts {
15
15
  session: Session
16
16
  message: string
17
17
  imagePath?: string
18
+ attachedFiles?: string[]
18
19
  apiKey: string | null
19
20
  systemPrompt?: string
20
21
  write: (data: string) => void
@@ -38,6 +39,7 @@ function buildToolCapabilityLines(enabledTools: string[]): string[] {
38
39
  if (enabledTools.includes('codex_cli')) lines.push('- Codex delegation is available (`delegate_to_codex_cli`) for deep coding/refactor tasks. Resume IDs may be returned via `[delegate_meta]`.')
39
40
  if (enabledTools.includes('opencode_cli')) lines.push('- OpenCode delegation is available (`delegate_to_opencode_cli`) for deep coding/refactor tasks. Resume IDs may be returned via `[delegate_meta]`.')
40
41
  if (enabledTools.includes('memory')) lines.push('- Long-term memory is available (`memory_tool`) to store and recall durable context.')
42
+ if (enabledTools.includes('sandbox')) lines.push('- Sandboxed code execution is available (`sandbox_exec`). Write and run JS/TS (Deno) or Python scripts in an isolated environment. Output includes stdout, stderr, and any files created as downloadable artifacts.')
41
43
  if (enabledTools.includes('manage_agents')) lines.push('- Agent management is available (`manage_agents`) to create or adjust specialist agents.')
42
44
  if (enabledTools.includes('manage_tasks')) lines.push('- Task management is available (`manage_tasks`) to create and track execution plans.')
43
45
  if (enabledTools.includes('manage_schedules')) lines.push('- Schedule management is available (`manage_schedules`) for recurring/ongoing runs.')
@@ -162,7 +164,7 @@ export interface StreamAgentChatResult {
162
164
  }
163
165
 
164
166
  export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<StreamAgentChatResult> {
165
- const { session, message, imagePath, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
167
+ const { session, message, imagePath, attachedFiles, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
166
168
 
167
169
  // fallbackCredentialIds is intentionally accepted for compatibility with caller signatures.
168
170
  void fallbackCredentialIds
@@ -310,7 +312,6 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
310
312
  const allToolIds = [
311
313
  'shell', 'files', 'edit_file', 'process', 'web_search', 'web_fetch', 'browser', 'memory',
312
314
  'claude_code', 'codex_cli', 'opencode_cli',
313
- 'orchestrator',
314
315
  'manage_agents', 'manage_tasks', 'manage_schedules', 'manage_skills',
315
316
  'manage_documents', 'manage_webhooks', 'manage_connectors', 'manage_sessions', 'manage_secrets',
316
317
  ]
@@ -318,13 +319,9 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
318
319
  const mcpDisabled = agentMcpDisabledTools ?? []
319
320
  const allDisabled = [...disabled, ...mcpDisabled]
320
321
  if (allDisabled.length > 0) {
321
- const delegateNote = disabled.includes('orchestrator')
322
- ? '\n\nIMPORTANT: The `delegate_to_agent` tool requires the `orchestrator` capability to be enabled. You must request access to `orchestrator` before you can delegate work to other agents.'
323
- : ''
324
322
  stateModifierParts.push(
325
323
  `## Disabled Tools\nThe following tools exist but are not enabled for you: ${allDisabled.join(', ')}.\n` +
326
- 'If you need one of these to complete a task, use the `request_tool_access` tool to ask the user for permission.' +
327
- delegateNote,
324
+ 'If you need one of these to complete a task, use the `request_tool_access` tool to ask the user for permission.',
328
325
  )
329
326
  }
330
327
  }
@@ -354,25 +351,82 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
354
351
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
355
352
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
356
353
 
357
- function buildLangChainContent(text: string, filePath?: string): any {
358
- if (!filePath || !fs.existsSync(filePath)) return text
354
+ async function buildContentForFile(filePath: string): Promise<{ type: string; [k: string]: any } | string | null> {
355
+ if (!fs.existsSync(filePath)) {
356
+ console.log(`[stream-agent-chat] FILE NOT FOUND: ${filePath}`)
357
+ return null
358
+ }
359
+ const name = filePath.split('/').pop() || 'file'
359
360
  if (IMAGE_EXTS.test(filePath)) {
360
- const data = fs.readFileSync(filePath).toString('base64')
361
+ const buf = fs.readFileSync(filePath)
362
+ if (buf.length === 0) {
363
+ console.warn(`[stream-agent-chat] Image file is empty: ${filePath}`)
364
+ return `[Attached image: ${name} — file is empty]`
365
+ }
366
+ const data = buf.toString('base64')
361
367
  const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
362
- const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
363
- return [
364
- { type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}` } },
365
- { type: 'text', text },
366
- ]
368
+ // Detect actual MIME from magic bytes (fall back to extension-based)
369
+ let mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
370
+ if (buf[0] === 0xFF && buf[1] === 0xD8) mimeType = 'image/jpeg'
371
+ else if (buf[0] === 0x89 && buf[1] === 0x50) mimeType = 'image/png'
372
+ else if (buf[0] === 0x47 && buf[1] === 0x49) mimeType = 'image/gif'
373
+ else if (buf[0] === 0x52 && buf[1] === 0x49) mimeType = 'image/webp'
374
+ return { type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}`, detail: 'auto' } }
367
375
  }
368
- if (TEXT_EXTS.test(filePath) || filePath.endsWith('.pdf')) {
376
+ if (filePath.endsWith('.pdf')) {
377
+ try {
378
+ // @ts-ignore — pdf-parse types
379
+ const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
380
+ const buf = fs.readFileSync(filePath)
381
+ const result = await pdfParse(buf)
382
+ const pdfText = (result.text || '').trim()
383
+ if (!pdfText) return `[Attached PDF: ${name} — no extractable text]`
384
+ // Truncate very large PDFs to avoid token limits
385
+ const maxChars = 100_000
386
+ const truncated = pdfText.length > maxChars ? pdfText.slice(0, maxChars) + '\n\n[... truncated]' : pdfText
387
+ return `[Attached PDF: ${name} (${result.numpages} pages)]\n\n${truncated}`
388
+ } catch {
389
+ return `[Attached PDF: ${name} — could not extract text]`
390
+ }
391
+ }
392
+ if (TEXT_EXTS.test(filePath)) {
369
393
  try {
370
394
  const fileContent = fs.readFileSync(filePath, 'utf-8')
371
- const name = filePath.split('/').pop() || 'file'
372
- return `[Attached file: ${name}]\n\n${fileContent}\n\n${text}`
373
- } catch { return text }
395
+ return `[Attached file: ${name}]\n\n${fileContent}`
396
+ } catch { return `[Attached file: ${name} — read error]` }
374
397
  }
375
- return `[Attached file: ${filePath.split('/').pop()}]\n\n${text}`
398
+ return `[Attached file: ${name}]`
399
+ }
400
+
401
+ async function buildLangChainContent(text: string, filePath?: string, extraFiles?: string[]): Promise<any> {
402
+ const filePaths: string[] = []
403
+ if (filePath) filePaths.push(filePath)
404
+ if (extraFiles?.length) {
405
+ for (const f of extraFiles) {
406
+ if (f && !filePaths.includes(f)) filePaths.push(f)
407
+ }
408
+ }
409
+ if (!filePaths.length) return text
410
+
411
+ const parts: any[] = []
412
+ const textParts: string[] = []
413
+ for (const fp of filePaths) {
414
+ const content = await buildContentForFile(fp)
415
+ if (!content) continue
416
+ if (typeof content === 'string') {
417
+ textParts.push(content)
418
+ } else {
419
+ parts.push(content)
420
+ }
421
+ }
422
+
423
+ const combinedText = textParts.length
424
+ ? `${textParts.join('\n\n')}\n\n${text}`
425
+ : text
426
+
427
+ if (parts.length === 0) return combinedText
428
+ parts.push({ type: 'text', text: combinedText })
429
+ return parts
376
430
  }
377
431
 
378
432
  // Auto-compaction: prune old history if approaching context window limit
@@ -397,14 +451,15 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
397
451
  const langchainMessages: Array<HumanMessage | AIMessage> = []
398
452
  for (const m of effectiveHistory.slice(-20)) {
399
453
  if (m.role === 'user') {
400
- langchainMessages.push(new HumanMessage({ content: buildLangChainContent(m.text, m.imagePath) }))
454
+ langchainMessages.push(new HumanMessage({ content: await buildLangChainContent(m.text, m.imagePath, m.attachedFiles) }))
401
455
  } else {
402
456
  langchainMessages.push(new AIMessage({ content: m.text }))
403
457
  }
404
458
  }
405
459
 
406
460
  // Add current message
407
- langchainMessages.push(new HumanMessage({ content: buildLangChainContent(message, imagePath) }))
461
+ const currentContent = await buildLangChainContent(message, imagePath, attachedFiles)
462
+ langchainMessages.push(new HumanMessage({ content: currentContent }))
408
463
 
409
464
  let fullText = ''
410
465
  let lastSegment = ''
@@ -25,3 +25,26 @@ test('validateTaskCompletion accepts screenshot delivery tasks with upload artif
25
25
 
26
26
  assert.equal(validation.ok, true)
27
27
  })
28
+
29
+ test('validateTaskCompletion accepts concise non-implementation result summaries', () => {
30
+ const validation = validateTaskCompletion({
31
+ title: 'Answer greeting',
32
+ description: 'Respond to a basic hello prompt.',
33
+ result: 'Hello! How can I help you today?',
34
+ error: null,
35
+ } as Partial<BoardTask>)
36
+
37
+ assert.equal(validation.ok, true)
38
+ })
39
+
40
+ test('validateTaskCompletion still enforces stricter minimum for implementation tasks', () => {
41
+ const validation = validateTaskCompletion({
42
+ title: 'Fix retry bug',
43
+ description: 'Implement queue retry fixes and verify.',
44
+ result: 'Patched queue retry bug.',
45
+ error: null,
46
+ } as Partial<BoardTask>)
47
+
48
+ assert.equal(validation.ok, false)
49
+ assert.ok(validation.reasons.some((reason) => reason.includes('Result summary is too short')))
50
+ })
@@ -11,7 +11,8 @@ interface TaskCompletionValidationOptions {
11
11
  report?: TaskReportArtifact | null
12
12
  }
13
13
 
14
- const MIN_RESULT_CHARS = 40
14
+ const MIN_RESULT_CHARS_IMPLEMENTATION = 40
15
+ const MIN_RESULT_CHARS_GENERIC = 20
15
16
 
16
17
  const WEAK_RESULT_PATTERNS: RegExp[] = [
17
18
  /what can i help you with/i,
@@ -45,12 +46,14 @@ export function validateTaskCompletion(
45
46
  const result = normalizeText(task.result)
46
47
  const error = normalizeText(task.error)
47
48
  const report = options.report || null
49
+ const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
48
50
 
49
51
  if (error) reasons.push('Task has a non-empty error field.')
50
52
 
51
53
  if (!result) reasons.push('Result summary is empty.')
52
54
  else {
53
- if (result.length < MIN_RESULT_CHARS) reasons.push(`Result summary is too short (${result.length} chars).`)
55
+ const minChars = implementationTask ? MIN_RESULT_CHARS_IMPLEMENTATION : MIN_RESULT_CHARS_GENERIC
56
+ if (result.length < minChars) reasons.push(`Result summary is too short (${result.length} chars; min ${minChars}).`)
54
57
  if (WEAK_RESULT_PATTERNS.some((rx) => rx.test(result))) {
55
58
  reasons.push('Result contains placeholder/planning language instead of completion evidence.')
56
59
  }
@@ -58,7 +61,6 @@ export function validateTaskCompletion(
58
61
 
59
62
  // If task description/title suggests implementation work, require concrete evidence in
60
63
  // the result summary OR task report.
61
- const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
62
64
  const hasResultEvidence = EXECUTION_EVIDENCE.test(result)
63
65
  const hasReportEvidence = report?.evidence.hasEvidence === true
64
66
  if (implementationTask && !hasResultEvidence && !hasReportEvidence) {
@@ -0,0 +1,85 @@
1
+ import { WebSocketServer, WebSocket } from 'ws'
2
+ import type { IncomingMessage } from 'http'
3
+ import { validateAccessKey } from './storage'
4
+
5
+ interface WsClient {
6
+ ws: WebSocket
7
+ topics: Set<string>
8
+ }
9
+
10
+ interface WsHub {
11
+ wss: WebSocketServer
12
+ clients: Set<WsClient>
13
+ }
14
+
15
+ const GK = '__swarmclaw_ws__' as const
16
+
17
+ function getHub(): WsHub | null {
18
+ return (globalThis as any)[GK] ?? null
19
+ }
20
+
21
+ export function initWsServer() {
22
+ if (getHub()) return
23
+
24
+ const port = Number(process.env.WS_PORT) || (Number(process.env.PORT) || 3456) + 1
25
+ const wss = new WebSocketServer({ port, path: '/ws' })
26
+ const clients = new Set<WsClient>()
27
+
28
+ const hub: WsHub = { wss, clients }
29
+ ;(globalThis as any)[GK] = hub
30
+
31
+ wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
32
+ // Auth: validate ?key= from upgrade URL
33
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
34
+ const key = url.searchParams.get('key') || ''
35
+ if (!validateAccessKey(key)) {
36
+ ws.close(4001, 'Unauthorized')
37
+ return
38
+ }
39
+
40
+ const client: WsClient = { ws, topics: new Set() }
41
+ clients.add(client)
42
+
43
+ ws.on('message', (raw) => {
44
+ try {
45
+ const msg = JSON.parse(String(raw))
46
+ if (msg.type === 'subscribe' && Array.isArray(msg.topics)) {
47
+ for (const t of msg.topics) {
48
+ if (typeof t === 'string') client.topics.add(t)
49
+ }
50
+ } else if (msg.type === 'unsubscribe' && Array.isArray(msg.topics)) {
51
+ for (const t of msg.topics) client.topics.delete(t)
52
+ }
53
+ } catch {
54
+ // ignore malformed messages
55
+ }
56
+ })
57
+
58
+ ws.on('close', () => {
59
+ clients.delete(client)
60
+ })
61
+
62
+ ws.on('error', () => {
63
+ clients.delete(client)
64
+ })
65
+ })
66
+
67
+ wss.on('error', (err) => {
68
+ console.error('[ws-hub] WebSocket server error:', err.message)
69
+ })
70
+
71
+ console.log(`[ws-hub] WebSocket server listening on port ${port}`)
72
+ }
73
+
74
+ export function notify(topic: string, action = 'update', id?: string) {
75
+ const hub = getHub()
76
+ if (!hub) return
77
+
78
+ const payload = JSON.stringify(id ? { topic, action, id } : { topic, action })
79
+
80
+ for (const client of hub.clients) {
81
+ if (client.topics.has(topic) && client.ws.readyState === WebSocket.OPEN) {
82
+ client.ws.send(payload)
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,42 @@
1
+ export interface ToolDefinition {
2
+ id: string
3
+ label: string
4
+ description: string
5
+ }
6
+
7
+ export const AVAILABLE_TOOLS: ToolDefinition[] = [
8
+ { id: 'shell', label: 'Shell', description: 'Execute commands in the working directory' },
9
+ { id: 'files', label: 'Files', description: 'Read, write, list, move, copy, and send files' },
10
+ { id: 'copy_file', label: 'Copy File', description: 'Copy files within the working directory' },
11
+ { id: 'move_file', label: 'Move File', description: 'Move/rename files within the working directory' },
12
+ { id: 'delete_file', label: 'Delete File', description: 'Delete files/directories (disabled by default)' },
13
+ { id: 'edit_file', label: 'Edit File', description: 'Search-and-replace editing within files' },
14
+ { id: 'process', label: 'Process', description: 'Monitor and control long-running shell commands' },
15
+ { id: 'web_search', label: 'Web Search', description: 'Search the web via DuckDuckGo' },
16
+ { id: 'web_fetch', label: 'Web Fetch', description: 'Fetch and extract text from URLs' },
17
+ { id: 'claude_code', label: 'Claude Code', description: 'Delegate complex tasks to Claude Code CLI' },
18
+ { id: 'codex_cli', label: 'Codex CLI', description: 'Delegate complex tasks to OpenAI Codex CLI' },
19
+ { id: 'opencode_cli', label: 'OpenCode CLI', description: 'Delegate complex tasks to OpenCode CLI' },
20
+ { id: 'browser', label: 'Browser', description: 'Playwright — browse, scrape, interact with web pages' },
21
+ { id: 'memory', label: 'Memory', description: 'Store and retrieve long-term memories across sessions' },
22
+ { id: 'sandbox', label: 'Sandbox', description: 'Run JS/TS/Python code in an isolated Deno sandbox' },
23
+ ]
24
+
25
+ export const PLATFORM_TOOLS: ToolDefinition[] = [
26
+ { id: 'manage_agents', label: 'Agents', description: 'Create, edit, and delete agents' },
27
+ { id: 'manage_tasks', label: 'Tasks', description: 'Create, edit, and delete tasks' },
28
+ { id: 'manage_schedules', label: 'Schedules', description: 'Create, edit, and delete schedules' },
29
+ { id: 'manage_skills', label: 'Skills', description: 'Create, edit, and delete skills' },
30
+ { id: 'manage_documents', label: 'Documents', description: 'Upload, search, and delete indexed documents' },
31
+ { id: 'manage_webhooks', label: 'Webhooks', description: 'Register webhooks that trigger agent sessions' },
32
+ { id: 'manage_connectors', label: 'Connectors', description: 'Create, edit, and delete connectors' },
33
+ { id: 'manage_sessions', label: 'Sessions', description: 'List sessions, send messages, and spawn session work' },
34
+ { id: 'manage_secrets', label: 'Secrets', description: 'Store and retrieve encrypted service secrets' },
35
+ ]
36
+
37
+ export const ALL_TOOLS: ToolDefinition[] = [...AVAILABLE_TOOLS, ...PLATFORM_TOOLS]
38
+
39
+ /** Flat id→label lookup for display */
40
+ export const TOOL_LABELS: Record<string, string> = Object.fromEntries(
41
+ ALL_TOOLS.map((t) => [t.id, t.label]),
42
+ )