@swarmclawai/swarmclaw 0.9.3 → 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 (50) 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/api/chatrooms/[id]/chat/route.ts +1 -1
  5. package/src/app/api/clawhub/install/route.ts +2 -0
  6. package/src/app/api/skills/[id]/route.ts +4 -0
  7. package/src/app/api/skills/route.ts +4 -0
  8. package/src/components/agents/agent-sheet.tsx +5 -5
  9. package/src/lib/server/agents/agent-thread-session.test.ts +64 -0
  10. package/src/lib/server/agents/agent-thread-session.ts +1 -1
  11. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +77 -0
  12. package/src/lib/server/agents/main-agent-loop.ts +259 -0
  13. package/src/lib/server/agents/orchestrator-lg.ts +12 -8
  14. package/src/lib/server/agents/orchestrator.ts +11 -7
  15. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +11 -10
  16. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +116 -3
  17. package/src/lib/server/chat-execution/chat-execution.ts +74 -26
  18. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +65 -30
  19. package/src/lib/server/chat-execution/stream-agent-chat.ts +69 -25
  20. package/src/lib/server/chatrooms/chatroom-helpers.test.ts +26 -0
  21. package/src/lib/server/chatrooms/chatroom-helpers.ts +11 -8
  22. package/src/lib/server/connectors/contact-boundaries.ts +101 -0
  23. package/src/lib/server/connectors/manager.test.ts +504 -73
  24. package/src/lib/server/connectors/manager.ts +40 -9
  25. package/src/lib/server/connectors/session-consolidation.ts +2 -0
  26. package/src/lib/server/connectors/session-kind.ts +7 -0
  27. package/src/lib/server/connectors/session.test.ts +104 -0
  28. package/src/lib/server/connectors/session.ts +5 -2
  29. package/src/lib/server/identity-continuity.test.ts +4 -3
  30. package/src/lib/server/identity-continuity.ts +8 -4
  31. package/src/lib/server/memory/session-archive-memory.ts +2 -1
  32. package/src/lib/server/session-reset-policy.test.ts +17 -3
  33. package/src/lib/server/session-reset-policy.ts +4 -2
  34. package/src/lib/server/session-tools/connector.ts +11 -10
  35. package/src/lib/server/session-tools/crud.ts +41 -7
  36. package/src/lib/server/session-tools/index.ts +2 -0
  37. package/src/lib/server/session-tools/manage-skills.test.ts +194 -0
  38. package/src/lib/server/session-tools/memory.ts +12 -23
  39. package/src/lib/server/session-tools/skill-runtime.test.ts +175 -0
  40. package/src/lib/server/session-tools/skill-runtime.ts +382 -0
  41. package/src/lib/server/session-tools/skills.ts +575 -0
  42. package/src/lib/server/skills/runtime-skill-resolver.test.ts +162 -0
  43. package/src/lib/server/skills/runtime-skill-resolver.ts +750 -0
  44. package/src/lib/server/skills/skill-discovery.ts +4 -0
  45. package/src/lib/server/skills/skills-normalize.test.ts +28 -0
  46. package/src/lib/server/skills/skills-normalize.ts +93 -1
  47. package/src/lib/server/storage.ts +1 -1
  48. package/src/lib/server/tasks/task-followups.test.ts +124 -0
  49. package/src/lib/server/tasks/task-followups.ts +88 -13
  50. package/src/types/index.ts +26 -2
@@ -24,6 +24,10 @@ const LEGACY_BUNDLED_SKILLS_DIR = path.join(DATA_DIR, 'skills')
24
24
 
25
25
  let cache: DiscoveryCache | null = null
26
26
 
27
+ export function clearDiscoveredSkillsCache(): void {
28
+ cache = null
29
+ }
30
+
27
31
  function buildCacheKey(cwd?: string): string {
28
32
  return `${cwd || ''}`
29
33
  }
@@ -16,6 +16,8 @@ metadata:
16
16
  bins:
17
17
  - curl
18
18
  primaryEnv: GITHUB_TOKEN
19
+ toolNames: [github_workspace, github-cli]
20
+ capabilities: [github, pull-requests]
19
21
  homepage: https://example.com/github-sync
20
22
  install:
