@swarmclawai/swarmclaw 1.9.11 → 1.9.12

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 CHANGED
@@ -183,7 +183,7 @@ Full hosted deployment guides live at https://swarmclaw.ai/docs/deployment
183
183
  - **Structured Sessions**: reusable bounded runs with templates, facilitators, participants, hidden live rooms, chatroom `/breakout`, durable transcripts, outputs, operator controls, and a visible protocols template gallery plus visual builder.
184
184
  - **Memory**: hybrid recall, graph traversal, journaling, durable documents, project-scoped context, automatic reflection memory, communication preferences, profile and boundary memory, significant events, and open follow-up loops.
185
185
  - **Wallets**: linked Base wallet generation, address management, approval-oriented limits, and agent payout identity.
186
- - **Connectors**: Discord, Slack, Telegram, WhatsApp, Teams, Matrix, OpenClaw, SwarmDock, SwarmFeed, and more.
186
+ - **Connectors**: Discord, Slack, Telegram, WhatsApp, Teams, Matrix, email, local file queues, OpenClaw, SwarmDock, SwarmFeed, and more.
187
187
  - **MCP Servers**: connect any Model Context Protocol server (stdio, SSE, or streamable HTTP) and inject its tools into agents alongside built-ins. Configure, test, and assign per-agent from the MCP Servers panel.
188
188
  - **Extensions**: external tool extensions, UI modules, hooks, install/update flows, and managed resource manifests for extension-owned agents, routines, local folders, gateways, and setup checks.
189
189
 
@@ -399,6 +399,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.9.12 Highlights
403
+
404
+ Local file-queue connector release: operators can bridge SwarmClaw to filesystem inbox, outbox, archive, and error folders without a hosted message bus.
405
+
406
+ - **File Queue connector.** Configure root, inbox, outbox, archive, and error folders from the connector sheet or CLI.
407
+ - **JSON command ingress.** External tools can drop command envelopes into the inbox, then SwarmClaw normalizes them into connector messages for the selected agent or chatroom.
408
+ - **Durable file handling.** Processed commands move to archive, malformed commands move to errors with diagnostic sidecars, and replies are written to outbox as structured JSON.
409
+ - **Connector runtime parity.** Queue traffic uses the existing connector session, policy, health, readiness, CLI, and follow-up delivery paths.
410
+
402
411
  ### v1.9.11 Highlights
403
412
 
404
413
  Task execution policy release: operators can attach ordered review, approval, and verification stages to board tasks, record decisions, and block premature completion until required stages clear.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.11",
3
+ "version": "1.9.12",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "clawd",
30
30
  "clawdbot",
31
31
  "moltbot",
32
+ "file-queue",
32
33
  "openclaw-skill",
33
34
  "openclaw-dashboard",
34
35
  "openclaw-gateway",
