@swarmclawai/swarmclaw 0.9.2 → 0.9.4

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 (75) hide show
  1. package/README.md +12 -10
  2. package/bundled-skills/google-workspace/SKILL.md +2 -0
  3. package/package.json +1 -1
  4. package/src/app/agents/page.tsx +2 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +1 -1
  6. package/src/app/api/clawhub/install/route.ts +2 -0
  7. package/src/app/api/skills/[id]/route.ts +4 -0
  8. package/src/app/api/skills/route.ts +4 -0
  9. package/src/app/globals.css +28 -0
  10. package/src/app/home/page.tsx +11 -0
  11. package/src/app/settings/page.tsx +12 -5
  12. package/src/components/agents/agent-sheet.tsx +5 -5
  13. package/src/components/connectors/connector-list.tsx +2 -5
  14. package/src/components/logs/log-list.tsx +2 -5
  15. package/src/components/providers/provider-list.tsx +2 -5
  16. package/src/components/runs/run-list.tsx +2 -6
  17. package/src/components/schedules/schedule-list.tsx +7 -1
  18. package/src/components/ui/full-screen-loader.tsx +0 -29
  19. package/src/components/ui/page-loader.tsx +69 -0
  20. package/src/lib/runtime/runtime-loop.ts +21 -1
  21. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  22. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  23. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  24. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  25. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  26. package/src/lib/server/agents/orchestrator.ts +11 -7
  27. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  28. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  29. package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
  30. package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
  31. package/src/lib/server/chat-execution/chat-execution.ts +116 -29
  32. package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
  33. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +67 -76
  34. package/src/lib/server/chat-execution/stream-agent-chat.ts +119 -110
  35. package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
  36. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  37. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  38. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  39. package/src/lib/server/connectors/manager.test.ts +504 -73
  40. package/src/lib/server/connectors/manager.ts +41 -10
  41. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  42. package/src/lib/server/connectors/session-kind.ts +7 -0
  43. package/src/lib/server/connectors/session.test.ts +104 -0
  44. package/src/lib/server/connectors/session.ts +5 -2
  45. package/src/lib/server/identity-continuity.test.ts +4 -3
  46. package/src/lib/server/identity-continuity.ts +8 -4
  47. package/src/lib/server/memory/memory-policy.test.ts +5 -15
  48. package/src/lib/server/memory/memory-policy.ts +11 -41
  49. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  50. package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
  51. package/src/lib/server/runtime/heartbeat-service.ts +5 -1
  52. package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
  53. package/src/lib/server/runtime/runtime-settings.ts +4 -0
  54. package/src/lib/server/runtime/session-run-manager.ts +2 -0
  55. package/src/lib/server/session-reset-policy.test.ts +17 -3
  56. package/src/lib/server/session-reset-policy.ts +4 -2
  57. package/src/lib/server/session-tools/connector.ts +11 -10
  58. package/src/lib/server/session-tools/crud.ts +41 -7
  59. package/src/lib/server/session-tools/delegate.ts +3 -3
  60. package/src/lib/server/session-tools/index.ts +2 -0
  61. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  62. package/src/lib/server/session-tools/memory.ts +209 -48
  63. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  64. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  65. package/src/lib/server/session-tools/skills.ts +575 -0
  66. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  67. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  68. package/src/lib/server/skills/skill-discovery.ts +4 -0
  69. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  70. package/src/lib/server/skills/skills-normalize.ts +93 -1
  71. package/src/lib/server/storage.ts +1 -1
  72. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  73. package/src/lib/server/tasks/task-followups.ts +88 -13
  74. package/src/types/index.ts +30 -2
  75. package/src/views/settings/section-runtime-loop.tsx +38 -0