21
23
  - kind: brew
@@ -32,6 +34,8 @@ Use the GitHub API.`,
32
34
  assert.equal(normalized.version, '1.2.3')
33
35
  assert.equal(normalized.primaryEnv, 'GITHUB_TOKEN')
34
36
  assert.equal(normalized.homepage, 'https://example.com/github-sync')
37
+ assert.deepEqual(normalized.toolNames, ['github_workspace', 'github-cli'])
38
+ assert.deepEqual(normalized.capabilities, ['github', 'pull-requests'])
35
39
  assert.equal(normalized.sourceFormat, 'openclaw')
36
40
  assert.match(normalized.content, /# Sync issues/)
37
41
  assert.equal(normalized.skillRequirements?.env?.[0], 'GITHUB_TOKEN')
@@ -52,3 +56,27 @@ Run with \`process.env.GITHUB_TOKEN\` and \`process.env.OPENAI_API_KEY\`.`,
52
56
  assert.ok(normalized.security?.missingDeclarations?.includes('GITHUB_TOKEN'))
53
57
  assert.ok(normalized.security?.missingDeclarations?.includes('OPENAI_API_KEY'))
54
58
  })
59
+
60
+ test('normalizeSkillPayload parses executable invocation metadata from frontmatter', () => {
61
+ const normalized = normalizeSkillPayload({
62
+ content: `---
63
+ name: task-triage
64
+ description: Route task triage through manage_tasks.
65
+ invocation:
66
+ userInvocable: false
67
+ command-dispatch: tool
68
+ command-tool: manage_tasks
69
+ command-arg-mode: raw
70
+ ---
71
+ # Task Triage
72
+
73
+ Route the request through manage_tasks.`,
74
+ })
75
+
76
+ assert.deepEqual(normalized.invocation, { userInvocable: false })
77
+ assert.deepEqual(normalized.commandDispatch, {
78
+ kind: 'tool',
79
+ toolName: 'manage_tasks',
80
+ argMode: 'raw',
81
+ })
82
+ })
@@ -1,5 +1,11 @@
1
1
  import path from 'path'
2
- import type { SkillInstallOption, SkillRequirements, SkillSecuritySummary } from '@/types'
2
+ import type {
3
+ SkillCommandDispatch,
4
+ SkillInstallOption,
5
+ SkillInvocationConfig,
6
+ SkillRequirements,
7
+ SkillSecuritySummary,
8
+ } from '@/types'
3
9
  import { dedup } from '@/lib/shared-utils'
4
10
 
5
11
  export type SkillSourceFormat = 'openclaw' | 'plain'
@@ -17,11 +23,15 @@ type NormalizeSkillInput = {
17
23
  homepage?: unknown
18
24
  primaryEnv?: unknown
19
25
  skillKey?: unknown
26
+ toolNames?: unknown
27
+ capabilities?: unknown
20
28
  always?: unknown
21
29
  installOptions?: unknown
22
30
  skillRequirements?: unknown
23
31
  detectedEnvVars?: unknown
24
32
  security?: unknown
33
+ invocation?: unknown
34
+ commandDispatch?: unknown
25
35
  frontmatter?: unknown
26
36
  }
27
37
 
@@ -38,11 +48,15 @@ export type NormalizedSkill = {
38
48
  homepage?: string
39
49
  primaryEnv?: string | null
40
50
  skillKey?: string | null
51
+ toolNames?: string[]
52
+ capabilities?: string[]
41
53
  always?: boolean
42
54
  installOptions?: SkillInstallOption[]
43
55
  skillRequirements?: SkillRequirements
44
56
  detectedEnvVars?: string[]
45
57
  security?: SkillSecuritySummary | null
58
+ invocation?: SkillInvocationConfig | null
59
+ commandDispatch?: SkillCommandDispatch | null
46
60
  frontmatter?: Record<string, unknown> | null
47
61
  }
48
62
 
@@ -279,6 +293,65 @@ function normalizeRequirements(value: unknown): SkillRequirements | undefined {
279
293
  return normalized
280
294
  }
281
295
 
