@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
@@ -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']
@@ -660,6 +670,7 @@ export interface Agent {
660
670
  heartbeatTarget?: 'last' | 'none' | string | null
661
671
  heartbeatGoal?: string | null
662
672
  heartbeatNextAction?: string | null
673
+ heartbeatLightContext?: boolean | null
663
674
  sessionResetMode?: SessionResetMode | null
664
675
  sessionIdleTimeoutSec?: number | null
665
676
  sessionMaxAgeSec?: number | null
@@ -1277,6 +1288,8 @@ export interface AppSettings {
1277
1288
  shellCommandTimeoutSec?: number
1278
1289
  claudeCodeTimeoutSec?: number
1279
1290
  cliProcessTimeoutSec?: number
1291
+ streamIdleStallSec?: number
1292
+ requiredToolKickoffSec?: number
1280
1293
  userAvatarSeed?: string
1281
1294
  elevenLabsEnabled?: boolean
1282
1295
  elevenLabsApiKey?: string | null
@@ -1298,6 +1311,7 @@ export interface AppSettings {
1298
1311
  heartbeatActiveStart?: string | null
1299
1312
  heartbeatActiveEnd?: string | null
1300
1313
  heartbeatTimezone?: string | null
1314
+ heartbeatLightContext?: boolean | null
1301
1315
  sessionResetMode?: SessionResetMode | null
1302
1316
  sessionIdleTimeoutSec?: number | null
1303
1317
  sessionMaxAgeSec?: number | null
@@ -1671,11 +1685,15 @@ export interface Skill {
1671
1685
  homepage?: string
1672
1686
  primaryEnv?: string | null
1673
1687
  skillKey?: string | null
1688
+ toolNames?: string[]
1689
+ capabilities?: string[]
1674
1690
  always?: boolean
1675
1691
  installOptions?: SkillInstallOption[]
1676
1692
  skillRequirements?: SkillRequirements
1677
1693
  detectedEnvVars?: string[]
1678
1694
  security?: SkillSecuritySummary | null
1695
+ invocation?: SkillInvocationConfig | null
1696
+ commandDispatch?: SkillCommandDispatch | null
1679
1697
  frontmatter?: Record<string, unknown> | null
1680
1698
  scope?: 'global' | 'agent'
1681
1699
  agentIds?: string[]
@@ -1683,6 +1701,16 @@ export interface Skill {
1683
1701
  updatedAt: number
1684
1702
  }
1685
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
+
1686
1714
  export interface SkillSecuritySummary {
1687
1715
  level: 'low' | 'medium' | 'high'
1688
1716
  notes: string[]
@@ -10,6 +10,8 @@ import {
10
10
  DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES,
11
11
  DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT,
12
12
  DEFAULT_SHELL_COMMAND_TIMEOUT_SEC,
13
+ DEFAULT_STREAM_IDLE_STALL_SEC,
14
+ DEFAULT_REQUIRED_TOOL_KICKOFF_SEC,
13
15
  } from '@/lib/runtime/runtime-loop'
14
16
  import type { LoopMode } from '@/types'
15
17
  import type { SettingsSectionProps } from './types'
@@ -212,6 +214,42 @@ export function RuntimeLoopSection({ appSettings, patchSettings, inputClass }: S
212
214
  </div>
213
215
  </div>
214
216
 
217
+ <label className="flex items-center gap-1.5 font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mt-5 mb-3">Stream &amp; Kickoff Timeouts (Seconds) <HintTip text="Controls how long to wait for model output and required tool usage before aborting a turn" /></label>
218
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
219
+ <div>
220
+ <label className="block text-[11px] text-text-3 mb-2">Idle Stall Timeout</label>
221
+ <input
222
+ type="number"
223
+ min={30}
224
+ max={600}
225
+ value={appSettings.streamIdleStallSec ?? DEFAULT_STREAM_IDLE_STALL_SEC}
226
+ onChange={(e) => {
227
+ const n = Number.parseInt(e.target.value, 10)
228
+ patchSettings({ streamIdleStallSec: Number.isFinite(n) ? n : DEFAULT_STREAM_IDLE_STALL_SEC })
229
+ }}
230
+ className={inputClass}
231
+ style={{ fontFamily: 'inherit' }}
232
+ />
233
+ <p className="text-[11px] text-text-3/60 mt-2">Aborts a turn if no tokens arrive for this long. Raise for slow local models.</p>
234
+ </div>
235
+ <div>
236
+ <label className="block text-[11px] text-text-3 mb-2">Required Tool Kickoff</label>
237
+ <input
238
+ type="number"
239
+ min={10}
240
+ max={120}
241
+ value={appSettings.requiredToolKickoffSec ?? DEFAULT_REQUIRED_TOOL_KICKOFF_SEC}
242
+ onChange={(e) => {
243
+ const n = Number.parseInt(e.target.value, 10)
244
+ patchSettings({ requiredToolKickoffSec: Number.isFinite(n) ? n : DEFAULT_REQUIRED_TOOL_KICKOFF_SEC })
245
+ }}
246
+ className={inputClass}
247
+ style={{ fontFamily: 'inherit' }}
248
+ />
249
+ <p className="text-[11px] text-text-3/60 mt-2">Max wait for a required tool call before forcing a continuation.</p>
250
+ </div>
251
+ </div>
252
+
215
253
  <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mt-6 mb-3">LLM Response Cache</label>
216
254
  <div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
217
255
  <div className="md:col-span-3 flex items-center gap-3">