@@ -429,7 +429,7 @@ export default function HomePage() {
429
429
  <StatCard label="Agents" value={String(agentCount)} hint="Total active agents configured in your dashboard" index={0} />
430
430
  <StatCard label="Active Tasks" value={String(activeTaskCount)} accent={activeTaskCount > 0} hint="Tasks currently running or queued for execution" index={1} />
431
431
  <StatCard label="Today's Cost" value={`$${todayCost.toFixed(2)}`} hint="Estimated API cost for today across all providers" index={2} />
432
- <StatCard label="Connectors" value={`${activeConnectorCount}/${allConnectors.length}`} accent={activeConnectorCount > 0} hint="Active bridges to chat platforms (Discord, Slack, etc.)" index={3} />
432
+ <StatCard label="Connectors" value={`${activeConnectorCount}/${allConnectors.length}`} accent={activeConnectorCount > 0} hint="Active bridges to chat platforms, local queues, and agent channels." index={3} />
433
433
  </div>
434
434
 
435
435
  {/* Cost trend sparkline */}
package/src/cli/index.ts CHANGED
@@ -1177,7 +1177,7 @@ export function buildProgram(): Command {
1177
1177
  connectors
1178
1178
  .command('create')
1179
1179
  .description('Create connector')
1180
- .requiredOption('--platform <platform>', 'Connector platform (discord|telegram|slack|whatsapp|openclaw|bluebubbles|signal|teams|googlechat|matrix)')
1180
+ .requiredOption('--platform <platform>', 'Connector platform (discord|telegram|slack|whatsapp|openclaw|bluebubbles|signal|teams|googlechat|matrix|email|filequeue|swarmdock)')
1181
1181
  .requiredOption('--agent-id <agentId>', 'Agent id')
1182
1182
  .option('--name <name>', 'Connector name')
1183
1183
  .option('--credential-id <credentialId>', 'Credential id')
@@ -86,6 +86,12 @@ interface ConnectorConfigField {
86
86
  }
87
87
 
88
88
  const FIELD_HINTS: Record<string, string> = {
89
+ rootDir: 'Root folder for the queue. Defaults to a managed folder under SwarmClaw data.',
90
+ inboxDir: 'Folder where external tools drop inbound JSON commands. Relative paths are resolved under Root Directory.',
91
+ outboxDir: 'Folder where SwarmClaw writes outbound JSON replies. Relative paths are resolved under Root Directory.',
92
+ archiveDir: 'Processed inbound JSON commands are moved here after routing.',
93
+ errorDir: 'Malformed or failed inbound JSON commands are moved here with an error sidecar.',
94
+ pollIntervalMs: 'How often SwarmClaw checks the inbox folder. Minimum 250 ms.',
89
95
  channelIds: "Find these in your platform's developer settings. Leave empty to allow all channels",
90
96
  chatIds: "Find these in your platform's developer settings. Leave empty to allow all chats",
91
97
  roomIds: 'Leave empty to allow all rooms visible to the bot',
@@ -366,6 +372,30 @@ const PLATFORMS: {
366
372
  { key: 'maxBudget', label: 'Max Budget (USDC micro-units)', placeholder: '5000000', help: '$1 = 1000000, $5 = 5000000' },
367
373
  ],
368
374
  },
375
+ {
376
+ id: 'filequeue',
377
+ label: 'File Queue',
378
+ color: '#22C55E',
379
+ setupSteps: [
380
+ 'Choose a root directory or let SwarmClaw create a managed one',
381
+ 'External tools write JSON commands into the inbox folder',
382
+ 'SwarmClaw archives processed commands and writes JSON replies into the outbox folder',
383
+ 'Use an agent or chatroom route to decide who handles inbound commands',
384
+ ],
385
+ tokenLabel: '',
386
+ tokenHelp: '',
387
+ configFields: [
388
+ { key: 'rootDir', label: 'Root Directory', placeholder: '~/swarmclaw-command-queue', help: 'Optional. Defaults to data/connectors/<id>/filequeue.', section: 'basic' },
389
+ { key: 'inboxDir', label: 'Inbox Directory', placeholder: 'inbox', help: 'Inbound JSON command directory. Relative paths resolve under Root Directory.', section: 'basic' },
390
+ { key: 'outboxDir', label: 'Outbox Directory', placeholder: 'outbox', help: 'Outbound JSON reply directory. Relative paths resolve under Root Directory.', section: 'basic' },
391
+ { key: 'archiveDir', label: 'Archive Directory', placeholder: 'archive', help: 'Processed command archive directory.', section: 'advanced' },
392
+ { key: 'errorDir', label: 'Error Directory', placeholder: 'errors', help: 'Malformed command quarantine directory.', section: 'advanced' },
393
+ { key: 'pollIntervalMs', label: 'Poll Interval (ms)', placeholder: '1000', help: 'How often to scan the inbox. Minimum 250 ms.', section: 'advanced' },
394
+ { key: 'defaultChannelId', label: 'Default Channel ID', placeholder: 'ops', help: 'Used when an inbound command omits channelId.', section: 'advanced' },
395
+ { key: 'defaultSenderId', label: 'Default Sender ID', placeholder: 'queue', help: 'Used when an inbound command omits senderId.', section: 'advanced' },
396
+ { key: 'defaultSenderName', label: 'Default Sender Name', placeholder: 'Queue', help: 'Used when an inbound command omits senderName.', section: 'advanced' },
397
+ ],
398
+ },
369
399
  ]
370
400
 
371
401
  const COMMON_CONFIG_FIELDS: ConnectorConfigField[] = [
@@ -780,10 +810,10 @@ export function ConnectorSheet() {
780
810
  await saveConnectorMutation.mutateAsync({
781
811
  id: editing?.id,
782
812
  payload: {
783
- name: name || `${platformConfig?.label} Bot`,
813
+ name: name || (platform === 'filequeue' ? `${platformConfig?.label} Connector` : `${platformConfig?.label} Bot`),
784
814
  platform,
785
815
  ...routePayload,
786
- credentialId: credentialId || null,
816
+ credentialId: showCredentialSection ? (credentialId || null) : null,
787
817
  config,
788
818
  },
789
819
  })
@@ -848,6 +878,7 @@ export function ConnectorSheet() {
848
878
  const credList = Object.values(credentials)
849
879
  const basicPlatformFields = platformConfig.configFields.filter((field) => field.section !== 'advanced')
850
880
  const advancedPlatformFields = platformConfig.configFields.filter((field) => field.section === 'advanced')
881
+ const showCredentialSection = Boolean(platformConfig.tokenLabel.trim())
851
882
  const basicAccessFields = ACCESS_CONTROL_FIELDS.filter((field) => field.section !== 'advanced')
852
883
  const advancedAccessFields = ACCESS_CONTROL_FIELDS.filter((field) => field.section === 'advanced')
853
884
  const hasConfiguredValue = useCallback((key: string) => Boolean(config[key]?.trim()), [config])
@@ -1014,7 +1045,7 @@ export function ConnectorSheet() {
1014
1045
  <div>
1015
1046
  <div className={`text-[14px] font-600 ${platform === p.id ? 'text-text' : 'text-text-2'}`}>{p.label}</div>
1016
1047
  <div className="text-[11px] text-text-3 mt-0.5">
1017
- {p.id === 'whatsapp' ? 'QR code pairing' : p.id === 'openclaw' ? 'WebSocket gateway' : p.id === 'bluebubbles' ? 'iMessage bridge' : p.id === 'signal' ? 'signal-cli binary' : p.id === 'matrix' ? 'Access token' : p.id === 'googlechat' ? 'Service account' : p.id === 'teams' ? 'Bot Framework' : 'Bot token'}
1048
+ {p.id === 'whatsapp' ? 'QR code pairing' : p.id === 'openclaw' ? 'WebSocket gateway' : p.id === 'bluebubbles' ? 'iMessage bridge' : p.id === 'signal' ? 'signal-cli binary' : p.id === 'matrix' ? 'Access token' : p.id === 'googlechat' ? 'Service account' : p.id === 'teams' ? 'Bot Framework' : p.id === 'filequeue' ? 'Local queue' : 'Bot token'}
1018
1049
  </div>
1019
1050
  </div>
1020
1051
  </button>
@@ -1074,7 +1105,7 @@ export function ConnectorSheet() {
1074
1105
  <input
1075
1106
  value={name}
1076
1107
  onChange={(e) => setName(e.target.value)}
1077
- placeholder={`My ${platformConfig.label} Bot`}
1108
+ placeholder={`My ${platformConfig.label} ${platform === 'filequeue' ? 'Connector' : 'Bot'}`}
1078
1109
  className={inputClass}
1079
1110
  style={{ fontFamily: 'inherit' }}
1080
1111
  />
@@ -1128,7 +1159,7 @@ export function ConnectorSheet() {
1128
1159
  </div>
1129
1160
 
1130
1161
  {/* Bot token credential */}
1131
- {platform !== 'whatsapp' && (
1162
+ {showCredentialSection && (
1132
1163
  <div className="mb-6">
1133
1164
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">{platformConfig.tokenLabel}</label>
1134
1165
  <p className="text-[12px] text-text-3/60 mb-2">{platformConfig.tokenHelp}</p>
@@ -92,7 +92,7 @@ function CommandPaletteInner({ setOpen }: { setOpen: (v: boolean) => void }) {
92
92
  { id: 'projects', label: 'Projects', description: 'Scoped workspaces for agents and tasks', keywords: ['workspace', 'scope'] },
93
93
  { id: 'chatrooms', label: 'Chatrooms', description: 'Shared multi-agent conversations', keywords: ['group', 'room', 'mentions'] },
94
94
  { id: 'schedules', label: 'Schedules', description: 'Recurring and timed automations', keywords: ['cron', 'automation', 'interval'] },
95
- { id: 'connectors', label: 'Connectors', description: 'Bridges to Slack, Discord, Telegram, and more', keywords: ['discord', 'slack', 'telegram', 'whatsapp'] },
95
+ { id: 'connectors', label: 'Connectors', description: 'Bridges to Slack, Discord, Telegram, file queues, and more', keywords: ['discord', 'slack', 'telegram', 'whatsapp', 'file queue'] },
96
96
  { id: 'memory', label: 'Memory', description: 'Stored agent memory and retrieval', keywords: ['knowledge', 'vector', 'retrieval'] },
97
97
  { id: 'knowledge', label: 'Knowledge', description: 'Shared knowledge base', keywords: ['docs', 'entries', 'facts'] },
98
98
  { id: 'providers', label: 'Providers', description: 'Model providers and endpoints', keywords: ['openai', 'anthropic', 'ollama', 'endpoint'] },
@@ -8,6 +8,10 @@ import {
8
8
 
9
9
  describe('connector platform metadata', () => {
10
10
  it('resolves legacy connector platforms used by stored runtime data', () => {
11
+ assert.deepEqual(resolveConnectorPlatformMeta('filequeue'), {
12
+ label: 'File Queue',
13
+ color: '#22C55E',
14
+ })
11
15
  assert.deepEqual(resolveConnectorPlatformMeta('webchat'), {
12
16
  label: 'Web Chat',
13
17
  color: '#0EA5E9',
@@ -26,6 +26,7 @@ export const CONNECTOR_PLATFORM_META: Record<ConnectorPlatform, { label: string;
26
26
  email: { label: 'Email', color: '#EA4335' },
27
27
  webchat: { label: 'Web Chat', color: '#0EA5E9' },
28
28
  mockmail: { label: 'MockMail', color: '#7C3AED' },
29
+ filequeue: { label: 'File Queue', color: '#22C55E' },
29
30
  swarmdock: { label: 'SwarmDock', color: '#F59E0B' },
30
31
  }
31
32
 
@@ -4,7 +4,7 @@ export type ConnectorReadinessState = 'needs_setup' | 'attention' | 'healthy'
4
4
  export type ConnectorReadinessCheckStatus = 'ready' | 'warning' | 'error'
5
5
 
6
6
  export interface ConnectorReadinessCheck {
7
- id: 'credentials' | 'route' | 'pairing' | 'connection' | 'gateway'
7
+ id: 'credentials' | 'route' | 'pairing' | 'connection' | 'gateway' | 'queue'
8
8
  label: string
9
9
  status: ConnectorReadinessCheckStatus
10
10
  detail: string
@@ -26,6 +26,7 @@ export function hasConnectorCredentials(connector: Connector): boolean {
26
26
  || connector.platform === 'openclaw'
27
27
  || connector.platform === 'signal'
28
28
  || connector.platform === 'email'
29
+ || connector.platform === 'filequeue'
29
30
  || connector.platform === 'swarmdock'
30
31
  || (connector.platform === 'bluebubbles' && (!!connector.credentialId || !!connector.config?.password))
31
32
  || !!connector.credentialId
@@ -53,12 +54,21 @@ export function getConnectorReadiness(connector: Connector): ConnectorReadiness
53
54
  const credentialsReady = hasConnectorCredentials(connector)
54
55
  const routeReady = hasRoute(connector)
55
56
  const checks: ConnectorReadinessCheck[] = [
56
- {
57
- id: 'credentials',
58
- label: 'Credentials',
59
- status: credentialsReady ? 'ready' : 'error',
60
- detail: credentialsReady ? 'Credential path is configured.' : 'Add the token, password, or pairing credential.',
61
- },
57
+ connector.platform === 'filequeue'
58
+ ? {
59
+ id: 'queue',
60
+ label: 'Queue folders',
61
+ status: 'ready',
62
+ detail: connector.config?.rootDir
63
+ ? `Watching ${connector.config.rootDir}`
64
+ : 'Using the managed local queue folder.',
65
+ }
66
+ : {
67
+ id: 'credentials',
68
+ label: 'Credentials',
69
+ status: credentialsReady ? 'ready' : 'error',
70
+ detail: credentialsReady ? 'Credential path is configured.' : 'Add the token, password, or pairing credential.',
71
+ },
62
72
  {
63
73
  id: 'route',
64
74
  label: 'Route target',
@@ -71,6 +71,7 @@ export async function getPlatform(platform: string) {
71
71
  case 'googlechat': return (await import('./googlechat')).default
72
72
  case 'matrix': return (await import('./matrix')).default
73
73
  case 'email': return (await import('./email')).default
74
+ case 'filequeue': return (await import('./filequeue')).default
74
75
  case 'swarmdock': return (await import('./swarmdock')).default
75
76
  }
76
77
 
@@ -181,7 +182,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
181
182
  botToken = swarmdockFallbackPrivateKey
182
183
  }
183
184
 
184
- if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email' && connector.platform !== 'swarmdock') {
185
+ if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email' && connector.platform !== 'filequeue' && connector.platform !== 'swarmdock') {
185
186
  throw new Error('No bot token configured')
186
187
  }
187
188
 
@@ -14,7 +14,7 @@ import { resolveImagePath } from '../resolve-image'
14
14
  // 1. Connector module resolution (getPlatform)
15
15
  // ---------------------------------------------------------------------------
16
16
  describe('getPlatform — connector module resolution', () => {
17
- const newPlatforms = ['matrix', 'googlechat', 'teams', 'signal', 'bluebubbles'] as const
17
+ const newPlatforms = ['matrix', 'googlechat', 'teams', 'signal', 'bluebubbles', 'filequeue'] as const
18
18
 
19
19
  for (const name of newPlatforms) {
20
20
  it(`returns a valid module for "${name}"`, async () => {
@@ -192,6 +192,7 @@ export async function autoStartConnectorIfNeeded(connector: Connector, body: Rec
192
192
  const hasCredentials = connector.platform === 'whatsapp'
193
193
  || connector.platform === 'openclaw'
194
194
  || (connector.platform === 'bluebubbles' && (!!connector.credentialId || !!connector.config.password))
195
+ || connector.platform === 'filequeue'
195
196
  || !!connector.credentialId
196
197
  if (!hasCredentials || body.autoStart === false) return
197
198
  try {
@@ -14,6 +14,7 @@ describe('connectorSupportsBinaryMedia — email', () => {
14
14
  it('still returns false for platforms that do not support outbound binary', () => {
15
15
  assert.equal(connectorSupportsBinaryMedia('signal'), false)
16
16
  assert.equal(connectorSupportsBinaryMedia('matrix'), false)
17
+ assert.equal(connectorSupportsBinaryMedia('filequeue'), false)
17
18
  })
18
19
  })
19
20
 
@@ -0,0 +1,141 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import test from 'node:test'
6
+
7
+ import type { Connector } from '@/types'
8
+ import {
9
+ drainFileQueueOnce,
10
+ normalizeFileQueueEnvelope,
11
+ resolveFileQueuePaths,
12
+ writeFileQueueOutbound,
13
+ } from './filequeue'
14
+
15
+ function makeConnector(rootDir: string): Connector {
16
+ return {
17
+ id: 'filequeue-1',
18
+ name: 'Local Queue',
19
+ platform: 'filequeue',
20
+ agentId: 'agent-1',
21
+ chatroomId: null,
22
+ credentialId: null,
23
+ config: {
24
+ rootDir,
25
+ defaultSenderId: 'queue',
26
+ defaultSenderName: 'Queue',
27
+ defaultChannelId: 'ops',
28
+ },
29
+ isEnabled: true,
30
+ status: 'running',
31
+ createdAt: 1,
32
+ updatedAt: 1,
33
+ }
34
+ }
35
+
36
+ test('normalizeFileQueueEnvelope maps command JSON into an inbound connector message', () => {
37
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
38
+ try {
39
+ const connector = makeConnector(rootDir)
40
+ const inbound = normalizeFileQueueEnvelope(connector, {
41
+ id: 'cmd-1',
42
+ channelId: 'ops',
43
+ senderId: 'jarvis',
44
+ senderName: 'JARVIS',
45
+ text: 'Summarize current status',
46
+ threadId: 'status-thread',
47
+ })
48
+
49
+ assert.equal(inbound.platform, 'filequeue')
50
+ assert.equal(inbound.channelId, 'ops')
51
+ assert.equal(inbound.senderId, 'jarvis')
52
+ assert.equal(inbound.senderName, 'JARVIS')
53
+ assert.equal(inbound.messageId, 'cmd-1')
54
+ assert.equal(inbound.threadId, 'status-thread')
55
+ assert.equal(inbound.text, 'Summarize current status')
56
+ } finally {
57
+ fs.rmSync(rootDir, { recursive: true, force: true })
58
+ }
59
+ })
60
+
61
+ test('drainFileQueueOnce archives processed commands and writes replies to outbox', async () => {
62
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
63
+ try {
64
+ const connector = makeConnector(rootDir)
65
+ const paths = resolveFileQueuePaths(connector)
66
+ fs.mkdirSync(paths.inboxDir, { recursive: true })
67
+ fs.writeFileSync(path.join(paths.inboxDir, '001.json'), JSON.stringify({
68
+ id: 'cmd-1',
69
+ senderId: 'jarvis',
70
+ senderName: 'JARVIS',
71
+ text: 'Run the release check',
72
+ }))
73
+
74
+ const result = await drainFileQueueOnce(connector, async (msg) => {
75
+ assert.equal(msg.channelId, 'ops')
76
+ assert.equal(msg.senderId, 'jarvis')
77
+ assert.equal(msg.text, 'Run the release check')
78
+ return 'Release check queued.'
79
+ })
80
+
81
+ assert.equal(result.processed, 1)
82
+ assert.equal(result.failed, 0)
83
+ assert.equal(fs.existsSync(path.join(paths.inboxDir, '001.json')), false)
84
+ assert.equal(fs.existsSync(path.join(paths.archiveDir, '001.json')), true)
85
+
86
+ const outboxFiles = fs.readdirSync(paths.outboxDir).filter((file) => file.endsWith('.json'))
87
+ assert.equal(outboxFiles.length, 1)
88
+ const outbound = JSON.parse(fs.readFileSync(path.join(paths.outboxDir, outboxFiles[0]), 'utf8')) as Record<string, unknown>
89
+ assert.equal(outbound.connectorId, connector.id)
90
+ assert.equal(outbound.channelId, 'ops')
91
+ assert.equal(outbound.text, 'Release check queued.')
92
+ assert.equal(outbound.replyToMessageId, 'cmd-1')
93
+ } finally {
94
+ fs.rmSync(rootDir, { recursive: true, force: true })
95
+ }
96
+ })
97
+
98
+ test('drainFileQueueOnce moves malformed JSON into the error directory', async () => {
99
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
100
+ try {
101
+ const connector = makeConnector(rootDir)
102
+ const paths = resolveFileQueuePaths(connector)
103
+ fs.mkdirSync(paths.inboxDir, { recursive: true })
104
+ fs.writeFileSync(path.join(paths.inboxDir, 'broken.json'), '{bad-json')
105
+
106
+ const result = await drainFileQueueOnce(connector, async () => {
107
+ throw new Error('should not route malformed envelopes')
108
+ })
109
+
110
+ assert.equal(result.processed, 0)
111
+ assert.equal(result.failed, 1)
112
+ assert.equal(fs.existsSync(path.join(paths.inboxDir, 'broken.json')), false)
113
+ assert.equal(fs.existsSync(path.join(paths.errorDir, 'broken.json')), true)
114
+ assert.equal(fs.existsSync(path.join(paths.errorDir, 'broken.json.error.txt')), true)
115
+ } finally {
116
+ fs.rmSync(rootDir, { recursive: true, force: true })
117
+ }
118
+ })
119
+
120
+ test('writeFileQueueOutbound stores structured command output in the outbox', async () => {
121
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-filequeue-'))
122
+ try {
123
+ const connector = makeConnector(rootDir)
124
+ const written = await writeFileQueueOutbound(connector, {
125
+ channelId: 'ops',
126
+ text: 'Done',
127
+ threadId: 'status-thread',
128
+ replyToMessageId: 'cmd-1',
129
+ })
130
+
131
+ const payload = JSON.parse(fs.readFileSync(written.path, 'utf8')) as Record<string, unknown>
132
+ assert.equal(payload.kind, 'swarmclaw.filequeue.outbound')
133
+ assert.equal(payload.connectorId, connector.id)
134
+ assert.equal(payload.channelId, 'ops')
135
+ assert.equal(payload.text, 'Done')
136
+ assert.equal(payload.threadId, 'status-thread')
137
+ assert.equal(payload.replyToMessageId, 'cmd-1')
138
+ } finally {
139
+ fs.rmSync(rootDir, { recursive: true, force: true })
140
+ }
141
+ })
@@ -0,0 +1,324 @@
1
+ import fs from 'node:fs'
2
+ import fsp from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ import { genId } from '@/lib/id'
7
+ import { CONNECTORS_DATA_DIR } from '@/lib/server/data-dir'
8
+ import { log } from '@/lib/server/logger'
9
+ import { errorMessage } from '@/lib/shared-utils'
10
+ import type { Connector } from '@/types'
11
+ import type {
12
+ ConnectorIngressResult,
13
+ ConnectorInstance,
14
+ InboundMessage,
15
+ OutboundSendOptions,
16
+ PlatformConnector,
17
+ } from './types'
18
+ import { resolveConnectorIngressReply } from './ingress-delivery'
19
+
20
+ const TAG = 'filequeue'
21
+ const DEFAULT_POLL_INTERVAL_MS = 1_000
22
+ const MIN_POLL_INTERVAL_MS = 250
23
+
24
+ export interface FileQueuePaths {
25
+ rootDir: string
26
+ inboxDir: string
27
+ outboxDir: string
28
+ archiveDir: string
29
+ errorDir: string
30
+ }
31
+
32
+ export interface FileQueueDrainResult {
33
+ processed: number
34
+ failed: number
35
+ }
36
+
37
+ export interface FileQueueOutboundInput {
38
+ channelId: string
39
+ text: string
40
+ threadId?: string
41
+ replyToMessageId?: string
42
+ options?: OutboundSendOptions
43
+ }
44
+
45
+ function asRecord(value: unknown): Record<string, unknown> | null {
46
+ return value && typeof value === 'object' && !Array.isArray(value)
47
+ ? value as Record<string, unknown>
48
+ : null
49
+ }
50
+
51
+ function clean(value: unknown): string {
52
+ return typeof value === 'string' ? value.trim() : ''
53
+ }
54
+
55
+ function expandHome(input: string): string {
56
+ if (input === '~') return os.homedir()
57
+ if (input.startsWith('~/') || input.startsWith('~\\')) {
58
+ return path.join(os.homedir(), input.slice(2))
59
+ }
60
+ return input
61
+ }
62
+
63
+ function resolveConfiguredPath(rootDir: string, configured: unknown, fallback: string): string {
64
+ const value = clean(configured)
65
+ if (!value) return path.join(rootDir, fallback)
66
+ const expanded = expandHome(value)
67
+ return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(rootDir, expanded)
68
+ }
69
+
70
+ function parsePollIntervalMs(value: unknown): number {
71
+ const parsed = typeof value === 'number'
72
+ ? value
73
+ : (typeof value === 'string' && value.trim() ? Number.parseInt(value.trim(), 10) : DEFAULT_POLL_INTERVAL_MS)
74
+ if (!Number.isFinite(parsed)) return DEFAULT_POLL_INTERVAL_MS
75
+ return Math.max(MIN_POLL_INTERVAL_MS, parsed)
76
+ }
77
+
78
+ function ensureFileQueueDirs(paths: FileQueuePaths): void {
79
+ for (const dir of [paths.rootDir, paths.inboxDir, paths.outboxDir, paths.archiveDir, paths.errorDir]) {
80
+ fs.mkdirSync(dir, { recursive: true })
81
+ }
82
+ }
83
+
84
+ export function resolveFileQueuePaths(connector: Connector): FileQueuePaths {
85
+ const config = connector.config || {}
86
+ const configuredRoot = clean(config.rootDir)
87
+ const rootDir = configuredRoot
88
+ ? path.resolve(expandHome(configuredRoot))
89
+ : path.join(CONNECTORS_DATA_DIR, connector.id, 'filequeue')
90
+ return {
91
+ rootDir,
92
+ inboxDir: resolveConfiguredPath(rootDir, config.inboxDir, 'inbox'),
93
+ outboxDir: resolveConfiguredPath(rootDir, config.outboxDir, 'outbox'),
94
+ archiveDir: resolveConfiguredPath(rootDir, config.archiveDir, 'archive'),
95
+ errorDir: resolveConfiguredPath(rootDir, config.errorDir, 'errors'),
96
+ }
97
+ }
98
+
99
+ function firstText(...values: unknown[]): string {
100
+ for (const value of values) {
101
+ const text = clean(value)
102
+ if (text) return text
103
+ }
104
+ return ''
105
+ }
106
+
107
+ function fileNameSafe(value: string): string {
108
+ return value
109
+ .split('')
110
+ .map((char) => {
111
+ const code = char.charCodeAt(0)
112
+ const isDigit = code >= 48 && code <= 57
113
+ const isUpper = code >= 65 && code <= 90
114
+ const isLower = code >= 97 && code <= 122
115
+ return isDigit || isUpper || isLower || char === '-' || char === '_' ? char : '_'
116
+ })
117
+ .join('')
118
+ .slice(0, 96) || 'item'
119
+ }
120
+
121
+ function resolveUniquePath(dir: string, basename: string): string {
122
+ const ext = path.extname(basename)
123
+ const stem = path.basename(basename, ext)
124
+ let candidate = path.join(dir, basename)
125
+ if (!fs.existsSync(candidate)) return candidate
126
+ for (let i = 1; i < 1_000; i += 1) {
127
+ candidate = path.join(dir, `${stem}-${i}${ext}`)
128
+ if (!fs.existsSync(candidate)) return candidate
129
+ }
130
+ return path.join(dir, `${stem}-${Date.now()}-${genId()}${ext}`)
131
+ }
132
+
133
+ async function moveFile(source: string, targetDir: string): Promise<string> {
134
+ await fsp.mkdir(targetDir, { recursive: true })
135
+ const target = resolveUniquePath(targetDir, path.basename(source))
136
+ try {
137
+ await fsp.rename(source, target)
138
+ } catch (err: unknown) {
139
+ const code = typeof err === 'object' && err && 'code' in err ? String((err as { code?: unknown }).code) : ''
140
+ if (code !== 'EXDEV') throw err
141
+ await fsp.copyFile(source, target)
142
+ await fsp.unlink(source)
143
+ }
144
+ return target
145
+ }
146
+
147
+ function readNestedEnvelope(record: Record<string, unknown>): Record<string, unknown> | null {
148
+ return asRecord(record.payload)
149
+ || asRecord(record.command)
150
+ || asRecord(record.message)
151
+ || asRecord(record.data)
152
+ }
153
+
154
+ export function normalizeFileQueueEnvelope(connector: Connector, envelope: unknown): InboundMessage {
155
+ const record = asRecord(envelope)
156
+ if (!record) throw new Error('File queue envelope must be a JSON object')
157
+ const nested = readNestedEnvelope(record)
158
+ const sender = asRecord(record.sender) || asRecord(nested?.sender)
159
+ const config = connector.config || {}
160
+ const id = firstText(record.id, record.messageId, record.commandId, nested?.id, nested?.messageId, genId())
161
+ const text = firstText(
162
+ record.text,
163
+ record.body,
164
+ record.prompt,
165
+ typeof record.command === 'string' ? record.command : '',
166
+ nested?.text,
167
+ nested?.body,
168
+ nested?.prompt,
169
+ typeof nested?.command === 'string' ? nested.command : '',
170
+ )
171
+ if (!text) throw new Error('File queue envelope requires text, body, prompt, or command')
172
+
173
+ const channelId = firstText(record.channelId, record.channel, nested?.channelId, nested?.channel, config.defaultChannelId, 'filequeue')
174
+ const senderId = firstText(record.senderId, sender?.id, sender?.senderId, nested?.senderId, config.defaultSenderId, 'filequeue')
175
+ const senderName = firstText(record.senderName, sender?.name, sender?.senderName, nested?.senderName, config.defaultSenderName, senderId)
176
+
177
+ return {
178
+ platform: 'filequeue',
179
+ channelId,
180
+ channelName: firstText(record.channelName, nested?.channelName, channelId),
181
+ senderId,
182
+ senderName,
183
+ text,
184
+ messageId: id,
185
+ replyToMessageId: firstText(record.replyToMessageId, nested?.replyToMessageId) || undefined,
186
+ threadId: firstText(record.threadId, nested?.threadId) || undefined,
187
+ isGroup: record.isGroup === true || nested?.isGroup === true,
188
+ }
189
+ }
190
+
191
+ export async function writeFileQueueOutbound(
192
+ connector: Connector,
193
+ input: FileQueueOutboundInput,
194
+ ): Promise<{ id: string; path: string; payload: Record<string, unknown> }> {
195
+ const paths = resolveFileQueuePaths(connector)
196
+ ensureFileQueueDirs(paths)
197
+ const id = genId()
198
+ const payload: Record<string, unknown> = {
199
+ id,
200
+ kind: 'swarmclaw.filequeue.outbound',
201
+ connectorId: connector.id,
202
+ connectorName: connector.name,
203
+ platform: 'filequeue',
204
+ channelId: input.channelId,
205
+ text: input.text,
206
+ createdAt: new Date().toISOString(),
207
+ }
208
+ if (input.threadId) payload.threadId = input.threadId
209
+ if (input.replyToMessageId) payload.replyToMessageId = input.replyToMessageId
210
+ if (input.options?.imageUrl) payload.imageUrl = input.options.imageUrl
211
+ if (input.options?.fileUrl) payload.fileUrl = input.options.fileUrl
212
+ if (input.options?.mediaPath) payload.mediaPath = input.options.mediaPath
213
+ if (input.options?.mimeType) payload.mimeType = input.options.mimeType
214
+ if (input.options?.fileName) payload.fileName = input.options.fileName
215
+ if (input.options?.caption) payload.caption = input.options.caption
216
+
217
+ const filename = `${Date.now()}-${fileNameSafe(id)}.json`
218
+ const outputPath = resolveUniquePath(paths.outboxDir, filename)
219
+ await fsp.writeFile(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
220
+ return { id, path: outputPath, payload }
221
+ }
222
+
223
+ export async function drainFileQueueOnce(
224
+ connector: Connector,
225
+ onMessage: (msg: InboundMessage) => Promise<ConnectorIngressResult>,
226
+ ): Promise<FileQueueDrainResult> {
227
+ const paths = resolveFileQueuePaths(connector)
228
+ ensureFileQueueDirs(paths)
229
+ const entries = await fsp.readdir(paths.inboxDir, { withFileTypes: true })
230
+ const files = entries
231
+ .filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === '.json')
232
+ .map((entry) => entry.name)
233
+ .sort((a, b) => a.localeCompare(b))
234
+ let processed = 0
235
+ let failed = 0
236
+
237
+ for (const file of files) {
238
+ const source = path.join(paths.inboxDir, file)
239
+ try {
240
+ const raw = await fsp.readFile(source, 'utf8')
241
+ const envelope = JSON.parse(raw) as unknown
242
+ const inbound = normalizeFileQueueEnvelope(connector, envelope)
243
+ const reply = await resolveConnectorIngressReply(onMessage, inbound)
244
+ if (reply) {
245
+ await writeFileQueueOutbound(connector, {
246
+ channelId: inbound.channelId,
247
+ text: reply.visibleText,
248
+ threadId: inbound.threadId,
249
+ replyToMessageId: inbound.messageId,
250
+ })
251
+ }
252
+ await moveFile(source, paths.archiveDir)
253
+ processed += 1
254
+ } catch (err: unknown) {
255
+ failed += 1
256
+ const message = errorMessage(err)
257
+ log.warn(TAG, `Failed to process file queue command ${file}: ${message}`)
258
+ try {
259
+ const moved = fs.existsSync(source)
260
+ ? await moveFile(source, paths.errorDir)
261
+ : path.join(paths.errorDir, file)
262
+ await fsp.writeFile(`${moved}.error.txt`, `${message}\n`, 'utf8')
263
+ } catch (moveErr: unknown) {
264
+ log.warn(TAG, `Failed to move malformed file queue command ${file}: ${errorMessage(moveErr)}`)
265
+ }
266
+ }
267
+ }
268
+
269
+ return { processed, failed }
270
+ }
271
+
272
+ const fileQueue: PlatformConnector = {
273
+ async start(connector, _botToken, onMessage): Promise<ConnectorInstance> {
274
+ const paths = resolveFileQueuePaths(connector)
275
+ ensureFileQueueDirs(paths)
276
+ let stopped = false
277
+ let draining = false
278
+ const pollIntervalMs = parsePollIntervalMs(connector.config?.pollIntervalMs)
279
+
280
+ const drain = async () => {
281
+ if (stopped || draining) return
282
+ draining = true
283
+ try {
284
+ const result = await drainFileQueueOnce(connector, onMessage)
285
+ if (result.processed || result.failed) {
286
+ log.info(TAG, `File queue drain for ${connector.name}: ${result.processed} processed, ${result.failed} failed`)
287
+ }
288
+ } catch (err: unknown) {
289
+ log.warn(TAG, `File queue drain failed for ${connector.name}: ${errorMessage(err)}`)
290
+ } finally {
291
+ draining = false
292
+ }
293
+ }
294
+
295
+ void drain()
296
+ const timer = setInterval(() => {
297
+ void drain()
298
+ }, pollIntervalMs)
299
+ timer.unref?.()
300
+
301
+ return {
302
+ connector,
303
+ authenticated: true,
304
+ supportsBinaryMedia: false,
305
+ stop: async () => {
306
+ stopped = true
307
+ clearInterval(timer)
308
+ },
309
+ isAlive: () => !stopped,
310
+ sendMessage: async (channelId: string, text: string, options?: OutboundSendOptions) => {
311
+ const written = await writeFileQueueOutbound(connector, {
312
+ channelId,
313
+ text,
314
+ threadId: options?.threadId,
315
+ replyToMessageId: options?.replyToMessageId,
316
+ options,
317
+ })
318
+ return { messageId: written.id }
319
+ },
320
+ }
321
+ },
322
+ }
323
+
324
+ export default fileQueue
@@ -210,6 +210,7 @@ const VALID_CONNECTOR_PLATFORMS = new Set([
210
210
  'googlechat',
211
211
  'matrix',
212
212
  'email',
213
+ 'filequeue',
213
214
  'webchat',
214
215
  'mockmail',
215
216
  ])
@@ -698,7 +698,7 @@ if (!IS_BUILD_BOOTSTRAP) {
698
698
  - **Tasks** — The Task Board tracks work items. Assign agents and they'll execute autonomously.
699
699
  - **Schedules** — Cron-based recurring jobs that run agents or tasks automatically.
700
700
  - **Skills** — Reusable markdown instruction files agents can discover and use by default; pin them to keep favorite workflows always-on.
701
- - **Connectors** — Bridge agents to Discord, Slack, Telegram, or WhatsApp.
701
+ - **Connectors** — Bridge agents to chat platforms, local file queues, and agent channels.
702
702
  - **Secrets** — Encrypted vault for API keys (Settings → Secrets).
703
703
 
704
704
  ## Tools
@@ -161,7 +161,7 @@ export const ConnectorCreateSchema = z.object({
161
161
  name: z.string().min(1, 'Connector name is required').optional(),
162
162
  platform: z.enum([
163
163
  'discord', 'telegram', 'slack', 'whatsapp', 'openclaw',
164
- 'bluebubbles', 'signal', 'teams', 'googlechat', 'matrix', 'email', 'swarmdock',
164
+ 'bluebubbles', 'signal', 'teams', 'googlechat', 'matrix', 'email', 'filequeue', 'swarmdock',
165
165
  ]),
166
166
  agentId: z.string().nullable().optional().default(null),
167
167
  chatroomId: z.string().nullable().optional().default(null),
@@ -28,6 +28,7 @@ export type ConnectorPlatform =
28
28
  | 'email'
29
29
  | 'webchat'
30
30
  | 'mockmail'
31
+ | 'filequeue'
31
32
  | 'swarmdock'
32
33
  export type ConnectorStatus = 'stopped' | 'running' | 'error' | 'starting'
33
34