296
+ function normalizeInvocationConfig(value: unknown): SkillInvocationConfig | null {
297
+ const source = asObject(value)
298
+ if (!source) return null
299
+ const userInvocable = asBoolean(source.userInvocable ?? source.user_invocable)
300
+ if (userInvocable === undefined) return null
301
+ return { userInvocable }
302
+ }
303
+
304
+ function normalizeCommandDispatch(params: {
305
+ frontmatter?: Record<string, unknown> | null
306
+ runtimeMeta?: Record<string, unknown> | null
307
+ input?: NormalizeSkillInput
308
+ }): SkillCommandDispatch | null {
309
+ const inputDispatch = asObject(params.input?.commandDispatch)
310
+ const inlineDispatch = asObject(params.frontmatter?.commandDispatch)
311
+ || asObject(params.frontmatter?.command_dispatch)
312
+ || asObject(params.runtimeMeta?.commandDispatch)
313
+ || asObject(params.runtimeMeta?.command_dispatch)
314
+
315
+ const kindRaw = asTrimmedString(
316
+ inputDispatch?.kind
317
+ ?? inlineDispatch?.kind
318
+ ?? params.frontmatter?.['command-dispatch']
319
+ ?? params.frontmatter?.command_dispatch
320
+ ?? params.runtimeMeta?.['command-dispatch']
321
+ ?? params.runtimeMeta?.command_dispatch,
322
+ )
323
+ if (!kindRaw || kindRaw.toLowerCase() !== 'tool') return null
324
+
325
+ const toolName = asTrimmedString(
326
+ inputDispatch?.toolName
327
+ ?? inputDispatch?.tool_name
328
+ ?? inlineDispatch?.toolName
329
+ ?? inlineDispatch?.tool_name
330
+ ?? params.frontmatter?.['command-tool']
331
+ ?? params.frontmatter?.command_tool
332
+ ?? params.runtimeMeta?.['command-tool']
333
+ ?? params.runtimeMeta?.command_tool,
334
+ )
335
+ if (!toolName) return null
336
+
337
+ const argModeRaw = asTrimmedString(
338
+ inputDispatch?.argMode
339
+ ?? inputDispatch?.arg_mode
340
+ ?? inlineDispatch?.argMode
341
+ ?? inlineDispatch?.arg_mode
342
+ ?? params.frontmatter?.['command-arg-mode']
343
+ ?? params.frontmatter?.command_arg_mode
344
+ ?? params.runtimeMeta?.['command-arg-mode']
345
+ ?? params.runtimeMeta?.command_arg_mode,
346
+ )
347
+
348
+ return {
349
+ kind: 'tool',
350
+ toolName,
351
+ argMode: argModeRaw && argModeRaw.toLowerCase() === 'raw' ? 'raw' : 'raw',
352
+ }
353
+ }
354
+
282
355
  function pickRuntimeMetadata(frontmatter: Record<string, unknown>): Record<string, unknown> | null {
283
356
  const metadata = asObject(frontmatter.metadata)
284
357
  if (metadata) {
@@ -395,11 +468,26 @@ export function normalizeSkillPayload(input: NormalizeSkillInput): NormalizedSki
395
468
  const skillKey = asTrimmedString(runtimeMeta?.skillKey)
396
469
  || asTrimmedString(input.skillKey)
397
470
  || null
471
+ const toolNames = asStringArray(runtimeMeta?.toolNames)
472
+ || asStringArray(runtimeMeta?.tools)
473
+ || asStringArray(input.toolNames)
474
+ || undefined
475
+ const capabilities = asStringArray(runtimeMeta?.capabilities)
476
+ || asStringArray(input.capabilities)
477
+ || undefined
398
478
  const always = asBoolean(runtimeMeta?.always) ?? asBoolean(input.always)
399
479
  const installOptions = normalizeInstallOptions(runtimeMeta?.install)
400
480
  || normalizeInstallOptions(input.installOptions)
401
481
  const skillRequirements = normalizeRequirements(runtimeMeta)
402
482
  || normalizeRequirements(input.skillRequirements)
483
+ const invocation = normalizeInvocationConfig(input.invocation)
484
+ || normalizeInvocationConfig(frontmatter?.invocation)
485
+ || normalizeInvocationConfig(runtimeMeta?.invocation)
486
+ const commandDispatch = normalizeCommandDispatch({
487
+ frontmatter,
488
+ runtimeMeta,
489
+ input,
490
+ })
403
491
 
404
492
  const sourceUrl = asTrimmedString(input.sourceUrl) || undefined
405
493
  const initialFilename = asTrimmedString(input.filename)
@@ -467,11 +555,15 @@ export function normalizeSkillPayload(input: NormalizeSkillInput): NormalizedSki
467
555
  homepage: homepage || undefined,
468
556
  primaryEnv,
469
557
  skillKey,
558
+ toolNames,
559
+ capabilities,
470
560
  always,
471
561
  installOptions,
472
562
  skillRequirements,
473
563
  detectedEnvVars: detectedEnvVars.length ? detectedEnvVars : preservedDetectedEnvVars,
474
564
  security,
565
+ invocation,
566
+ commandDispatch,
475
567
  frontmatter,
476
568
  }
477
569
  }
