@swarmclawai/swarmclaw 1.9.1 → 1.9.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 (35) hide show
  1. package/README.md +21 -1
  2. package/electron-dist/main.js +218 -0
  3. package/package.json +3 -3
  4. package/scripts/ensure-sandbox-browser-image.mjs +12 -2
  5. package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
  6. package/src/app/api/extensions/managed-resources/route.ts +116 -0
  7. package/src/app/api/knowledge/hygiene/route.ts +19 -1
  8. package/src/app/api/portability/export/route.test.ts +17 -0
  9. package/src/app/api/portability/export/route.ts +11 -2
  10. package/src/cli/index.js +2 -0
  11. package/src/cli/spec.js +2 -0
  12. package/src/lib/server/agents/delegation-advisory.test.ts +1 -0
  13. package/src/lib/server/agents/delegation-advisory.ts +10 -0
  14. package/src/lib/server/chat-execution/iteration-event-handler.ts +24 -8
  15. package/src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts +117 -0
  16. package/src/lib/server/chat-execution/reasoning-tag-scrubber.ts +219 -0
  17. package/src/lib/server/extension-managed-resources.test.ts +159 -0
  18. package/src/lib/server/extension-managed-resources.ts +905 -0
  19. package/src/lib/server/extensions.ts +113 -2
  20. package/src/lib/server/knowledge-sources.test.ts +45 -0
  21. package/src/lib/server/knowledge-sources.ts +33 -0
  22. package/src/lib/server/portability/export.ts +10 -0
  23. package/src/lib/server/session-tools/crud.ts +25 -2
  24. package/src/lib/server/session-tools/extension-creator.ts +50 -0
  25. package/src/lib/server/session-tools/manage-tasks.test.ts +7 -2
  26. package/src/lib/server/tasks/task-route-service.ts +18 -1
  27. package/src/lib/server/tasks/task-service.test.ts +60 -2
  28. package/src/lib/server/tasks/task-service.ts +35 -0
  29. package/src/lib/validation/schemas.ts +2 -0
  30. package/src/types/agent.ts +2 -0
  31. package/src/types/app-settings.ts +8 -0
  32. package/src/types/extension.ts +132 -0
  33. package/src/types/misc.ts +1 -1
  34. package/src/types/schedule.ts +3 -0
  35. package/src/views/settings/extension-manager.tsx +157 -1
