@swarmclawai/swarmclaw 0.7.1 → 0.7.3

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 (237) hide show
  1. package/README.md +155 -150
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +37 -9
  5. package/src/app/api/agents/route.ts +13 -2
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
  9. package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
  10. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  11. package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
  12. package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
  13. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  14. package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
  15. package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
  16. package/src/app/api/{sessions → chats}/route.ts +21 -7
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  18. package/src/app/api/connectors/doctor/route.ts +13 -0
  19. package/src/app/api/files/open/route.ts +16 -14
  20. package/src/app/api/memory/maintenance/route.ts +11 -1
  21. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  22. package/src/app/api/openclaw/skills/route.ts +11 -3
  23. package/src/app/api/plugins/dependencies/route.ts +24 -0
  24. package/src/app/api/plugins/install/route.ts +15 -92
  25. package/src/app/api/plugins/route.ts +6 -26
  26. package/src/app/api/plugins/settings/route.ts +40 -0
  27. package/src/app/api/plugins/ui/route.ts +1 -0
  28. package/src/app/api/settings/route.ts +49 -7
  29. package/src/app/api/tasks/[id]/route.ts +15 -6
  30. package/src/app/api/tasks/bulk/route.ts +2 -2
  31. package/src/app/api/tasks/route.ts +9 -4
  32. package/src/app/api/usage/route.ts +30 -0
  33. package/src/app/api/webhooks/[id]/route.ts +8 -1
  34. package/src/app/page.tsx +9 -2
  35. package/src/cli/index.js +39 -33
  36. package/src/cli/index.ts +43 -49
  37. package/src/cli/spec.js +29 -27
  38. package/src/components/agents/agent-card.tsx +16 -13
  39. package/src/components/agents/agent-chat-list.tsx +104 -4
  40. package/src/components/agents/agent-list.tsx +54 -22
  41. package/src/components/agents/agent-sheet.tsx +209 -18
  42. package/src/components/agents/cron-job-form.tsx +3 -3
  43. package/src/components/agents/inspector-panel.tsx +110 -50
  44. package/src/components/auth/access-key-gate.tsx +36 -97
  45. package/src/components/auth/setup-wizard.tsx +5 -38
  46. package/src/components/chat/chat-area.tsx +39 -27
  47. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
  48. package/src/components/chat/chat-header.tsx +299 -314
  49. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
  50. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  51. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  52. package/src/components/chat/message-bubble.tsx +4 -1
  53. package/src/components/chat/message-list.tsx +5 -3
  54. package/src/components/chat/session-debug-panel.tsx +1 -1
  55. package/src/components/chat/tool-request-banner.tsx +3 -3
  56. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  59. package/src/components/connectors/connector-list.tsx +265 -127
  60. package/src/components/connectors/connector-sheet.tsx +218 -1
  61. package/src/components/home/home-view.tsx +129 -5
  62. package/src/components/layout/app-layout.tsx +392 -182
  63. package/src/components/layout/mobile-header.tsx +26 -8
  64. package/src/components/plugins/plugin-list.tsx +487 -254
  65. package/src/components/plugins/plugin-sheet.tsx +236 -13
  66. package/src/components/projects/project-detail.tsx +183 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  68. package/src/components/shared/agent-picker-list.tsx +2 -2
  69. package/src/components/shared/command-palette.tsx +111 -25
  70. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  71. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +78 -1
  73. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  74. package/src/components/shared/settings/section-providers.tsx +1 -1
  75. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  76. package/src/components/shared/settings/section-secrets.tsx +6 -6
  77. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  78. package/src/components/shared/settings/section-voice.tsx +5 -1
  79. package/src/components/shared/settings/section-web-search.tsx +10 -2
  80. package/src/components/shared/settings/settings-page.tsx +244 -56
  81. package/src/components/tasks/approvals-panel.tsx +205 -18
  82. package/src/components/tasks/task-board.tsx +242 -46
  83. package/src/components/usage/metrics-dashboard.tsx +147 -1
  84. package/src/components/wallets/wallet-panel.tsx +17 -5
  85. package/src/components/webhooks/webhook-sheet.tsx +8 -8
  86. package/src/lib/auth.ts +17 -0
  87. package/src/lib/chat-streaming-state.test.ts +108 -0
  88. package/src/lib/chat-streaming-state.ts +108 -0
  89. package/src/lib/chat.ts +1 -1
  90. package/src/lib/{sessions.ts → chats.ts} +28 -18
  91. package/src/lib/openclaw-agent-id.test.ts +14 -0
  92. package/src/lib/openclaw-agent-id.ts +31 -0
  93. package/src/lib/providers/claude-cli.ts +1 -1
  94. package/src/lib/server/agent-assignment.test.ts +112 -0
  95. package/src/lib/server/agent-assignment.ts +169 -0
  96. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  97. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  98. package/src/lib/server/approvals.ts +483 -75
  99. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  100. package/src/lib/server/browser-state.test.ts +118 -0
  101. package/src/lib/server/browser-state.ts +123 -0
  102. package/src/lib/server/build-llm.test.ts +36 -0
  103. package/src/lib/server/build-llm.ts +11 -4
  104. package/src/lib/server/builtin-plugins.ts +34 -0
  105. package/src/lib/server/capability-router.ts +10 -8
  106. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  107. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  108. package/src/lib/server/chat-execution.ts +285 -165
  109. package/src/lib/server/chatroom-health.test.ts +26 -0
  110. package/src/lib/server/chatroom-health.ts +2 -3
  111. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  112. package/src/lib/server/chatroom-helpers.ts +48 -8
  113. package/src/lib/server/connectors/discord.ts +175 -11
  114. package/src/lib/server/connectors/doctor.test.ts +80 -0
  115. package/src/lib/server/connectors/doctor.ts +116 -0
  116. package/src/lib/server/connectors/manager.ts +948 -112
  117. package/src/lib/server/connectors/policy.test.ts +222 -0
  118. package/src/lib/server/connectors/policy.ts +452 -0
  119. package/src/lib/server/connectors/slack.ts +188 -9
  120. package/src/lib/server/connectors/telegram.ts +65 -15
  121. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  122. package/src/lib/server/connectors/thread-context.ts +72 -0
  123. package/src/lib/server/connectors/types.ts +41 -11
  124. package/src/lib/server/cost.ts +34 -1
  125. package/src/lib/server/daemon-state.ts +61 -3
  126. package/src/lib/server/data-dir.ts +13 -0
  127. package/src/lib/server/delegation-jobs.test.ts +140 -0
  128. package/src/lib/server/delegation-jobs.ts +248 -0
  129. package/src/lib/server/document-utils.test.ts +47 -0
  130. package/src/lib/server/document-utils.ts +397 -0
  131. package/src/lib/server/heartbeat-service.ts +14 -40
  132. package/src/lib/server/heartbeat-source.test.ts +22 -0
  133. package/src/lib/server/heartbeat-source.ts +7 -0
  134. package/src/lib/server/identity-continuity.test.ts +77 -0
  135. package/src/lib/server/identity-continuity.ts +127 -0
  136. package/src/lib/server/mailbox-utils.ts +347 -0
  137. package/src/lib/server/main-agent-loop.ts +28 -1103
  138. package/src/lib/server/memory-db.ts +4 -6
  139. package/src/lib/server/memory-tiers.ts +40 -0
  140. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  141. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  142. package/src/lib/server/openclaw-exec-config.ts +5 -6
  143. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  144. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  145. package/src/lib/server/openclaw-sync.ts +3 -2
  146. package/src/lib/server/orchestrator-lg.ts +20 -9
  147. package/src/lib/server/orchestrator.ts +7 -7
  148. package/src/lib/server/playwright-proxy.mjs +27 -3
  149. package/src/lib/server/plugins.test.ts +207 -0
  150. package/src/lib/server/plugins.ts +927 -66
  151. package/src/lib/server/provider-health.ts +38 -6
  152. package/src/lib/server/queue.ts +13 -28
  153. package/src/lib/server/scheduler.ts +2 -0
  154. package/src/lib/server/session-archive-memory.test.ts +85 -0
  155. package/src/lib/server/session-archive-memory.ts +230 -0
  156. package/src/lib/server/session-mailbox.ts +8 -18
  157. package/src/lib/server/session-reset-policy.test.ts +99 -0
  158. package/src/lib/server/session-reset-policy.ts +311 -0
  159. package/src/lib/server/session-run-manager.ts +33 -82
  160. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  161. package/src/lib/server/session-tools/calendar.ts +366 -0
  162. package/src/lib/server/session-tools/canvas.ts +1 -1
  163. package/src/lib/server/session-tools/chatroom.ts +4 -2
  164. package/src/lib/server/session-tools/connector.ts +114 -10
  165. package/src/lib/server/session-tools/context.ts +21 -5
  166. package/src/lib/server/session-tools/crawl.ts +447 -0
  167. package/src/lib/server/session-tools/crud.ts +74 -28
  168. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  169. package/src/lib/server/session-tools/delegate.ts +497 -24
  170. package/src/lib/server/session-tools/discovery.ts +24 -6
  171. package/src/lib/server/session-tools/document.ts +283 -0
  172. package/src/lib/server/session-tools/edit_file.ts +4 -2
  173. package/src/lib/server/session-tools/email.ts +320 -0
  174. package/src/lib/server/session-tools/extract.ts +137 -0
  175. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  176. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  177. package/src/lib/server/session-tools/file.ts +241 -25
  178. package/src/lib/server/session-tools/git.ts +1 -1
  179. package/src/lib/server/session-tools/http.ts +1 -1
  180. package/src/lib/server/session-tools/human-loop.ts +227 -0
  181. package/src/lib/server/session-tools/image-gen.ts +380 -0
  182. package/src/lib/server/session-tools/index.ts +130 -50
  183. package/src/lib/server/session-tools/mailbox.ts +276 -0
  184. package/src/lib/server/session-tools/memory.ts +172 -3
  185. package/src/lib/server/session-tools/monitor.ts +151 -8
  186. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  187. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  188. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  189. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  190. package/src/lib/server/session-tools/platform.ts +148 -7
  191. package/src/lib/server/session-tools/plugin-creator.ts +89 -26
  192. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  193. package/src/lib/server/session-tools/replicate.ts +301 -0
  194. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  195. package/src/lib/server/session-tools/sandbox.ts +4 -2
  196. package/src/lib/server/session-tools/schedule.ts +24 -12
  197. package/src/lib/server/session-tools/session-info.ts +43 -7
  198. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  199. package/src/lib/server/session-tools/shell.ts +5 -2
  200. package/src/lib/server/session-tools/subagent.ts +194 -28
  201. package/src/lib/server/session-tools/table.ts +587 -0
  202. package/src/lib/server/session-tools/wallet.ts +42 -12
  203. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  204. package/src/lib/server/session-tools/web.ts +926 -91
  205. package/src/lib/server/storage.ts +255 -16
  206. package/src/lib/server/stream-agent-chat.ts +116 -268
  207. package/src/lib/server/structured-extract.test.ts +72 -0
  208. package/src/lib/server/structured-extract.ts +373 -0
  209. package/src/lib/server/task-mention.test.ts +16 -2
  210. package/src/lib/server/task-mention.ts +61 -10
  211. package/src/lib/server/tool-aliases.ts +66 -18
  212. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  213. package/src/lib/server/tool-capability-policy.ts +38 -27
  214. package/src/lib/server/tool-retry.ts +2 -0
  215. package/src/lib/server/watch-jobs.test.ts +173 -0
  216. package/src/lib/server/watch-jobs.ts +532 -0
  217. package/src/lib/server/ws-hub.ts +5 -3
  218. package/src/lib/tool-definitions.ts +4 -0
  219. package/src/lib/validation/schemas.test.ts +26 -0
  220. package/src/lib/validation/schemas.ts +10 -1
  221. package/src/lib/ws-client.ts +14 -12
  222. package/src/proxy.ts +5 -5
  223. package/src/stores/use-app-store.ts +5 -11
  224. package/src/stores/use-chat-store.ts +38 -9
  225. package/src/types/index.ts +352 -47
  226. package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
  227. package/src/components/sessions/new-session-sheet.tsx +0 -253
  228. package/src/lib/server/main-session.ts +0 -24
  229. package/src/lib/server/session-run-manager.test.ts +0 -23
  230. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  231. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  232. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  233. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  234. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  235. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  236. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  237. /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