@@ -724,7 +724,7 @@ if (!IS_BUILD_BOOTSTRAP) {
724
724
  - **Providers** — Configure LLM backends in Settings → Providers: Claude Code CLI, OpenAI Codex CLI, OpenCode CLI, Anthropic, OpenAI, Google Gemini, DeepSeek, Groq, Together AI, Mistral AI, xAI (Grok), Fireworks AI, Ollama, OpenClaw, or custom OpenAI-compatible endpoints.
725
725
  - **Tasks** — The Task Board tracks work items. Assign agents and they'll execute autonomously.
726
726
  - **Schedules** — Cron-based recurring jobs that run agents or tasks automatically.
727
- - **Skills** — Reusable markdown instruction files you attach to agents to specialize them.
727
+ - **Skills** — Reusable markdown instruction files agents can discover and use by default; pin them to keep favorite workflows always-on.
728
728
  - **Connectors** — Bridge agents to Discord, Slack, Telegram, or WhatsApp.
729
729
  - **Secrets** — Encrypted vault for API keys (Settings → Secrets).
730
730
 
@@ -279,6 +279,8 @@ describe('task-followups', () => {
279
279
  const sessions = {
280
280
  'sess-1': {
281
281
  id: 'sess-1',
282
+ name: 'connector:slack:dm',
283
+ user: 'connector',
282
284
  connectorContext: {
283
285
  connectorId: 'conn-1',
284
286
  channelId: 'ch-ctx',
@@ -471,6 +473,128 @@ describe('task-followups', () => {
471
473
  })
472
474
  })
473
475
 
476
+ describe('taskAlreadyDeliveredToConnectorTarget', () => {
477
+ it('returns true when the task session already delivered to the same connector target', () => {
478
+ const task = {
479
+ id: 'task-delivered',
480
+ title: 'Delivered task',
481
+ description: '',
482
+ agentId: 'agent-1',
483
+ sessionId: 'task-session',
484
+ status: 'completed' as const,
485
+ createdAt: Date.now(),
486
+ updatedAt: Date.now(),
487
+ }
488
+ const sessions = {
489
+ 'task-session': {
490
+ id: 'task-session',
491
+ messages: [
492
+ {
493
+ role: 'assistant',
494
+ text: 'Sent it.',
495
+ toolEvents: [
496
+ {
497
+ name: 'connector_message_tool',
498
+ input: '{}',
499
+ output: JSON.stringify({
500
+ status: 'voice_sent',
501
+ connectorId: 'conn-wa',
502
+ to: '447700900111@s.whatsapp.net',
503
+ messageId: 'msg-1',
504
+ }),
505
+ },
506
+ ],
507
+ },
508
+ ],
509
+ },
510
+ }
511
+ const connectors = {
512
+ 'conn-wa': {
513
+ id: 'conn-wa',
514
+ name: 'WA',
515
+ platform: 'whatsapp' as const,
516
+ agentId: 'agent-1',
517
+ config: {},
518
+ enabled: true,
519
+ createdAt: Date.now(),
520
+ updatedAt: Date.now(),
521
+ },
522
+ }
523
+
524
+ const delivered = mod.taskAlreadyDeliveredToConnectorTarget({
525
+ task: task as import('@/types').BoardTask,
526
+ target: {
527
+ connectorId: 'conn-wa',
528
+ channelId: '+44 7700 900111',
529
+ },
530
+ sessions: sessions as Record<string, import('@/lib/server/tasks/task-followups').SessionLike>,
531
+ connectors: connectors as Record<string, import('@/types').Connector>,
532
+ })
533
+
534
+ assert.equal(delivered, true)
535
+ })
536
+
537
+ it('returns false when connector delivery was to a different target', () => {
538
+ const task = {
539
+ id: 'task-other-target',
540
+ title: 'Other target',
541
+ description: '',
542
+ agentId: 'agent-1',
543
+ sessionId: 'task-session',
544
+ status: 'completed' as const,
545
+ createdAt: Date.now(),
546
+ updatedAt: Date.now(),
547
+ }
548
+ const sessions = {
549
+ 'task-session': {
550
+ id: 'task-session',
551
+ messages: [
552
+ {
553
+ role: 'assistant',
554
+ text: 'Sent it.',
555
+ toolEvents: [
556
+ {
557
+ name: 'connector_message_tool',
558
+ input: '{}',
559
+ output: JSON.stringify({
560
+ status: 'sent',
561
+ connectorId: 'conn-wa',
562
+ to: '447700900222@s.whatsapp.net',
563
+ messageId: 'msg-2',
564
+ }),
565
+ },
566
+ ],
567
+ },
568
+ ],
569
+ },
570
+ }
571
+ const connectors = {
572
+ 'conn-wa': {
573
+ id: 'conn-wa',
574
+ name: 'WA',
575
+ platform: 'whatsapp' as const,
576
+ agentId: 'agent-1',
577
+ config: {},
578
+ enabled: true,
579
+ createdAt: Date.now(),
580
+ updatedAt: Date.now(),
581
+ },
582
+ }
583
+
584
+ const delivered = mod.taskAlreadyDeliveredToConnectorTarget({
585
+ task: task as import('@/types').BoardTask,
586
+ target: {
587
+ connectorId: 'conn-wa',
588
+ channelId: '+44 7700 900111',
589
+ },
590
+ sessions: sessions as Record<string, import('@/lib/server/tasks/task-followups').SessionLike>,
591
+ connectors: connectors as Record<string, import('@/types').Connector>,
592
+ })
593
+
594
+ assert.equal(delivered, false)
595
+ })
596
+ })
597
+
474
598
  // ---- isSendableAttachment ----
475
599
 
476
600
  describe('isSendableAttachment', () => {
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import type { BoardTask, Connector } from '@/types'
3
+ import type { BoardTask, Connector, MessageToolEvent } from '@/types'
4
4
  import { normalizeWhatsappTarget } from '@/lib/server/connectors/response-media'
5
+ import { isDirectConnectorSession } from '@/lib/server/connectors/session-kind'
5
6
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
6
7
  import { loadConnectors, loadSessions, UPLOAD_DIR } from '@/lib/server/storage'
7
8
  import { errorMessage } from '@/lib/shared-utils'
@@ -64,6 +65,8 @@ export interface ConnectorTaskFollowupTarget {
64
65
  threadId?: string | null
65
66
  }
66
67
 
68
+ const CONNECTOR_DELIVERY_STATUSES = new Set(['sent', 'voice_sent'])
69
+
67
70
  function isEnabledFlag(value: unknown): boolean {
68
71
  if (typeof value === 'boolean') return value
69
72
  if (typeof value !== 'string') return false
@@ -223,18 +226,20 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
223
226
  const sourceSession = sessions[sourceSessionId]
224
227
  if (!sourceSession) return null
225
228
 
226
- const sessionContextTarget = normalizeTarget({
227
- connectorId: typeof sourceSession.connectorContext?.connectorId === 'string'
228
- ? sourceSession.connectorContext.connectorId
229
- : null,
230
- channelId: typeof sourceSession.connectorContext?.channelId === 'string'
231
- ? sourceSession.connectorContext.channelId
232
- : null,
233
- threadId: typeof sourceSession.connectorContext?.threadId === 'string'
234
- ? sourceSession.connectorContext.threadId
235
- : null,
236
- })
237
- if (sessionContextTarget) return sessionContextTarget
229
+ if (isDirectConnectorSession(sourceSession)) {
230
+ const sessionContextTarget = normalizeTarget({
231
+ connectorId: typeof sourceSession.connectorContext?.connectorId === 'string'
232
+ ? sourceSession.connectorContext.connectorId
233
+ : null,
234
+ channelId: typeof sourceSession.connectorContext?.channelId === 'string'
235
+ ? sourceSession.connectorContext.channelId
236
+ : null,
237
+ threadId: typeof sourceSession.connectorContext?.threadId === 'string'
238
+ ? sourceSession.connectorContext.threadId
239
+ : null,
240
+ })
241
+ if (sessionContextTarget) return sessionContextTarget
242
+ }
238
243
 
239
244
  if (!Array.isArray(sourceSession.messages)) return null
240
245
 
@@ -312,6 +317,68 @@ export function collectTaskConnectorFollowupTargets(params: {
312
317
  return targets
313
318
  }
314
319
 
320
+ function normalizeFollowupChannelForConnector(connector: Connector | undefined, channelId: string | null | undefined): string {
321
+ const raw = typeof channelId === 'string' ? channelId.trim() : ''
322
+ if (!raw) return ''
323
+ return connector?.platform === 'whatsapp' ? normalizeWhatsappTarget(raw) : raw
324
+ }
325
+
326
+ function extractDeliveredConnectorTarget(event: MessageToolEvent | null | undefined): {
327
+ connectorId: string
328
+ channelId: string
329
+ } | null {
330
+ if (!event || event.name !== 'connector_message_tool' || event.error === true || !event.output) return null
331
+ try {
332
+ const parsed = JSON.parse(event.output) as Record<string, unknown>
333
+ const status = typeof parsed.status === 'string' ? parsed.status.trim().toLowerCase() : ''
334
+ const connectorId = typeof parsed.connectorId === 'string' ? parsed.connectorId.trim() : ''
335
+ const channelId = typeof parsed.to === 'string' ? parsed.to.trim() : ''
336
+ if (!CONNECTOR_DELIVERY_STATUSES.has(status) || !connectorId || !channelId) return null
337
+ return { connectorId, channelId }
338
+ } catch {
339
+ return null
340
+ }
341
+ }
342
+
343
+ export function taskAlreadyDeliveredToConnectorTarget(params: {
344
+ task: BoardTask
345
+ target: ConnectorTaskFollowupTarget
346
+ sessions: Record<string, SessionLike>
347
+ connectors: Record<string, Connector>
348
+ }): boolean {
349
+ const taskRecord = params.task as BoardTask & {
350
+ checkpoint?: {
351
+ lastSessionId?: string | null
352
+ } | null
353
+ }
354
+ const taskSessionId = typeof taskRecord.sessionId === 'string' && taskRecord.sessionId.trim()
355
+ ? taskRecord.sessionId.trim()
356
+ : typeof taskRecord.checkpoint?.lastSessionId === 'string' && taskRecord.checkpoint.lastSessionId.trim()
357
+ ? taskRecord.checkpoint.lastSessionId.trim()
358
+ : ''
359
+ if (!taskSessionId) return false
360
+ const session = params.sessions[taskSessionId]
361
+ if (!session || !Array.isArray(session.messages)) return false
362
+
363
+ const connector = params.connectors[params.target.connectorId]
364
+ const normalizedTargetChannel = normalizeFollowupChannelForConnector(connector, params.target.channelId)
365
+ if (!normalizedTargetChannel) return false
366
+
367
+ for (let index = session.messages.length - 1; index >= 0; index -= 1) {
368
+ const message = session.messages[index]
369
+ if (!message || message.role !== 'assistant' || !Array.isArray(message.toolEvents)) continue
370
+ for (const event of message.toolEvents) {
371
+ const delivered = extractDeliveredConnectorTarget(event as MessageToolEvent)
372
+ if (!delivered) continue
373
+ if (delivered.connectorId !== params.target.connectorId) continue
374
+ const normalizedDeliveredChannel = normalizeFollowupChannelForConnector(connector, delivered.channelId)
375
+ if (normalizedDeliveredChannel === normalizedTargetChannel) return true
376
+ }
377
+ }
378
+
379
+ return false
380
+ }
381
+
315
382
  export async function notifyConnectorTaskFollowups(params: {
316
383
  task: BoardTask
317
384
  statusLabel: string
@@ -347,6 +414,14 @@ export async function notifyConnectorTaskFollowups(params: {
347
414
  for (const target of targets) {
348
415
  const connector = connectors[target.connectorId]
349
416
  if (!connector) continue
417
+ if (taskAlreadyDeliveredToConnectorTarget({
418
+ task,
419
+ target,
420
+ sessions: sessions as Record<string, SessionLike>,
421
+ connectors: connectors as Record<string, Connector>,
422
+ })) {
423
+ continue
424
+ }
350
425
 
351
426
  const template = typeof (connector as any).config?.taskFollowupTemplate === 'string'
352
427
  ? (connector as any).config.taskFollowupTemplate.trim()
@@ -49,6 +49,15 @@ export interface SessionArchiveState {
49
49
  exportPath?: string | null
50
50
  }
51
51
 
52
+ export interface SessionSkillRuntimeState {
53
+ selectedSkillId?: string | null
54
+ selectedSkillName?: string | null
55
+ selectedAt?: number | null
56
+ lastAction?: 'select' | 'load' | 'run' | null
57
+ lastRunAt?: number | null
58
+ lastRunToolName?: string | null
59
+ }
60
+
52
61
  export interface CanvasMetricItem {
53
62
  label: string
54
63
  value: string
@@ -234,6 +243,7 @@ export interface Session {
234
243
  lastSessionResetReason?: string | null
235
244
  identityState?: IdentityContinuityState | null
236
245
  sessionArchiveState?: SessionArchiveState | null
246
+ skillRuntimeState?: SessionSkillRuntimeState | null
237
247
  pinned?: boolean
238
248
  file?: string | null
239
249
  queuedCount?: number
@@ -642,8 +652,8 @@ export interface Agent {
642
652
  plugins?: string[] // e.g. ['browser', 'memory'] — enabled plugin IDs
643
653
  /** @deprecated Use `plugins` instead. Kept for backward compat with stored data. */
644
654
  tools?: string[]
645
- skills?: string[] // e.g. ['frontend-design'] — Claude Code skills to use
646
- skillIds?: string[] // IDs of uploaded skills from the Skills manager
655
+ skills?: string[] // e.g. ['frontend-design'] — pinned Claude Code skills to mention explicitly
656
+ skillIds?: string[] // IDs of pinned managed skills to keep always-on for this agent
647
657
  mcpServerIds?: string[] // IDs of configured MCP servers to inject tools from
648
658
  mcpDisabledTools?: string[] // MCP tool names disabled for this agent (denylist)
649
659
  capabilities?: string[] // e.g. ['frontend', 'screenshots', 'research', 'devops']
@@ -1675,11 +1685,15 @@ export interface Skill {
1675
1685
  homepage?: string
1676
1686
  primaryEnv?: string | null
1677
1687
  skillKey?: string | null
1688
+ toolNames?: string[]
1689
+ capabilities?: string[]
1678
1690
  always?: boolean
1679
1691
  installOptions?: SkillInstallOption[]
1680
1692
  skillRequirements?: SkillRequirements
1681
1693
  detectedEnvVars?: string[]
1682
1694
  security?: SkillSecuritySummary | null
1695
+ invocation?: SkillInvocationConfig | null
1696
+ commandDispatch?: SkillCommandDispatch | null
1683
1697
  frontmatter?: Record<string, unknown> | null
1684
1698
  scope?: 'global' | 'agent'
1685
1699
  agentIds?: string[]
@@ -1687,6 +1701,16 @@ export interface Skill {
1687
1701
  updatedAt: number
1688
1702
  }
1689
1703
 
1704
+ export interface SkillInvocationConfig {
1705
+ userInvocable?: boolean
1706
+ }
1707
+
1708
+ export interface SkillCommandDispatch {
1709
+ kind: 'tool'
1710
+ toolName: string
1711
+ argMode?: 'raw'
1712
+ }
1713
+
1690
1714
  export interface SkillSecuritySummary {
1691
1715
  level: 'low' | 'medium' | 'high'
1692
1716
  notes: string[]