@@ -28,8 +28,16 @@ function makeSession(overrides: Partial<Session> = {}): Session {
28
28
 
29
29
  test('inferSessionResetType distinguishes direct, group, and thread sessions', () => {
30
30
  assert.equal(inferSessionResetType(makeSession()), 'direct')
31
- assert.equal(inferSessionResetType(makeSession({ connectorContext: { isGroup: true } })), 'group')
32
- assert.equal(inferSessionResetType(makeSession({ connectorContext: { threadId: 'thread-1' } })), 'thread')
31
+ assert.equal(inferSessionResetType(makeSession({
32
+ name: 'connector:group',
33
+ user: 'connector',
34
+ connectorContext: { isGroup: true },
35
+ })), 'group')
36
+ assert.equal(inferSessionResetType(makeSession({
37
+ name: 'connector:thread',
38
+ user: 'connector',
39
+ connectorContext: { threadId: 'thread-1' },
40
+ })), 'thread')
33
41
  })
34
42
 
35
43
  test('resolveSessionResetPolicy falls back to type defaults', () => {
@@ -37,7 +45,13 @@ test('resolveSessionResetPolicy falls back to type defaults', () => {
37
45
  assert.equal(direct.mode, 'idle')
38
46
  assert.equal(direct.idleTimeoutSec, 12 * 60 * 60)
39
47
 
40
- const thread = resolveSessionResetPolicy({ session: makeSession({ connectorContext: { threadId: 'thread-1' } }) })
48
+ const thread = resolveSessionResetPolicy({
49
+ session: makeSession({
50
+ name: 'connector:thread',
51
+ user: 'connector',
52
+ connectorContext: { threadId: 'thread-1' },
53
+ }),
54
+ })
41
55
  assert.equal(thread.mode, 'idle')
42
56
  assert.equal(thread.idleTimeoutSec, 4 * 60 * 60)
43
57
  })
@@ -1,4 +1,5 @@
1
1
  import type { Agent, AppSettings, Session, SessionResetMode, SessionResetType } from '@/types'
2
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
2
3
 
3
4
  export interface ResolvedSessionResetPolicy {
4
5
  type: SessionResetType
@@ -140,9 +141,10 @@ export function inferSessionResetType(
140
141
  opts?: { isGroup?: boolean | null; threadId?: string | null },
141
142
  ): SessionResetType {
142
143
  if ((session?.sessionType as string | undefined) === 'orchestrated') return 'main'
143
- const threadId = opts?.threadId ?? session?.connectorContext?.threadId ?? null
144
+ const connectorContext = isDirectConnectorSession(session) ? session?.connectorContext : null
145
+ const threadId = opts?.threadId ?? connectorContext?.threadId ?? null
144
146
  if (threadId) return 'thread'
145
- const isGroup = opts?.isGroup ?? session?.connectorContext?.isGroup ?? false
147
+ const isGroup = opts?.isGroup ?? connectorContext?.isGroup ?? false
146
148
  return isGroup ? 'group' : 'direct'
147
149
  }
148
150
 
@@ -13,6 +13,7 @@ import { normalizeToolInputArgs } from './normalize-tool-args'
13
13
  import { safeJsonParseObject } from '../json-utils'
14
14
  import { tryResolvePathWithinBaseDir } from '../path-utils'
15
15
  import { dedup, errorMessage } from '@/lib/shared-utils'
16
+ import { isDirectConnectorSession } from '../connectors/session-kind'
16
17
 
17
18
  const CONNECTOR_ACTION_DEDUPE_TTL_MS = 30_000
18
19
  const CONNECTOR_TURN_SEND_TTL_MS = 180_000
@@ -341,11 +342,14 @@ function trimToString(value: unknown): string {
341
342
 
342
343
  function resolveSessionConnectorTargets(
343
344
  session: {
345
+ user?: string
346
+ name?: string
344
347
  connectorContext?: Record<string, unknown>
345
348
  messages?: Array<Record<string, unknown>>
346
349
  } | null | undefined,
347
350
  connectorId: string,
348
351
  ): Array<{ channelId: string; senderId?: string; senderName?: string }> {
352
+ if (!isDirectConnectorSession(session)) return []
349
353
  const targets: Array<{ channelId: string; senderId?: string; senderName?: string }> = []
350
354
  const seen = new Set<string>()
351
355
  const pushTarget = (target: { channelId: string; senderId?: string; senderName?: string } | null) => {
@@ -368,6 +372,7 @@ function resolveSessionConnectorTargets(
368
372
 
369
373
  const messages = Array.isArray(session?.messages) ? session.messages : []
370
374
  for (let i = messages.length - 1; i >= 0; i -= 1) {
375
+ if (messages[i]?.historyExcluded === true) continue
371
376
  const source = messages[i]?.source as Record<string, unknown> | undefined
372
377
  if (!source || trimToString(source.connectorId) !== connectorId) continue
373
378
  const channelId = trimToString(source.channelId)
@@ -386,8 +391,9 @@ function pickChannelTarget(params: {
386
391
  connector: { config?: Record<string, string> }
387
392
  connectorId: string
388
393
  to?: string
389
- recentChannelId: string | null
390
394
  currentSession?: {
395
+ user?: string
396
+ name?: string
391
397
  connectorContext?: Record<string, unknown>
392
398
  messages?: Array<Record<string, unknown>>
393
399
  } | null
@@ -421,9 +427,6 @@ function pickChannelTarget(params: {
421
427
  const outbound = connector.config?.outboundTarget?.trim()
422
428
  if (outbound) channelId = outbound
423
429
  }
424
- if (!channelId && params.recentChannelId) {
425
- channelId = params.recentChannelId
426
- }
427
430
  if (!channelId) {
428
431
  const allowed = parseCsv(connector.config?.allowedJids)
429
432
  if (allowed.length) channelId = allowed[0]
@@ -562,7 +565,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
562
565
  const {
563
566
  listRunningConnectors,
564
567
  sendConnectorMessage,
565
- getConnectorRecentChannelId,
566
568
  scheduleConnectorFollowUp,
567
569
  performConnectorMessageAction,
568
570
  } = await import('../connectors/manager')
@@ -646,6 +648,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
646
648
 
647
649
  const currentSession = bctx.resolveCurrentSession?.()
648
650
  const sessionId = bctx.ctx?.sessionId || currentSession?.id || undefined
651
+ const connectorScopedSessionId = isDirectConnectorSession(currentSession) ? sessionId : undefined
649
652
 
650
653
  if (actionName === 'send' || actionName === 'send_voice_note' || actionName === 'schedule_followup') {
651
654
  const settings = loadSettings()
@@ -662,7 +665,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
662
665
  connector,
663
666
  connectorId: selected.id,
664
667
  to,
665
- recentChannelId: getConnectorRecentChannelId(selected.id),
666
668
  currentSession,
667
669
  })
668
670
  if (target.error) return target.error
@@ -728,7 +730,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
728
730
  const sent = await sendConnectorMessage({
729
731
  connectorId: selected.id, channelId, text: '', mediaPath: voicePath, mimeType: outboundMimeType,
730
732
  fileName: fileName?.trim() || 'voicenote.mp3', caption: caption?.trim() || undefined, ptt: ptt ?? true,
731
- sessionId,
733
+ sessionId: connectorScopedSessionId,
732
734
  replyToMessageId: replyToMessageId?.trim() || undefined,
733
735
  threadId: threadId?.trim() || undefined,
734
736
  })
@@ -768,7 +770,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
768
770
  connectorId: selected.id,
769
771
  channelId,
770
772
  text: followupText,
771
- sessionId,
773
+ sessionId: connectorScopedSessionId,
772
774
  delaySec: followupDelay,
773
775
  dedupeKey: dedupeKey?.trim() || undefined,
774
776
  imageUrl: media.imageUrl,
@@ -793,7 +795,7 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
793
795
 
794
796
  const sent = await sendConnectorMessage({
795
797
  connectorId: selected.id, channelId, text: message?.trim() || '',
796
- sessionId,
798
+ sessionId: connectorScopedSessionId,
797
799
  imageUrl: media.imageUrl, fileUrl: media.fileUrl, mediaPath: media.mediaPath,
798
800
  mimeType: mimeType?.trim() || undefined, fileName: fileName?.trim() || undefined,
799
801
  caption: caption?.trim() || undefined,
@@ -815,7 +817,6 @@ async function executeConnectorAction(input: ConnectorActionInput, bctx: Connect
815
817
  connector: resolved.connector,
816
818
  connectorId: selected.id,
817
819
  to,
818
- recentChannelId: getConnectorRecentChannelId(selected.id),
819
820
  currentSession,
820
821
  })
821
822
  if (target.error) return target.error
@@ -46,6 +46,8 @@ import { safePath, findBinaryOnPath } from './context'
46
46
  import { normalizeToolInputArgs } from './normalize-tool-args'
47
47
  import type { BoardTask } from '@/types'
48
48
  import { dedup } from '@/lib/shared-utils'
49
+ import { isDirectConnectorSession } from '../connectors/session-kind'
50
+ import { buildManageSkillsDescription, executeManageSkillsAction } from './skills'
49
51
 
50
52
  // ---------------------------------------------------------------------------
51
53
  // Document helpers
@@ -258,8 +260,10 @@ function deriveScheduleFollowupTarget(sessionId: string | null | undefined): {
258
260
  }
259
261
  }
260
262
 
261
- const contextTarget = pickSourceFields(session.connectorContext || undefined)
262
- if (contextTarget.followupConnectorId && contextTarget.followupChannelId) return contextTarget
263
+ if (isDirectConnectorSession(session as { user?: string; name?: string })) {
264
+ const contextTarget = pickSourceFields(session.connectorContext || undefined)
265
+ if (contextTarget.followupConnectorId && contextTarget.followupChannelId) return contextTarget
266
+ }
263
267
 
264
268
  const messages = Array.isArray(session.messages) ? session.messages : []
265
269
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -447,12 +451,17 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
447
451
  if (ctx?.projectId) {
448
452
  description += `\n\nCurrent project context: "${ctx.projectName || ctx.projectId}" (projectId "${ctx.projectId}"). Omit "projectId" to link the secret to this active project.`
449
453
  }
454
+ } else if (toolKey === 'manage_skills') {
455
+ description = buildManageSkillsDescription()
450
456
  }
451
457
 
452
458
  tools.push(
453
459
  tool(
454
460
  async (rawArgs) => {
455
461
  const normalized = normalizeToolInputArgs((rawArgs ?? {}) as Record<string, unknown>)
462
+ if (toolKey === 'manage_skills') {
463
+ return executeManageSkillsAction(normalized, bctx)
464
+ }
456
465
  const action = normalized.action as string | undefined
457
466
  const id = normalized.id as string | undefined
458
467
  const data = normalized.data as string | undefined
@@ -881,11 +890,36 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
881
890
  {
882
891
  name: toolKey,
883
892
  description,
884
- schema: z.object({
885
- action: z.enum(['list', 'get', 'create', 'update', 'delete']).describe('The CRUD action to perform'),
886
- id: z.string().optional().describe('Resource ID (required for get, update, delete)'),
887
- data: z.string().optional().describe('JSON string of fields for create/update'),
888
- }).passthrough(),
893
+ schema: toolKey === 'manage_skills'
894
+ ? z.object({
895
+ action: z.enum([
896
+ 'list',
897
+ 'get',
898
+ 'create',
899
+ 'update',
900
+ 'delete',
901
+ 'status',
902
+ 'search_available',
903
+ 'recommend_for_task',
904
+ 'attach',
905
+ 'install',
906
+ ]).describe('The manage_skills action to perform'),
907
+ id: z.string().optional().describe('Stored skill ID or runtime skill selector'),
908
+ skillId: z.string().optional().describe('Alternate skill selector'),
909
+ name: z.string().optional().describe('Skill name or marketplace name'),
910
+ query: z.string().optional().describe('Search query or task description'),
911
+ task: z.string().optional().describe('Task description for skill recommendation'),
912
+ url: z.string().optional().describe('Remote skill URL for install'),
913
+ approvalId: z.string().optional().describe('Approved install request id'),
914
+ attach: z.boolean().optional().describe('Attach the skill to the current agent after install'),
915
+ agentId: z.string().optional().describe('Target agent id for attach'),
916
+ data: z.string().optional().describe('JSON string of fields for create/update'),
917
+ }).passthrough()
918
+ : z.object({
919
+ action: z.enum(['list', 'get', 'create', 'update', 'delete']).describe('The CRUD action to perform'),
920
+ id: z.string().optional().describe('Resource ID (required for get, update, delete)'),
921
+ data: z.string().optional().describe('JSON string of fields for create/update'),
922
+ }).passthrough(),
889
923
  },
890
924
  ),
891
925
  )
@@ -931,13 +931,13 @@ const DelegatePlugin: Plugin = {
931
931
  name: 'Core Delegate',
932
932
  description: 'Delegate complex multi-file tasks to specialized CLI backends or other agents.',
933
933
  hooks: {
934
- getCapabilityDescription: () => 'I can hand off deep coding work to Claude Code, Codex, or Gemini CLI (`delegate`) for complex multi-file refactors and code generation. Resume IDs may come back via `[delegate_meta]`.',
935
- getOperatingGuidance: () => ['CRITICAL: `execute_command` (not delegation) for running servers, installs, scripts. Delegation sessions end and kill processes.', 'Delegate only for deep multi-file code work: refactors, debugging, generation, test suites.'],
934
+ getCapabilityDescription: () => 'I can hand off coding work to Claude Code, Codex, OpenCode, or Gemini CLI (`delegate`) for file creation, refactoring, debugging, code generation, and multi-file edits. Resume IDs may come back via `[delegate_meta]`.',
935
+ getOperatingGuidance: () => ['CRITICAL: `execute_command` (not delegation) for running servers, installs, scripts. Delegation sessions end and kill processes.', 'Delegate for code tasks: writing/creating files, refactors, debugging, generation, test suites, data exports to files.'],
936
936
  } as PluginHooks,
937
937
  tools: [
938
938
  {
939
939
  name: 'delegate',
940
- description: 'Delegate to a specialized backend (Claude, Codex, OpenCode, Gemini). Supports background jobs with action=status|list|wait|cancel.',
940
+ description: 'Delegate to a specialized backend (Claude, Codex, OpenCode, Gemini) for code tasks: writing files, refactoring, debugging, code generation, and multi-file edits. Supports background jobs with action=status|list|wait|cancel.',
941
941
  parameters: {
942
942
  type: 'object',
943
943
  properties: {
@@ -45,6 +45,7 @@ import { buildExtractTools } from './extract'
45
45
  import { buildTableTools } from './table'
46
46
  import { buildCrawlTools } from './crawl'
47
47
  import { buildGoogleWorkspaceTools } from './google-workspace'
48
+ import { buildSkillRuntimeTools } from './skill-runtime'
48
49
  import './connector'
49
50
  import { normalizeToolInputArgs } from './normalize-tool-args'
50
51
  import { enforceFileAccessPolicy } from './file-access-policy'
@@ -172,6 +173,7 @@ export async function buildSessionTools(cwd: string, enabledPlugins: string[], c
172
173
  ['calendar', buildCalendarTools],
173
174
  ['replicate', buildReplicateTools],
174
175
  ['google_workspace', buildGoogleWorkspaceTools],
176
+ ['use_skill', buildSkillRuntimeTools],
175
177
  ['mailbox', buildMailboxTools],
176
178
  ['ask_human', buildHumanLoopTools],
177
179
  ['document', buildDocumentTools],
@@ -0,0 +1,194 @@
1
+ import { after, before, describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import type { Agent, Skill } from '@/types'
7
+
8
+ const originalEnv = {
9
+ DATA_DIR: process.env.DATA_DIR,
10
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
11
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
12
+ }
13
+
14
+ let tempDir = ''
15
+ let workspaceDir = ''
16
+ let buildCrudTools: Awaited<typeof import('./crud')>['buildCrudTools']
17
+ let loadSkills: Awaited<typeof import('../storage')>['loadSkills']
18
+ let loadAgent: Awaited<typeof import('../storage')>['loadAgent']
19
+ let saveAgents: Awaited<typeof import('../storage')>['saveAgents']
20
+ let saveSessions: Awaited<typeof import('../storage')>['saveSessions']
21
+ let upsertApproval: Awaited<typeof import('../storage')>['upsertApproval']
22
+
23
+ function buildManageSkillsTool() {
24
+ const tools = buildCrudTools({
25
+ cwd: workspaceDir,
26
+ ctx: { sessionId: 'skill-session', agentId: 'agent-skill-test', platformAssignScope: 'self' },
27
+ hasPlugin: (name) => name === 'manage_skills',
28
+ hasTool: (name) => name === 'manage_skills',
29
+ cleanupFns: [],
30
+ commandTimeoutMs: 1_000,
31
+ claudeTimeoutMs: 1_000,
32
+ cliProcessTimeoutMs: 1_000,
33
+ persistDelegateResumeId: () => {},
34
+ readStoredDelegateResumeId: () => null,
35
+ resolveCurrentSession: () => null,
36
+ activePlugins: ['manage_skills', 'google_workspace'],
37
+ })
38
+ const tool = tools.find((entry) => entry.name === 'manage_skills')
39
+ assert.ok(tool, 'expected manage_skills tool')
40
+ return tool!
41
+ }
42
+
43
+ before(async () => {
44
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-manage-skills-'))
45
+ workspaceDir = path.join(tempDir, 'workspace')
46
+ process.env.DATA_DIR = path.join(tempDir, 'data')
47
+ process.env.WORKSPACE_DIR = workspaceDir
48
+ process.env.SWARMCLAW_BUILD_MODE = '1'
49
+ fs.mkdirSync(process.env.DATA_DIR, { recursive: true })
50
+ fs.mkdirSync(workspaceDir, { recursive: true })
51
+
52
+ const crudMod = await import('./crud')
53
+ buildCrudTools = crudMod.buildCrudTools
54
+
55
+ const storageMod = await import('../storage')
56
+ loadSkills = storageMod.loadSkills
57
+ loadAgent = storageMod.loadAgent
58
+ saveAgents = storageMod.saveAgents
59
+ saveSessions = storageMod.saveSessions
60
+ upsertApproval = storageMod.upsertApproval
61
+
62
+ saveAgents({
63
+ 'agent-skill-test': {
64
+ id: 'agent-skill-test',
65
+ name: 'Skill Tester',
66
+ provider: 'openai',
67
+ model: 'gpt-test',
68
+ plugins: ['manage_skills'],
69
+ tools: ['manage_skills'],
70
+ skillIds: [],
71
+ platformAssignScope: 'self',
72
+ createdAt: Date.now(),
73
+ updatedAt: Date.now(),
74
+ } satisfies Agent,
75
+ })
76
+ saveSessions({
77
+ 'skill-session': {
78
+ id: 'skill-session',
79
+ name: 'Skill Session',
80
+ cwd: workspaceDir,
81
+ user: 'tester',
82
+ provider: 'openai',
83
+ model: 'gpt-test',
84
+ messages: [],
85
+ createdAt: Date.now(),
86
+ lastActiveAt: Date.now(),
87
+ sessionType: 'human',
88
+ agentId: 'agent-skill-test',
89
+ plugins: ['manage_skills'],
90
+ heartbeatEnabled: false,
91
+ },
92
+ })
93
+ })
94
+
95
+ after(() => {
96
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
97
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
98
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
99
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
100
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
101
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
102
+ fs.rmSync(tempDir, { recursive: true, force: true })
103
+ })
104
+
105
+ describe('manage_skills runtime actions', () => {
106
+ it('status reports resolved local skills with eligibility and metadata', async () => {
107
+ const manageSkills = buildManageSkillsTool()
108
+ const created = await manageSkills.invoke({
109
+ action: 'create',
110
+ name: 'workspace-helper',
111
+ description: 'Automate workspace docs.',
112
+ content: '# Workspace Helper\nUse the workspace workflow.',
113
+ toolNames: ['google_workspace'],
114
+ capabilities: ['docs', 'workspace'],
115
+ })
116
+ const createdSkill = JSON.parse(String(created)) as Skill
117
+
118
+ const raw = await manageSkills.invoke({ action: 'status', query: 'workspace docs' })
119
+ const result = JSON.parse(String(raw)) as Array<Record<string, unknown>>
120
+
121
+ const statusEntry = result.find((entry) => entry.storageId === createdSkill.id)
122
+ assert.ok(statusEntry)
123
+ assert.equal(statusEntry?.eligible, true)
124
+ assert.deepEqual(statusEntry?.toolNames, ['google_workspace'])
125
+ })
126
+
127
+ it('attach materializes a discovered project skill and binds it to the current agent', async () => {
128
+ const localSkillDir = path.join(workspaceDir, 'skills', 'project-helper')
129
+ fs.mkdirSync(localSkillDir, { recursive: true })
130
+ fs.writeFileSync(path.join(localSkillDir, 'SKILL.md'), `---
131
+ name: project-helper
132
+ description: Project-local helper.
133
+ metadata:
134
+ openclaw:
135
+ toolNames: [google_workspace]
136
+ ---
137
+ # Project Helper
138
+
139
+ Use the project helper.
140
+ `)
141
+
142
+ const manageSkills = buildManageSkillsTool()
143
+ const raw = await manageSkills.invoke({
144
+ action: 'attach',
145
+ name: 'project-helper',
146
+ })
147
+ const result = JSON.parse(String(raw)) as Record<string, unknown>
148
+ const skillId = String(result.skillId || '')
149
+ const agent = loadAgent('agent-skill-test') as Agent
150
+
151
+ assert.ok(skillId)
152
+ assert.ok(loadSkills()[skillId], 'discovered skill copied into managed storage')
153
+ assert.ok(agent.skillIds?.includes(skillId), 'skill attached to current agent')
154
+ })
155
+
156
+ it('install is approval-gated and can install a remote skill after approval', async () => {
157
+ const manageSkills = buildManageSkillsTool()
158
+ const firstRaw = await manageSkills.invoke({
159
+ action: 'install',
160
+ name: 'remote-helper',
161
+ url: 'https://clawhub.ai/skills/remote-helper',
162
+ content: '# Remote Helper\nUse the remote helper.',
163
+ attach: true,
164
+ })
165
+ const first = JSON.parse(String(firstRaw)) as Record<string, unknown>
166
+ const approval = first.approval as { id: string }
167
+
168
+ assert.equal(first.requiresApproval, true)
169
+ assert.ok(approval?.id)
170
+
171
+ upsertApproval(approval.id, {
172
+ ...(first.approval as Record<string, unknown>),
173
+ status: 'approved',
174
+ updatedAt: Date.now(),
175
+ })
176
+
177
+ const secondRaw = await manageSkills.invoke({
178
+ action: 'install',
179
+ name: 'remote-helper',
180
+ url: 'https://clawhub.ai/skills/remote-helper',
181
+ content: '# Remote Helper\nUse the remote helper.',
182
+ attach: true,
183
+ approvalId: approval.id,
184
+ })
185
+ const second = JSON.parse(String(secondRaw)) as Record<string, unknown>
186
+ const installedSkill = second.skill as Skill
187
+ const agent = loadAgent('agent-skill-test') as Agent
188
+
189
+ assert.equal(second.ok, true)
190
+ assert.ok(installedSkill?.id)
191
+ assert.ok(loadSkills()[installedSkill.id])
192
+ assert.ok(agent.skillIds?.includes(installedSkill.id), 'approved install can attach to the agent')
193
+ })
194
+ })