@@ -1,4 +1,5 @@
1
- import type { Agent, UsageRecord } from '@/types'
1
+ import type { Agent, UsageRecord, PluginDefinitionCost } from '@/types'
2
+ import type { StructuredToolInterface } from '@langchain/core/tools'
2
3
  import { loadSessions, loadUsage } from './storage'
3
4
 
4
5
  // Model cost table: [inputCostPer1M, outputCostPer1M] in USD
@@ -65,6 +66,38 @@ export function getModelCosts(): Record<string, [number, number]> {
65
66
  return { ...MODEL_COSTS }
66
67
  }
67
68
 
69
+ /**
70
+ * Estimate the number of tokens a tool definition occupies in the LLM context.
71
+ * Uses ~4 chars per token as a rough approximation.
72
+ */
73
+ export function estimateToolDefinitionTokens(t: StructuredToolInterface): number {
74
+ let chars = (t.name || '').length + (t.description || '').length
75
+ try {
76
+ const schema = typeof t.schema === 'object' ? JSON.stringify(t.schema) : ''
77
+ chars += schema.length
78
+ } catch { /* ignore */ }
79
+ return Math.ceil(chars / 4)
80
+ }
81
+
82
+ /**
83
+ * Build per-plugin definition cost estimates from a set of tools and their plugin mapping.
84
+ */
85
+ export function buildPluginDefinitionCosts(
86
+ tools: StructuredToolInterface[],
87
+ toolToPluginMap: Record<string, string>,
88
+ ): PluginDefinitionCost[] {
89
+ const totals = new Map<string, number>()
90
+ for (const t of tools) {
91
+ const pluginId = toolToPluginMap[t.name] || '_unknown'
92
+ const tokens = estimateToolDefinitionTokens(t)
93
+ totals.set(pluginId, (totals.get(pluginId) || 0) + tokens)
94
+ }
95
+ return Array.from(totals.entries()).map(([pluginId, estimatedTokens]) => ({
96
+ pluginId,
97
+ estimatedTokens,
98
+ }))
99
+ }
100
+
68
101
  export interface AgentSpendWindows {
69
102
  hourly: number
70
103
  daily: number
@@ -22,6 +22,12 @@ import type { WebhookRetryEntry } from '@/types'
22
22
  import { createNotification } from '@/lib/server/create-notification'
23
23
  import { pingProvider, OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
24
24
  import { runIntegrityMonitor } from '@/lib/server/integrity-monitor'
25
+ import { recoverStaleDelegationJobs } from './delegation-jobs'
26
+ import {
27
+ listPendingApprovalsNeedingConnectorNotification,
28
+ markApprovalConnectorNotificationAttempt,
29
+ markApprovalConnectorNotificationSent,
30
+ } from './approvals'
25
31
 
26
32
  const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
27
33
  const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
@@ -161,6 +167,7 @@ export function startDaemon(options?: { source?: string; manualStart?: boolean }
161
167
  try {
162
168
  validateCompletedTasksQueue()
163
169
  cleanupFinishedTaskSessions()
170
+ recoverStaleDelegationJobs()
164
171
  startScheduler()
165
172
  startQueueProcessor()
166
173
  startBrowserSweep()
@@ -413,14 +420,14 @@ async function processWebhookRetries() {
413
420
  claudeSessionId: null,
414
421
  codexThreadId: null,
415
422
  opencodeSessionId: null,
416
- delegateResumeIds: { claudeCode: null, codex: null, opencode: null },
423
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
417
424
  messages: [],
418
425
  createdAt: ts,
419
426
  lastActiveAt: ts,
420
- sessionType: 'orchestrated',
427
+ sessionType: 'human',
421
428
  agentId: agent.id,
422
429
  parentSessionId: null,
423
- tools: agent.tools || [],
430
+ plugins: agent.plugins || agent.tools || [],
424
431
  heartbeatEnabled: (agent.heartbeatEnabled as boolean | undefined) ?? true,
425
432
  heartbeatIntervalSec: (agent.heartbeatIntervalSec as number | null | undefined) ?? null,
426
433
  }
@@ -664,6 +671,51 @@ async function runOpenClawGatewayHealthChecks() {
664
671
  }
665
672
  }
666
673
 
674
+ async function runPendingApprovalConnectorNotifications(now: number) {
675
+ const running = listRunningConnectors()
676
+ const pending = listPendingApprovalsNeedingConnectorNotification({
677
+ now,
678
+ runningConnectors: running,
679
+ })
680
+ if (!pending.length) return
681
+
682
+ for (const reminder of pending) {
683
+ try {
684
+ const result = await sendConnectorMessage({
685
+ connectorId: reminder.connectorId,
686
+ channelId: reminder.channelId,
687
+ text: reminder.text,
688
+ threadId: reminder.threadId || undefined,
689
+ })
690
+ markApprovalConnectorNotificationSent(reminder.approvalId, {
691
+ at: now,
692
+ connectorId: result.connectorId,
693
+ channelId: result.channelId,
694
+ threadId: reminder.threadId || null,
695
+ messageId: result.messageId || null,
696
+ })
697
+ createNotification({
698
+ type: 'info',
699
+ title: 'Approval reminder sent',
700
+ message: 'A pending approval reminder was delivered over an active connector.',
701
+ dedupKey: `approval-connector-reminder:${reminder.approvalId}`,
702
+ entityType: 'approval',
703
+ entityId: reminder.approvalId,
704
+ })
705
+ } catch (err: unknown) {
706
+ const errorMsg = err instanceof Error ? err.message : String(err)
707
+ markApprovalConnectorNotificationAttempt(reminder.approvalId, {
708
+ at: now,
709
+ connectorId: reminder.connectorId,
710
+ channelId: reminder.channelId,
711
+ threadId: reminder.threadId || null,
712
+ lastError: errorMsg,
713
+ })
714
+ console.warn(`[daemon] Approval connector reminder failed for ${reminder.approvalId}: ${errorMsg}`)
715
+ }
716
+ }
717
+ }
718
+
667
719
  async function runHealthChecks() {
668
720
  // Continuously keep the completed queue honest.
669
721
  validateCompletedTasksQueue()
@@ -739,6 +791,12 @@ async function runHealthChecks() {
739
791
  console.error('[daemon] OpenClaw gateway health check failed:', err instanceof Error ? err.message : String(err))
740
792
  }
741
793
 
794
+ try {
795
+ await runPendingApprovalConnectorNotifications(now)
796
+ } catch (err: unknown) {
797
+ console.error('[daemon] Approval connector reminder check failed:', err instanceof Error ? err.message : String(err))
798
+ }
799
+
742
800
  // Integrity drift monitoring for identity/config/plugin files.
743
801
  try {
744
802
  const integrity = runIntegrityMonitor(loadSettings())
@@ -19,3 +19,16 @@ function resolveWorkspaceDir(): string {
19
19
  }
20
20
 
21
21
  export const WORKSPACE_DIR = resolveWorkspaceDir()
22
+
23
+ function resolveBrowserProfilesDir(): string {
24
+ if (process.env.BROWSER_PROFILES_DIR) return process.env.BROWSER_PROFILES_DIR
25
+ const external = path.join(os.homedir(), '.swarmclaw', 'browser-profiles')
26
+ try {
27
+ fs.mkdirSync(external, { recursive: true })
28
+ return external
29
+ } catch {
30
+ return path.join(DATA_DIR, 'browser-profiles')
31
+ }
32
+ }
33
+
34
+ export const BROWSER_PROFILES_DIR = resolveBrowserProfilesDir()
@@ -0,0 +1,140 @@
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 { after, before, describe, it } from 'node:test'
6
+
7
+ const originalEnv = {
8
+ DATA_DIR: process.env.DATA_DIR,
9
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
10
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
11
+ }
12
+
13
+ let tempDir = ''
14
+ let delegationJobs: typeof import('./delegation-jobs')
15
+
16
+ before(async () => {
17
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-delegation-jobs-'))
18
+ process.env.DATA_DIR = path.join(tempDir, 'data')
19
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
20
+ process.env.SWARMCLAW_BUILD_MODE = '1'
21
+ delegationJobs = await import('./delegation-jobs')
22
+ })
23
+
24
+ after(() => {
25
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
26
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
27
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
28
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
29
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
30
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ })
33
+
34
+ describe('delegation-jobs', () => {
35
+ it('tracks a queued job through running and completion', () => {
36
+ const job = delegationJobs.createDelegationJob({
37
+ kind: 'delegate',
38
+ parentSessionId: 'session-1',
39
+ backend: 'codex',
40
+ task: 'Refactor the module',
41
+ cwd: process.cwd(),
42
+ })
43
+
44
+ const started = delegationJobs.startDelegationJob(job.id, { backend: 'codex' })
45
+ const completed = delegationJobs.completeDelegationJob(job.id, 'done')
46
+
47
+ assert.equal(started?.status, 'running')
48
+ assert.equal(completed?.status, 'completed')
49
+ assert.equal(completed?.resultPreview, 'done')
50
+ assert.equal(delegationJobs.listDelegationJobs({ parentSessionId: 'session-1' }).length >= 1, true)
51
+ })
52
+
53
+ it('keeps cancellation terminal even if late completions arrive', () => {
54
+ const job = delegationJobs.createDelegationJob({
55
+ kind: 'subagent',
56
+ parentSessionId: 'session-2',
57
+ agentId: 'agent-1',
58
+ task: 'Do the background work',
59
+ cwd: process.cwd(),
60
+ })
61
+
62
+ let cancelled = false
63
+ delegationJobs.startDelegationJob(job.id, { agentId: 'agent-1' })
64
+ delegationJobs.registerDelegationRuntime(job.id, {
65
+ cancel: () => {
66
+ cancelled = true
67
+ },
68
+ })
69
+
70
+ const stopped = delegationJobs.cancelDelegationJob(job.id)
71
+ const afterComplete = delegationJobs.completeDelegationJob(job.id, 'late success')
72
+ const afterFail = delegationJobs.failDelegationJob(job.id, 'late failure')
73
+
74
+ assert.equal(cancelled, true)
75
+ assert.equal(stopped?.status, 'cancelled')
76
+ assert.equal(afterComplete?.status, 'cancelled')
77
+ assert.equal(afterFail?.status, 'cancelled')
78
+ })
79
+
80
+ it('recovers stale running jobs as failed', () => {
81
+ const stale = delegationJobs.createDelegationJob({
82
+ kind: 'delegate',
83
+ parentSessionId: 'session-3',
84
+ backend: 'claude',
85
+ task: 'Stale job',
86
+ cwd: process.cwd(),
87
+ })
88
+
89
+ delegationJobs.startDelegationJob(stale.id)
90
+ delegationJobs.updateDelegationJob(stale.id, {
91
+ startedAt: Date.now() - 60_000,
92
+ })
93
+
94
+ const recovered = delegationJobs.recoverStaleDelegationJobs(-1)
95
+ const latest = delegationJobs.getDelegationJob(stale.id)
96
+
97
+ assert.equal(recovered >= 1, true)
98
+ assert.equal(latest?.status, 'failed')
99
+ assert.match(String(latest?.error || ''), /interrupted/i)
100
+ })
101
+
102
+ it('cancels all running jobs for a parent session', () => {
103
+ const jobA = delegationJobs.createDelegationJob({
104
+ kind: 'delegate',
105
+ parentSessionId: 'session-bulk',
106
+ backend: 'codex',
107
+ task: 'Task A',
108
+ cwd: process.cwd(),
109
+ })
110
+ const jobB = delegationJobs.createDelegationJob({
111
+ kind: 'subagent',
112
+ parentSessionId: 'session-bulk',
113
+ agentId: 'agent-2',
114
+ task: 'Task B',
115
+ cwd: process.cwd(),
116
+ })
117
+ const untouched = delegationJobs.createDelegationJob({
118
+ kind: 'delegate',
119
+ parentSessionId: 'other-session',
120
+ backend: 'claude',
121
+ task: 'Task C',
122
+ cwd: process.cwd(),
123
+ })
124
+
125
+ delegationJobs.startDelegationJob(jobA.id)
126
+ delegationJobs.startDelegationJob(jobB.id)
127
+ delegationJobs.startDelegationJob(untouched.id)
128
+
129
+ const cancelled = delegationJobs.cancelDelegationJobsForParentSession('session-bulk', 'Stopped by user')
130
+
131
+ assert.equal(cancelled, 2)
132
+ assert.equal(delegationJobs.getDelegationJob(jobA.id)?.status, 'cancelled')
133
+ assert.equal(delegationJobs.getDelegationJob(jobB.id)?.status, 'cancelled')
134
+ assert.equal(delegationJobs.getDelegationJob(untouched.id)?.status, 'running')
135
+ assert.equal(
136
+ delegationJobs.getDelegationJob(jobA.id)?.checkpoints?.some((entry) => entry.note === 'Stopped by user'),
137
+ true,
138
+ )
139
+ })
140
+ })
@@ -0,0 +1,248 @@
1
+ import { genId } from '@/lib/id'
2
+ import type { DelegationJobArtifact, DelegationJobCheckpoint, DelegationJobRecord, DelegationJobStatus } from '@/types'
3
+ import { loadDelegationJobs, upsertDelegationJob } from './storage'
4
+ import { notify } from './ws-hub'
5
+
6
+ interface DelegationRuntimeHandle {
7
+ cancel?: () => void
8
+ }
9
+
10
+ const runtimeKey = '__swarmclaw_delegation_job_runtime__' as const
11
+ const runtimeScope = globalThis as typeof globalThis & {
12
+ [runtimeKey]?: Map<string, DelegationRuntimeHandle>
13
+ }
14
+ const runtimeHandles = runtimeScope[runtimeKey] ?? (runtimeScope[runtimeKey] = new Map())
15
+
16
+ function now() {
17
+ return Date.now()
18
+ }
19
+
20
+ function isTerminalStatus(status: DelegationJobStatus | null | undefined): boolean {
21
+ return status === 'completed' || status === 'failed' || status === 'cancelled'
22
+ }
23
+
24
+ function notifyDelegationJobsChanged() {
25
+ notify('delegation_jobs')
26
+ }
27
+
28
+ export interface CreateDelegationJobInput {
29
+ kind: DelegationJobRecord['kind']
30
+ task: string
31
+ backend?: DelegationJobRecord['backend']
32
+ parentSessionId?: string | null
33
+ childSessionId?: string | null
34
+ agentId?: string | null
35
+ agentName?: string | null
36
+ cwd?: string | null
37
+ }
38
+
39
+ export function createDelegationJob(input: CreateDelegationJobInput): DelegationJobRecord {
40
+ const createdAt = now()
41
+ const job: DelegationJobRecord = {
42
+ id: genId(10),
43
+ kind: input.kind,
44
+ status: 'queued',
45
+ backend: input.backend ?? null,
46
+ parentSessionId: input.parentSessionId ?? null,
47
+ childSessionId: input.childSessionId ?? null,
48
+ agentId: input.agentId ?? null,
49
+ agentName: input.agentName ?? null,
50
+ cwd: input.cwd ?? null,
51
+ task: input.task,
52
+ result: null,
53
+ resultPreview: null,
54
+ error: null,
55
+ checkpoints: [{
56
+ at: createdAt,
57
+ note: 'Job queued',
58
+ status: 'queued',
59
+ }],
60
+ artifacts: [],
61
+ resumeId: null,
62
+ resumeIds: {},
63
+ createdAt,
64
+ updatedAt: createdAt,
65
+ startedAt: null,
66
+ completedAt: null,
67
+ }
68
+ upsertDelegationJob(job.id, job)
69
+ notifyDelegationJobsChanged()
70
+ return job
71
+ }
72
+
73
+ export function getDelegationJob(id: string): DelegationJobRecord | null {
74
+ const all = loadDelegationJobs()
75
+ const current = all[id]
76
+ if (!current || typeof current !== 'object') return null
77
+ return current as DelegationJobRecord
78
+ }
79
+
80
+ export function listDelegationJobs(filter?: {
81
+ parentSessionId?: string | null
82
+ status?: DelegationJobStatus | null
83
+ }): DelegationJobRecord[] {
84
+ return Object.values(loadDelegationJobs())
85
+ .filter((job): job is DelegationJobRecord => !!job && typeof job === 'object')
86
+ .filter((job) => !filter?.parentSessionId || job.parentSessionId === filter.parentSessionId)
87
+ .filter((job) => !filter?.status || job.status === filter.status)
88
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
89
+ }
90
+
91
+ export function updateDelegationJob(
92
+ id: string,
93
+ patch: Partial<DelegationJobRecord>,
94
+ ): DelegationJobRecord | null {
95
+ const current = getDelegationJob(id)
96
+ if (!current) return null
97
+ const next: DelegationJobRecord = {
98
+ ...current,
99
+ ...patch,
100
+ updatedAt: now(),
101
+ }
102
+ upsertDelegationJob(id, next)
103
+ notifyDelegationJobsChanged()
104
+ return next
105
+ }
106
+
107
+ export function appendDelegationCheckpoint(
108
+ id: string,
109
+ note: string,
110
+ status?: DelegationJobStatus,
111
+ ): DelegationJobRecord | null {
112
+ const current = getDelegationJob(id)
113
+ if (!current) return null
114
+ if (isTerminalStatus(current.status) && status && status !== current.status) {
115
+ return current
116
+ }
117
+ const checkpoints = [...(current.checkpoints || []), { at: now(), note, status }]
118
+ return updateDelegationJob(id, {
119
+ status: isTerminalStatus(current.status) ? current.status : (status ?? current.status),
120
+ checkpoints: checkpoints.slice(-24),
121
+ })
122
+ }
123
+
124
+ export function startDelegationJob(id: string, patch?: Partial<DelegationJobRecord>): DelegationJobRecord | null {
125
+ const current = getDelegationJob(id)
126
+ if (!current) return null
127
+ if (isTerminalStatus(current.status)) return current
128
+ return updateDelegationJob(id, {
129
+ ...patch,
130
+ status: 'running',
131
+ startedAt: now(),
132
+ })
133
+ }
134
+
135
+ export function completeDelegationJob(
136
+ id: string,
137
+ result: string,
138
+ patch?: Partial<DelegationJobRecord>,
139
+ ): DelegationJobRecord | null {
140
+ runtimeHandles.delete(id)
141
+ const current = getDelegationJob(id)
142
+ if (!current) return null
143
+ if (isTerminalStatus(current.status)) return current
144
+ return updateDelegationJob(id, {
145
+ ...patch,
146
+ status: 'completed',
147
+ result,
148
+ resultPreview: result.slice(0, 1000),
149
+ error: null,
150
+ completedAt: now(),
151
+ })
152
+ }
153
+
154
+ export function failDelegationJob(id: string, error: string, patch?: Partial<DelegationJobRecord>): DelegationJobRecord | null {
155
+ runtimeHandles.delete(id)
156
+ const current = getDelegationJob(id)
157
+ if (!current) return null
158
+ if (isTerminalStatus(current.status)) return current
159
+ return updateDelegationJob(id, {
160
+ ...patch,
161
+ status: 'failed',
162
+ error,
163
+ completedAt: now(),
164
+ })
165
+ }
166
+
167
+ export function cancelDelegationJob(id: string): DelegationJobRecord | null {
168
+ const current = getDelegationJob(id)
169
+ if (!current) return null
170
+ if (isTerminalStatus(current.status)) return current
171
+ const runtime = runtimeHandles.get(id)
172
+ try {
173
+ runtime?.cancel?.()
174
+ } catch {
175
+ // best-effort cancel
176
+ }
177
+ runtimeHandles.delete(id)
178
+ const checkpoint: DelegationJobCheckpoint = {
179
+ at: now(),
180
+ note: 'Job cancelled',
181
+ status: 'cancelled',
182
+ }
183
+ return updateDelegationJob(id, {
184
+ status: 'cancelled',
185
+ completedAt: now(),
186
+ error: null,
187
+ checkpoints: [
188
+ ...(current.checkpoints || []),
189
+ checkpoint,
190
+ ].slice(-24),
191
+ })
192
+ }
193
+
194
+ export function cancelDelegationJobsForParentSession(
195
+ parentSessionId: string,
196
+ note = 'Parent session cancelled',
197
+ ): number {
198
+ if (!parentSessionId) return 0
199
+ const jobs = listDelegationJobs({ parentSessionId })
200
+ .filter((job) => job.status === 'queued' || job.status === 'running')
201
+ let cancelled = 0
202
+ for (const job of jobs) {
203
+ const next = cancelDelegationJob(job.id)
204
+ if (!next || next.status !== 'cancelled') continue
205
+ cancelled += 1
206
+ const checkpoints = Array.isArray(next.checkpoints) ? next.checkpoints : []
207
+ const last = checkpoints[checkpoints.length - 1]
208
+ if (!last || last.note !== note) {
209
+ const checkpoint: DelegationJobCheckpoint = {
210
+ at: now(),
211
+ note,
212
+ status: 'cancelled',
213
+ }
214
+ updateDelegationJob(job.id, {
215
+ checkpoints: [
216
+ ...checkpoints,
217
+ checkpoint,
218
+ ].slice(-24),
219
+ })
220
+ }
221
+ }
222
+ return cancelled
223
+ }
224
+
225
+ export function registerDelegationRuntime(id: string, handle: DelegationRuntimeHandle) {
226
+ runtimeHandles.set(id, handle)
227
+ }
228
+
229
+ export function appendDelegationArtifacts(id: string, artifacts: DelegationJobArtifact[]): DelegationJobRecord | null {
230
+ const current = getDelegationJob(id)
231
+ if (!current) return null
232
+ return updateDelegationJob(id, {
233
+ artifacts: [...(current.artifacts || []), ...artifacts].slice(-24),
234
+ })
235
+ }
236
+
237
+ export function recoverStaleDelegationJobs(maxAgeMs = 15 * 60_000): number {
238
+ const threshold = now() - maxAgeMs
239
+ const stale = listDelegationJobs().filter((job) =>
240
+ (job.status === 'queued' || job.status === 'running')
241
+ && !runtimeHandles.has(job.id)
242
+ && (job.updatedAt || job.createdAt) < threshold,
243
+ )
244
+ for (const job of stale) {
245
+ failDelegationJob(job.id, 'Delegation job was interrupted before completion.')
246
+ }
247
+ return stale.length
248
+ }
@@ -0,0 +1,47 @@
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 { describe, it } from 'node:test'
6
+ import { extractDocumentArtifact, loadTabularFile, normalizeInlineRows, writeStructuredTable } from './document-utils'
7
+
8
+ describe('document-utils', () => {
9
+ it('extracts structured tables from JSON arrays', async () => {
10
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-doc-utils-'))
11
+ try {
12
+ const jsonPath = path.join(tempDir, 'people.json')
13
+ fs.writeFileSync(jsonPath, JSON.stringify([
14
+ { name: 'Ada', score: 10 },
15
+ { name: 'Grace', score: 9 },
16
+ ], null, 2))
17
+
18
+ const artifact = await extractDocumentArtifact(jsonPath)
19
+ assert.equal(artifact.method, 'json')
20
+ assert.equal(artifact.tables.length, 1)
21
+ assert.deepEqual(artifact.tables[0].headers, ['name', 'score'])
22
+ assert.equal(artifact.tables[0].rowCount, 2)
23
+ } finally {
24
+ fs.rmSync(tempDir, { recursive: true, force: true })
25
+ }
26
+ })
27
+
28
+ it('normalizes inline rows and round-trips CSV output', async () => {
29
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-doc-utils-'))
30
+ try {
31
+ const table = normalizeInlineRows([
32
+ { city: 'London', population: 9 },
33
+ { city: 'Paris', population: 2 },
34
+ ])
35
+ const csvPath = path.join(tempDir, 'cities.csv')
36
+ const written = await writeStructuredTable(csvPath, table)
37
+ const loaded = await loadTabularFile(csvPath)
38
+
39
+ assert.equal(written.format, 'csv')
40
+ assert.deepEqual(loaded.headers, ['city', 'population'])
41
+ assert.equal(loaded.rowCount, 2)
42
+ assert.equal(String(loaded.rows[0].city), 'London')
43
+ } finally {
44
+ fs.rmSync(tempDir, { recursive: true, force: true })
45
+ }
46
+ })
47
+ })