@@ -11,6 +11,7 @@ import type {
11
11
  ExtensionUIDefinition,
12
12
  ExtensionProviderDefinition,
13
13
  ExtensionConnectorDefinition,
14
+ ExtensionManagedResources,
14
15
  Session,
15
16
  ExtensionPackageManager,
16
17
  ExtensionDependencyInstallStatus,
@@ -467,11 +468,60 @@ function coerceTools(rawTools: unknown): ExtensionToolDef[] {
467
468
  return []
468
469
  }
469
470
 
471
+ function coerceManagedResources(raw: Record<string, unknown>): ExtensionManagedResources | undefined {
472
+ const explicit = isRecord(raw.managedResources)
473
+ ? raw.managedResources as Record<string, unknown>
474
+ : {}
475
+ const agents = Array.isArray(explicit.agents)
476
+ ? explicit.agents
477
+ : Array.isArray(raw.agents)
478
+ ? raw.agents
479
+ : undefined
480
+ const schedules = Array.isArray(explicit.schedules)
481
+ ? explicit.schedules
482
+ : Array.isArray(raw.schedules)
483
+ ? raw.schedules
484
+ : undefined
485
+ const routines = Array.isArray(explicit.routines)
486
+ ? explicit.routines
487
+ : Array.isArray(raw.routines)
488
+ ? raw.routines
489
+ : undefined
490
+ const localFolders = Array.isArray(explicit.localFolders)
491
+ ? explicit.localFolders
492
+ : Array.isArray(raw.localFolders)
493
+ ? raw.localFolders
494
+ : undefined
495
+ const gatewayPlatforms = Array.isArray(explicit.gatewayPlatforms)
496
+ ? explicit.gatewayPlatforms
497
+ : Array.isArray(raw.gatewayPlatforms)
498
+ ? raw.gatewayPlatforms
499
+ : undefined
500
+ const setupChecks = Array.isArray(explicit.setupChecks)
501
+ ? explicit.setupChecks
502
+ : Array.isArray(raw.setupChecks)
503
+ ? raw.setupChecks
504
+ : undefined
505
+
506
+ const managedResources: ExtensionManagedResources = {
507
+ agents: agents as ExtensionManagedResources['agents'],
508
+ schedules: schedules as ExtensionManagedResources['schedules'],
509
+ routines: routines as ExtensionManagedResources['routines'],
510
+ localFolders: localFolders as ExtensionManagedResources['localFolders'],
511
+ gatewayPlatforms: gatewayPlatforms as ExtensionManagedResources['gatewayPlatforms'],
512
+ setupChecks: setupChecks as ExtensionManagedResources['setupChecks'],
513
+ }
514
+
515
+ return Object.values(managedResources).some((value) => Array.isArray(value) && value.length > 0)
516
+ ? managedResources
517
+ : undefined
518
+ }
519
+
470
520
  function normalizeExtension(mod: unknown): Extension | null {
471
521
  const modObj = mod as Record<string, unknown>
472
522
  const raw: Record<string, unknown> = (modObj?.default as Record<string, unknown>) || modObj
473
523
 
474
- if (raw.name && (raw.hooks || raw.tools || raw.ui || raw.providers || raw.connectors)) {
524
+ if (raw.name && (raw.hooks || raw.tools || raw.ui || raw.providers || raw.connectors || raw.managedResources || raw.agents || raw.schedules || raw.routines || raw.localFolders || raw.gatewayPlatforms || raw.setupChecks)) {
475
525
  const hooks = isRecord(raw.hooks) ? (raw.hooks as ExtensionHooks) : {}
476
526
  return {
477
527
  name: raw.name as string,
@@ -484,6 +534,7 @@ function normalizeExtension(mod: unknown): Extension | null {
484
534
  ui: isRecord(raw.ui) ? (raw.ui as ExtensionUIDefinition) : undefined,
485
535
  providers: Array.isArray(raw.providers) ? (raw.providers as ExtensionProviderDefinition[]) : undefined,
486
536
  connectors: Array.isArray(raw.connectors) ? (raw.connectors as ExtensionConnectorDefinition[]) : undefined,
537
+ managedResources: coerceManagedResources(raw),
487
538
  } as Extension
488
539
  }
489
540
 
@@ -639,6 +690,7 @@ interface LoadedExtension {
639
690
  ui?: ExtensionUIDefinition
640
691
  providers?: ExtensionProviderDefinition[]
641
692
  connectors?: ExtensionConnectorDefinition[]
693
+ managedResources?: ExtensionManagedResources
642
694
  isBuiltin?: boolean
643
695
  }
644
696
 
@@ -1017,6 +1069,7 @@ class ExtensionManager {
1017
1069
  ui: p.ui,
1018
1070
  providers: p.providers,
1019
1071
  connectors: p.connectors,
1072
+ managedResources: p.managedResources || coerceManagedResources(p as unknown as Record<string, unknown>),
1020
1073
  isBuiltin: true
1021
1074
  })
1022
1075
  this.markExtensionSuccess(id)
@@ -1064,6 +1117,7 @@ class ExtensionManager {
1064
1117
  ui: ext.ui,
1065
1118
  providers: ext.providers,
1066
1119
  connectors: ext.connectors,
1120
+ managedResources: ext.managedResources,
1067
1121
  })
1068
1122
  this.markExtensionSuccess(file)
1069
1123
  } catch (err: unknown) {
@@ -1145,6 +1199,55 @@ class ExtensionManager {
1145
1199
  return allUI
1146
1200
  }
1147
1201
 
1202
+ getManagedResourceExtensions(): Array<{
1203
+ extensionId: string
1204
+ extensionName: string
1205
+ enabled: boolean
1206
+ isBuiltin: boolean
1207
+ source?: ExtensionMeta['source']
1208
+ managedResources: ExtensionManagedResources
1209
+ }> {
1210
+ this.load()
1211
+ const result: Array<{
1212
+ extensionId: string
1213
+ extensionName: string
1214
+ enabled: boolean
1215
+ isBuiltin: boolean
1216
+ source?: ExtensionMeta['source']
1217
+ managedResources: ExtensionManagedResources
1218
+ }> = []
1219
+
1220
+ for (const [id, entry] of this.extensions.entries()) {
1221
+ const managedResources = entry.managedResources
1222
+ if (!managedResources) continue
1223
+ if (!Object.values(managedResources).some((value) => Array.isArray(value) && value.length > 0)) continue
1224
+ result.push({
1225
+ extensionId: id,
1226
+ extensionName: entry.meta.name,
1227
+ enabled: entry.meta.enabled,
1228
+ isBuiltin: entry.isBuiltin === true,
1229
+ source: entry.meta.source,
1230
+ managedResources,
1231
+ })
1232
+ }
1233
+
1234
+ return result
1235
+ }
1236
+
1237
+ getManagedResources(extensionId: string): ExtensionManagedResources | null {
1238
+ this.load()
1239
+ const candidateIds = expandExtensionIds([extensionId])
1240
+ for (const id of candidateIds) {
1241
+ const loaded = this.extensions.get(id)
1242
+ if (loaded?.managedResources) return loaded.managedResources
1243
+ const builtin = this.builtins.get(id)
1244
+ if (builtin) {
1245
+ return builtin.managedResources || coerceManagedResources(builtin as unknown as Record<string, unknown>) || null
1246
+ }
1247
+ }
1248
+ return null
1249
+ }
1250
+
1148
1251
  listExtensionIds(): string[] {
1149
1252
  this.load()
1150
1253
  return Array.from(this.extensions.keys())
@@ -1842,11 +1945,14 @@ class ExtensionManager {
1842
1945
  const failures = this.readFailureState()
1843
1946
  const metas: ExtensionMeta[] = []
1844
1947
 
1845
- const describeCapabilities = (loaded?: LoadedExtension, fallback?: Extension): Pick<ExtensionMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields'> => {
1948
+ const describeCapabilities = (loaded?: LoadedExtension, fallback?: Extension): Pick<ExtensionMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields' | 'managedAgentCount' | 'managedScheduleCount' | 'localFolderCount' | 'gatewayPlatformCount' | 'setupCheckCount'> => {
1846
1949
  const tools = loaded?.tools || fallback?.tools || []
1847
1950
  const hooks = loaded?.hooks || fallback?.hooks || {}
1848
1951
  const providers = loaded?.providers || fallback?.providers || []
1849
1952
  const connectors = loaded?.connectors || fallback?.connectors || []
1953
+ const managedResources = loaded?.managedResources
1954
+ || fallback?.managedResources
1955
+ || (fallback ? coerceManagedResources(fallback as unknown as Record<string, unknown>) : undefined)
1850
1956
  const hasUi = !!(loaded?.ui || fallback?.ui)
1851
1957
  const settingsFields = loaded?.ui?.settingsFields || fallback?.ui?.settingsFields
1852
1958
  return {
@@ -1855,6 +1961,11 @@ class ExtensionManager {
1855
1961
  hasUI: hasUi,
1856
1962
  providerCount: Array.isArray(providers) ? providers.length : 0,
1857
1963
  connectorCount: Array.isArray(connectors) ? connectors.length : 0,
1964
+ managedAgentCount: managedResources?.agents?.length || 0,
1965
+ managedScheduleCount: (managedResources?.schedules?.length || 0) + (managedResources?.routines?.length || 0),
1966
+ localFolderCount: managedResources?.localFolders?.length || 0,
1967
+ gatewayPlatformCount: managedResources?.gatewayPlatforms?.length || 0,
1968
+ setupCheckCount: managedResources?.setupChecks?.length || 0,
1858
1969
  settingsFields: settingsFields?.length ? settingsFields : undefined,
1859
1970
  }
1860
1971
  }
@@ -259,3 +259,48 @@ test('runKnowledgeHygieneMaintenance keeps same-content sources separate when vi
259
259
  assert.equal(output.agent1Hits, 2)
260
260
  assert.equal(output.agent2Hits, 1)
261
261
  })
262
+
263
+ test('pruneArchivedKnowledgeSources deletes old archived sources and records prune actions', () => {
264
+ const output = runWithTempDataDir<{
265
+ pruned: number
266
+ sourceStillExists: boolean
267
+ hitCount: number
268
+ actionKind: string | null
269
+ }>(`
270
+ const knowledgeMod = await import('./src/lib/server/knowledge-sources.ts')
271
+ const storageMod = await import('./src/lib/server/storage.ts')
272
+ const knowledge = knowledgeMod.default || knowledgeMod
273
+ const storage = storageMod.default || storageMod
274
+
275
+ const source = await knowledge.createKnowledgeSource({
276
+ kind: 'manual',
277
+ title: 'Old Archived Notes',
278
+ content: 'prune candidate payload',
279
+ })
280
+
281
+ await knowledge.archiveKnowledgeSource(source.source.id, { reason: 'obsolete' })
282
+ const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000
283
+ storage.patchKnowledgeSource(source.source.id, (current) => current ? {
284
+ ...current,
285
+ archivedAt: oldTimestamp,
286
+ maintenanceUpdatedAt: oldTimestamp,
287
+ updatedAt: oldTimestamp,
288
+ } : null)
289
+
290
+ const result = await knowledge.pruneArchivedKnowledgeSources({ olderThanDays: 30 })
291
+ const summary = await knowledge.getKnowledgeHygieneSummary()
292
+ const hits = await knowledge.searchKnowledgeHits({ query: 'candidate', includeArchived: true })
293
+
294
+ console.log(JSON.stringify({
295
+ pruned: result.pruned,
296
+ sourceStillExists: Boolean(storage.loadKnowledgeSource(source.source.id)),
297
+ hitCount: hits.length,
298
+ actionKind: summary.recentActions.find((action) => action.sourceId === source.source.id)?.kind || null,
299
+ }))
300
+ `, { prefix: 'swarmclaw-knowledge-prune-' })
301
+
302
+ assert.equal(output.pruned, 1)
303
+ assert.equal(output.sourceStillExists, false)
304
+ assert.equal(output.hitCount, 0)
305
+ assert.equal(output.actionKind, 'prune')
306
+ })
@@ -31,6 +31,7 @@ import {
31
31
  import { onNextIdleWindow } from '@/lib/server/runtime/idle-window'
32
32
 
33
33
  const KNOWLEDGE_STALE_AFTER_MS = 1000 * 60 * 60 * 24 * 14
34
+ const DEFAULT_PRUNE_ARCHIVED_AFTER_DAYS = 30
34
35
  const CHUNK_TARGET_CHARS = 2200
35
36
  const CHUNK_OVERLAP_CHARS = 320
36
37
  const MAX_KNOWLEDGE_SCAN = 10_000
@@ -1049,6 +1050,38 @@ export async function supersedeKnowledgeSource(
1049
1050
  return getKnowledgeSourceDetail(updated.id)
1050
1051
  }
1051
1052
 
1053
+ export async function pruneArchivedKnowledgeSources(input?: {
1054
+ olderThanDays?: number | null
1055
+ now?: number
1056
+ }): Promise<{ pruned: number; sourceIds: string[] }> {
1057
+ await ensureLegacyKnowledgeBackfill()
1058
+ const now = typeof input?.now === 'number' && Number.isFinite(input.now) ? input.now : Date.now()
1059
+ const olderThanDays = typeof input?.olderThanDays === 'number' && Number.isFinite(input.olderThanDays)
1060
+ ? Math.max(1, Math.trunc(input.olderThanDays))
1061
+ : DEFAULT_PRUNE_ARCHIVED_AFTER_DAYS
1062
+ const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000
1063
+ const sourceIds: string[] = []
1064
+
1065
+ for (const source of listStoredSources()) {
1066
+ if (!sourceIsArchived(source) && !sourceIsSuperseded(source)) continue
1067
+ const lifecycleAt = source.archivedAt || source.maintenanceUpdatedAt || source.updatedAt
1068
+ if (lifecycleAt > cutoff) continue
1069
+ const title = source.title
1070
+ const removed = await deleteKnowledgeSource(source.id)
1071
+ if (!removed) continue
1072
+ sourceIds.push(source.id)
1073
+ recordMaintenanceAction({
1074
+ kind: 'prune',
1075
+ sourceId: source.id,
1076
+ relatedSourceId: source.duplicateOfSourceId || source.supersededBySourceId || null,
1077
+ summary: `Pruned ${title}`,
1078
+ createdAt: now,
1079
+ })
1080
+ }
1081
+
1082
+ return { pruned: sourceIds.length, sourceIds }
1083
+ }
1084
+
1052
1085
  function sameSourceOrigin(left: KnowledgeSource, right: KnowledgeSource): boolean {
1053
1086
  if (left.id === right.id) return false
1054
1087
  if (left.sourceUrl && right.sourceUrl) return left.sourceUrl === right.sourceUrl
@@ -34,6 +34,16 @@ export interface PortableManifest {
34
34
  extensions?: PortableExtensionRef[]
35
35
  }
36
36
 
37
+ export function buildPortableExportFilename(manifest: Pick<PortableManifest, 'exportedAt'> = { exportedAt: new Date().toISOString() }): string {
38
+ const safeStamp = manifest.exportedAt
39
+ .replaceAll(':', '')
40
+ .replaceAll('.', '')
41
+ .replaceAll('-', '')
42
+ .replace('T', '-')
43
+ .replace('Z', 'Z')
44
+ return `swarmclaw-export-${safeStamp}.json`
45
+ }
46
+
37
47
  export type PortableAgent = Omit<Agent,
38
48
  | 'id' | 'credentialId' | 'fallbackCredentialIds' | 'apiEndpoint'
39
49
  | 'threadSessionId' | 'lastUsedAt' | 'totalCost' | 'trashedAt'
@@ -43,6 +43,7 @@ import {
43
43
  buildDelegationTaskProfile,
44
44
  formatDelegationRationale,
45
45
  resolveDelegationAdvisory,
46
+ type DelegationWorkType,
46
47
  } from '@/lib/server/agents/delegation-advisory'
47
48
  import type { ToolBuildContext } from './context'
48
49
  import { normalizeToolInputArgs } from './normalize-tool-args'
@@ -102,6 +103,17 @@ function buildTaskDelegationText(parsed: Record<string, unknown>): string {
102
103
  return [title, description].filter(Boolean).join('\n\n').trim()
103
104
  }
104
105
 
106
+ function normalizeDelegationWorkType(value: unknown): DelegationWorkType | null {
107
+ return value === 'coding'
108
+ || value === 'research'
109
+ || value === 'writing'
110
+ || value === 'review'
111
+ || value === 'operations'
112
+ || value === 'general'
113
+ ? value
114
+ : null
115
+ }
116
+
105
117
  async function resolveManagedTaskDelegation(params: {
106
118
  parsed: Record<string, unknown>
107
119
  agents: ReturnType<typeof loadAgents>
@@ -122,9 +134,11 @@ async function resolveManagedTaskDelegation(params: {
122
134
  return { assignedAgentId: params.assignedAgentId, advisory: null }
123
135
  }
124
136
 
137
+ const decisionStartedAt = Date.now()
125
138
  const explicitCapabilities = normalizeStringList(params.parsed.requiredCapabilities)
139
+ const explicitWorkType = normalizeDelegationWorkType(params.parsed.workType)
126
140
  const classificationText = buildTaskDelegationText(params.parsed)
127
- const classification = (!explicitCapabilities.length && classificationText && params.ctx?.sessionId)
141
+ const classification = (!explicitCapabilities.length && !explicitWorkType && classificationText && params.ctx?.sessionId)
128
142
  ? await classifyMessage({
129
143
  sessionId: params.ctx.sessionId,
130
144
  agentId: currentAgentId,
@@ -134,6 +148,7 @@ async function resolveManagedTaskDelegation(params: {
134
148
 
135
149
  const profile = buildDelegationTaskProfile({
136
150
  classification,
151
+ workType: explicitWorkType,
137
152
  requiredCapabilities: explicitCapabilities,
138
153
  })
139
154
  if (!profile.substantial) {
@@ -167,10 +182,18 @@ async function resolveManagedTaskDelegation(params: {
167
182
  advisory: {
168
183
  recommendedAgentId: recommended.agentId,
169
184
  recommendedAgentName: recommended.agentName,
185
+ routeKey: recommended.routeKey,
170
186
  rationale: formatDelegationRationale(recommended),
171
187
  workType: profile.workType,
172
188
  requiredCapabilities: profile.requiredCapabilities,
173
189
  autoAssigned,
190
+ routingMode: explicitCapabilities.length || explicitWorkType
191
+ ? 'deterministic'
192
+ : classification
193
+ ? 'classified'
194
+ : 'default',
195
+ decisionLatencyMs: Date.now() - decisionStartedAt,
196
+ score: Number(recommended.score.toFixed(3)),
174
197
  },
175
198
  }
176
199
  }
@@ -465,7 +488,7 @@ export function buildCrudTools(bctx: ToolBuildContext): StructuredToolInterface[
465
488
  }
466
489
  description += '\n\nCreate/update calls accept either `data` as a JSON string or direct top-level fields like `title`, `description`, `status`, `agentId`, and `projectId`.'
467
490
  if (canAssignOtherAgents) {
468
- description += '\n\nWhen you omit an assignee, the runtime may auto-assign the task to a materially better-fit teammate based on `requiredCapabilities` or the classified work type. If you set an explicit assignee, it is respected in v1, but the response may include `delegationAdvisory` when another teammate is a better fit.'
491
+ description += '\n\nWhen you omit an assignee, the runtime may auto-assign the task to a materially better-fit teammate based on `requiredCapabilities`, explicit `workType`, or the classified work type. Use `workType:"coding"|"research"|"writing"|"review"|"operations"|"general"` for deterministic routing without a classifier call. If you set an explicit assignee, it is respected in v1, but the response may include `delegationAdvisory` when another teammate is a better fit.'
469
492
  }
470
493
  description += '\n\nFor follow-up work, set `continueFromTaskId` (or `followUpToTaskId`) to a prior task ID. The new task will inherit the predecessor\'s project/agent/session context, block on that task by default, and reuse its execution session when possible.'
471
494
  if (ctx?.projectId) {
@@ -138,6 +138,54 @@ module.exports = {
138
138
  }
139
139
  ],
140
140
 
141
+ // --- Managed Resources (Paperclip-compatible) ---
142
+ managedResources: {
143
+ agents: [
144
+ {
145
+ agentKey: 'researcher',
146
+ displayName: 'Managed Researcher',
147
+ description: 'Reusable agent this extension can provision.',
148
+ systemPrompt: 'Research carefully and cite durable evidence.',
149
+ provider: 'openai',
150
+ model: 'gpt-4o-mini',
151
+ capabilities: ['research', 'analysis'],
152
+ extensions: ['web', 'memory']
153
+ }
154
+ ],
155
+ schedules: [
156
+ {
157
+ scheduleKey: 'daily-digest',
158
+ displayName: 'Daily Digest',
159
+ agentRef: { resourceKind: 'agent', resourceKey: 'researcher' },
160
+ taskPrompt: 'Prepare the daily digest.',
161
+ scheduleType: 'cron',
162
+ cron: '0 9 * * *',
163
+ timezone: 'UTC',
164
+ status: 'paused'
165
+ }
166
+ ],
167
+ localFolders: [
168
+ {
169
+ folderKey: 'workspace',
170
+ displayName: 'Workspace Folder',
171
+ access: 'readWrite',
172
+ requiredDirectories: ['inputs', 'outputs']
173
+ }
174
+ ],
175
+ gatewayPlatforms: [
176
+ {
177
+ platformKey: 'openai-compatible-api',
178
+ displayName: 'OpenAI-compatible API',
179
+ transport: 'http',
180
+ endpoint: 'http://127.0.0.1:8642/v1',
181
+ authMode: 'bearer'
182
+ }
183
+ ],
184
+ setupChecks: [
185
+ { checkKey: 'api-key', displayName: 'API key configured', kind: 'env', target: 'OPENAI_API_KEY', required: true }
186
+ ]
187
+ },
188
+
141
189
  // --- Real OpenClaw Format (register API) ---
142
190
  register(api) {
143
191
  api.registerHook('agent:start', (ctx) => {
@@ -162,6 +210,8 @@ Key rules:
162
210
  - If your extension needs npm/pnpm/yarn/bun packages, include a packageJson object during scaffold or call install_dependencies later.
163
211
  - Dependency installs are run by the extension manager inside a per-extension workspace using the selected package manager with scripts disabled.
164
212
  - Extension settings are declared through ui.settingsFields and stored per extension ID
213
+ - Managed resources let an extension declare provisionable agents, schedules/routines, trusted local folders, gateway platforms, and setup checks. Operators reconcile them through Extensions > Managed Resources or /api/extensions/managed-resources.
214
+ - Paperclip-compatible top-level agents, routines, and localFolders are also accepted; SwarmClaw reconciles routines as schedules when they include schedule timing.
165
215
  - Keep extensions focused: one clear purpose per extension
166
216
  `
167
217
  }
@@ -172,7 +172,7 @@ describe('manage_tasks tool', () => {
172
172
  action: 'create',
173
173
  title: 'Implement API integration',
174
174
  description: 'Build the API client and fix the failing tests.',
175
- requiredCapabilities: ['coding'],
175
+ workType: 'coding',
176
176
  status: 'backlog',
177
177
  })
178
178
 
@@ -185,7 +185,11 @@ describe('manage_tasks tool', () => {
185
185
  assert.equal(output.stored.agentId, 'builder')
186
186
  assert.equal(output.response.delegationAdvisory.autoAssigned, true)
187
187
  assert.equal(output.response.delegationAdvisory.recommendedAgentId, 'builder')
188
- assert.deepEqual(output.response.delegationAdvisory.requiredCapabilities, ['coding'])
188
+ assert.deepEqual(output.response.delegationAdvisory.requiredCapabilities, ['coding', 'implementation', 'debugging'])
189
+ assert.equal(output.response.delegationAdvisory.routingMode, 'deterministic')
190
+ assert.match(output.response.delegationAdvisory.routeKey, /^coding:coding,debugging,implementation:builder$/)
191
+ assert.equal(typeof output.response.delegationAdvisory.decisionLatencyMs, 'number')
192
+ assert.equal(output.stored.workflowStateId, 'in_progress')
189
193
  })
190
194
 
191
195
  it('keeps an explicit assignee but returns delegation advisory when another teammate is a better fit', () => {
@@ -262,5 +266,6 @@ describe('manage_tasks tool', () => {
262
266
  assert.equal(output.stored.agentId, 'researcher')
263
267
  assert.equal(output.response.delegationAdvisory.autoAssigned, false)
264
268
  assert.equal(output.response.delegationAdvisory.recommendedAgentId, 'builder')
269
+ assert.equal(output.response.delegationAdvisory.routingMode, 'deterministic')
265
270
  })
266
271
  })
@@ -28,7 +28,11 @@ import {
28
28
  type PrepareTaskExecutionWorkspaceOptions,
29
29
  } from '@/lib/server/tasks/task-execution-workspace'
30
30
  import { resolveTaskAgentFromDescription } from '@/lib/server/tasks/task-mention'
31
- import { applyTaskPatch, prepareTaskCreation } from '@/lib/server/tasks/task-service'
31
+ import {
32
+ applyTaskPatch,
33
+ prepareTaskCreation,
34
+ resolveAssignmentWorkflowStateTransition,
35
+ } from '@/lib/server/tasks/task-service'
32
36
  import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
33
37
  import { queueSwarmFeedTaskCompletionWake } from '@/lib/server/swarmfeed-runtime'
34
38
  import { notify } from '@/lib/server/ws-hub'
@@ -427,12 +431,25 @@ export function bulkUpdateTasksFromRoute(body: Record<string, unknown>): Service
427
431
  }
428
432
  }
429
433
  if ('agentId' in body) {
434
+ const previousAgentId = tasks[id].agentId
435
+ const previousWorkflowStateId = tasks[id].workflowStateId || null
430
436
  tasks[id].agentId = body.agentId === null ? '' : String(body.agentId)
437
+ const workflowTransition = resolveAssignmentWorkflowStateTransition({
438
+ previousAgentId,
439
+ nextAgentId: tasks[id].agentId,
440
+ previousWorkflowStateId,
441
+ explicitWorkflowState: Object.prototype.hasOwnProperty.call(body, 'workflowStateId'),
442
+ })
443
+ if (workflowTransition) tasks[id].workflowStateId = workflowTransition
431
444
  }
432
445
  if ('projectId' in body) {
433
446
  if (body.projectId === null) delete tasks[id].projectId
434
447
  else tasks[id].projectId = String(body.projectId)
435
448
  }
449
+ if ('workflowStateId' in body) {
450
+ if (body.workflowStateId === null) delete tasks[id].workflowStateId
451
+ else tasks[id].workflowStateId = String(body.workflowStateId)
452
+ }
436
453
  tasks[id].updatedAt = Date.now()
437
454
  updated += 1
438
455
  results.push(id)
@@ -3,8 +3,11 @@ import { describe, it } from 'node:test'
3
3
 
4
4
  import { computeTaskFingerprint } from '@/lib/task-dedupe'
5
5
  import type { BoardTask } from '@/types'
6
-
7
- import { applyTaskPatch, prepareTaskCreation } from '@/lib/server/tasks/task-service'
6
+ import {
7
+ applyTaskPatch,
8
+ prepareTaskCreation,
9
+ resolveAssignmentWorkflowStateTransition,
10
+ } from '@/lib/server/tasks/task-service'
8
11
 
9
12
  function makeTask(overrides: Partial<BoardTask> = {}): BoardTask {
10
13
  return {
@@ -106,3 +109,58 @@ describe('task service helpers', () => {
106
109
  assert.equal(task.status, 'queued')
107
110
  })
108
111
  })
112
+
113
+ describe('task-service assignment workflow transitions', () => {
114
+ it('moves newly assigned backlog workflow tasks to in_progress without queueing runtime work', () => {
115
+ const task = makeTask({ agentId: '', workflowStateId: 'backlog' })
116
+ applyTaskPatch({
117
+ task,
118
+ patch: { agentId: 'agent-builder' },
119
+ now: 100,
120
+ })
121
+
122
+ assert.equal(task.agentId, 'agent-builder')
123
+ assert.equal(task.status, 'backlog')
124
+ assert.equal(task.workflowStateId, 'in_progress')
125
+ assert.equal(task.updatedAt, 100)
126
+ })
127
+
128
+ it('preserves explicit workflow state patches', () => {
129
+ const task = makeTask({ workflowStateId: 'todo' })
130
+ applyTaskPatch({
131
+ task,
132
+ patch: { agentId: 'agent-builder', workflowStateId: 'needs_review' },
133
+ now: 100,
134
+ })
135
+
136
+ assert.equal(task.workflowStateId, 'needs_review')
137
+ })
138
+
139
+ it('seeds assigned task creation into the in_progress workflow lane', () => {
140
+ const prepared = prepareTaskCreation({
141
+ input: {
142
+ title: 'Build the client',
143
+ description: '',
144
+ agentId: 'builder',
145
+ },
146
+ tasks: {},
147
+ now: 200,
148
+ })
149
+
150
+ assert.equal(prepared.ok, true)
151
+ if (prepared.ok) {
152
+ assert.equal(prepared.task.status, 'backlog')
153
+ assert.equal(prepared.task.workflowStateId, 'in_progress')
154
+ }
155
+ })
156
+
157
+ it('leaves already-started workflow states alone', () => {
158
+ const next = resolveAssignmentWorkflowStateTransition({
159
+ previousAgentId: '',
160
+ nextAgentId: 'agent-builder',
161
+ previousWorkflowStateId: 'needs_review',
162
+ })
163
+
164
+ assert.equal(next, null)
165
+ })
166
+ })
@@ -20,6 +20,28 @@ const TASK_STATUS_VALUES = new Set([
20
20
  'archived',
21
21
  ])
22
22
 
23
+ const ASSIGNMENT_START_WORKFLOW_STATES = new Set(['triage', 'backlog', 'todo'])
24
+
25
+ function hasOwn(record: Record<string, unknown>, key: string): boolean {
26
+ return Object.prototype.hasOwnProperty.call(record, key)
27
+ }
28
+
29
+ export function resolveAssignmentWorkflowStateTransition(params: {
30
+ previousAgentId?: string | null
31
+ nextAgentId?: string | null
32
+ previousWorkflowStateId?: string | null
33
+ explicitWorkflowState?: boolean
34
+ }): string | null {
35
+ if (params.explicitWorkflowState) return null
36
+ const previousAgentId = typeof params.previousAgentId === 'string' ? params.previousAgentId.trim() : ''
37
+ const nextAgentId = typeof params.nextAgentId === 'string' ? params.nextAgentId.trim() : ''
38
+ if (!nextAgentId || nextAgentId === previousAgentId) return null
39
+ const currentWorkflow = typeof params.previousWorkflowStateId === 'string' && params.previousWorkflowStateId.trim()
40
+ ? params.previousWorkflowStateId.trim()
41
+ : 'backlog'
42
+ return ASSIGNMENT_START_WORKFLOW_STATES.has(currentWorkflow) ? 'in_progress' : null
43
+ }
44
+
23
45
  export function deriveTaskTitle(input: { title?: unknown; description?: unknown }): string {
24
46
  const explicit = typeof input.title === 'string' ? input.title.replace(/\s+/g, ' ').trim() : ''
25
47
  if (explicit && !/^untitled task$/i.test(explicit)) return explicit.slice(0, 120)
@@ -157,6 +179,7 @@ export type PrepareTaskCreationResult =
157
179
 
158
180
  export function prepareTaskCreation(options: PrepareTaskCreationOptions): PrepareTaskCreationResult {
159
181
  const seed = options.seed ? { ...options.seed } : {}
182
+ delete seed.workType
160
183
  const explicitTitle = typeof options.input.title === 'string' ? options.input.title.trim() : ''
161
184
  const derivedTitle = deriveTaskTitle(options.input)
162
185
  const nextTitle = options.deriveTitleFromDescription
@@ -194,6 +217,9 @@ export function prepareTaskCreation(options: PrepareTaskCreationOptions): Prepar
194
217
  qualityGate,
195
218
  },
196
219
  })
220
+ if (!task.workflowStateId && task.agentId) {
221
+ task.workflowStateId = 'in_progress'
222
+ }
197
223
  task.fingerprint = computeTaskFingerprint(task.title || 'Untitled Task', task.agentId || '')
198
224
 
199
225
  const duplicate = task.fingerprint
@@ -227,6 +253,8 @@ export interface ApplyTaskPatchOptions {
227
253
 
228
254
  export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
229
255
  const nextPatch = { ...options.patch }
256
+ const previousAgentId = options.task.agentId
257
+ const previousWorkflowStateId = options.task.workflowStateId || null
230
258
  if (Object.prototype.hasOwnProperty.call(nextPatch, 'status')) {
231
259
  const normalized = normalizeTaskStatusInput(nextPatch.status, options.task.status)
232
260
  if (normalized) nextPatch.status = normalized
@@ -240,6 +268,13 @@ export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
240
268
 
241
269
  Object.assign(options.task, nextPatch, { updatedAt: options.now })
242
270
  if (options.clearProjectIdWhenNull && nextPatch.projectId === null) delete options.task.projectId
271
+ const workflowTransition = resolveAssignmentWorkflowStateTransition({
272
+ previousAgentId,
273
+ nextAgentId: options.task.agentId,
274
+ previousWorkflowStateId,
275
+ explicitWorkflowState: hasOwn(nextPatch, 'workflowStateId'),
276
+ })
277
+ if (workflowTransition) options.task.workflowStateId = workflowTransition
243
278
 
244
279
  if (options.task.status === 'completed') {
245
280
  const { validation } = refreshTaskCompletionValidation(options.task, options.settings)
@@ -200,6 +200,8 @@ export const TaskCreateSchema = z.object({
200
200
  retryBackoffSec: z.number().optional(),
201
201
  priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
202
202
  dueAt: z.number().nullable().optional(),
203
+ workflowStateId: z.string().nullable().optional(),
204
+ requiredCapabilities: z.array(z.string()).optional(),
203
205
  provisionWorkspace: z.boolean().optional(),
204
206
  previewLinks: z.array(z.object({
205
207
  id: z.string().optional(),
@@ -2,6 +2,7 @@ import type { ProviderId, ProviderType, OllamaMode } from './provider'
2
2
  import type { SessionResetMode, IdentityContinuityState } from './session'
3
3
  import type { SkillAllowlistMode } from './skill'
4
4
  import type { DreamConfig } from './dream'
5
+ import type { ExtensionManagedResourceMarker } from './extension'
5
6
 
6
7
  // --- SwarmFeed Heartbeat ---
7
8
 
@@ -237,6 +238,7 @@ export interface Agent {
237
238
  swarmfeedLastAutoPostAt?: number | null
238
239
  origin?: 'swarmdock' | 'swarmfeed' | 'swarmclaw' | 'external'
239
240
  swarmfeedHeartbeat?: SwarmFeedHeartbeatConfig | null
241
+ managedByExtension?: ExtensionManagedResourceMarker | null
240
242
 
241
243
  // SwarmDock (marketplace integration)
242
244
  swarmdockEnabled?: boolean